Preview & Draft Content SEO

Every composable stack ships a preview pipeline — a branch deployment, a draft mode, an editor’s live preview pane — and each one is a second copy of the site that nobody assigned an indexing policy. This section treats preview and draft content as an isolation problem rather than a directive problem: contain the environment, authenticate access, and verify the containment from the outside.

Prerequisites

Before hardening previews, confirm you have:

  • A distinct preview hostname or deployment domain — per-branch URLs on a platform domain, or a dedicated subdomain you control.
  • A secret management mechanism for the preview token, available to both the CMS and the front end without being committed to the repository.
  • Access control at the edge — platform-level password protection, an identity proxy, or middleware that can reject requests before rendering.
  • A publish-state field on every content type so the front end can distinguish published from draft without inferring it from timestamps.
  • A working sitemap generator you can filter, since drafts must never appear in it — see XML sitemap generation for headless.

The Three Leak Paths

Unpublished content escapes through exactly three routes, and the countermeasure differs for each. Treating all three as “add a noindex” is why the problem recurs.

How unpublished content reaches the index, and what stops each path Three parallel paths from unpublished content to the search index — a preview hostname, draft mode on the production host, and discovery surfaces such as sitemaps and feeds — each annotated with the control that closes it. Unpublished content preview hostname branch deployment draft mode on the production host discovery surfaces sitemap, feed, listing 401 at the edge before any render signed cookie only expiring, not shareable filter at the source query published only Each path needs its own control — a noindex closes none of them properly

Step-by-Step Implementation Workflow

Step 1 — Separate the preview environment by hostname

A preview served from a path on the production host inherits production’s robots rules, cookies, and canonical logic, and every mistake becomes a production mistake. Give it its own origin.

// middleware.js — hostname decides the environment, never a path prefix
const PREVIEW_HOSTS = new Set(['preview.example.com']);

export function middleware(request) {
  const host = request.headers.get('host') ?? '';
  const res = NextResponse.next();
  if (PREVIEW_HOSTS.has(host) || host.endsWith('.vercel.app')) {
    res.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive');
  }
  return res;
}

SEO impact: Every response from a preview origin carries suppression, including JSON, images, and feeds that no template would have covered.

Validation: curl -sI https://preview.example.com/ | grep -i x-robots-tag returns the directive; the same command against production returns nothing.

Step 2 — Put authentication in front of the preview host

A noindex stops indexing; it does not stop reading. Preview content is unreleased material, so the correct control is an authentication challenge.

// middleware.js — reject unauthenticated preview requests before rendering
export function middleware(request) {
  const host = request.headers.get('host') ?? '';
  if (!isPreviewHost(host)) return NextResponse.next();

  const auth = request.headers.get('authorization');
  const expected = 'Basic ' + btoa(`preview:${process.env.PREVIEW_PASSWORD}`);
  if (auth !== expected) {
    return new Response('Authentication required', {
      status: 401,
      headers: {
        'WWW-Authenticate': 'Basic realm="preview"',
        'X-Robots-Tag': 'noindex, nofollow',
      },
    });
  }
  return NextResponse.next();
}

SEO impact: A crawler that discovers a preview URL from an external link receives a 401 and has nothing to index — the failure mode becomes harmless rather than merely unlikely.

Validation: curl -s -o /dev/null -w '%{http_code}\n' https://preview.example.com/blog/my-post returns 401.

Step 3 — Make draft mode a signed, expiring grant

Draft mode on the production host is genuinely useful — it lets an editor see unpublished content in the real environment. It becomes dangerous when entering it is as easy as adding a query parameter.

// app/api/draft/route.js
import { draftMode } from 'next/headers';
import { createHmac, timingSafeEqual } from 'node:crypto';

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const slug = searchParams.get('slug') ?? '';
  const exp = Number(searchParams.get('exp') ?? 0);
  const sig = searchParams.get('sig') ?? '';

  if (Date.now() > exp) return new Response('Link expired', { status: 410 });

  const expected = createHmac('sha256', process.env.PREVIEW_SECRET)
    .update(`${slug}:${exp}`)
    .digest('hex');
  const ok =
    sig.length === expected.length &&
    timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok) return new Response('Invalid signature', { status: 401 });

  (await draftMode()).enable();
  return Response.redirect(new URL(`/blog/${slug}`, request.url), 307);
}

SEO impact: Draft rendering requires a cookie that only a signed, time-limited link can set. A crawler following a leaked link after expiry gets a 410, and a crawler following it before expiry still has no cookie on the subsequent fetch, so it sees published content.

Validation: Request the draft URL with --cookie '' and confirm the response contains the published body, not the draft body.

Step 4 — Force draft responses to be uncacheable and unindexable

Draft mode renders on the production host, so a shared cache is one misconfiguration away from serving a draft to the public.

// app/blog/[slug]/page.jsx
import { draftMode } from 'next/headers';

export async function generateMetadata({ params }) {
  const { isEnabled } = await draftMode();
  const post = await cms.posts.get(params.slug, { draft: isEnabled });
  return {
    title: post.title,
    robots: isEnabled ? { index: false, follow: false } : { index: true, follow: true },
  };
}
// middleware.js — never let a draft response enter a shared cache
if (request.cookies.has('__prerender_bypass')) {
  res.headers.set('Cache-Control', 'private, no-store, max-age=0');
  res.headers.set('X-Robots-Tag', 'noindex, nofollow');
}

SEO impact: Even if a draft response escapes, it is marked unindexable and is barred from any shared cache that might serve it to an anonymous visitor.

Validation: curl -sI --cookie '__prerender_bypass=1' https://example.com/blog/my-post | grep -iE 'cache-control|x-robots-tag'.

Step 5 — Filter drafts out of every discovery surface

The most common leak is not the preview host at all: it is a draft entry appearing in a sitemap, a listing page, or an RSS feed because the query forgot a filter.

// lib/cms.js — one published-only query used by every discovery surface
export const publishedFilter = {
  status: 'published',
  publishedAt: { lte: new Date().toISOString() },
};

export async function listIndexable(type) {
  return cms.query(type, { where: publishedFilter, orderBy: 'publishedAt:desc' });
}
// app/sitemap.js — the sitemap uses the same helper, never a raw query
import { listIndexable } from '@/lib/cms';

export default async function sitemap() {
  const posts = await listIndexable('posts');
  return posts.map((p) => ({
    url: `https://example.com/blog/${p.slug}`,
    lastModified: p.updatedAt,
  }));
}

SEO impact: A single shared filter means adding a new discovery surface cannot silently reintroduce drafts, which is exactly how the leak reappears six months after it was fixed.

Validation: Create a draft entry, regenerate the sitemap, and confirm the slug is absent. Then check the listing page and the feed for the same slug.

Scheduled Publishing and the Cache Boundary

Scheduled content adds a time dimension: an entry is legitimately unpublished now and legitimately public in four hours, and the cache in front of the site does not know that. The failure is symmetrical — publish early and you leak, publish late and the sitemap advertises a URL that returns a 404.

Scheduled publishing and the cache boundary A timeline from draft through scheduled to published, marking the two operations — cache purge and sitemap regeneration — that must occur at the publish moment and the failure that results if either runs early or late. publish moment draft created scheduled crawled 401 or published fallback absent from sitemap 200 with full content present in sitemap purge cache + regenerate sitemap early purge leaks the draft late purge serves a 404

The resolution is to make the publish event, not a clock, drive the invalidation: the CMS webhook that flips the status also purges the edge cache and regenerates the sitemap, so all three change together. The webhook mechanics are covered in automating sitemap regeneration on CMS publish.

HTTP Headers & CDN Directives Reference

Header Value on preview / draft Rationale
X-Robots-Tag noindex, nofollow, noarchive Covers every response type, including assets and JSON no template renders
WWW-Authenticate Basic realm="preview" Makes an unauthenticated fetch a challenge rather than a readable page
Cache-Control private, no-store, max-age=0 Prevents a shared cache from ever holding an unpublished response
Vary Cookie on draft-capable routes Stops a cache from serving a draft-cookie response to an anonymous request
Set-Cookie HttpOnly; Secure; SameSite=Lax; Max-Age=3600 Bounds the draft grant in time and keeps it out of client scripts
Referrer-Policy same-origin on preview hosts Stops preview URLs leaking into third-party analytics and error trackers

Validation Protocol

Preview isolation is verified from the outside, because every check that runs inside your network is testing a different request than a crawler makes.

Verify from outside the network An internal request succeeding via an IP allowlist while an external anonymous request reveals whether the preview is actually protected. from the office network allowlisted — proves nothing from outside, anonymous crawler user agent expect 401 anything else is an exposure

Confirm the preview host refuses anonymous requests

curl -s -o /dev/null -w '%{http_code}\n' https://preview.example.com/blog/my-post
# Expect 401

Confirm draft mode cannot be entered without a signature

curl -s -o /dev/null -w '%{http_code}\n' 'https://example.com/api/draft?slug=my-post'
# Expect 401 — no exp, no sig

Confirm a draft slug is absent from every discovery surface

slug=unpublished-post
for u in /sitemap.xml /blog/ /feed.xml; do
  printf '%s: ' "$u"
  curl -s "https://example.com$u" | grep -c "$slug"
done
# Expect 0 for all three

Confirm an anonymous fetch of a draft URL returns published content

curl -s --cookie '' https://example.com/blog/my-post | grep -c 'DRAFT WATERMARK'
# Expect 0

Troubleshooting

Symptom Root cause Fix
Preview URLs appearing in search results Preview host readable and linked externally Add authentication at the edge; a noindex alone cannot prevent discovery
Published page outranked by a preview copy Duplicate content on two hosts, crawler picked the wrong canonical Authenticate the preview host, then request removal of the indexed preview URLs
Draft content served to anonymous visitors Draft response entered a shared cache Set Cache-Control: private, no-store and Vary: Cookie on draft-capable routes
Scheduled post 404s after going live Sitemap regenerated before the cache purged Drive both from the publish webhook so they change atomically
Draft slugs in the RSS feed only Feed builds from a raw query that skips the published filter Route every discovery surface through one shared query helper
Preview deployment indexed despite noindex Directive added client-side after hydration Emit it as a response header at the edge, as in Step 1

Preview leaks are a supply-chain problem

The reason preview environments keep appearing in search results long after teams believe the problem is solved is that discovery happens outside the boundary anyone is watching. A preview URL enters the world through a ticket, a chat message, a monitoring alert, or a screenshot tool, and every one of those paths is invisible from the site being protected.

That is why the controls in this section are weighted towards refusing requests rather than towards directives. A directive assumes the crawler reached your server and behaved well; an authentication challenge assumes nothing. On a surface whose discovery path you do not control, the second assumption is the only safe one.

It also explains why preview hardening tends to decay. Each new hosting integration, each new deployment target, each new review tool introduces a fresh URL shape, and the protection that covered the previous ones may not cover this one. A scheduled external check against every known preview hostname is a small cost that catches this class of drift, and it is the only control on this page that keeps working without anyone remembering it.

Draft mode deserves the same scrutiny as production auth

Draft mode is frequently built as a developer convenience and inherits none of the review that production authentication receives. That is a mismatch, because it grants access to unreleased material on the production hostname — the same risk profile as an admin surface, implemented with a query parameter.

Treating it as an access-control feature rather than a preview feature brings the right questions with it: how is the grant issued, how long does it last, what is its scope, and how is it revoked. All four have straightforward answers, and asking them at design time costs far less than discovering at incident time that a two-year-old shared secret still works.

Pages in This Section

Frequently Asked Questions

Is noindex enough to protect a preview deployment? No. A noindex keeps the preview out of results but leaves the content publicly readable, and it fails the moment a preview URL is shared, scraped, or linked from a ticket. Preview environments should answer unauthenticated requests with a challenge; the directive is a second line of defence, not the mechanism.

Why do preview URLs end up in the index at all? Almost always through external discovery rather than crawling of your production site — a link pasted into a public issue, a chat integration that unfurls URLs, or a monitoring tool that exposes them. Because the links originate outside your control, blocking crawl paths on your own site does not help; the environment itself has to refuse anonymous requests.

Can draft content damage rankings for the published version? Yes, when the draft is reachable at a different URL with near-identical content. Google then has two candidates for the same content and may select the draft, suppressing the published page and creating a canonical dispute that outlives the draft itself.

Should preview hosts appear in Search Console? It is worth verifying them, precisely so you can see whether anything leaked. A verified preview property lets you check coverage, spot indexed preview URLs early, and use the removals tool if one appears. Just never submit a sitemap for it.


Part of: Headless Architecture & Rendering Strategy Fundamentals

Related