Skip to content

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 the links-edge Worker as LINKS_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 with content-type from stored metadata and cache-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 deploy reconciles 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-run reports 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 resolveAssetRefs to upload each asset:<id> blob to R2 (idempotent, keyed by content hash) and rewrite the ref to an absolute https://<host>/i/<hash> URL rather than a data: URI. This preserves the original design goal (the published page still has zero dependency on Console) while removing the megabytes. Keep the existing MAX_INLINED_BYTES guard 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, then curl -s https://gusthebass.com/links | wc -c returns under 150000, and curl -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 explicit width/height to eliminate layout shift. Original bytes stay in links.assets untouched so re-encoding is always reversible and re-runnable.
  • Verification: Playwright loads https://gusthebass.com/links at 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

  • Files: ~/creatortrack/components/apps/link-in-bio/LinkBioPreview.tsx
  • What: Add data-kind, data-label, data-link-id, and where applicable data-product-id and data-price-cents to 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 carry productId and priceCents (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 (new pixelEventsScript, wire in at the script assembly block around :833-844)
  • What: One always-emitted delegated listener on a[href^="/r/"], reusing the exact selector LINK_EFFECT_SCRIPT already uses (effects.ts:204). Event mapping: any outbound click fires Meta trackCustom LinkClick and GA4 select_content; a storefront/product click fires Meta ViewContent with content_ids, value, currency and GA4 select_item; a Shopify checkout link fires Meta InitiateCheckout and GA4 begin_checkout. Every call is wrapped so a missing fbq/gtag or any thrown error is a silent no-op that can never block navigation. Fires TikTok ttq.track too when a TikTok pixel is configured.
  • Verification: Playwright loads the page, clicks a link, asserts a request to facebook.com/tr with ev=LinkClick and one to google-analytics.com/g/collect with en=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 Lead and GA4 generate_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:299 currently reads only funnel.settings.pixels, so funnels are silently untracked. Resolve against links.app_settings per-field, exactly as pages already do at render.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-page funnel, then curl -s <funnel url> | grep -c fbevents.js returns 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; edit render.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 --noEmit clean; 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 a gusthebass.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-run with 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 sites AppDef (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) and sites.pages (site_id, path, parent_id, order, title, blocks, theme override, published_at). Idempotent DDL. RLS workspace_isolation plus the publish GUC exception clause, matching the links.assets policy verbatim.
  • Verification: python3 scripts/forge_workspace_migrate.py --dry-run then 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, upsertPageHtml per 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.com route 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.js returns 1; visual diff against the old page; docker ps on 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, update reference_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