Plan: CreatorTrack Sites app + pixel/analytics overhaul¶
URL: https://mkdocs.justinsforge.com/memory/plans/creatortrack-sites-app-and-pixels-2026-07-13/
Date: 2026-07-13
Repos: ~/creatortrack (Next.js), ~/forge/infra/links-edge (Cloudflare Worker), ~/forge/sites/ (legacy static sites)
Approved spec: Three sequenced phases. Phase 0 moves published-page images off base64-inlining and onto Cloudflare R2, which cuts the live link page from 4.8MB to about 70KB and is the leading suspect for Meta's Event Setup Tool failing to detect an otherwise correctly installed pixel. Phase 1 adds click and conversion events to the Meta pixel, GA4, and TikTok on link pages and funnel steps, and makes brand-level pixel settings inherit by default everywhere, so a new page or funnel is tracked with zero configuration. Phase 2 builds a new CreatorTrack app called Sites: a multi-page site builder that reuses the links module's block model, theme system, publish pipeline, Worker, and analytics, with gusthebass.com as the first migration, retiring the hand-maintained nginx container.
Verified facts this plan rests on:
- Live pixel 1023973010013934 and GA4 G-7E48T0V3CK are set on links.app_settings for workspace 85, brand gusthebass, and both render correctly into /links, /secretdrop, /michael.
- The Meta snippet is the canonical one, sits at byte 833 inside <head>, and initializes in a real browser (fbq.instance.pixelsByID contains the id). Facebook's crawler UA fetches the page with a 200. The installation is not broken.
- The published page is 4,795,548 bytes. 4,723,436 of those bytes are three base64 PNGs (largest single: 1.88MB). Real page content is roughly 70KB.
- Inlining is intentional: lib/links/assets-store.ts:6-9 documents that the page is served from Cloudflare KV and "must never depend on Console being reachable".
Out of scope this round:
- Meta Conversions API (server-side events). Blocked on a dataset access token from the Meta business portfolio. Correct follow-on after Phase 1.
- Consent / cookie gating (GDPR-style banner).
- Migrating any site other than gusthebass.com in Phase 2.
Phase 0: Get images off the page (unblocks Meta, fixes load time)¶
Task 0.1: Provision an R2 bucket and public asset binding¶
- Files:
/home/justinwieb/forge/infra/links-edge/wrangler.toml,~/.forge-secrets/cloudflare.env(read only, do not write secrets into the repo) - What: Create R2 bucket
links-assets, bind it to thelinks-edgeWorker asLINKS_ASSETS. Public access is served through the Worker, not a public bucket URL, so the asset path stays on the brand domain and inherits the existing cache rules. - Verification:
npx wrangler r2 bucket list | grep links-assets - Commit:
feat(links-edge): bind links-assets R2 bucket
Task 0.2: Serve /i/<assetId> from the Worker¶
- Files:
/home/justinwieb/forge/infra/links-edge/src/index.ts, new/home/justinwieb/forge/infra/links-edge/src/asset.ts - What: Add a
GET /i/<assetId>route that streams the object from R2 withcontent-typefrom stored metadata andcache-control: public, max-age=31536000, immutable. Asset ids are content-addressed so they are safe to cache forever. Return 404 on miss, never fall through to the page handler. - Verification: after 0.4 publishes an asset,
curl -sI https://gusthebass.com/i/<id> | grep -E 'HTTP|content-type|cache-control' - Commit:
feat(links-edge): serve R2 assets at /i/<id>
Task 0.3: Add the /i/* route to the zone and the reconcile script¶
- Files:
/home/justinwieb/forge/infra/links-edge/wrangler.toml,/home/justinwieb/forge/scripts/forge_links_routes_reconcile.py - What: Add
<host>/i/*for every brand host to the toml route list (path-scoped, same as/r/*).wrangler deployreconciles zone routes to the toml and drops API-added routes, so this must live in the toml, not be added via the API. - Verification:
python3 scripts/forge_links_routes_reconcile.py --dry-runreports no missing routes after deploy. - Commit:
feat(links-edge): route /i/* on all brand hosts
Task 0.4: Publish assets to R2 instead of inlining them¶
- Files:
~/creatortrack/lib/links/assets-store.ts(resolveAssetRefs),~/creatortrack/lib/links/cf.ts - What: Change
resolveAssetRefsto upload eachasset:<id>blob to R2 (idempotent, keyed by content hash) and rewrite the ref to an absolutehttps://<host>/i/<hash>URL rather than adata:URI. This preserves the original design goal (the published page still has zero dependency on Console) while removing the megabytes. Keep the existingMAX_INLINED_BYTESguard as a hard upload cap. Uploads happen inside the publish transaction; a failed upload fails the publish loudly rather than silently falling back to inlining. - Verification: republish
/links, thencurl -s https://gusthebass.com/links | wc -creturns under 150000, andcurl -s https://gusthebass.com/links | grep -c 'data:image'returns 0. - Commit:
feat(links): publish images to R2, stop inlining base64
Task 0.5: Re-encode, resize, and serve responsive variants¶
- Files:
~/creatortrack/lib/links/assets-store.ts, new~/creatortrack/lib/links/image-pipeline.ts,~/creatortrack/components/apps/link-in-bio/LinkBioPreview.tsx - What: Three compounding wins, all on upload, using
sharp: - Re-encode. The current assets are PNGs, which is a lossless format meant for logos and line art, not photos. That is why one image is 1.88MB. Convert photographic sources to WebP at quality 82 (and AVIF at quality 50 as a
<source>preferred by supporting browsers). Typical reduction for a photo is 85 to 95 percent with no perceptible difference. Keep PNG only when the source has transparency and few colors (a real logo), decided by a heuristic on alpha channel plus color count, not by extension. - Resize. Generate 320/640/1280 width variants. Never upscale past the source width.
- Serve. Emit
<picture>with AVIF/WebP sources,srcset+sizes,loading="lazy"on below-fold images, and explicitwidth/heightto eliminate layout shift. Original bytes stay inlinks.assetsuntouched so re-encoding is always reversible and re-runnable. - Verification: Playwright loads
https://gusthebass.com/linksat a 390px viewport and reports total transferred bytes under 400KB (from 4.8MB);curl -s https://gusthebass.com/links | grep -c 'data:image'returns 0; visual diff against the pre-change screenshot shows no perceptible difference. - Commit:
feat(links): webp/avif re-encode + resize + responsive picture elements
Task 0.5b: Backfill existing assets through the pipeline¶
- Files: new
/home/justinwieb/forge/scripts/forge_links_assets_backfill.py - What: Run every existing row in
links.assets(6 today) through the new pipeline and upload the variants to R2. Idempotent, keyed by content hash, safe to re-run. Originals are never mutated. - Verification: script reports 6/6 processed; re-running reports 6/6 already present and uploads nothing.
- Commit:
feat(links): backfill existing assets through image pipeline
Task 0.6: Backfill and re-verify Meta detection¶
- Files: none (operational)
- What: Republish all three live pages. Then re-run Meta's Event Setup Tool against
https://gusthebass.com/links, and confirm with the Meta Pixel Helper extension. - Verification: Meta Event Setup Tool loads the page and offers events instead of "A pixel wasn't detected". If it still fails at 70KB, the cause is not page weight and we escalate to Meta Test Events with a
test_event_code. - Commit: none (no code change)
Phase 1: Pixel click events + inheritance by default¶
Task 1.1: Stamp tracking metadata onto link anchors¶
- Files:
~/creatortrack/components/apps/link-in-bio/LinkBioPreview.tsx - What: Add
data-kind,data-label,data-link-id, and where applicabledata-product-idanddata-price-centsto every/r/anchor (link block :388, affiliate :397, links-group :666/:695, storefront product :722-724, grid :742-749, music :829/:847, media-kit :992, socials :357). Storefront items already carryproductIdandpriceCents(storefrontGrid.ts:11-20); they simply are not exposed to the DOM today. - Verification:
curl -s https://gusthebass.com/links | grep -o 'data-kind="[a-z-]*"' | sort | uniq -c - Commit:
feat(links): stamp tracking metadata on tracked anchors
Task 1.2: Emit a click-event script on published pages¶
- Files:
~/creatortrack/lib/links/render.ts(newpixelEventsScript, wire in at the script assembly block around :833-844) - What: One always-emitted delegated listener on
a[href^="/r/"], reusing the exact selectorLINK_EFFECT_SCRIPTalready uses (effects.ts:204). Event mapping: any outbound click fires MetatrackCustom LinkClickand GA4select_content; a storefront/product click fires MetaViewContentwithcontent_ids,value,currencyand GA4select_item; a Shopify checkout link fires MetaInitiateCheckoutand GA4begin_checkout. Every call is wrapped so a missingfbq/gtagor any thrown error is a silent no-op that can never block navigation. Fires TikTokttq.tracktoo when a TikTok pixel is configured. - Verification: Playwright loads the page, clicks a link, asserts a request to
facebook.com/trwithev=LinkClickand one togoogle-analytics.com/g/collectwithen=select_content. - Commit:
feat(links): pixel + GA4 click and commerce events
Task 1.3: Fire Lead on email capture¶
- Files:
~/creatortrack/lib/links/render.ts(captureScript, :338-497) - What: On a successful capture submit, fire Meta
Leadand GA4generate_lead. Only on success, never on submit-attempt, so the numbers mean something. - Verification: Playwright submits the capture form against a staging page; assert
ev=Lead. - Commit:
feat(links): fire Lead on successful email capture
Task 1.4: Funnels inherit brand pixels¶
- Files:
~/creatortrack/lib/links/funnel-render.ts(:299),~/creatortrack/app/api/links/funnels/publish/route.ts - What:
funnel-render.ts:299currently reads onlyfunnel.settings.pixels, so funnels are silently untracked. Resolve againstlinks.app_settingsper-field, exactly as pages already do atrender.ts:866-871. Reuse the same resolver rather than duplicating the merge logic. Emit the same click-event script from 1.2 on funnel steps. - Verification: publish the
welcome-pagefunnel, thencurl -s <funnel url> | grep -c fbevents.jsreturns 1 with no pixel set on the funnel itself. - Commit:
fix(links): funnels inherit brand pixel settings
Task 1.5: Extract one shared pixel resolver¶
- Files: new
~/creatortrack/lib/links/pixels.ts; editrender.ts,funnel-render.ts - What: Single source of truth for "given a brand, a page/funnel override, and app settings, what pixels apply". Pages, funnels, and (in Phase 2) Sites all call it. This is what makes "I never think about it again" structurally true instead of a promise.
- Verification:
npx tsc --noEmitclean; both publish paths produce identical pixel head for the same brand. - Commit:
refactor(links): single pixel resolver shared by pages and funnels
Task 1.6: Guard the apex-slug foot-gun¶
- Files:
/home/justinwieb/forge/scripts/forge_links_routes_reconcile.py(:36-38) - What: The slug-to-host map sends an empty slug to
gusthebass.com, meaning a page published with a blank slug would install agusthebass.com/*Worker route and hijack the marketing site. Refuse to reconcile a wildcard-at-apex route unless explicitly opted in. Unrelated to pixels, but it is live and adjacent, and Phase 2 makes the apex a real target. - Verification:
python3 scripts/forge_links_routes_reconcile.py --dry-runwith a blank-slug page in the DB exits non-zero with a clear message. - Commit:
fix(links): refuse to route wildcard at apex without opt-in
Phase 2: The Sites app¶
Why this is smaller than "build a site builder": the links module already has the block model, theme and token system, publish pipeline to Cloudflare KV, a Worker that serves pages, click tracking, visitor profiles, and (after Phase 1) a pixel layer. What it lacks is multi-page routing, navigation, and a page hierarchy. That is the actual gap.
Design decisions locked in:
- New Postgres schema sites.*, following the one-schema-per-app convention. Reuses links.assets (do not duplicate the asset store) and the shared pixel resolver from 1.5.
- A Site owns: a host, a page tree (path, parent, nav order), a shared header/footer, and brand-level theme + pixel inheritance. Site pages reuse BlockKind from lib/links-store.ts:292.
- Publishing writes page:<host>:<path> KV keys, identical to how links pages already publish. No new Worker serving logic is needed for the happy path.
- Sites and Links share one brand, one theme, one pixel, one analytics surface. That is the whole point.
Task 2.1: Register the Sites app¶
- Files:
~/creatortrack/lib/apps.ts(AppDef list, alongside the link-in-bio entry at :380-403),~/creatortrack/components/ui/icons - What: Add the
sitesAppDef (id, name "Sites", route/sites, icon, category). One entry feeds the App Library, the install API, and the sidebar. - Verification: app appears in the App Library and installs into workspace 85.
- Commit:
feat(sites): register the Sites app
Task 2.2: Schema and migration¶
- Files: new
~/creatortrack/db/migrations/<ts>_feat_sites_schema.sql,~/creatortrack/db/schema.ts - What:
sites.sites(workspace_id, brand, host, theme, nav, published_at) andsites.pages(site_id, path, parent_id, order, title, blocks, theme override, published_at). Idempotent DDL. RLSworkspace_isolationplus the publish GUC exception clause, matching thelinks.assetspolicy verbatim. - Verification:
python3 scripts/forge_workspace_migrate.py --dry-runthen apply;psql -c "\dt sites.*". - Commit:
feat(sites): sites schema + RLS
Task 2.3: Page tree editor¶
- Files: new
~/creatortrack/app/(suite)/sites/*,~/creatortrack/components/apps/sites/* - What: Site list, page tree (create/rename/reorder/nest), and a block editor that reuses the existing links
BlockEditor. Do not fork the block editor; parameterize it. - Verification: create a site, add three nested pages, reorder them, reload, order persists.
- Commit:
feat(sites): page tree + block editor
Task 2.4: Navigation and shared chrome¶
- Files:
~/creatortrack/components/apps/sites/SiteChrome.tsx,~/creatortrack/lib/sites/render.ts - What: Header nav derived from the page tree, footer, and per-site theme. Renders into the same static HTML shape links pages use.
- Verification: published site nav links resolve to the correct paths; no 404s.
- Commit:
feat(sites): navigation + shared header/footer
Task 2.5: Publish pipeline¶
- Files: new
~/creatortrack/app/api/sites/publish/route.ts,~/creatortrack/lib/links/cf.ts - What: Mirror
app/api/links/publish/route.ts: resolve assets (now R2, per Phase 0), render,upsertPageHtmlper page path,ensureRoute. Pixels come from the shared resolver, so every Sites page is tracked with zero configuration. - Verification: publish a two-page test site to a staging host; both pages return 200 and contain the pixel.
- Commit:
feat(sites): publish pipeline to Cloudflare KV
Task 2.6: Migrate gusthebass.com¶
- Files:
/home/justinwieb/forge/sites/gusthebass.com/landing/index.html(source of truth to port), Worker route config - What: Rebuild the landing page as a Sites page, publish it to the apex, cut the
gusthebass.comroute from the nginx tunnel to the Worker, and confirm the pixel fires on the homepage for the first time. Keep the nginx container up but unrouted for one week as rollback, then retire it. - Verification:
curl -s https://gusthebass.com/ | grep -c fbevents.jsreturns 1; visual diff against the old page;docker pson media-server still shows the container stopped-but-present. - Commit:
feat(sites): migrate gusthebass.com apex off nginx
Task 2.7: Register and document¶
- Files:
/home/justinwieb/forge/MEMORY.md, new/home/justinwieb/forge/memory/general/reference_creatortrack_sites_app.md, updatereference_links_module.md - What: Topic file for the Sites app, MEMORY.md index entry, and update the links module topic file with the R2 asset change and the pixel event surface. Note the retired nginx container in
system-map/fleet.md. - Verification:
bash scripts/forge_eval_run.sh - Commit:
docs(memory): register Sites app + links pixel/R2 changes