> From the [Strife developer docs](https://strife.app/docs/reference/snapshots). Every page is listed in [llms.txt](https://strife.app/docs/llms.txt).

# Snapshots

Snapshots let an editor hand someone without a Strife account a short-lived URL that shows a frozen, unpublished version of a document on your own site.

An editor creates a **snapshot** (that is what the Studio calls it) from a document's draft — or, from the History drawer, from a previously published version or the page as it is live right now (see [Creating a snapshot from a published version](#creating-a-snapshot-from-a-published-version)). Strife freezes a copy of that content — every language included — into a *snapshot* with a secret token, and the link points at your site:

```
{origin}{locale root prefix}/snapshot/{token}
```

For example `https://example.com/snapshot/k3m9x2qa` or, on a single-host site with a `/sv/` root, `https://example.com/sv/snapshot/k3m9x2qa`. The origin is the site address configured for the language the editor was viewing; the prefix is the root path segment your public URLs already carry for that language (empty on host-per-locale sites); the token segment is the only new path. A token is 8 lowercase letters or digits (`[a-z0-9]{8}`), minted by a cryptographic random generator and never derived from the document id; Strife checks it is unused on the team before creating the snapshot, because the site path is team-wide.

**What kind of secret the token is.** 36⁸ ≈ 41 bits makes a snapshot an *unlisted link* — the same class as an unlisted video or a file-sharing link — not a cryptographic secret. That is a deliberate trade for a link people can read out and type. It holds because the only way to test a guess is a request to your site that answers 200 or 404 (there is nothing to attack offline), a snapshot lives at most 7 days, and a guessed token exposes exactly one frozen page. Two things on your side keep it that way: answer every miss — unknown, expired, revoked, malformed — with the same ordinary not-found (both SDKs do), and keep `/snapshot/*` out of anything that would enumerate or cache it (see [Headers, caching, analytics and logs](#headers-caching-analytics-and-logs)).

Your site resolves the request the way it resolves any other: the SDK matches `/snapshot/{token}` after your locale resolution, looks the snapshot up in the `Content/ByUrl` index, and renders it in your normal layout as if it were published content — navigation, surrounding content and referenced documents come from published content; only the shared document is substituted. The reviewer needs no `?token=`, and the route exposes nothing beyond the one snapshot its token names.

Every link expires **7 days** after creation (a fixed server setting, not per team), and editors can revoke one early. An expired, revoked, unknown or malformed link resolves exactly like content that does not exist: your site's ordinary not-found, with no dedicated "link expired" page.

## What you need

Snapshots are a contract between three moving parts. All three must come from the release that ships snapshots; there is no runtime flag.

| Part | Requirement | How to tell |
| --- | --- | --- |
| `@strifeapp/strife` | The release that ships snapshots, deployed with `strife push` | Its bundled `Content/ByUrl` source emits `snapshotToken` |
| `@strifeapp/astro` (Astro sites) | ≥ the version published with this feature | The package exports `@strifeapp/astro/snapshot-middleware` and `@strifeapp/astro/snapshot` |
| `Strife` NuGet package (.NET sites) | ≥ the version published with this feature | `Strife.Services.SnapshotContext` exists and `ContextService` has `GetSnapshot` |

Because the developer docs publish before the packages do, the exact version numbers are in each package's changelog; the checks in the right-hand column are the reliable test.

## Deploy the content index first

The snapshot route reads snapshot rows from the `Content/ByUrl` index, and the index only emits them when it was built from a `@strifeapp/strife` release that includes snapshots. Update the package and run [`strife push`](https://strife.app/docs/reference/cli/commands.md#strife-push) — that redeploys the index. Until that has happened on a team, Strife **refuses to create a snapshot** for it, so an old index can never map a snapshot as a public page. The editor sees a toast and nothing is created:

| Refusal (`409`, `error` in the body) | When | What the editor sees |
| --- | --- | --- |
| `snapshot_index_missing` | No `Content/ByUrl` index is deployed on the team | "This site's content index needs updating. Ask your developer to run strife push." |
| `snapshot_index_predates_snapshots` | The deployed index was built from a source without `snapshotToken` | Same toast |
| `snapshot_index_deploying` | The definition already carries `snapshotToken`, but a rolling or side-by-side replacement of the index is still in progress on the cluster | "This site's content index is still updating. Try again in a few minutes." |

The reverse is guarded too. While a team holds an active snapshot, `strife push` from an older `@strifeapp/strife` pin — one whose index source lacks `snapshotToken` — is refused with `409 snapshots_require_newer_index`, because deploying it would expose the shared drafts as public pages. Update the pin and push again, or wait until every snapshot has expired or been revoked.

## What the index emits

A snapshot is a working-copy sibling, `{id}/snapshot/{token}`, next to `/draft` and `/scheduled`, with the role-locked status `snapshot`. The index emits one row per language in the snapshot, and each row's `url` **is the snapshot path** — the origin-chain root's slug segment for that language (empty when the root slug is `/`), then `/snapshot/{token}` — built even for `disableURL` templates. No snapshot row carries the document's canonical URL. Each row carries:

| Field | Value on a snapshot row | On every other row |
| --- | --- | --- |
| `url` | The snapshot path for the row's language, e.g. `/snapshot/{token}` or `/sv/snapshot/{token}` | The page's canonical URL (or `null` for `disableURL` templates) |
| `status` | `"snapshot"` | `published`, `unpublished`, `draft`, `scheduled` |
| `draft` | `true` | `false` for published rows |
| `snapshotToken` | The token from the id | `null` |
| `snapshotName` | The editor-given name (or the server's "Snapshot N" fallback); plain text | `null` |
| `expiresAt` | UTC round-trip string ending in `Z` | `null` |
| `snapshotLocale` | The language the editor was viewing when sharing — the one the link targets | `null` |

Because snapshot rows carry `draft: true` and `status: "snapshot"`, a public query (`draft == false` or `status == "published"`) never matches them; a soft-deleted snapshot emits no row at all. The four fields are stored, so they project with `selectFields` / `ProjectInto`. Full field semantics: [Index URL](https://strife.app/docs/reference/database/index-url.md).

There are two equivalent ways to resolve a snapshot, and both end at the same row:

* **The SDK path** (both SDKs): validate the token segment as 8 lowercase letters or digits, query with **exact equality** on `snapshotToken` plus `status == "snapshot"` plus the resolved locale, and check `expiresAt` on the row — the middleware does all of this for you, with the locale fallback below.
* **The raw path** (no SDK — see [Serving snapshots without the SDK](#serving-snapshots-without-the-sdk)): your site's ordinary exact-URL lookup, with `status == "snapshot"` admitted on exact-URL matches. An expiry check is **not required** on this path: an expired or revoked snapshot is soft-deleted and its rows disappear — normally within about a minute of the instant, and at the next worker start when an alarm was missed. The row still carries `expiresAt` if you want a notice or a belt-and-braces check.

### Every language is reachable

The snapshot contains every language the draft had at snapshot time, and the index emits a row for each, so one link serves them all: the reviewer switches language with your site's own language switcher or prefix. The link the editor copies targets the language they were viewing (`snapshotLocale`). On either SDK, a resolver that picks a language the snapshot has no row for falls back to the `snapshotLocale` row rather than answering not-found (see below); a site on the raw path resolves the exact locale only — a missing row is its ordinary not-found.

## Astro

The integration does the routing; you author the page. With the `@strifeapp/astro` release that ships snapshots installed, `strife()` registers `@strifeapp/astro/snapshot-middleware` as a `pre` middleware — after the edit-mode middleware, so edit mode is forced **off** on snapshot routes and `<LivePreview />` never activates there. For a snapshot path (`/snapshot/{token}` after an optional single locale-prefix segment, where the tail is a valid token — 8 lowercase letters or digits; any other tail such as `/help/snapshot/link` is an ordinary site route and passes through) it looks the snapshot up for the locale Astro resolved (`context.currentLocale`), and:

* on a hit sets `Astro.locals.snapshot = { docId, name, expiresAt: Date, locale, content }` and lets your page render it;
* on any miss renders **your 404 page** through Astro's rewrite, re-wrapped so the status is `404`.

Both answers carry `X-Robots-Tag: noindex, nofollow`, `Cache-Control: private, no-store` and `Referrer-Policy: no-referrer`. The middleware logs nothing on a hit and never logs the token.

### The page

Copy the package's [`templates/snapshot-page.astro`](https://github.com/wieldyapp/wieldy/blob/master/src/sdk/js/src/packages/astro/templates/snapshot-page.astro) to `src/pages/snapshot/[token].astro`:

```astro
---
export const prerender = false;              // rendered on demand — a snapshot cannot be built ahead
import Layout from '../../layouts/Layout.astro';

const snapshot = Astro.locals.snapshot;
if (!snapshot) return new Response(null, { status: 404 });   // only when the middleware is not registered

const { name, expiresAt, locale, content } = snapshot;
const expires = new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' }).format(expiresAt);
---
<Layout title={content.displayName ?? 'Preview'}>
  <aside role="status">
    {name ? `Preview “${name}”` : 'Preview'} — this link stops working {expires} (UTC).
  </aside>
  <article>
    <h1>{content.heading ?? content.displayName}</h1>
  </article>
</Layout>
```

Three rules keep a snapshot a snapshot:

* **`export const prerender = false`.** The page is rendered per request; the middleware must run before it.
* **Render `name` as text only** — never `set:html`. It is editor-typed input.
* **Emit no Open Graph or other social tags.** The URL is a secret; a link preview would fetch and cache the page elsewhere.

`content` is the `Content/ByUrl` row for the resolved locale: `docId`, `locale`, `collection`, `displayName`, `url`, `snapshotName`, `expiresAt`, plus every field you list in the integration's `snapshot.fields` option (your template fields), so one lookup both decides the 404 and feeds the render:

```javascript
strife({
  snapshot: {
    fields: ['heading', 'body'],   // projected onto Astro.locals.snapshot.content
  },
});
```

Without `snapshot.fields`, `content` carries the row fields only and the page queries its own fields by `docId` + `locale` (`whereEquals('docId', content.docId)`, `whereEquals('status', 'snapshot')`). Pick your layout and components by `content.collection`, exactly as your published pages do.

### Sites that resolve locale in their own middleware

Astro's `currentLocale` only exists with Astro i18n routing. If your site resolves its language itself — a cookie, a header, its own path scheme — opt out of the automatic registration and compose the middleware where the locale is known, or a multi-locale snapshot resolves the wrong (or no) row:

```javascript
// astro.config.mjs
strife({ snapshot: { middleware: false } });
```

```typescript
// src/middleware.ts
import { sequence } from 'astro:middleware';
import { createSnapshotMiddleware } from '@strifeapp/astro/snapshot-middleware';

export const onRequest = sequence(
  myLocaleMiddleware,
  createSnapshotMiddleware({ resolveLocale: (context) => context.locals.locale }),
);
```

| Option | Where | Description |
| --- | --- | --- |
| `snapshot.fields` | integration | Extra `Content/ByUrl` fields to project onto `content`. |
| `snapshot.middleware: false` | integration | Skip the automatic registration; compose the middleware yourself. |
| `resolveLocale(context)` | `createSnapshotMiddleware` | Resolve the request's locale. Defaults to `context.currentLocale`. |

Two more things to know:

* A miss is rendered through Astro's rewrite to `/404`. Astro forbids rewriting from an on-demand route to a **prerendered** page, so if your `src/pages/404.astro` is static the middleware answers a bare `404` (headers included) and your adapter or host serves the static page; add `export const prerender = false` to `404.astro` to render it through the rewrite instead.
* `resolveSnapshot(token, locale)` from `@strifeapp/astro/snapshot` is the same lookup as a function, returning the snapshot context or `null`, if you need it outside the middleware.
* **Locale fallback** works as in .NET (below): when the resolved locale has no row in the snapshot, the middleware serves the snapshot's own `snapshotLocale` row instead of the 404. Without any locale resolution on a multi-locale site the token is ambiguous; the middleware prefers the `snapshotLocale` row among the candidates, but do not rely on that — resolve a locale.

## .NET

Nothing to register: `MapStrife()` already covers it. With the `Strife` package release that ships snapshots, the route resolver runs your locale resolver's path normalisation first, then matches `/snapshot/{token}` before anything is logged. On a hit it sets the same route values as a public page, so the controller for the snapshot's collection runs and `[FromContentRoute]` binds the snapshot's content exactly as it binds published content. The reviewer needs no `?token=`: the authorization filter admits the request for the one snapshot the route resolved — a request flagged for snapshot A whose resolved content is anything else is a 404.

The resolver stores a `SnapshotContext` on the request. Read it through `ContextService`, which is registered by `AddStrife()` and mirrors `IsInEditMode`:

```csharp
using Strife.Services;

public class BlogPostController : Controller
{
    private readonly ContextService _context;
    public BlogPostController(ContextService context) { _context = context; }

    public IActionResult Index([FromContentRoute] BlogPost content)
    {
        var snapshot = _context.GetSnapshot(HttpContext);   // null on every non-snapshot request
        ViewData["Snapshot"] = snapshot;
        return View(content);
    }
}
```

```cshtml
@{ var snapshot = ViewData["Snapshot"] as Strife.Services.SnapshotContext; }
@if (snapshot != null)
{
    <aside role="status">
        Preview @(string.IsNullOrEmpty(snapshot.Name) ? "" : $"“{snapshot.Name}”") — this link stops working @snapshot.ExpiresAt.ToString("f") (UTC).
    </aside>
}
```

`SnapshotContext` carries `DocId` (the snapshot's id — it contains the token, never log it), `Name` (plain text — render it through Razor's encoding `@` binding only, never `Html.Raw`), `ExpiresAt` (UTC) and `Locale` (the row that was served). Use `GetSnapshot(HttpContext) != null` to skip your analytics and to leave out Open Graph tags on the response.

On hits and misses alike the resolver sets `X-Robots-Tag: noindex, nofollow`, `Cache-Control: private, no-store` and `Referrer-Policy: no-referrer`, and logs only `hit` / `miss` with the locale — never the path, the token or the document id.

**Locale fallback.** The resolver queries for the locale your `ILocaleResolver` returned. If the snapshot has no row for it (an Accept-Language or cookie-driven locale the draft never had, or a link pasted under another prefix), it falls back to the snapshot's own `snapshotLocale` row instead of answering not-found. A multi-locale site *without* locale resolution leaves the token ambiguous: the resolver prefers the `snapshotLocale` row among the candidates it fetched, but do not rely on that — resolve a locale.

## Serving snapshots without the SDK

Because a snapshot row's `url` is the snapshot path, a site can serve snapshots through the same by-URL resolution it already uses for pages — no middleware, no dedicated route. For an Astro site that queries the index itself, [Snapshots on an Astro site that queries the index](https://strife.app/docs/how-to-guides/snapshots-astro-without-the-sdk.md) walks through the same contract in code. The whole contract:

1. **Admit `status == "snapshot"` in exact-URL lookups only.** Wherever your site resolves the current page by exact `url` + locale, extend the visibility predicate: `status == "published" || status == "snapshot"` (keeping any legacy `status == null && draft == false` branch). Apply it at **every** exact-URL lookup — middleware and any template that re-queries by URL — but **never** in collection or listing queries: a listing that admits snapshot rows enumerates secret tokens.
2. **The locale clause is load-bearing.** Snapshot-row `url`s coincide across languages whenever the root slug is not locale-varying, and your site cannot know which case it is in — always pair `url` with the resolved locale. A raw site resolves the exact locale only; there is no fallback row on this path.
3. **Resolve per request.** Keep snapshot paths out of prerendering, ISR/static generation, CDN and edge caches, and any data-layer caching of by-URL results. Expiry and revocation work by the rows disappearing — a cached lookup serves a dead link until the cache expires.
4. **No expiry code required.** The soft-delete removes the rows (normally within about a minute; at the next worker start when an alarm was missed). `expiresAt` is on the row for a notice or an optional stricter check.
5. **Own the hygiene the SDK would otherwise set.** Everything under [Headers, caching, analytics and logs](#headers-caching-analytics-and-logs) applies, plus: send `X-Robots-Tag: noindex, nofollow`, `Cache-Control: private, no-store` and `Referrer-Policy: no-referrer` yourself, and make sure your own token-gated edit or preview affordances never activate on snapshot paths.
6. **Mind your locale-redirect layer.** If your site 301s prefix-less paths to a locale prefix, a snapshot URL passes through it like any page — fine, as long as the redirect preserves the path and the redirected lookup still applies the predicate above.

The snapshot renders through your normal view mapping — the row carries the same `collection` and template fields as any page in its collection, plus `snapshotName` and `expiresAt` for an optional banner (render `snapshotName` as text only, never HTML).

## Headers, caching, analytics and logs

The token is a bearer secret of the unlisted-link class described at the top: whoever has the URL sees the snapshot until it expires, and nothing may make the token easier to find than guessing at web-request rates. The SDKs mark every snapshot response, but a few things only you can do:

* **Keep `/snapshot/*` out of CDN caching rules.** The response carries `Cache-Control: private, no-store`; make sure no edge rule overrides it, and never key a cache on the path.
* **Keep `/snapshot/*` out of analytics.** A snapshot is not audience traffic, and a beacon would carry the path, i.e. the token. In Astro, `<Insights />` renders nothing and sends no beacon on a snapshot page. A .NET site must exclude snapshot responses itself (`GetSnapshot(HttpContext) != null`), and so must any third-party analytics on either stack.
* **Keep access-log retention short.** A URL with a secret in its path is inherently logged by proxies, load balancers and hosts; the SDK snapshot branches log only the outcome and the locale, but your infrastructure's logs are yours to trim.
* **Emit no Open Graph or social tags** on the snapshot page, and **never render the snapshot name as HTML.**
* **Never activate your own edit/preview UI on `/snapshot/*`.** The SDKs force edit mode off on snapshot routes; a site serving snapshots raw must make sure a valid editor token in the request does not switch on draft branching or a live-preview client there.
* `Referrer-Policy: no-referrer` keeps the URL out of the `Referer` header of anything the page links to; keep it if you set your own referrer policy.

## From an MCP client

The Strife MCP server exposes the same snapshot domain as the Studio panel through three tools, so an agent working on content for a team member can share, list and stop links without switching to the Studio:

| Tool | What it does | Who may call it |
| --- | --- | --- |
| `create_snapshot` | Freezes a document's working copy and returns the link, name, expiry and who took it. Takes an optional `locale` (default: the team's default locale; it must be one the working copy contains) and an optional `name`. | Team member with edit rights (a Contributor can, a Viewer cannot) |
| `list_snapshots` | A document's active snapshots — not revoked, not expired — newest first, each with its link for the language it was taken in. | Any team member |
| `revoke_snapshot` | Stops one link at once; no confirmation step. Revoking an already revoked, expired or unknown snapshot succeeds and reports that nothing changed. | Team member with edit rights |

The link a tool returns is byte-for-byte the one the Studio copies: the server composes it from the team's site address for the language and the origin chain of the working copy (or, when listing, of the frozen snapshot row), exactly as the Studio does. The same gates apply as to the Studio's endpoints — team membership on all three, the deployed content index on create (a site that has not run `strife push` gets the same `snapshot_index_missing` / `snapshot_index_predates_snapshots` / `snapshot_index_deploying` refusal the Studio shows) — and the same hygiene: the token travels only inside the returned link, never in a log line, trace or error report. A snapshot id can never be the target of the edit, discard or publish tools.

## Restoring a snapshot with a locale list

The Studio (and the internal `apply-snapshot` endpoint behind it) can restore an active snapshot into the document's draft. `apply-snapshot` now accepts an optional `locales` field alongside the snapshot id: `null` (the default) replaces the whole draft with the snapshot's content, exactly as before; a non-empty subset of the team's configured locales restores only those locales and leaves every other locale in the draft byte-identical to what it was, with non-localizable fields always taken from the snapshot regardless of the selection. This is the same locale-selective merge — including the dirty-marking and activity rules — that restoring a Published Version uses. See [Versions and History](https://strife.app/docs/reference/versions.md#restore-semantics) for the full contract, the sibling `apply-version` endpoint, and how Published Versions relate to snapshots inside History.

## Creating a snapshot from a published version

The Studio's History drawer offers **Share** on every published row, not only on the draft: an editor can hand a reviewer an earlier version, or the live page as it is now, without touching the draft. Nothing changes for your site — the snapshot is an ordinary snapshot with the usual route, index rows, expiry and restore; only what gets frozen differs. The internal endpoint behind it is the same one the draft uses, `POST /content/snapshot/{activeTeam}`, with two optional fields:

| Field | Type | Notes |
| --- | --- | --- |
| `documentId` | string | The document (any role id; the canonical is what gets snapshotted). A snapshot id is refused. |
| `name` | string \| null | Optional editor-given name (at most 60 characters). Absent: `Snapshot N` for the draft; `Version {yyyy-MM-dd HH:mm}` (the publish stamp, UTC) for a version or the live page |
| `locale` | string \| null | The locale the link targets. Absent: the source's own locale |
| `source` | `"draft"` \| `"live"` \| `"version"` | What to freeze. Absent means `"draft"` — the body every earlier caller sends |
| `version` | string | The opaque reference from a [`versions`](https://strife.app/docs/reference/versions.md#get-contentversionsactiveteam) row, when `source` is `"version"` |

The same gates apply whatever the source: team membership, `EditContent`, and the deployed content index (the `409` refusals above run before the source is read). A Contributor can share a published version — the snapshot is not live content, so `ChangeLiveContent` is not needed. Refusals specific to the new sources:

| Status | When |
| --- | --- |
| 400 | `source: "version"` without a reference, or with a malformed one (`bad_reference` wording) |
| 404 | The reference names no stamped revision of this document — another document's revision, an unstamped one, or one RavenDB no longer holds |
| 400 | `source: "live"` on a page that is not published (unpublished or archived), or `source: "version"` on an archived page |
| 400 | An unknown `source` |

The snapshot document records where it came from, so History can say so on the row:

| Field | Value |
| --- | --- |
| `snapshotSource` | `"draft"`, `"live"` or `"version"`. A snapshot created before this field existed carries none and reads as `"draft"` |
| `sourcePublishedAt` | The source's publish stamp (`__publishedAt`, the versions-list format), or `null` for the draft and for a page published before stamps existed |
| `sourcePublishedBy` | The user id that published the source, or `null` |

The content index does **not** emit these fields, so the site, the SDKs and the reviewer never see them, and nothing needs redeploying. A restore never copies them into the draft. The MCP `create_snapshot` tool keeps snapshotting the working copy only.

## Lifetime and expiry

`expiresAt` is stamped at creation, 7 days ahead. When the instant passes, Strife's worker soft-deletes the snapshot (a reconcile sweep catches any missed alarm), and the index drops its rows. Both SDKs also compare `expiresAt` with the clock on every request, so a link stops resolving at the instant even if the soft-delete lags. Archiving or deleting the document revokes its snapshots in the same cascade that removes `/draft` and `/scheduled`; publishing or discarding the document leaves them untouched — the reviewer's link keeps showing what it showed when it was sent.

Snapshots are only ever soft-deleted, never hard-deleted and never given `@expires`: they replicate to every editor's client like the other siblings, and a hard delete would leave ghosts there.
