How-to guides
Snapshots on an Astro site that queries the index
Serve snapshots from an Astro site that queries the Content/ByUrl index itself, without the SDK's snapshot middleware or a dedicated snapshot route.
If your Astro site resolves pages by querying Content/ByUrl directly — a middleware that looks the current URL up in the index and puts the row on Astro.locals — you do not need the SDK's snapshot middleware, the snapshot/[token].astro page, or any new route to serve snapshots. A snapshot's rows carry the snapshot path as their url, so the lookup you already have resolves them. The integration is one clause in one query, plus three projected fields.
This guide is the worked version of the contract in Snapshots → Serving snapshots without the SDK. Read that section for the rules; read this one for the code.
Vocabulary. Editors see these as snapshots in Strife. The mechanism keeps the name snapshot everywhere you touch it — the /snapshot/{token} path, status: "snapshot", snapshotToken, snapshotName. Same thing, two audiences.
Before you start
- Deploy the index. Run
strife pushfrom a@strifeapp/striferelease that ships snapshots. Until that has happened, Strife refuses to create a snapshot at all, so nothing can reach your site. - Render on demand. Snapshot paths must not be prerendered, statically generated, or cached at the edge. On a site with
output: "server"this is already true; on a hybrid site, make sure the route that resolves pages isprerender = false. - You do not need
@strifeapp/astroupgraded. This path uses no SDK snapshot API. A site pinned to a pre-snapshot version of the package works exactly the same.
1. Admit status == "snapshot" — in the by-URL lookup only
Your visibility predicate today is some form of "published, plus the legacy null-status fallback". Snapshot rows carry status: "snapshot" and draft: true, so nothing you have matches them. Add the one branch, and add it only to the query that resolves the current page by exact URL:
// src/data/contentIndex.ts
export function visibleByUrlQuery<T = any>(
session: IDocumentSession,
locale: string,
url: string,
editMode: boolean,
): IDocumentQuery<T> {
if (editMode) {
return visibleContentQuery<T>(session, locale, editMode).whereEquals("url", url);
}
// A snapshot's rows carry the snapshot URL itself ("{root-slug}/snapshot/{token}"),
// so admitting status = "snapshot" here is the whole integration. Exact-URL lookups
// ONLY — listings stay published-only, or they would enumerate secret snapshot tokens.
return session
.query<T>({ indexName: CONTENT_INDEX })
.whereEquals("locale", locale)
.andAlso()
.openSubclause()
.whereEquals("status", "published")
.orElse()
.whereEquals("status", "snapshot")
.orElse()
.openSubclause()
.whereEquals("status", null)
.andAlso()
.whereEquals("draft", false)
.closeSubclause()
.closeSubclause()
.andAlso()
.whereEquals("url", url);
}Three things about that query are load-bearing:
- The locale clause. Snapshot
urls coincide across languages whenever the site's root slug does not vary by locale, and your code cannot tell which case it is in. Always pairurlwith the resolved locale. A site on this path resolves the exact locale only — there is nosnapshotLocalefallback here, so a reviewer who switches to a language the snapshot never had gets your ordinary not-found. - The url clause stays outside the visibility subclause. Keep the OR-branches wrapped so the caller's
urlbinds with AND. A strayorElseat the top level turns "published or snapshot, at this URL" into "published, or anything shared anywhere". - Listings keep the old predicate. Collection queries, sitemaps, hreflang alternates and search all stay published-only. A listing that admits snapshot rows publishes the secret tokens.
2. Project the three fields you will read
The snapshot fields are stored in the index, so they come back through selectFields. Add them where you project the current page's row:
// src/middlewares/loadRouteData.ts
async function findPageDataByPath(session, locale, pathname, editMode) {
return await visibleByUrlQuery(session, locale, pathname, editMode)
.selectFields([
"id",
"displayName",
"url",
"collection",
"status", // "snapshot" tells the rest of the site what it is looking at
"snapshotName", // the editor's label, for an optional banner
"expiresAt", // UTC round-trip string, for an optional banner
"publishedDate",
// …your existing fields
])
.firstOrNull();
}collection comes back exactly as it does for the published document, so your view mapping picks the same layout and components it always would. That is the point of this approach: a snapshot is not a special kind of page, it is the same page with unpublished content.
3. Let your published-check pass a snapshot row
Most sites of this shape have a guard that 404s anything without a publish date. A snapshot deliberately has none:
// src/middlewares/checkPublished.ts
const isPublished =
pageData &&
!pageData.deleted &&
// A snapshot renders a frozen, unpublished snapshot on purpose; its row only
// exists while the snapshot is active (expiry soft-deletes it).
(pageData.status === "snapshot" ||
(pageData.publishedDate && new Date(pageData.publishedDate) < new Date()));You do not need an expiry check. Expiry and revocation work by the rows disappearing: Strife soft-deletes the snapshot, and the index drops its rows — normally within about a minute of the instant, and at the next worker start if an alarm was missed. expiresAt is on the row if you want to show it, or to add a stricter check of your own.
4. Optional: tell the reviewer what they are looking at
Nothing above renders a banner, and the page works without one. If you want it, the row carries what you need:
---
// src/layouts/Layout.astro
const page = Astro.locals.pageData;
---
{page?.status === "snapshot" && (
<aside role="status" class="…">
{page.snapshotName ? `Preview “${page.snapshotName}”` : "Preview"} — this link stops working
{new Intl.DateTimeFormat(locale, { dateStyle: "long", timeStyle: "short", timeZone: "UTC" })
.format(new Date(page.expiresAt))} (UTC).
</aside>
)}Render snapshotName as text only, never set:html. It is editor-typed input.
5. Own the hygiene the SDK would have set
This is the part a raw integration inherits, and the part worth reviewing before you ship. On snapshot responses:
| What | Why |
|---|---|
X-Robots-Tag: noindex, nofollow | The URL is a secret; keep it out of search indexes. |
Cache-Control: private, no-store, and no CDN rule that overrides it | A cached lookup serves a revoked link until the cache expires. |
Referrer-Policy: no-referrer | Otherwise the token leaks in the Referer of everything the page links to. |
No Open Graph or social tags | A link preview would fetch and cache the page somewhere else. |
No analytics beacon | A snapshot is not audience traffic, and the beacon carries the path — that is the token. |
Your own edit/preview affordances stay off | A valid editor token in the request must not switch on draft branching or a live-preview client on a snapshot path. |
The last row is the one that bites. If your site imports a live-preview client unconditionally, the editor's canvas can push draft content into a page that is supposed to be frozen — the reviewer's link and the editor's view then disagree. Gate that import on edit mode, and make sure edit mode is false when status === "snapshot".
Also check your locale-redirect layer: if the site 301s prefix-less paths onto a locale prefix, a snapshot URL passes through it like any page. That is fine, as long as the redirect preserves the path and the redirected request still runs the lookup above.
What you are not building
Worth stating, because the SDK path has all of these and this one has none:
- No
/snapshot/[token].astropage and no route registration — the snapshot path is a URL your resolver already handles. - No
snapshotTokenquery and no token validation — you match onurl, and an unknown token simply matches nothing. - No expiry logic.
- No new dependency or SDK version bump.
Verify it end to end
- In Strife, open a page with unpublished changes and create a snapshot. Copy the link.
- Open it in the language the snapshot was created from → the page renders in your normal design, with the draft's content, plus your banner if you added one.
- Open the same token under a different locale prefix → your ordinary 404. (Exact locale only, by design.)
- Load a listing that includes the page → the snapshot is not in it, and the token appears nowhere in the HTML.
- Revoke the snapshot in Strife, wait a moment, reload → 404.
- Check the response headers from step 2 against the table above.
If step 2 renders the published version instead of the draft, your lookup found the canonical row first: the snapshot branch is missing from the query that actually resolved the page, or a second by-URL query downstream re-resolved it with the published-only predicate.