Architecture Note: Choosing a Sanity Read Path and Trust Boundaries for Web Apps
Sanity exposes content through GROQ over an edge-cached CDN endpoint and a direct API. This architecture note covers the smallest viable read path, token trust boundaries, webhook revalidation, and failure modes for a web app using Content Lake.
07 Jul 2025, 17:13 UTC

The problem: two read paths, one trust boundary
When you connect a web application to Sanity, the first architectural decision is not the schema—it is the read path. Sanity exposes content through GROQ over two distinct endpoints: an edge-cached CDN endpoint that is eventually consistent, and a direct project API endpoint that is strongly consistent. Choosing the wrong path, or shipping the wrong token to the browser, turns a content backend into a security or reliability problem. The smallest viable design uses one public read path through the CDN and one server-only preview path through the direct API, with webhook-driven revalidation to keep pages fresh.
Requirements for the read path
Before selecting endpoints, write down what the application must actually do:
- Public, anonymous reads must be fast and cacheable at the edge.
- Preview reads must reflect unpublished drafts immediately after an editor saves.
- No write-capable credential may reach the browser bundle.
- Content changes must propagate to the rendered site within a predictable window that your team can measure.
If a requirement is missing from this list, it probably does not belong in the first version of the design.
The smallest suitable design: CDN for public, API for preview
Sanity’s client library selects between the two endpoints with a single flag. The smallest design that satisfies the requirements above is therefore two client instances, not one.
| Path | Endpoint | Consistency | Typical use | Token placement |
|---|---|---|---|---|
| Public | CDN | Eventual | Anonymous page traffic | None, or a narrowly scoped read token |
| Preview | Direct API | Strong | Draft views, read-your-writes | Server-only read token |
The following configuration illustrates the split. Replace the placeholders with your project values, and pin apiVersion to a date you have tested against current documentation rather than copying a date from an example.
// Public reads: edge-cached, eventually consistent
const publicClient = createClient({
projectId: 'your-project-id',
dataset: 'production',
apiVersion: 'YYYY-MM-DD', // verify against current docs
useCdn: true
});
// Preview reads: direct API, strongly consistent
const previewClient = createClient({
projectId: 'your-project-id',
dataset: 'production',
apiVersion: 'YYYY-MM-DD',
useCdn: false,
token: process.env.SANITY_READ_TOKEN // server-only, read role
});
All mutations—creating, updating, or deleting documents—run server-side with a token whose role is limited to the required datasets. The browser never holds that token.
Trust boundaries and token scoping
Sanity Studio authenticates project members in the browser. That authentication is separate from API tokens. A common failure is treating Studio access as permission to embed an admin or write token in a client bundle. It is not.
Studio, server, and browser
- Studio runs in the browser and uses project-member authentication. It does not need an API token to edit content if members are granted access through the project dashboard.
- Server holds the write token and any preview read token. Environment variables on the server are the correct place for these values.
- Browser receives either no token (public dataset) or a read-only token scoped to the public dataset. If the dataset is private, a read token is required, but it must not have write or admin scope.
Token roles and CORS origin lists are project-wide settings. An overly broad origin list or an admin-scoped token inside a serverless function widens the blast radius of a leak. Scope tokens to the minimum role and dataset needed, and restrict CORS origins to the domains that actually serve your application.
Data boundaries: region and API version
A dataset’s region is fixed at creation and cannot be changed later. This makes region selection a one-way architectural decision that affects latency and data-residency posture. Decide it before the first production dataset exists; migrating later means creating a new dataset and copying content, not flipping a switch.
API version is pinned per client. Sanity evolves GROQ behavior over time, and an API version bump can change query semantics or reject previously valid parameters. Pin the version, test representative queries after any upgrade, and treat a version change as a code change rather than a configuration tweak.
Operational checks: webhooks, consistency, and revalidation
Webhooks connect Sanity to your deployment pipeline. When a document changes, Sanity can POST a payload to your server, which then revalidates the affected pages. Three operational checks keep this path honest:
- Alert on webhook delivery failures. If the endpoint is down or returns errors, content changes will not reach the site.
- Log GROQ errors and response latency per endpoint. Separate metrics for the CDN path and the direct API path make it obvious when one is degraded.
- Verify propagation delay. Publish a change, then run the same query against both endpoints and record how long the CDN takes to reflect it. Do not assume synchronous updates.
Idempotent revalidation
Webhook delivery is at-least-once. Your handler may receive the same payload more than once, and payloads do not guarantee ordering. Make revalidation idempotent so a duplicate event does not trigger redundant work or, worse, an inconsistent cache state.
// Illustrative pattern: idempotent revalidation handler
async function handleWebhook(payload) {
const docId = payload.ids?.[0];
const eventKey = `revalidate:${docId}:${payload.transactionId}`;
// Skip if this exact event was already processed
if (await cache.get(eventKey)) {
return new Response('Duplicate ignored', { status: 200 });
}
await cache.set(eventKey, true, { ttl: 3600 });
await revalidatePath(`/content/${docId}`);
return new Response('OK', { status: 200 });
}
The cache key combines the document identifier with the transaction identifier from the webhook payload. That combination makes replays detectable without relying on delivery order.
Failure modes to design around
CDN staleness after publish
The CDN endpoint is eventually consistent. A read immediately after a write may return a stale document. This breaks read-your-writes flows in the browser. If an editor must see a change the instant it saves, that read belongs on the direct API path with a server-side token, not on the public CDN path.
Token leakage through client bundles
A write or admin token in browser JavaScript is a full compromise of the dataset. Inspect the deployed bundle and server environment: confirm no write-capable token is reachable from client code, and confirm read tokens are limited to the intended datasets.
GROQ failures after an API version bump
Invalid slices or unsupported query parameters can surface only after an API version change. A query that works on one pinned version may fail on another. Test queries in isolation before promoting a version change to production.
Duplicate and out-of-order webhook events
Because delivery is at-least-once, a naive handler that blindly revalidates on every POST can produce duplicate work or race conditions. The idempotent pattern above is the minimum defense.
Conditions that would change the design
Previews must show drafts
If editors need to see unpublished drafts, the preview path must use the direct API endpoint with a drafts perspective and a server-only token. The public CDN path cannot serve draft content.
Read volume grows
As traffic increases, lean harder on the CDN and cache headers. The direct API path is for freshness-sensitive reads, not for scaling public traffic.
Blue/green releases are needed
If you need to promote content between environments without mutating production in place, use dataset aliases and a promote step. This changes the deployment topology from a single dataset to an aliased pair.
Self-hosted Studio
Self-hosting Studio changes deployment and upgrade cadence. Schema and plugin upgrades are coupled to the Studio build, so a self-hosted choice commits your team to a release schedule. Managed hosting avoids that coupling but reduces control over the Studio runtime.
Verifying the result
You can check the design without guessing:
- Run the same GROQ query against both the CDN and API endpoints immediately after a publish. Confirm the observed propagation delay matches your consistency assumption.
- Inspect the deployed client bundle and server environment for token scope. No write-capable token should appear in browser code.
- Trigger a content change end-to-end and verify the webhook fires, the revalidation handler ignores a replayed payload, and the page reflects the change within the expected window.
- Check the project’s dataset region and API version settings in the dashboard, then re-run a representative query to confirm behavior under the current API version rather than an assumed one.
Limitations
API version dates, exact rate limits, result-size caps, and draft-perspective parameter semantics are version-sensitive. Confirm them against current Sanity documentation before relying on specific numbers. The consistency window for CDN propagation is observable but not guaranteed to a fixed millisecond value; measure it in your own project and region. Dataset region is immutable, so treat it as a decision made once, early, and deliberately.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.