Draft Previews for Static Site Generators Without a Server
As part of Draft State Management, this guide solves preview for sites that have no server at runtime: Astro in static mode, Eleventy, Hugo or Jekyll, deployed as files to a CDN or object storage. Such sites render everything at build time, so there is no request-time code that could fetch a draft, and the usual “draft mode” recipes from server-rendered frameworks do not apply.
Three architectures work, with different trade-offs in speed, fidelity and cost: a continuously rebuilt preview deployment, a small on-demand preview function that renders the same templates with draft data, and a client-side preview shell that fetches drafts in the browser. Most teams pick the first or second, and the choice mostly depends on how long a full build takes.
The Problem
A documentation team runs Eleventy with Sanity. Builds take four minutes for 1,800 pages. Their first preview setup was a second Netlify site built with the Sanity drafts perspective, triggered by a webhook on every draft save. Editors typed a sentence, waited four minutes, found a typo, and waited four more. Worse, the preview builds consumed most of the monthly build minutes, because Sanity’s auto-save fired a webhook every few seconds while someone was typing, and the build queue backed up for hours on busy days.
The team needed two things the setup could not give them: edits visible in about a second, and build costs that did not grow with typing speed.
How Each Approach Works
A preview deployment is a second copy of the site built with the preview token and the drafts perspective, deployed to a protected hostname. It needs no code changes beyond reading the state from an environment variable, and it shows exactly what the live site will look like. Its weakness is latency: every edit waits for a full build. Debouncing build hooks, for example one build at most every two minutes, and incremental builds reduce cost but not latency.
An on-demand preview function is a serverless function that imports the site’s templates, fetches one page’s draft data and renders that single page on request. Astro with a server adapter can render chosen routes on demand while the rest stays static. Eleventy offers a serverless plugin in some versions, and teams can call its programmatic API from a function. For Hugo, whose templates cannot run in a JavaScript function, teams render a single page by running the Hugo binary with a draft data file inside the function. Editors see an edit as soon as the CMS has saved it.
A client-side preview shell is a static page that fetches draft content in the browser and renders it with client-side components. It needs no infrastructure, but the rendering differs from the real templates, and the preview token must be exposed to the browser, which is only acceptable with a read-only token restricted to an authenticated editor session.
Implementation
The example below is an on-demand preview function for an Astro site that otherwise builds statically. It uses Astro’s per-route prerender control: every page is prerendered at build time except the preview route, which renders on demand through the server adapter.
// src/pages/preview/[...slug].astro
---
export const prerender = false; // the only on-demand route in an otherwise static site
import { createClient } from "@sanity/client";
import { verifyPreviewSignature } from "../../lib/preview-signature";
import DocPage from "../../layouts/DocPage.astro";
const url = new URL(Astro.request.url);
const slug = Astro.params.slug ?? "";
const sig = url.searchParams.get("sig") ?? "";
const exp = Number(url.searchParams.get("exp") ?? "0");
if (!(await verifyPreviewSignature(slug, exp, sig)) || exp < Date.now() / 1000) {
return new Response("Preview link invalid or expired", { status: 401 });
}
const client = createClient({
projectId: import.meta.env.SANITY_PROJECT,
dataset: "production",
apiVersion: "2025-02-19",
useCdn: false,
token: import.meta.env.SANITY_VIEWER_TOKEN, // server-only read token
perspective: "previewDrafts",
});
const doc = await client.fetch(`*[_type == "doc" && slug.current == $slug][0]`, { slug });
if (!doc) return new Response("No draft or published document for this slug", { status: 404 });
Astro.response.headers.set("Cache-Control", "private, no-store");
Astro.response.headers.set("X-Robots-Tag", "noindex, nofollow");
---
<DocPage doc={doc} preview={true} />
The route reuses the exact layout the static pages use, so fidelity is identical, and the preview prop lets the layout show a banner. The signature check uses an HMAC over the slug and expiry with a server secret. Sanity’s preview URL configuration generates links with those parameters, so editors never see or share the secret itself.
For Eleventy, the equivalent is a function that calls Eleventy’s programmatic API with a single input file and a data override, or a small renderer that loads the same Nunjucks or Liquid templates with the draft data. The structure is the same: verify, fetch one document, render with the real templates, return uncached HTML.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| Preview route | on demand, all others prerendered | Static performance for readers, live preview for editors. |
| Draft source | Sanity previewDrafts, Contentful Preview API, Storyblok version=draft |
The CMS resolves drafts over published content. |
| Token | server-only read token | Never bundled into client code. |
| Link signing | HMAC of slug and expiry | Shareable, expiring links without a shared secret in URLs. |
| Response headers | private, no-store, X-Robots-Tag: noindex |
Never cached, never indexed. |
| Build hooks | publish events only | Draft saves never trigger production builds. |
Gotchas & Edge Cases
- Auto-save webhooks. Draft auto-saves can fire webhooks every few seconds. Never connect them to production builds, and debounce them if they feed a preview deployment.
- Listing pages in preview. An on-demand function that renders one page shows the draft on that page but not on listing pages built statically. Render listing routes on demand in preview as well, or accept that listings show published content only.
- Asset URLs. Draft documents can reference assets that are not yet published. Most CMS asset CDNs serve them anyway, but some require authentication for unpublished assets; the function must use signed asset URLs in that case.
- Template code that assumes build time. Templates that read the filesystem or rely on global build data, such as a collection of all pages, need those dependencies available in the function. Keep preview-rendered layouts free of whole-site data, or load a cached copy of it.
- Preview deployments indexed by search engines. Protect preview hostnames with authentication or at least
noindexheaders and a disallowingrobots.txt.
Verifying the Result
Open a document in the CMS, change its title, and click the preview button: the preview route should show the new title within a second or two, with the preview banner, and the response headers should include no-store and noindex. Request the same preview URL after its expiry, or with a modified slug, and expect a 401. Finally, confirm that a draft save triggers no production build in your hosting dashboard.
Rollout Checklist
- Choose the approach from build duration: on demand if builds take longer than a minute.
- Reuse the production layouts in the preview renderer; never maintain a separate preview template.
- Sign preview links with an expiry and verify them server-side.
- Send
no-storeandnoindexon every preview response. - Connect only publish events to production build hooks.
- Add a preview banner with an exit link and the draft’s last saved time.
Frequently Asked Questions
Can a purely static host run the preview function?
Not by itself; the function needs a runtime. Most static hosts, including Netlify, Vercel and Cloudflare Pages, offer functions alongside static files, so the preview route can live in the same project.
Is a client-side preview shell ever the right choice?
For internal tools and prototypes, where fidelity and token exposure matter less, it is quick to build. For editorial previews that decide whether content ships, the rendering differences undermine trust in the preview.
How do visual editing tools fit in?
Tools that overlay click-to-edit controls on the preview, such as Sanity’s Presentation tool or Storyblok’s visual editor, need a live preview URL that renders drafts on each request. The on-demand function provides exactly that, which makes it the natural foundation for live editing integration patterns.
What if our templates are written in Go, as with Hugo?
Hugo templates cannot run inside a JavaScript function, but a function can run the Hugo binary. Package Hugo with the function, write the draft document to a temporary content file, render the single page with a minimal config that points at the site’s layouts, and return the generated HTML. It adds a few hundred milliseconds compared with a JavaScript renderer, which editors do not notice.
Does the preview function slow down the static site?
No. The rest of the site stays prerendered and served from the CDN. Only preview requests, made by a handful of editors, reach the function.