> From the [Strife developer docs](https://strife.app/docs/how-to-guides/snapshots-astro-without-the-sdk). Every page is listed in [llms.txt](https://strife.app/docs/llms.txt).

# 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](https://strife.app/docs/reference/snapshots.md#serving-snapshots-without-the-sdk). Read that section for the rules; read this one for the code.

> [!NOTE]
> **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 push`](https://strife.app/docs/reference/cli/commands.md#strife-push) from a `@strifeapp/strife` release 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 is `prerender = false`.
* **You do not need `@strifeapp/astro` upgraded.** 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:

```typescript
// 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 `url`s 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 pair `url` with the resolved locale. A site on this path resolves the **exact** locale only — there is no `snapshotLocale` fallback 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 `url` binds with AND. A stray `orElse` at 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:

```typescript
// 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:

```typescript
// 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:

```astro
---
// 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].astro` page and no route registration — the snapshot path *is* a URL your resolver already handles.
* No `snapshotToken` query and no token validation — you match on `url`, and an unknown token simply matches nothing.
* No expiry logic.
* No new dependency or SDK version bump.

## Verify it end to end

1. In Strife, open a page with unpublished changes and create a snapshot. Copy the link.
2. 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.
3. Open the same token under a different locale prefix → your ordinary 404. (Exact locale only, by design.)
4. Load a listing that includes the page → the snapshot is not in it, and the token appears nowhere in the HTML.
5. Revoke the snapshot in Strife, wait a moment, reload → 404.
6. 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.
