Static site generation has a branding problem. Say "static site" and people picture a blog with twelve posts and a contact form. So how far can you actually push it before you need a backend?
Further than most people assume. This is a walkthrough of a production site that has no database, no API layer, no user accounts and no server-side state, and still ships 232 prerendered pages with per-user results, shareable links and dynamic social cards.
The site is a Spanish political test with nine ideological axes, seventeen parties, fifty-four questions. It is in Spanish, but nothing here depends on reading it. Treat it as the reference implementation.
The architecture in one sentence
Three data files are the source of truth, everything else is derived at build time, and everything user-specific happens in the browser.
That is the whole trick. The rest is consequences.
1. Derive pages, don't author them
The site has 232 URLs. Almost none of them were written by hand.
There are three data modules: the axes, the parties, and the questions. From those, generateStaticParams produces every content route:
// app/ejes/[id]/page.tsx
export function generateStaticParams() {
return AXES.map((a) => ({ id: a.id }))
}
The interesting one is the comparison pages. Seventeen parties means 17 × 16 / 2 = 136 unique pairs, and each pair gets its own page, its own metadata and its own canonical URL:
export function allPairs() {
const out = []
for (let i = 0; i < PARTIES.length; i++)
for (let j = i + 1; j < PARTIES.length; j++)
out.push({ a: PARTIES[i].id, b: PARTIES[j].id })
return out
}
export function generateStaticParams() {
return allPairs().map((p) => ({ pair: pairSlug(p.a, p.b) }))
}
136 pages from twelve lines. And because the page body is computed from the same vectors, recalibrating one party silently rewrites the sixteen pages that involve it. No CMS, no migration, no content drift. The numbers on the page cannot disagree with the model, because they are the model.
One detail worth stealing: normalise the slug order.
// pp-vs-psoe and psoe-vs-pp must not become two URLs with identical content
export function pairSlug(a: string, b: string) {
const [x, y] = [a, b].sort(
(m, n) => PARTIES.findIndex(p => p.id === m) - PARTIES.findIndex(p => p.id === n)
)
return `${x}-vs-${y}`
}
Both spellings resolve, but the canonical tag always points at the ordered one. Combinatorial routes are a duplicate-content generator if you skip this.
2. Do the computation in the browser
The test scores nine axes, ranks seventeen parties by weighted affinity, computes a conviction score from mirror questions and an affective-distance score, and renders five visualisations. None of that touches a server.
It is a few hundred lines of pure functions over an array of numbers. There is no reason to pay a network round trip for it, and no reason to store it.
The consequence people underestimate: if the computation is client-side, the data never has to exist anywhere. No database means no schema, no migrations, no connection pooling, no GDPR data-processing chapter, no breach surface, no backup story, no per-request cost. All of that complexity was never load-bearing. It was there to move numbers to a place where the same arithmetic could happen.
3. Put the state in the URL
Results still need to be shareable. The move is to encode the answers into the URL instead of storing them.
Fifty-four answers on a five-point Likert scale need three bits each. Forty-two ideological answers pack into sixteen bytes, which is about twenty-two characters of base64url:
export function encodeAnswers(answers: number[]): string {
const bits = answers.map((a) => a + 2) // -2..+2 -> 0..4
const buf = new Uint8Array(Math.ceil(bits.length * 3 / 8))
bits.forEach((v, i) => {
const bit = i * 3
const byte = bit >> 3
const shift = bit & 7
buf[byte] |= (v << shift) & 0xff
if (shift > 5) buf[byte + 1] |= v >> (8 - shift)
})
return toBase64Url(buf)
}
Now /resultado?a=BQkTAg... is a complete, self-contained result. Share it, bookmark it, send it to a friend. Nothing was written anywhere. Working state lives in sessionStorage and dies with the tab.
One warning from experience: be strict when decoding. If the payload is shorter than expected because it came from an older version with fewer questions, fail loudly instead of padding. A tolerant decoder silently produces a wrong result, which is far worse than an error page.
4. Where the edge runtime earns its keep
There is exactly one dynamic route on the whole site, and it exists for a good reason: the social card for a shared result has to render that person's result.
next/og on the edge runtime does this in a few milliseconds:
export const runtime = 'edge'
export function GET(req: Request) {
const answers = decode(new URL(req.url).searchParams.get('a'))
const scores = computeAxes(answers)
return new ImageResponse(<Card scores={scores} />, { width: 1200, height: 630 })
}
That is the correct shape for "static by default": everything prerendered, one narrow dynamic endpoint where personalisation genuinely requires it.
5. The deployment traps
This is the part I actually want to hand over, because each of these cost real hours and none of them announce themselves.
Social crawlers get blocked on preview URLs. Vercel's bot protection sits in front of preview and deployment URLs. Result: X, Telegram, WhatsApp and Bluesky crawlers never reached the OG endpoint and every share rendered blank, while everything looked perfect in local testing and in the card validators you were pasting the production URL into. The fix was to pre-generate static PNGs at build time for every page that doesn't need personalisation, and serve them from the CDN with no gate in front:
// package.json
"scripts": { "og:generate": "tsx scripts/generate-og.tsx" }
Fourteen PNGs, three seconds, and the dynamic endpoint stays for the one route that needs it.
metadataBase must be your canonical domain. The tempting line is:
// wrong
metadataBase: new URL(`https://${process.env.VERCEL_URL}`)
VERCEL_URL is the hashed per-deployment hostname. Your OG tags then point at yoursite-a1b2c3-team.vercel.app, which is exactly the host that bot protection blocks. Hardcode the real domain and allow an env override for local work.
Satori does not eat your fonts. The image renderer behind next/og supports TTF and OTF only. WOFF2 fails, and variable fonts break opentype.js at parseFvarTable. If you use next/font with variable families (you probably do), you cannot reuse them in OG generation. Ship a static TTF alongside, or use the built-in font and design around it. Also watch your glyphs: arrows like → and ↔ render as tofu in many static TTFs. Use ASCII.
Don't run next build while the dev server is up. They share .next/ and stomp on each other's chunks, and the error you get is Cannot find module './xyz.js', which sends you hunting for an import problem that does not exist.
6. Structured data: only two types do anything
Static generation makes it trivial to emit JSON-LD everywhere, which tempts you to emit all of it. Most of it does nothing visible.
As of mid-2026, the two that produce a visible change in Google's result are BreadcrumbList (replaces the URL line with your site path) and Article. FAQPage was deprecated and retired. ItemList, DefinedTerm and the domain-specific types do help an engine understand the entity, but they will not render anything.
Two things worth getting right:
// The current page does not link to itself: omit `item` on the last crumb.
itemListElement: items.map((c, i) => ({
'@type': 'ListItem',
position: i + 1,
name: c.name,
...(i === items.length - 1 ? {} : { item: `${SITE}${c.path}` }),
}))
And declare your Organization once in the root layout with a stable @id, then reference it by @id from every Article instead of repeating the object. One entity, many references.
7. Third-party requests: zero
next/font self-hosts Google Fonts at build time, so the page makes no request to fonts.googleapis.com. No CDN scripts, no tag manager, no analytics pixel. Combined with client-side computation, the network tab on a page load shows your own domain and nothing else.
That is a privacy claim any reader can verify in ten seconds with devtools, which is worth more than a paragraph in a policy page.
8. Where this stops working
Being honest about the boundary is the point of the exercise:
- Content that changes between builds. Prices, stock, live scores. You need ISR or a real fetch.
- Anything multi-user. Shared state between two people is a database, full stop.
- Auth. Client-side computation means the client can compute anything. Fine when the data is the user's own answers. Not fine when it's someone else's.
- Very large combinatorial spaces. 136 pages is nothing. 136,000 makes build time your problem, and that is where ISR or on-demand generation starts to pay.
The heuristic I'd give: if the server would only be reformatting data the client already has, you don't need the server. Test scoring, pricing calculators, configurators, quizzes, converters, most dashboards over static datasets. Very often the backend exists because the framework tutorial had one.
The short version
- Author data, generate pages. 136 comparison pages from twelve lines of loop.
- Normalise combinatorial slugs or you have built a duplicate-content machine.
- Compute in the browser and the data never needs to exist.
- Put shareable state in the URL, and decode strictly.
- Keep one dynamic endpoint for the thing that genuinely varies per user.
- Budget an afternoon for OG images. They will not work the first time.
The working example is at testpolitica.com, and the piece of it that best shows the derivation trick is the nine axes index: every one of those pages, and the 136 party comparisons behind them, is a function of the same three files.
Top comments (0)