feat(blog-app): prerender the blog so crawlers see the content (BAPP-13) - #50
Open
nakomis wants to merge 1 commit into
Open
feat(blog-app): prerender the blog so crawlers see the content (BAPP-13)#50nakomis wants to merge 1 commit into
nakomis wants to merge 1 commit into
Conversation
blog.nakomis.com was a client-rendered SPA, so every URL returned the same ~2.3KB empty shell. Crawlers, link unfurlers and the AdSense reviewer — which is already running against these pages — all saw markup with no content in it. Two independent halves. Surfacing the markdown. deploy.sh has always synced content/blog/*.md to /posts/, and CloudFront has always served it, but nothing linked to it, so the cleanest representation of every post was undiscoverable. buildContent.ts now emits llms.txt from the same loop that writes the sitemap, so the two cannot drift. The sitemap stays HTML-only: sitemaps are for canonical pages, and listing both representations would muddle canonicalisation. Prerendering. A second Vite pass builds src/entry-server.tsx, and scripts/prerender.ts renders every route to its own HTML file with per-page title, description, og:* and canonical tags — plus a rel=alternate pointing at the markdown. Pages go from 2.3KB of nothing to 12-38KB of article. That required making the content load synchronous. getBlogPosts() was async over a compile-time constant and was awaited in a useEffect, which never runs under renderToString — prerendering it as-was would have baked in "Loading post...". It does no I/O, so the effect, the loading state and the error state all go; readers stop seeing a loading frame on content that was already in the bundle. ThemeToggle seeded useState from localStorage, which runs during render and so during SSR. It is caught (it was written for private mode) but the server would emit the 'system' icon whilst the client's first render emitted 'dark', which is a hydration mismatch for anyone who has picked a theme. It now starts at 'system' and resolves in an effect. Nothing flashes — the inline script in index.html still sets the real theme before first paint. Infrastructure. The URI rewrite has to be merged into the existing viewer- request function rather than added alongside it, because CloudFront permits only one function per event type per behaviour. /<slug>/ is rewritten rather than 301'd: a redirect drops the query string, which would silently eat utm_* and gclid on any ad click landing on the slashed form. The rel=canonical on every page is the right tool for the duplicate. The error responses mapped 404 and 403 to /index.html with status 200. That was necessary while the SPA had to boot and route client-side, but it is a soft 404 — every typo URL looked like a valid page carrying thin content. Now that every route is a real file, misses get a real 404 and a real error page. Verified against a local server that reproduces the CloudFront behaviour: correct status codes, no hydration warnings with a theme stored, theme persistence, client-side navigation, and the rewrite logic exercised over assets, markdown, trailing slashes and both hostnames.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
blog.nakomis.com was a client-rendered SPA. Every URL returned the same ~2,333-byte empty shell, and the content only appeared after React booted. Crawlers, link unfurlers and the AdSense reviewer — which is already running against these pages (
ca-pub-1712135199843745is live inindex.html) — all saw a page with nothing in it.Two independent halves, either of which stands on its own.
1. Surfacing the markdown
deploy.shhas always syncedcontent/blog/*.mdto/posts/, and CloudFront has always served it:Nothing linked to it, so the cleanest representation of every post was effectively undiscoverable.
buildContent.tsnow emitsllms.txtfrom the same loop that writes the sitemap, so the two cannot drift.The sitemap stays HTML-only — sitemaps are for canonical pages, and listing both representations would muddle canonicalisation. Each prerendered page also carries
<link rel="alternate" type="text/markdown">.2. Prerendering
A second Vite pass builds
src/entry-server.tsx;scripts/prerender.tsrenders every route to its own HTML file with per-page<title>, description,og:*, Twitter card andrel=canonical.//2026-07-30-the-1092-bytes…/2026-03-30-rag-search-static-blog25 routes plus a real 404 page.
Making the content load synchronous
getBlogPosts()wasasyncover a compile-time constant and wasawaited inside auseEffect. Effects don't run underrenderToString, so prerendering as-was would have bakedLoading post...into all 24 pages.It does no I/O, so the effect, the loading state and the error state all go. Readers stop seeing a loading frame on content that was already in the bundle.
ThemeToggle
useState<Choice>(readChoice)readslocalStorageduring render, and therefore during SSR. It's caught (it was written for private-browsing mode), but the server would emit thesystemicon whilst the client's first render emitteddark— a hydration mismatch for anyone who has picked a theme. It now starts atsystemand resolves in an effect.Nothing flashes: the inline script in
index.htmlstill applies the real theme before first paint. Only the button's own icon settles a moment later.3. Infrastructure
The rewrite is merged into the existing viewer-request function, not added alongside it — CloudFront permits only one function per event type per behaviour, and
legacyRedirectFunctionwas already there./<slug>/is rewritten rather than 301'd. A redirect drops the query string (CloudFront doesn't carry it intolocation), which would silently eatutm_*andgclidon any ad click landing on the slashed form. Therel=canonicalon every page is the correct tool for the duplicate.The soft 404 is fixed. The error responses mapped 404 and 403 to
/index.htmlwith status 200 — necessary while the SPA had to boot and route client-side, but it meant every typo URL looked to a crawler like a valid page carrying thin content. Now every route is a real file, so a miss is a genuine miss. (403 is listed as well as 404 because the bucket is private behind OAC with onlys3:GetObject— with nos3:ListBucket, S3 answers a missing key with AccessDenied.)There is no CI for infra in this repo —
cdk deployis manual, perinfra/README.md. The web deploy must go first./slugstill resolves via the old fallback; React reports a hydration mismatch and client-renders, so the page works, just less efficiently./slug→/slug.html, S3 misses, and/404.htmlisn't in the bucket yet.So: let
scheduled-publishbuild and deploy the site, confirm the.htmlfiles are live, thencdk deploy BlogStack --profile nakom.is-admin.Verification
Tested against a local server reproducing the CloudFront behaviour (rewrite, trailing slash, 404 mapping):
/200,/slug200,/slug/200 (same bytes),/typo404,/posts/*.md200text/markdown,llms.txtandsitemap.xml200<title>and onedescriptionper page; og/canonical/alternate correct; 91<p>in the sample postlocalStoragedocument.titleupdates still work.md/.ico/.txt/.xmlassets, extensionless paths, trailing slashes, deep paths and both hostnamescdk synthis clean and the synthesised function and error responses were checked directly.Not doing
AdSense placement changes; anything to
cv.nakomis.com/nakom.is(staysDisallow: /); the Substack mirror or announce pipeline; a framework migration.