Last updated on

Previewing unpublished drafts on a static site


I write posts for this site in Payload, and until recently there was a gap in the workflow: I couldn't see a draft the way a reader would. The admin editor shows you the raw fields and a rich-text box. It doesn't show you the post rendered in the actual page layout, with the real typography, the code blocks highlighted, the diagrams drawn. To check that, I had to publish, and publishing a post just to look at it, then unpublishing if something was off, is a bad way to work. The window where a half-finished post is live is small, but it's real, and it shows up in the RSS feed the moment it exists.

So I built draft preview. An authenticated editor can now open any unpublished draft rendered on the real site, at its real URL, before it's published. A public visitor hitting that same URL still gets a 404. The post stays out of the blog list, the RSS feed, and the sitemap until I actually publish it.

Here's how it fits together, and the one part that turned out to be trickier than I expected.

The setup: drafts on, but the public site is published-only

Posts and Pages have Payload's drafts/versions enabled. Every single-document fetch on the site defaults to published-only, so an unpublished draft returns nothing:

const post = await getPostBySlug(slug)
// where: slug matches AND _status is published

That default is load-bearing. If a draft could ever come back from one of these queries, it would leak: onto the page, into the RSS feed, into the static params Next.js prebuilds at build time. So the fetch helpers take a single opt-in flag, and nothing turns it on except the preview path:

export async function getPostBySlug(slug: string, { draft = false }: DraftOption = {}) {
  return payload.find({
    collection: 'posts',
    where: draft ? { slug: { equals: slug } } : { and: [{ slug: { equals: slug } }, PUBLISHED] },
    draft,
  })
}

When draft is true, the published-only filter drops and Payload returns the latest draft version. Only the single-post and single-page fetches accept the flag. The list, archive, and RSS queries don't. They're published-only with no way to ask otherwise, so a draft can't surface in a listing even by mistake.

Next.js Draft Mode, gated behind the admin session

Next.js ships a feature called Draft Mode for exactly this. When it's on, a special cookie rides along with the request, your pages can read draftMode().isEnabled, and cached/static pages get bypassed for that request. The pages branch on it:

const { isEnabled: draft } = await draftMode()
const post = await getPostBySlug(slug, { draft })

The catch is that Next.js gives you the toggle but not the authorization. draftMode().enable() will happily turn draft mode on for anyone who calls the route that calls it. If I wired that up naively, any anonymous visitor could flip the switch and read every unpublished post on the site. That's the whole ballgame. A preview feature that skips the auth check is just a public draft feed with extra steps.

So the route enables draft mode only after it confirms a logged-in Payload admin:

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const path = searchParams.get('path')

  if (!isSafePreviewPath(path)) {
    return new Response('Invalid preview path', { status: 400 })
  }

  const payload = await getPayload({ config })
  const { user } = await payload.auth({ headers: await nextHeaders() })
  if (!user) {
    return new Response('Unauthorized', { status: 401 })
  }

  const draft = await draftMode()
  draft.enable()
  redirect(path)
}

payload.auth reads the admin session cookie off the incoming request and hands back the user or null. No user, no draft mode: the request gets a 401 and the cookie is never set. The cookie Next.js sets is httpOnly and signed with the build secret, so it can't be forged after the fact either. The admin edit view's "Preview" button just links here with the post's path, so previewing is one click.

The part I got wrong the first time: the redirect

That route takes a path from the query string and redirects to it. A redirect target that comes from user-controlled input is an open redirect waiting to happen. I want to send the editor to /blog/my-post on my own site. I do not want someone handing an admin a preview link that quietly bounces them to evil.com.

The obvious guard is: require the path to start with a single / and reject anything starting with // or /\`, since `//evil.com is a protocol-relative URL that navigates off-site. I wrote that. It's not enough.

Browsers strip ASCII Tab, newline, and carriage-return characters out of a URL anywhere before they parse it. So a path like /<Tab>/evil.com, a slash followed by a literal Tab and then the host, passes a "starts with a single slash" check, because at that point it genuinely does. But the browser strips the Tab and re-forms the string into //evil.com, and now you're off-site. The query parameter arrives already percent-decoded, so a %09 in the link is a real Tab by the time the guard sees it.

The fix is two steps. First, reject any character a browser would strip or rewrite, meaning control characters, other whitespace, and backslashes:

function hasUnsafePathChar(path: string): boolean {
  for (const ch of path) {
    const code = ch.codePointAt(0) ?? 0
    if (code < 0x20 || code === 0x7f) return true
    if (ch === '\\' || /\s/.test(ch)) return true
  }
  return false
}

Then resolve the path against a fixed base and require the origin to come back unchanged, instead of trusting a prefix match on the raw string:

export function isSafePreviewPath(path: string | null | undefined): path is string {
  if (typeof path !== 'string' || path.length === 0) return false
  if (path[0] !== '/') return false
  if (hasUnsafePathChar(path)) return false
  try {
    return new URL(path, RESOLVE_BASE).origin === RESOLVE_BASE
  } catch {
    return false
  }
}

The base is https://preview.invalid. The .invalid suffix is a reserved TLD that can never resolve to a real host, so it can't collide with an origin an attacker controls. Only a genuinely site-relative path resolves back to that origin. //evil.com parses fine and gets rejected because its origin changed.

The try/catch earned its place through a second mistake. I first reasoned that once a string passed the leading-slash and character checks, new URL(path, base) could never throw, so I removed the catch as dead code. A review bot caught the regression on the next commit: a path like //, ///, or //?x passes both checks but has an empty authority, which is an invalid host for a special scheme, and the URL parser throws a TypeError. Without the catch, those inputs turned a clean 400 into a 500. Restored it, added the missing test cases, moved on. The lesson I keep relearning: "this can't throw" is a claim to verify with a test, not an invariant to assert in my head.

Keeping the site static

The thing I most wanted to avoid was making the whole site pay for a feature only I use. Reading draftMode() in a page could, in principle, opt every request into dynamic rendering, and then the homepage and every blog post stop being statically served.

It doesn't, and this is the detail that makes the whole approach viable. Reading draftMode().isEnabled doesn't register a dynamic dependency in Next.js. Only a request that actually carries the draft-mode cookie bypasses the cache. A normal reader never has that cookie, so they get the same static homepage and prebuilt blog pages as before. I confirmed this against the production build output: / stays Static, /blog/[slug] stays SSG, and only the two /next/* routes are dynamic. generateStaticParams stays published-only too, so no draft is ever prebuilt.

The exit hatch

Draft mode is a cookie, which means it's sticky: turn it on and you keep browsing drafts until something turns it off. That's an easy way to fool yourself into thinking a change is live when you're actually still looking at a draft. So there's a banner across the top of every page while draft mode is on, with a one-click way out:

Draft preview — this content is not published.  [Exit preview]

One small thing worth noting there: that "Exit preview" link is a plain <a>, not the site's usual Link component. It points at a route handler that clears the draft cookie, and clearing a cookie needs a real full-page GET so the Set-Cookie in the response applies to the top-level document. Client-side routing would fetch the handler as data and the cookie change wouldn't stick. So: a bare anchor, on purpose.

What it comes to

The whole feature is a handful of small pieces, each doing one thing: a fetch flag that defaults to safe, a route that checks auth before flipping a switch, a redirect guard that assumes the browser is adversarial, and a banner that won't let me forget I'm in preview. None of it is much code. The part that took the longest was the redirect guard, and the reason it took the longest is that I twice trusted my own reasoning over a test. The tests are cheap. My reasoning, it turns out, is not always right.

Comments

No comments yet. Be the first to comment.

Leave a comment