Date: 2026-05-24 – 2026-05-25
Status at end of day: All four plans complete. SEO, structured data, and the missing pages shipped; AdSense wired with lazyOnload; Lighthouse audited and its limits documented; a machine migration survived two silently-failing bugs; and the content migration was rebuilt from authoritative WordPress data across three rounds — 130 recipes patched, inline images and galleries included. 73 tests passing, 0 lint errors, 0 TypeScript errors. Production deploy is the only thing left.
Everything here is built with Claude Code, and I'll say the quiet part out loud: the productivity is genuinely addictive. Across these four days Claude wrote structured-data generators, traced a TagError through three wrong theories, rebuilt a migration pipeline, and audited my own CLAUDE.md against the codebase — work that would have taken me days alone, compressed into hours. But the reason it works is the part that doesn't show up in a demo. I'm not vibe coding: I don't fire a vague prompt, accept the first plausible-looking output, and ship because the page rendered. I'm vibe engineering — using the planning tools and the implementation tools deliberately. Plans written to disk before any code. Subagent-per-task with a spec review and a code-quality review on top. Verification passes that produce zero commits but catch real gaps. Audits that compare what the code says against what it does. The thing I keep proving to myself over these days is that being a senior engineer is what unlocks the full benefit of the model, not a reason to skip it: the failures below are never crashes, they're plausible-but-wrong output — a 1×1 pixel "image," a migration that silently discards post.content, a push() I was sure fired twice — and catching that class of failure is exactly the judgment you bring around the AI, not instead of it. Going YOLO would have shipped every one of these. Engineering the collaboration caught them.
TLDR
- Day 07 (Plan 4, partial): Recipe JSON-LD, Open Graph + Twitter metadata for all 11 pages, sitemap.xml, robots.txt, custom 404 + error pages, loading skeletons — on a clean worktree branch. Two-stage review caught a relative JSON-LD image URL (Google rejects it), a nullable
quantitythat would print "undefined salt", a 1×1-pixel OG image, and six pages hardcoding the site URL. - Day 08 (planning pivot): No code. The plan assumed the Instagram Basic Display API — Meta killed it in December 2024. Skipped Instagram entirely, skipped YouTube by choice to keep scope tight, and documented exactly which AdSense credentials were still needed.
- Day 09 (Plan 4 complete): Merged the SEO branch, wired AdSense via
next/scriptstrategy="lazyOnload", ran Lighthouse. First run scored Performance 41; fixed the favicon 404, anaria-hidden-focustrap, a WCAG label mismatch, and a sticky-footer CLS bug. Performance settled at 65–74 (AdSense is an inherent drag), Accessibility 96 ✅, SEO 100 ✅. A CLAUDE.md audit caught two real bugs that had been marked "fix in Plan 4" and never fixed. - Day 10 (new machine + content rebuild): An AdSense TagError fixed on the fourth attempt — three internally-consistent theories about React Strict Mode, all wrong, broken only by instrumentation. A Postgres "cannot connect" error that was a Plan 4 production hardening (removed db port) silently breaking local dev. Then a side-by-side against the live site exposed a migration that discarded
post.contentandpost.date— fixed across three rounds, ending with a full authoritative extraction (fresh WXR, zip-recipes SQL dump, full media library) and a nasty regex-alternation bug where a paragraph block ate the adjacent image block. - The throughline: every flop across these days was a silent one — plausible output with a piece quietly missing or wrong. None threw. All were caught by looking at the real thing (the rendered DOM, the database, the live site) instead of trusting the model's account of it.
Day 07 — Plan 4 (Partial): SEO, Structured Data, and Missing Pages
Status: Plan 4 tasks 6–11 complete on branch worktree-plan4-seo-production. Tasks 1–5 (Instagram, YouTube, AdSense) deferred pending API credentials. 73 tests still passing.
What we did today
Plan 4 is the production-readiness pass: integrations, SEO, Lighthouse. We split it into two halves — the credential-free tasks (6–11) and the third-party integrations (1–5, needing Instagram/YouTube/AdSense API keys). Today was the credential-free half.
Six tasks executed via subagent-driven development: fresh implementer subagent per task, spec compliance review, then code quality review. Two tasks needed fix loops.
Task 6: Recipe JSON-LD structured data
Added <script type="application/ld+json"> as the first child of <article> in the Single Recipe page with full Schema.org Recipe type: name, description, image (absolute URL), author (Person), datePublished, prepTime/cookTime/totalTime in ISO 8601 PT{n}M format, recipeYield, recipeCategory, recipeIngredient (array of strings), recipeInstructions (HowToStep array with position + text).
Two correctness issues caught by code quality review:
image.urlis a relative path in local dev (/api/media/file/...). Schema.org requires absolute URLs. Fixed with:image.url.startsWith('http') ? image.url : \${SITE_URL}${image.url}``.The
quantityfield on ingredients is notrequired: truein the Payload collection — it's nullable. The initial implementation would have produced"undefined salt to taste"for unquantified ingredients. Fixed withfilter(Boolean).join(' ').trim()after a nullable-typed cast.
Also extracted named types (IngredientItem, StepItem) to replace repeated inline casts in the map callbacks.
Task 7: Open Graph + Twitter Card metadata
Extended metadata on all 8 frontend pages. Single Recipe gets dynamic OG with the recipe's featured image (absolute URL, same guard as JSON-LD). All other pages get a shared default OG image.
Two issues caught by code quality review:
The generated
og-default.jpgwas 1×1 pixel (332 bytes). Social scrapers (Facebook, Twitter, Slack) require a minimum image size; 1200×630 is the standard recommendation. Regenerated using thesharppackage (already a project dependency) from an SVG source with the site's parchment background, burnt-orange title, and grey tagline. Final file: ~27KB.All 6 static page
metadataexports hardcodedhttps://herfoodblog.comdirectly. This would produce wrong canonical and OG URLs in staging or preview environments. Fixed by creatingsrc/lib/site.ts:
export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://herfoodblog.com'
All pages now import and use SITE_URL. The two dynamic pages that already used const siteUrl = process.env... were also updated to use the shared constant — removing the duplicate definitions.
Tasks 8–9: sitemap.ts and robots.ts
Straight implementations using Next.js 15's built-in MetadataRoute.Sitemap and MetadataRoute.Robots. Both use SITE_URL.
Sitemap includes 7 static routes with explicit priorities and change frequencies, plus one entry per published recipe from getAllRecipeSlugs(). Robots disallows /admin/ and /api/.
Both passed spec and quality review on first pass.
Task 10: Custom 404 and error pages
not-found.tsx — 404 page with "Looks like this recipe got lost in the oven" message (baking-appropriate) and a Link back home. Design tokens throughout: font-display for the "404", text-accent for the number, bg-background for the page.
error.tsx — global error boundary with 'use client' and a "Try again" button calling reset(). The error prop is typed as required by Next.js but aliased to _error to satisfy the no-unused-vars lint rule without suppressing the rule globally.
Both passed review on first pass.
Task 11: Loading skeletons
SkeletonCard component with animate-pulse, matching the recipe card aspect ratio and text line placeholder shapes. Six cards in a 1/2/3 column responsive grid — matches the real recipe feed layout.
src/app/(frontend)/recipes/loading.tsx re-exports from ../loading — DRY, no duplication.
Passed review on first pass.
AI flops
JSON-LD relative image URL. The initial implementation passed image.url directly to the JSON-LD image field. In local dev, Payload serves media as relative paths (/api/media/file/...). Schema.org and Google's Rich Results validator require absolute URLs. The implementer didn't check the runtime value — the spec said "use image.url" and it did. Code quality review caught it. Fix: wrap in an absolute-URL guard using SITE_URL.
Nullable ingredient quantity. The Payload collection has quantity as a non-required text field, meaning it can be null or undefined. The initial implementation cast it as string and used it directly in a template literal — producing "undefined salt to taste" in the structured data for any recipe with an unquantified ingredient. This is a silent data corruption: TypeScript wouldn't catch it because the cast suppressed the type check, and the JSON would look valid but fail Google's recipe parser. Code quality review caught it.
1×1 OG image. Technically the plan said to "Place a default OG image" — the implementer placed one. It just happened to be a 332-byte JPEG of a single pixel. Both spec review (which checks for file existence) and code quality review (which checks for fitness for purpose) were needed to catch this — the spec reviewer passed it, the quality reviewer flagged it. The fix used sharp, which was already in the project's dependencies.
AI wins
The two-stage review caught all three issues before any bad code landed on a commit. The JSON-LD relative URL and nullable quantity bugs would have silently shipped to production and only surfaced during Google Search Console review weeks later. The 1×1 OG image would have broken every social share preview immediately.
Tasks 8–11 (sitemap, robots, 404, error, loading) all passed both review stages on the first pass — the specs were tight enough and the implementations mechanical enough that there was nothing to catch.
Honest take
The review process paid for itself on tasks 6 and 7. The nullable quantity bug in particular was the kind of issue that's invisible to the author (the spec said "map ingredients to strings", the code maps ingredients to strings, TypeScript is happy), only visible to someone asking "what happens when a field that looks required isn't?" That's exactly what a code quality reviewer should do — probe the assumptions, not just check the surface.
The 1×1 OG image is the funniest failure of the day. The implementer was given a script to generate it using canvas, which wasn't installed. It fell back to the JPEG byte array — which happened to be 1×1. Spec review passed it. Quality review asked "will this actually work?" and the answer was no. The fix took 30 seconds with sharp. The lesson: verify fitness for purpose, not just presence.
Day 08 — Plan 4 Planning: Skipping Instagram and YouTube, AdSense Only
Status: No new code. Resolved credential questions for Plan 4 Tasks 1–4. Tasks 1 and 2 scrapped; only AdSense (Tasks 3–4) remains before Task 12.
What we did today
Short planning session. No code written, no commits to app code. The goal was to figure out what credentials were needed for Plan 4 Tasks 1–4 so they could be gathered and the tasks implemented in the next session.
Wrote documents/credential_requirements.md explaining how to get each credential — Instagram token, YouTube API key and channel ID, AdSense publisher ID, and three AdSense slot IDs. Walked through the setup process for each service.
Then tried to actually get the Instagram token. That's where the plan fell apart.
The Instagram API problem
The plan called for the Instagram Basic Display API — a simple personal API that generates a token tied to your own Instagram account, letting you read your own recent posts. It's been the standard approach for food bloggers and personal sites wanting to display their Instagram feed for years.
Meta shut it down in December 2024.
The Facebook Developer portal no longer lists Instagram Basic Display as an available product. The replacement is the Instagram Graph API, which requires:
- Converting your Instagram account to a Business or Creator account
- Creating and connecting a Facebook Page
- Going through a more complex token flow
For a personal food blog where the goal is "show 9 recent photos in the sidebar," that's a disproportionate amount of setup and ongoing maintenance. The decision was easy: skip it. The InstagramFeed component already returns null when the env var is absent — no changes needed, the sidebar just doesn't have a feed section.
YouTube: skipped by choice
The YouTube embed was straightforward to set up (Google Cloud Console, enable YouTube Data API v3, generate an API key). But given that Instagram was already being dropped, the decision was made to skip YouTube too and keep the scope tight. The component returns null without credentials. Can be added later with no code changes — just add the env vars.
What's still needed: AdSense only
Two credentials remain before Plan 4 can close out:
Task 3 — AdSense script:
NEXT_PUBLIC_ADSENSE_CLIENT_ID— the publisher ID from AdSense Account settings (ca-pub-...)
Task 4 — AdSense slot IDs:
NEXT_PUBLIC_ADSENSE_SLOT_RECIPE_MID— Display ad between intro and ingredientsNEXT_PUBLIC_ADSENSE_SLOT_RECIPE_BOTTOM— Display ad between steps and commentsNEXT_PUBLIC_ADSENSE_SLOT_MOBILE_FOOTER— Sticky footer ad on mobile
All three ad units should be Display ads type (not In-feed, In-article, or Multiplex).
Full setup guide in documents/credential_requirements.md.
Also fixed: stale memory from previous session
At the start of this session, cleaned up two stale entries left over from the previous session:
memory/execution_state.mdhad a line readingBranch: worktree-plan3-interactive-features — not yet mergedeven though the branch had been merged to main in the same session. Updated to reflect the merge.memory/MEMORY.mdindex still described Plan 3 as pending merge. Updated.
Small admin, but worth doing before starting anything new.
Honest take
This is what a planning session looks like when the plan was written six months before execution and one of the APIs it relied on was killed in the interim. The Instagram Basic Display API deprecation wasn't something the plan could have anticipated — it was announced and removed in a short window. The right response was to notice it quickly, document the decision, and move on rather than trying to force the Graph API replacement into a scope that doesn't need it.
The session produced one document and a few memory updates. Not a satisfying output compared to a task-execution day, but it's the right call to close out bad assumptions cleanly before touching code.
Day 09 — Plan 4 Complete: AdSense, SEO, Lighthouse
Status: Plan 4 fully complete. All 4 plans done. SEO branch merged, AdSense wired, Lighthouse audit run. Production deploy is the only thing left.
What we did today
Short, clean session. Three things: merge the SEO branch, wire up AdSense, update all the docs.
Merging the SEO branch
The worktree-plan4-seo-production branch had been sitting for a session waiting on AdSense credentials. Eight commits, all clean. Fast-forward merge into main, no conflicts. Files added:
src/app/sitemap.ts— Next.js built-in sitemap generationsrc/app/robots.ts— blocks/adminand/apifrom indexerssrc/app/not-found.tsx— custom 404 pagesrc/app/error.tsx— custom error boundarysrc/app/(frontend)/loading.tsx— skeleton for the home/recipe list pagespublic/og-default.jpg— fallback OG image (1200×630)- Plus JSON-LD structured data and OG/Twitter metadata on all 7 page routes
Worktree removed after merge.
Task 3: AdSense script
One change to src/app/layout.tsx — add the AdSense loader script:
<Script
src={`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${process.env.NEXT_PUBLIC_ADSENSE_CLIENT_ID}`}
strategy="lazyOnload"
crossOrigin="anonymous"
/>
strategy="lazyOnload" is the important part. It tells Next.js to defer the script until after the page is interactive, so it never blocks first paint or TTI. The AdSense docs want you to put this in <head> — that's the fast way to tank your Core Web Vitals score on every page. lazyOnload means ads may take an extra second to appear, which is the right trade-off for a food blog where recipe content and images need to load first.
The component also returns null if NEXT_PUBLIC_ADSENSE_CLIENT_ID isn't set, so local dev without the env var won't trigger any ad calls or console errors.
Task 4: Ad placement
Created two components and made one change to the recipe page.
AdSlot.tsx — the base component. Client component because it needs useEffect to push to window.adsbygoogle. Renders an <ins> tag with the right data attributes, wrapped in a div with overflow-hidden and aria-hidden="true".
StickyFooterAd.tsx — mobile-only, dismissable sticky footer. Two pieces of state: dismissed and isMobile. The isMobile check runs in a useEffect since window isn't available during SSR. Includes a close button so it's not permanently glued to the bottom of the screen. Only renders when the client ID env var is set.
The dismiss handler is wrapped in useCallback before the effect that references it — per the CLAUDE.md gotcha about "cannot access before declaration" linter errors when you declare async handlers after the effects that use them.
In-content placements in src/app/(frontend)/recipes/[slug]/page.tsx:
Hero image
↓
Title + meta
↓
CookMode button
↓
[AD: RECIPE_MID slot — rectangle] ← new
↓
IngredientChecklist
↓
Method steps
↓
Equipment
↓
[AD: RECIPE_BOTTOM slot — rectangle] ← new
↓
Newsletter
↓
SocialShareBar
↓
Comments
The monetisation constraint in CLAUDE.md is explicit: never inside ingredient checklist, method steps, between step numbers, or Cook Mode. Both placements respect that — one before the checklist, one after the steps but before the newsletter signup. No ad ever sits inside a cooking workflow element.
StickyFooterAd is added to src/app/(frontend)/layout.tsx so it appears on all frontend pages, not just recipe pages.
No flops (before Lighthouse)
Nothing broke. npx tsc --noEmit came back clean. npm run lint produced zero errors (the 20 warnings are all pre-existing any types in the migration scripts). All 73 tests pass.
The only thing worth noting: the CLAUDE.md useCallback gotcha for dismiss handlers was applied proactively in StickyFooterAd.tsx without hitting the linter error first. That's the memory system working as intended — a fix documented from a previous session preventing the same mistake in a new one.
Task 12: The Lighthouse audit
Then the Lighthouse audit happened.
First run, home page:
| Category | Score | Target |
|---|---|---|
| Performance | 41 | 90 |
| Accessibility | 91 | 95 |
| Best Practices | 73 | 90 |
| SEO | 100 ✅ | 100 |
SEO nailed it first try. Everything else was below target.
What was broken (fixable)
Favicon 404. No favicon.ico existed, so every page load 404'd for it. Created src/app/icon.svg — a brown rounded-rectangle with an "M" in the brand parchment colour. Next.js App Router picks it up automatically and adds the <link> tag.
aria-hidden-focus in AdSlot. The AdSlot component had aria-hidden="true" on its wrapper div. The idea was to mark ads as decorative content for screen readers. The problem: AdSense injects focusable elements (iframes, links) into that div. When a focusable element lives inside an aria-hidden container, keyboard users can tab to it but screen readers skip it — a real accessibility trap. Removed aria-hidden from the wrapper.
label-content-name-mismatch in Footer. The social links showed abbreviated text (Fa, Pi, In, Yo) but had aria-label="Facebook" etc. WCAG 2.5.3 says if an interactive element has visible text, the accessible name must contain it. "Facebook" contains "Fa" as a prefix but Lighthouse still flags it — and rightly so, because a speech recognition user saying "click Facebook" needs the link's accessible name to match the full word they're saying. Fixed by expanding to full text labels and removing the redundant aria-label.
CLS from StickyFooterAd. The sticky footer ad was causing a cumulative layout shift of 0.20 (home) and 0.34 (recipe). The component used useState(false) for an isMobile flag, then set it in a useEffect. This meant: SSR renders nothing, hydration runs the effect, isMobile becomes true, and the fixed footer element is inserted into the DOM post-hydration. Even though fixed elements don't affect document flow, Lighthouse counts a fixed element entering the viewport as a layout shift. Refactored StickyFooterAd to always render using CSS md:hidden instead. The element is in the DOM from the first render — no late insertion, no shift.
What's still broken (not fixable without removing ads)
After fixes, second run:
| Category | Score | Target |
|---|---|---|
| Performance | 65-74 | 90 |
| Accessibility | 96 ✅ | 95 |
| Best Practices | 77 | 90 |
| SEO | 100 ✅ | 100 |
Accessibility crossed target. SEO held. Performance and Best Practices are still short.
The performance drag is AdSense. The Google scripts add 460ms of script evaluation time and 127KB of JS that's flagged as unused. strategy="lazyOnload" defers the load, but once the script runs it runs slowly. LCP on the home page was 5.9s in the Lighthouse simulation (Moto G Power, slow 4G throttling). On a real device with a normal connection it's faster — but AdSense is an inherent cost.
Best Practices at 77 is also AdSense. The two failures are third-party cookies (DoubleClick — unavoidable with AdSense) and bf-cache blocked by Payload CMS's Cache-Control: no-store API responses.
The remaining accessibility failure (color-contrast) is a design token issue: #C0622A (the accent colour) on #FDFAF5 (parchment background) has a 3.98:1 contrast ratio — below the 4.5:1 WCAG AA requirement for small text. It's close, and at bold weight the requirement drops to 3:1 which it passes. But at regular-weight 14px it fails. Fixing this would require either darkening the brand accent colour or bolding all small accent-colour text. That's a design decision for another day.
The plan targets (Performance ≥ 90, Best Practices ≥ 90) were written before AdSense was confirmed. For an ad-supported food blog, 65-74 performance is typical — most monetised food blogs score in that range. The scores that matter for organic search ranking — LCP, CLS, FID — are all passing in the green on real-device testing.
Post-Lighthouse: verification pass catches a missed fix
After calling the Lighthouse session done, ran a full feature verification pass against the running app — Playwright at 390px and 1440px, hitting every feature marked complete across all four plans.
One bug found: the Header social links.
The Footer had been fixed during the Lighthouse session. The social links in Footer.tsx originally showed abbreviated text (Fa, Pi, In, Yo) but had aria-label="Facebook" etc. That violates WCAG 2.5.3: if an interactive element has visible text, the accessible name must contain that visible text. A speech recognition user saying "click Facebook" will fail if the accessible name says "Facebook" but the visible text says something different. Fixed in Footer — full text, no separate aria-label.
The Header had the same pattern. The top-of-page social bar in Header.tsx showed "FB", "PIN", "IG", "YT" with aria-label="Facebook" etc. Same violation, same component type, different file. Wasn't caught during the Lighthouse fix because the Lighthouse report flagged the Footer links specifically and that's where attention went.
The fix was the same: expand to full text, drop the redundant aria-label.
// before
<a href="..." aria-label="Facebook" ...>FB</a>
<a href="..." aria-label="Pinterest" ...>PIN</a>
<a href="..." aria-label="Instagram" ...>IG</a>
<a href="..." aria-label="YouTube" ...>YT</a>
// after
<a href="https://facebook.com/herfoodblog" ...>Facebook</a>
<a href="https://pinterest.com/herfoodblog" ...>Pinterest</a>
<a href="https://instagram.com/herfoodblog" ...>Instagram</a>
<a href="https://youtube.com/@herfoodblog" ...>YouTube</a>
This is the kind of bug that Lighthouse can miss. Lighthouse runs on one page at a time. It flagged the Footer links on the home page during the audit. It didn't flag the Header because the audit was focused and the Header's social bar is small. The Playwright verification pass opened the accessibility snapshot of the live DOM and made the mismatch obvious.
The lesson here is straightforward: when you fix a class of bug in one place, grep for the same pattern everywhere else before calling it done. "This type of fix" is a better search term than the specific file.
CLAUDE.md audit catches two more misses
After the verification pass, ran /claude-md-management:claude-md-improver against the project CLAUDE.md. Scored 81/100. Five issues found.
1. Category page <title> uses raw slug — never fixed. The CLAUDE.md gotcha said: "Known SEO gap; fix in Plan 4." Plan 4 is done. The fix never landed.
generateMetadata in src/app/(frontend)/recipes/page.tsx:
const title = category ? `${category} Recipes` : 'All Recipes'
category here is the raw URL param — the slug, not the display name. So the <title> for the cheesecakes page reads "cheesecake Recipes" — lowercase slug, no capitalisation, no pluralisation. The <h1> on the page is fine because it uses the category name from Payload. But the <title> tag — which is what Google shows in search results — shows the raw slug. Lighthouse's SEO score of 100 doesn't catch this because Lighthouse checks for the presence of a <title> tag, not whether its content makes sense.
2. Stale content in CLAUDE.md. Three other issues cleaned up:
- "Build order" table (Week 1 → Plan 1, etc.) — describes what already happened; removed
- Plan 4 task list — verbose breakdown no longer needed now that everything is done; replaced with a one-liner
- "Pre-plan audit gate" phrasing — still said "Don't start Plan N+1 with broken tooling" when all plans are complete; rephrased to "before starting any code work session"
documents/repo layout entry — listed only01_claude-requirements.md; updated to mention the credential checklist and home server setup guide also there
None of these were bugs in the product. But stale context in CLAUDE.md is how the next session starts confused — Claude reads it before doing anything, and wrong information has real cost.
Fixing the remaining issues
The CLAUDE.md audit surfaced two real bugs that should be fixed, not just documented.
Category page titles
The fix: add getCategoryBySlug() to src/lib/payload.ts, then use it in both generateMetadata and the page component.
// payload.ts
export async function getCategoryBySlug(slug: string) {
const payload = await getPayloadClient()
const result = await payload.find({
collection: 'categories',
where: { slug: { equals: slug } },
limit: 1,
depth: 0,
})
return result.docs[0] ?? null
}
In the page component the recipes query and category lookup are parallelised:
const [{ docs: recipes, totalPages }, categoryDoc] = await Promise.all([
getPublishedRecipes({ page, limit: 12, category }),
category ? getCategoryBySlug(category) : Promise.resolve(null),
])
Result confirmed in browser: <title> is now "Cheesecakes Recipes | My Love of Baking" and the <h1> reads "Cheesecakes Recipes". Both use the display name from Payload, not the URL slug.
Color contrast
The accent color #C0622A has a 4.03:1 contrast ratio against the parchment background #FDFAF5. WCAG AA requires 4.5:1 for normal text. One-line change to src/app/globals.css:
--color-accent: #B05826; /* was #C0622A — darkened for WCAG AA (4.75:1 on parchment) */
The new value is 8-10% darker per channel. In practice the change is barely perceptible — the color still reads as the same warm orange-brown. The contrast ratio goes from 4.03:1 to ~4.75:1, comfortable headroom above the 4.5:1 threshold.
Follow-up session: README overhaul
Short session, no code. Documentation only.
The README was accurate but thin on developer context — fine for someone who already knows the stack, sparse for a new contributor. Two things changed.
Technology Stack section. Added a full breakdown of every piece of the stack, grouped by role: core framework (Next.js 15, Payload CMS v3, PostgreSQL, TypeScript), styling (Tailwind CSS v4 — worth calling out that v4 has no tailwind.config.js, and the four self-hosted Google Fonts), infrastructure (Docker + Compose, Caddy, GitHub Actions, Cloudflare R2), third-party integrations (Kit, Nodemailer, AdSense, Instagram, YouTube API), and testing tooling (Vitest, ESLint). Each entry explains what it does in this project, not just what the library is. The rendering strategy (SSG vs ISR, which pages use which) went in too because it affects how a developer thinks about data freshness.
Dev setup gaps. Several things that would trip up a new contributor: NEXT_PUBLIC_SERVER_URL missing from the .env.local example; the Checks section only documenting npm test (added npm run lint and npx tsc --noEmit with a note about the 3 harmless importmap false positives); npm run devsafe undocumented; and an environment-variables table separating required-for-dev from optional, with plain-English notes on what degrades gracefully when each optional group is absent.
Plan status table. Fixed — still showed Plan 3 as "pending merge" and Plan 4 as "Not started". No AI flops; Claude drafted the Technology Stack tables correctly on the first pass.
Honest take
The Lighthouse run was a useful reality check. The plan targets of ≥90 for Performance and Best Practices were optimistic given AdSense. A food blogger's Lighthouse scores are going to be dragged by ad scripts — that's the trade-off for the revenue. The right response is to fix what can be fixed, document what can't, and not pretend the numbers are different than they are.
The CLS fix was the most interesting one. The StickyFooterAd was using a React state variable to detect mobile, which caused the element to pop into existence after hydration. The fix — use CSS md:hidden so the element is always in the DOM — is exactly the kind of insight that comes from running the app rather than just writing tests. You can't catch "element enters DOM post-hydration and causes layout shift" with a unit test. You have to actually run Lighthouse.
The end-of-session audit pattern paid off today. The CLAUDE.md improver caught two real bugs — a stale "fix in Plan 4" note that had never been fixed, and a known accessibility failure that had been deferred as "a design decision for another day." Running the audit before closing the session turned those into actual fixes rather than carry-forward debt. The next session opens with a clean slate — which matters, because Claude Code has no memory between sessions and reads CLAUDE.md and the memory files cold every time.
Day 10 — New Machine, Old Bug, Three Wrong Theories
Status: Machine migration guide in README. AdSense TagError fixed on the fourth attempt. Content fixes complete in three rounds — 130 recipes patched, gallery images included, authoritative zip-recipes data applied from SQL dump. All 73 tests passing.
New machine setup questions
Moving to a new Mac raised a couple of questions before touching any code.
First: reuse PAYLOAD_SECRET from the old machine's .env.local or generate a new one? Reuse it. It's a JWT signing key for the Payload admin — it doesn't protect content data, doesn't affect password hashes in the database (those are bcrypt), and there's no security reason to rotate it between personal dev machines. Copy the whole .env.local and save the trouble of reconstructing the R2, Kit, SMTP, Instagram, and YouTube vars from scratch.
Second: how to move the Postgres data. The README had first-time setup instructions but nothing about migrating an existing database. The answer is pg_dump through the Docker container:
# Old machine
docker compose up -d db
docker compose exec db pg_dump -U payload herfoodblog_db > mlob-backup.sql
zip -r mlob-media.zip media/
# New machine (after cloning and env files)
docker compose up -d db
docker compose exec -T db psql -U payload herfoodblog_db < mlob-backup.sql
unzip mlob-media.zip
That went into the README as a new "Moving to a new machine" section. The -T flag on the restore command is the non-obvious one — it disables pseudo-terminal allocation, required when you're piping stdin from a file rather than a real terminal.
The AdSense TagError
On the new machine, opening http://localhost:3000:
Uncaught TagError: adsbygoogle.push() error:
All 'ins' elements in the DOM with class=adsbygoogle already have ads in them.
This had been present since Plan 4. It was just never the most urgent thing.
Theory 1: React Strict Mode double-invoke (wrong)
The obvious explanation: React Strict Mode, enabled by default in Next.js dev, runs effects twice. AdSlot.tsx calls push() in a useEffect. Two effect runs, two push calls, AdSense throws on the second.
Fix: useRef(false) guard so the second effect run exits early:
const pushed = useRef(false)
useEffect(() => {
if (pushed.current) return
pushed.current = true
;(window.adsbygoogle = window.adsbygoogle ?? []).push({})
}, [])
React's docs say refs persist through Strict Mode's simulated unmount/remount. The ref should still be true when the second run fires. Early return. One push. User tested. Error still there.
Theory 2: element status check (wrong)
Maybe the ref wasn't persisting. Tried a different guard: check data-adsbygoogle-status on the <ins> element directly. AdSense sets this attribute after processing a slot. If it's already set, skip:
useEffect(() => {
const el = insRef.current
if (!el || el.getAttribute('data-adsbygoogle-status')) return
;(window.adsbygoogle = window.adsbygoogle ?? []).push({})
}, [])
This fails for a timing reason: AdSense processes push calls asynchronously. By the time Strict Mode's second effect run fires, the status attribute isn't set yet — AdSense hasn't had a chance to process the first push. Both checks pass, push is called twice anyway. User tested. Error still there.
Theory 3: setTimeout + cleanup (wrong)
setTimeout(0) defers to a macrotask. React Strict Mode's cleanup runs synchronously — before any macrotask fires. So cleanup cancels the first timer, the second effect queues a new one, only that second timer fires. One push:
useEffect(() => {
const timer = setTimeout(() => {
if (el.getAttribute('data-adsbygoogle-status')) return
;(window.adsbygoogle = window.adsbygoogle ?? []).push({})
}, 0)
return () => clearTimeout(timer)
}, [])
User tested. Error still there.
Three theories. Three fixes. All wrong. Time to look at what's actually happening rather than reason about it.
Adding instrumentation
Added console.log at every stage: effect mount, timer fire, push call, cleanup. Then used the Playwright MCP browser tool to navigate to http://localhost:3000 and read the console directly.
[AdSlot] effect mount, slot: 7607471390
[AdSlot] timer fired, status: null slot: 7607471390
[AdSlot] calling push()
[AdSlot] cleanup, slot: 7607471390
Push is called exactly once. No second effect mount. No second calling push(). And the TagError was still there. So the premise of all three theories — push() was being called twice — was wrong.
The actual root cause
The Playwright session also caught the 403 from Google's ad server. The timing in the console: the 403 arrived before the TagError. AdSense had already contacted Google's servers and attempted to load an ad before our push() call even ran.
The <ins> element was initialized before our push() queued. When AdSense's script loads, it auto-scans the DOM for all <ins class="adsbygoogle"> elements and initializes them directly — no push() required. Our push() then fired, found all elements already done, and AdSense threw the TagError. The root cause was never React Strict Mode.
The actual fix
useEffect(() => {
const el = insRef.current
if (!el || el.getAttribute('data-adsbygoogle-status')) return
// AdSense auto-inits <ins> elements when its script loads on the first page
// visit. Only call push() once AdSense is already running — handles the SPA
// navigation case where a new slot appears after the script has loaded.
if (!window.adsbygoogle || window.adsbygoogle.push === Array.prototype.push) return
try {
;(window.adsbygoogle = window.adsbygoogle ?? []).push({})
} catch { /* ignore */ }
}, [])
Before AdSense loads, window.adsbygoogle is the plain array we initialize; its push is Array.prototype.push. After AdSense loads and takes over the object, push is AdSense's own function. That comparison tells you whether AdSense is running. On initial page load AdSense hasn't loaded when effects fire → skip, and AdSense auto-scans later. On SPA navigation AdSense is already loaded → call push() to initialize the new slot. First attempt threw TypeError: Cannot read properties of undefined (reading 'push') — window.adsbygoogle was undefined before the layout's init line had run. Added the null guard.
The Postgres connection error
With the AdSense issue closed, the next console error was:
Error: cannot connect to Postgres
at getPayloadClient
at getPublishedRecipes
at HomePage
Four hypotheses before the actual cause. Password mismatch — checked both files, passwords matched. Missing database — docker compose exec db psql -U payload -l showed herfoodblog_db present with all tables; the repeating FATAL: database "payload" does not exist logs were just the healthcheck's pg_isready connecting to the default db. Malformed DATABASE_URI — the URI had the database name. Wrong config var — payload.config.ts correctly reads DATABASE_URI.
The actual cause: docker-compose.yml's db service had no ports entry at all.
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: herfoodblog_db
POSTGRES_USER: payload
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
No ports: - "5432:5432". The database container was running fine, but port 5432 was only accessible from other containers on the Docker network — not from the host. npm run dev runs the Next.js server on the host and connects to localhost:5432. Without the port mapping, that connection goes nowhere.
This was a Plan 4 regression. The docker-compose.yml was updated in Plan 4 for production deployment, and one of those changes removed the exposed Postgres port as a security improvement. Correct for production — the app container connects to db via the Docker internal network using hostname db. Silently wrong for local dev. The fix exposes the port bound to localhost only:
ports:
- "127.0.0.1:5432:5432"
Pre-production content validation
With the database restored and the dev server running cleanly, the next question was: does the local site actually match the live WordPress site? Used Playwright to open both side by side — local dev at /recipes/sri-lankan-style-chicken-red-curry and the live site at the same URL. The reaction: "I see a huge miss in the content." That was accurate.
The live WordPress page has intro/body text (3–4 paragraphs, per-ingredient tips) and interleaved Amazon affiliate "SHOP NOW" cards above the recipe card. The local dev page had: hero image, category badge, title, "00" next to Serves, ingredients, steps, newsletter, comments. No intro. "00" instead of blank time. No affiliate links, no notes, no date, no tags.
The migration script was the cause. In createRecipes():
const introText = post.excerpt.trim() || post.title
post.excerpt was empty, so intro fell back to the title — and recipe.intro wasn't even rendered in the page template. post.content (the full WP body, with intro paragraphs and Amazon blocks) was parsed into the struct and then discarded. post.date — same, never written, so publishedAt was NULL for every recipe. equipment was hard-coded to []. The notes field didn't exist in the schema. And the "00" was a React && gotcha: {recipe.prepTime && <span>...</span>} renders the number 0 as text when prepTime is 0.
After confirming the full list of misses, a fix plan was drafted rather than jumping into code: add a notes field, write a patch-recipes.ts that updates existing records, and rewrite the page template to render intro/date/notes/tags/author bio/related recipes.
Executing the content fix plan (round 1)
Five steps. Schema (notes textarea, regenerate types). Patch script (patch-recipes.ts) with three extractors — extractIntro (paragraph blocks → Lexical with bold), extractEquipment (Amazon URLs from wp:media-text blocks), scrapeNotes (the zip-recipes zrdn-element_notes element from the live HTML). Page template rewrite. Verification.
First dry run showed equipment: 0 item(s) for a recipe with four visible Amazon cards. The wp:media-text opening-comment regex was [^/]*? — it stopped at the first / in the JSON attributes embedded in the comment, and Amazon URLs have slashes. Changed to [\s\S]*?. Second dry run found 4 items, 6 intro paragraphs, the published date, and notes. Full run: 130 updated, 0 skipped, 0 failed (~300ms each for the polite live-site fetch, ~7 minutes total).
Playwright confirmed on the curry recipe: intro (6 paragraphs, bold tips via RichText), published date "September 23, 2020", no "00" (prep/cook are 0, hidden by the > 0 guard), notes, 4 Amazon affiliate links, 6 tag pills, author bio, and a "You might also like" grid of 3 same-category cards.
Second content audit: inline images and serving size (round 2)
A second comparison on challah bread revealed two more misses. Inline step images — the live site interleaves 8 stage photos between paragraphs; the round-1 script only extracted wp:paragraph blocks and silently skipped wp:image. Serving size string — the card shows a person count ("10") and a description ("1 slice"); we only stored the number.
The fix: a new patch-body-images.ts that processes post.content matching both block types in document order, downloads each image, uploads to Payload media, and inserts a Lexical upload node at the right position. The Lexical upload node shape required reading Payload's source directly: { type: 'upload', version: 2, fields: {}, format: '', relationTo: 'media', value: <numeric-id> }. Full run: 280 new images uploaded, 92 reused, 46 serving sizes populated. (The WXR was exported 2026-05-23 — challah has 8 step photos live but only 4 in the export; a structural cutoff.)
WordPress data extraction (round 3)
After the second audit the pattern was clear: scraping rendered HTML from a plugin's output is inherently fragile, and the WXR has a fixed export date. Chose Option C — full authoritative extraction: fresh WXR, a database dump of the single zip-recipes table (wpjc_amd_zlrecipe_recipes — custom wpjc_ prefix, older amd_zlrecipe naming from the plugin's "ZL Recipes" past), and the entire wp-content/uploads/ media library via SFTP. The scripts then read local files instead of hitting the live site. Guide written to documents/extraction-plan.md. (One clarification: the WXR contains both wp:post_id and the slug, so the scripts join the recipe table's post_id to Payload records with no separate mapping export — that section was renamed "no action needed.")
After the files arrived:
patch-zrdn.ts — reads the SQL dump (45 rows with valid post_id), joins via a WXR-built id→slug map. 37 rows had no usable data (predate the plugin install); only 8 recipes had something — 3 serving_size, 5 notes. Run last (after patch-body-images.ts, which always writes servingSize even null) to lock the authoritative SQL values on top.
patch-body-images.ts — local files + galleries. resolveLocalFile() strips the wp-content/uploads/ prefix and reads from resources/wp-uploads/, falling back to the network only if missing. expandGalleries() replaces each wp:gallery wrapper with its nested wp:image children as bare blocks before the main loop.
The regex alternation bug. A dry run on challah showed only 2 images instead of 5 after gallery expansion. The block regex shared a closing group between types:
/<!-- (wp:paragraph|wp:image)(?:[\s\S]*?)? -->([\s\S]*?)<!-- \/(wp:paragraph|wp:image) -->/g
The non-greedy [\s\S]*? extended a paragraph match past <!-- /wp:paragraph --> and closed it at the next <!-- /wp:image --> — the paragraph block consumed the adjacent image block. The fix: stop sharing a regex. Run a paragraph-only and an image-only regex separately, collect matches with m.index, sort by document position, iterate in order.
const paraRegex = /<!-- wp:paragraph(?:[\s\S]*?)? -->([\s\S]*?)<!-- \/wp:paragraph -->/g
const imageRegex = /<!-- wp:image(?:[\s\S]*?)? -->([\s\S]*?)<!-- \/wp:image -->/g
const blocks: Block[] = []
while ((m = paraRegex.exec(body)) !== null) blocks.push({ type: 'paragraph', index: m.index, inner: m[1] })
while ((m = imageRegex.exec(body)) !== null) blocks.push({ type: 'image', index: m.index, inner: m[1] })
blocks.sort((a, b) => a.index - b.index)
After the fix, challah dry run: 13 paragraphs, 5 images. Full run: 130 updated, 0 failed.
Honest take
Two bugs, a content audit, and a full rebuild — the session ended up more complete than expected, and every incident had the same shape: a reasonable assumption, never verified. Push is called twice. The port is open. The migration is complete. All three were wrong, and the fix in each case was to check what's actually there instead of what should be there.
On the AdSense TagError: three wrong theories in a row, each internally consistent — React Strict Mode double-invoke is a real problem, the useRef guard is its correct fix, the setTimeout trick is valid for that exact problem. The hypotheses were reasonable; they were wrong because the assumption underneath all three (push being called twice) was never tested. What broke the loop wasn't a smarter theory — it was instrumentation. I should have added logging after theory 1 failed, not after theory 3. Measure first, theorize second.
The Playwright MCP tool was what made all of this tractable. Without it, debugging would have been "user re-tests, reports result, repeat" — a slow loop with no visibility. With it, the console output was readable directly, including the ordering of the 403 and the TagError, and once we had that ordering the root cause was obvious in 30 seconds. The same discipline — look at the real thing — is what surfaced the migration misses (a side-by-side against the live site) and what kept the three content rounds honest (a --slug dry run on one known recipe before every full run). Systems that fail silently are the most dangerous, and the only reliable defense is verifying output against reality rather than against the model's confident account of it.
This is part of an ongoing series documenting the rebuild of a friend's food blog from WordPress to a custom Next.js stack, built with AI assistance.

Comments
Post a Comment