I run a guide site for Sand Blocks: Drop Puzzle, a mobile physics puzzle where you pull colored sand into pits. Every level gets a page with a video, a board image, and a short written guide. Right now that is 575 level pages, plus a searchable index, range hubs, a blog, and the usual about, contact, and privacy pages. The sitemap has 607 URLs.
The site has no accounts and no user-generated content. New levels arrive in batches every week or two. A rebuild is a perfectly good content update mechanism, so I set output: "export" and kept the database out of the request path.
const nextConfig = {
output: "export",
images: {
unoptimized: true,
remotePatterns: [{ protocol: "https", hostname: "img.mobilecasualgames.com" }],
},
trailingSlash: false,
};
With static export I gave up API routes, ISR, middleware, and server-side image optimization. The level data comes from Neon, but only at build time: a script dumps it to src/data/levels.json, and the pages import that file. The same JSON feeds the client-side filter and the sitemap generator, so the site cannot disagree with itself.
That part worked. The two problems below did not show up in a normal browser test.
The og:url that pointed at the homepage
The first version of app/layout.tsx had this:
openGraph: {
url: SITE_ORIGIN,
siteName: SITE_NAME,
title: "...",
description: "...",
}
I assumed a level page that set its own openGraph.title and openGraph.description would replace the layout's openGraph object. Next.js merges metadata field by field. The page kept its own title, description, and image, and inherited url: "https://sand-blocks.org" from the layout. Every level page told social crawlers it was the homepage.
I found it with curl and grep, not with a browser. The canonical link was right. The og:url was wrong. Nothing looked broken in dev tools, and the page rendered fine, so it could have sat there for months.
The fix was to remove url from the layout entirely and set it only on pages that need it:
// app/levels/[id]/page.tsx
openGraph: {
url: absoluteUrl(`/levels/${level.id}`),
// title, description, image, ...
}
metadataBase handles relative URLs for canonical and images, but it does not fill in og:url. If you use a shared layout, check a deep page's rendered head before you trust it.
The default level list that was not in the HTML
The /levels page has a search box and pagination. My first version put the whole browser in a client component and read useSearchParams() to get ?q= and ?page=. During static export, Next.js skipped prerendering that subtree. The exported HTML had the page shell and nothing else: no level links, no range links. A crawler that does not run JavaScript saw an empty page.
This one is easy to miss because the site works in a browser. React hydrates, the client component renders, and the list appears. You have to curl the exported HTML or disable JavaScript to see the hole.
The fix was to split the page into two layers. The server component renders the default view, including the range navigation and the first 40 level cards. The client component only takes over when the URL has filter state.
// app/levels/page.tsx (server component)
<div id="levels-default-view">
<LevelsControls ... />
<LevelsGrid items={allLevels.slice(0, PAGE_SIZE)} />
<LevelsPagination ... />
</div>
<Suspense fallback={null}>
<LevelsBrowser />
</Suspense>
"use client";
export default function LevelsBrowser() {
const searchParams = useSearchParams();
const q = searchParams.get("q") ?? "";
const page = Math.max(1, Number(searchParams.get("page") ?? "1"));
const active = Boolean(q) || page > 1;
useEffect(() => {
const el = document.getElementById("levels-default-view");
if (el) el.hidden = active;
}, [active]);
if (!active) return null;
// Filter the bundled levels.json and render the result.
}
With no query string, the client component returns null and the exported HTML stays visible. With ?q=level 275 or ?page=4, it hides the default view and renders the filtered list from the same JSON. The default view is not wrapped in the client boundary, so it survives the export.
I also moved the range navigation out of the client component. It used to render only after hydration, which meant the links between the 40-level range pages were missing from the HTML. Now those links are in the server-rendered part, and each range page gets a real link from the hub.
Sitemap generation with a merge step
The sitemap script rebuilds the URLs it owns: static pages, level pages, range hubs, author pages, blog posts. It parses the existing sitemap first and keeps anything it does not recognize. That sounds fussy until you have hand-added a landing page and watched a generator delete it on the next run.
After a deploy I run a small IndexNow script that reads the sitemap and pings Bing, Yandex, Seznam, and Naver. Google finds pages on its own schedule. For a site that grows by adding level pages, the nudge is worth the two minutes.
What the tradeoff looks like
Static export is not free. There are no API routes, no incremental regeneration, no middleware, and no server-side image resizing. Level thumbnails are pre-sized WebP files on Cloudflare R2. The search is a client-side filter over a JSON file. The 404 is a static page. The production deploy is a directory of HTML, CSS, JS, and images served by a Cloudflare Worker with an assets binding.
For this site, that is a good trade. The content changes at human speed, and the pages are read far more than they are written. There is no per-user state. The build takes longer than a server-rendered deploy would, and I am fine with that.
If you are building something similar, the two checks I would run first are: curl a deep page and read the head, then look at the exported HTML with JavaScript disabled. Both bugs above passed a normal browser test.
The site is at https://sand-blocks.org/.
Top comments (0)