TLDR: In Next.js App Router,
generateStaticParamsis NOT a whitelist. Drafts you exclude from the list are still publicly accessible by default. One line fixes it:export const dynamicParams = false.
The Setup
I rebuilt my site from scratch on Next.js 16 — migrating 19 posts and 20 attachments off pika.page (a hosted micro-blog I'd been using) into a proper Obsidian markdown pipeline.
Real posts. Frontmatter. published: true/false status fields.
The assumption was simple: I'd filter drafts out of generateStaticParams, and they'd be invisible.
I was wrong.
The Assumption That Didn't Hold
Here's how I thought it worked:
generateStaticParamsreturns slugs for published posts only- Draft slugs aren't in that list
- Therefore, draft slugs 404
Clean. Logical. Completely incorrect.
What I missed: Next.js App Router ships with dynamicParams set to true by default.
That setting means: any slug not in your static list still gets rendered on demand.
So when the page's data-fetch loaded a post by slug, it fetched the post — draft or not — and rendered it. No gating. No 404. Just… here's your unpublished content, served warm.
Anyone who knew (or guessed) a draft slug could read it.
The Fix That Worked
One line, top of the post route:
export const dynamicParams = false;
That's it. Now the static list IS the allowlist. Any slug not returned by generateStaticParams gets a 404, full stop.
The cost is real and worth knowing: this makes the route fully static. New published posts won't appear until you redeploy. That's the tradeoff — and for a personal blog where I control the publish pipeline, it's completely fine.
Why This One Stings
I'd been careful about published/draft logic everywhere else — the frontmatter filter, the post list, the RSS feed. But I'd never questioned whether the static params list actually enforced a boundary on the route itself.
It doesn't. Not by default. You have to explicitly tell Next.js "only the slugs I listed are valid."
The word "static" in generateStaticParams implies containment. It doesn't mean that at all — it just means pre-rendered. The dynamic fallback is always on unless you turn it off.
If you're building any Next.js blog with draft content, export const dynamicParams = false belongs in your post route from day one — before you ever write a draft, not after.
P.S. This came up on the very first deploy after migrating the content. Caught it fast, but the lesson is: don't trust filtering logic at the data layer to substitute for route-level gating. The framework's default is "render anything you can." Make it earn each slug.
One change made: adrianwaldron.com → my site (author's own domain, per Rule 2). Everything else was already clean — pika.page and Obsidian are public platforms (kept), all numbers are technical counts (kept), no money figures or company/client names present.