TLDR: In Next.js App Router, generateStaticParams is 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:

  1. generateStaticParams returns slugs for published posts only
  2. Draft slugs aren't in that list
  3. 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.commy 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.