Guides
Content from Sanity
Read Sanity content in async components and prerender it, preview drafts on server-rendered routes behind a secret, and rebuild the static pages when editors publish.
Sanity keeps the content and its editing studio; Lumiose reads it with
@sanity/client, which runs in server code. Published content is read at build time, so the
pages are static files; drafts are read per request on a separate, server-rendered preview route.
| File | Role |
|---|---|
src/server/sanity.server.ts |
The Sanity clients, the queries and the preview cookie. Server-only. |
src/components/Story.stsx |
A story, rendered the same way for published content and drafts. |
src/routes/stories/page.stsx |
The list of stories, prerendered. |
src/routes/stories/[slug]/page.stsx |
One page per published story, prerendered with staticPaths(). |
src/routes/api/preview/route.ts |
Turns preview on (a signed cookie) when the secret matches. |
src/routes/preview/stories/[slug]/page.stsx |
The draft of a story, rendered per request for previewers. |
src/routes/api/sanity/webhook/route.ts |
Optional: Sanity's publish webhook, verified, then the host's build hook. |
Status: written against Sanity's documentation and the
@sanity/client8 and@sanity/webhook4 packages (their types check every example on this page); not run against a Sanity project.
#Setup
pnpm add @sanity/client
pnpm add @sanity/webhook # only for the webhook endpoint| Variable | Use |
|---|---|
SANITY_PROJECT_ID |
The project id, from the Sanity dashboard. |
SANITY_DATASET |
Usually production. |
SANITY_READ_TOKEN |
A token with read access to drafts, for previews only. Never in browser code. |
SANITY_PREVIEW_SECRET |
A random string the studio's preview link carries (openssl rand -base64 32). |
LUMIOSE_SECRET |
Signs the preview cookie (Server). |
The pages here are static (the project default) except the preview route and the endpoints,
which say export const mode = "server". A site with no server at all can skip previews and the
webhook endpoint, and point Sanity's webhook straight at the host's build hook
(below).
#The clients and queries
One client reads published content through Sanity's CDN. The preview client reads drafts with a
token, without the CDN. Both live in a *.server.ts module, so the token never reaches browser code
(LUM4003), and are created on first use, when env() has the host's
variables.
// src/server/sanity.server.ts
import { createClient, type SanityClient } from "@sanity/client";
import { env, signedCookie } from "lumiose";
/** Set by /api/preview; the preview route renders drafts only while it is valid. */
export const previewCookie = signedCookie("sanity-preview", { maxAge: 60 * 60 });
export interface Story {
title: string;
slug: string;
summary: string;
publishedAt: string | null;
}
let published: SanityClient | undefined;
function required(name: string): string {
const value = env(name);
if (value === undefined) throw new Error(`${name} is not set.`);
return value;
}
/** Published content, through the CDN. With `preview`, drafts, with the read token. */
function sanity(preview = false): SanityClient {
published ??= createClient({
projectId: required("SANITY_PROJECT_ID"),
dataset: required("SANITY_DATASET"),
apiVersion: "2026-10-01",
useCdn: true,
perspective: "published",
});
return preview
? published.withConfig({
token: required("SANITY_READ_TOKEN"),
useCdn: false,
perspective: "drafts",
})
: published;
}
const STORY = `{ title, "slug": slug.current, summary, publishedAt }`;
export function listStories(): Promise<Story[]> {
return sanity().fetch(
`*[_type == "story" && defined(slug.current)] | order(publishedAt desc) ${STORY}`,
);
}
export function getStory(slug: string, preview = false): Promise<Story | null> {
return sanity(preview).fetch(`*[_type == "story" && slug.current == $slug][0] ${STORY}`, {
slug,
});
}Rich text (Portable Text) renders to HTML with a framework-agnostic library such as
@portabletext/to-html, written with innerHTML; this guide keeps a plain summary field.
#Reading content
Pages await their content in async components. query() caches each result for the request (or
the build), so a layout and a page that need the same story share one fetch
(Async components).
// src/components/Story.stsx
import type { Story as StoryData } from "../server/sanity.server.ts";
export function Story({ story }: { story: StoryData }) {
return (
<article class="story">
<h1>{story.title}</h1>
{story.publishedAt && <time datetime={story.publishedAt}>{story.publishedAt.slice(0, 10)}</time>}
<p>{story.summary}</p>
</article>
);
}
<style>
.story time { color: gray; }
</style>// src/routes/stories/page.stsx: built once per deploy
import { Title, href, query } from "lumiose";
import { listStories } from "../../server/sanity.server.ts";
export default async function Stories() {
const stories = await query(["stories"], listStories);
return (
<main>
<Title>Stories</Title>
<ul>
{stories.map((story) => (
<li key={story.slug}>
<a href={href("/stories/[slug]", { slug: story.slug })}>{story.title}</a>
</li>
))}
</ul>
</main>
);
}// src/routes/stories/[slug]/page.stsx: one file per published story
import { Meta, Title, notFound, query, type PageProps } from "lumiose";
import { Story } from "../../../components/Story.stsx";
import { getStory, listStories } from "../../../server/sanity.server.ts";
export async function staticPaths() {
return (await listStories()).map((story) => ({ params: { slug: story.slug } }));
}
export default async function StoryPage({ params }: PageProps<"/stories/[slug]">) {
const story = (await query(["story", params.slug], () => getStory(params.slug))) ?? notFound();
return (
<main>
<Title>{story.title}</Title>
<Meta name="description" content={story.summary} />
<Story story={story} />
</main>
);
}#Previewing drafts
Drafts change on every keystroke and need a token, so they cannot be prerendered. A preview route renders them per request, for people who opened the studio's preview link. The link carries the secret; the endpoint checks it, sets a signed cookie and redirects to the preview page, which reads the cookie on every request.
// src/routes/api/preview/route.ts: GET /api/preview?secret=…&slug=…
import { env, href, type Endpoint } from "lumiose";
import { previewCookie } from "../../../server/sanity.server.ts";
export const mode = "server";
export const GET: Endpoint<"/api/preview"> = async ({ url, cookies }) => {
const secret = env("SANITY_PREVIEW_SECRET");
const slug = url.searchParams.get("slug");
if (secret === undefined || url.searchParams.get("secret") !== secret || slug === null) {
return new Response("Invalid preview link", { status: 401 });
}
await previewCookie.set(cookies, "1");
return Response.redirect(new URL(href("/preview/stories/[slug]", { slug }), url), 307);
};// src/routes/preview/stories/[slug]/page.stsx: the draft, rendered per request
import { Meta, Title, cookies, notFound, query, response, type PageProps } from "lumiose";
import { Story } from "../../../../components/Story.stsx";
import { getStory, previewCookie } from "../../../../server/sanity.server.ts";
export const mode = "server";
export default async function StoryPreview({ params }: PageProps<"/preview/stories/[slug]">) {
if ((await previewCookie.get(cookies())) !== "1") notFound();
response().setHeader("cache-control", "private, no-store");
const story =
(await query(["story", params.slug, "drafts"], () => getStory(params.slug, true))) ?? notFound();
return (
<main>
<Title>{`Preview: ${story.title}`}</Title>
<Meta name="robots" content="noindex" />
<p role="status">Preview of the latest draft.</p>
<Story story={story} />
</main>
);
}In the studio, point the preview link (the Presentation tool's preview URL, or a document action)
at https://example.com/api/preview?secret=<SANITY_PREVIEW_SECRET>&slug=<slug>. To refresh the
preview while an editor types, reload the page, or poll from an island that calls
invalidate() on an interval.
#Rebuilding when content changes
Published pages are files, so new content needs a new build. Most hosts give a build hook: a URL that starts a deploy when something posts to it (Netlify build hooks, Vercel deploy hooks, Cloudflare Pages deploy hooks, a CI workflow dispatch).
Without a server: in Sanity's dashboard, create a webhook that posts to the build hook on
create, update and delete, with the filter _type == "story". No code runs in the app.
With a server: point the webhook at an endpoint instead. It verifies Sanity's signature (set a secret on the webhook), so nobody else can trigger builds, keeps the build hook's URL off Sanity, and can skip changes that do not affect the site:
// src/routes/api/sanity/webhook/route.ts
import { SIGNATURE_HEADER_NAME, isValidSignature } from "@sanity/webhook";
import { env, type Endpoint } from "lumiose";
export const mode = "server";
export const POST: Endpoint<"/api/sanity/webhook"> = async ({ request, platform }) => {
const secret = env("SANITY_WEBHOOK_SECRET");
const hook = env("BUILD_HOOK_URL");
const signature = request.headers.get(SIGNATURE_HEADER_NAME);
const body = await request.text(); // the exact bytes Sanity signed
if (secret === undefined || hook === undefined || signature === null) {
return new Response(null, { status: 400 });
}
if (!(await isValidSignature(body, signature, secret))) {
return new Response("Invalid signature", { status: 401 });
}
const { _type } = JSON.parse(body) as { _type?: string };
if (_type === "story") platform.waitUntil(fetch(hook, { method: "POST" }));
return new Response(null, { status: 202 });
};The webhook posts JSON, which Lumiose's cross-site check leaves to the endpoint: the signature is
the check. Set the webhook's projection to {_type} (or more) so the body carries what the
endpoint reads.
#Common mistakes
- Reading drafts on a static page. A prerendered page cannot read cookies
(LUM5001) and would bake one moment's draft into a public file. Keep
drafts on the
serverpreview route. - The read token in browser code. It reads every draft; keep it in
*.server.ts, and fetch in server components or endpoints. - Expecting published edits to appear without a build. The pages are files: trigger a build
from the webhook, or make the route
serverand read through the CDN on every request. - Verifying the webhook against re-serialized JSON. Pass
await request.text()toisValidSignature, then parse. - A preview page without
noindexandno-store. Search engines and shared caches would keep a draft. The preview page sets both.