---
title: Preview Mode
description: Enable secure previews of draft content before publishing.
---

import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";

EmDash's preview system lets editors view unpublished content through secure, time-limited URLs. Preview links use HMAC-SHA256 signed tokens that you can share with reviewers without exposing your entire draft content.

## How It Works

1. The admin generates a preview URL for a draft post
2. The URL contains a signed `_preview` query parameter with an expiration time
3. EmDash's middleware automatically verifies the token and sets up the request context
4. Your template code calls `getEmDashEntry()` as normal — draft content is served automatically

Preview is **implicit**. Your template code doesn't need to handle tokens or pass preview options — the middleware and query functions handle everything via `AsyncLocalStorage`.

## Setting Up Preview

Preview works out of the box — EmDash auto-generates a per-site preview
secret on first use and stores it in the database. No env vars required
for the common case.

Set `EMDASH_PREVIEW_SECRET` in your environment only if you need to:

- Share the secret across multiple processes (e.g. a separate preview
  Worker that signs URLs and sends them to your main site for verification)
- Pin the secret to a value you control for compliance/audit reasons
- Migrate to a known value when restoring from a backup

```bash title=".env"
# Optional: override the auto-generated secret
EMDASH_PREVIEW_SECRET="your-random-secret-key-here"
```

If set, the env value wins over the DB-stored value.

That's it. Your existing templates work with preview automatically:

```astro title="src/pages/posts/[...slug].astro"
---
import { getEmDashEntry } from "emdash";

const { slug } = Astro.params;

// No special preview handling needed — the middleware
// detects _preview tokens and serves draft content automatically
const { entry, isPreview, error } = await getEmDashEntry("posts", slug);

if (error) {
  return new Response("Server error", { status: 500 });
}

if (!entry) {
  return Astro.redirect("/404");
}
---

{isPreview && (
  <div class="preview-banner">
    You are viewing a preview. This content is not published.
  </div>
)}

<article>
  <h1>{entry.data.title}</h1>
</article>
```

The `isPreview` flag is `true` when draft content is being served via a valid preview token.

## Generating Preview URLs

Use `getPreviewUrl()` to create preview links. The function takes the
secret as an explicit argument:

```ts
import { getPreviewUrl } from "emdash";

const previewUrl = await getPreviewUrl({
	collection: "posts",
	id: "my-draft-post",
	secret: import.meta.env.EMDASH_PREVIEW_SECRET,
	expiresIn: "1h",
});
// Returns: /posts/my-draft-post?_preview=eyJjaWQ...
```

When `EMDASH_PREVIEW_SECRET` isn't set, EmDash auto-generates and stores
a per-site secret in the database for token verification. The
`getPreviewUrl()` template helper still requires you to pass the secret
explicitly — pin your env var if you call it from page templates. Most
sites use the admin UI's "Generate preview link" button instead, which
goes through the API and uses the resolved secret automatically.

With a base URL for absolute links:

```ts
const fullUrl = await getPreviewUrl({
	collection: "posts",
	id: "my-draft-post",
	secret: import.meta.env.EMDASH_PREVIEW_SECRET,
	baseUrl: "https://example.com",
});
// Returns: https://example.com/posts/my-draft-post?_preview=eyJjaWQ...
```

With a custom path pattern:

```ts
const blogUrl = await getPreviewUrl({
	collection: "posts",
	id: "my-draft-post",
	secret: import.meta.env.EMDASH_PREVIEW_SECRET,
	pathPattern: "/blog/{id}",
});
// Returns: /blog/my-draft-post?_preview=eyJjaWQ...
```

### Locale-aware paths

`pathPattern` also supports a `{locale}` placeholder. Pass an empty `locale`
when the entry is in the default locale and `prefixDefaultLocale` is `false`;
adjacent slashes left by the empty value are collapsed automatically.

```ts
await getPreviewUrl({
	collection: "posts",
	id: "hello",
	secret,
	pathPattern: "/{locale}/{id}",
	locale: "pt-br",
});
// Returns: /pt-br/hello?_preview=...

await getPreviewUrl({
	collection: "posts",
	id: "hello",
	secret,
	pathPattern: "/{locale}/{id}",
	locale: "", // default locale, no prefix
});
// Returns: /hello?_preview=...
```

The admin's "View on site" link goes through `POST /_emdash/api/content/{collection}/{id}/preview-url`,
which reads the entry's locale, looks up the site's i18n config and supplies
the `locale` automatically. To change the default pattern used by that
endpoint, set `EMDASH_PREVIEW_PATH_PATTERN` (e.g. `/{locale}/{id}`) — request
bodies still win when they include their own `pathPattern`.

## Token Expiration

Control how long preview links remain valid:

```ts
// Valid for 1 hour (default)
await getPreviewUrl({ ..., expiresIn: "1h" });

// Valid for 30 minutes
await getPreviewUrl({ ..., expiresIn: "30m" });

// Valid for 1 day
await getPreviewUrl({ ..., expiresIn: "1d" });

// Valid for 2 weeks
await getPreviewUrl({ ..., expiresIn: "2w" });

// Valid for 3600 seconds
await getPreviewUrl({ ..., expiresIn: 3600 });
```

Supported units: `s` (seconds), `m` (minutes), `h` (hours), `d` (days), `w` (weeks).

## Verifying Tokens

Use `verifyPreviewToken()` to validate incoming preview requests:

```ts
import { verifyPreviewToken } from "emdash";

// From a URL (extracts _preview query parameter)
const result = await verifyPreviewToken({
	url: Astro.url,
	secret: import.meta.env.EMDASH_PREVIEW_SECRET,
});

// Or with a token directly
const result = await verifyPreviewToken({
	token: someTokenString,
	secret: import.meta.env.EMDASH_PREVIEW_SECRET,
});
```

The result indicates whether the token is valid:

```ts
if (result.valid) {
	// Token is valid
	console.log(result.payload.cid); // "posts:my-draft-post"
	console.log(result.payload.exp); // Expiry timestamp
	console.log(result.payload.iat); // Issued-at timestamp
} else {
	// Token is invalid
	console.log(result.error);
	// "none" - no token present
	// "malformed" - token structure is invalid
	// "invalid" - signature verification failed
	// "expired" - token has expired
}
```

## Preview Indicator

You can show a visual indicator when content is being previewed. The `isPreview` flag returned by `getEmDashEntry` tells you when draft content is being served:

```astro
{isPreview && (
  <div class="preview-banner" role="alert">
    <strong>Preview</strong> — You are viewing unpublished content.
    <a href={Astro.url.pathname}>Exit preview</a>
  </div>
)}
```

<Aside type="tip">
	For authenticated editors using visual editing, EmDash automatically injects a floating toolbar
	that indicates edit/preview mode. You only need a custom preview banner for shared preview links.
</Aside>

## Helper Functions

### `isPreviewRequest(url)`

Check if a URL contains a preview token:

```ts
import { isPreviewRequest } from "emdash";

if (isPreviewRequest(Astro.url)) {
	// Handle preview request
}
```

### `getPreviewToken(url)`

Extract the token string from a URL:

```ts
import { getPreviewToken } from "emdash";

const token = getPreviewToken(Astro.url);
// Returns the token string or null
```

### `parseContentId(contentId)`

Parse a content ID into collection and ID:

```ts
import { parseContentId } from "emdash";

const { collection, id } = parseContentId("posts:my-draft-post");
// { collection: "posts", id: "my-draft-post" }
```

## Token Format

Preview tokens use a compact format: `base64url(payload).base64url(signature)`

The payload contains:

- `cid` — Content ID in format `collection:id`
- `exp` — Expiry timestamp (seconds since epoch)
- `iat` — Issued-at timestamp (seconds since epoch)

Tokens are signed with HMAC-SHA256 using your preview secret.

<Aside type="caution">
	Keep your `EMDASH_PREVIEW_SECRET` secure. Anyone with this secret can generate valid preview tokens for
	any content.
</Aside>

## Complete Example

A full blog post page with preview and visual editing support:

```astro title="src/pages/posts/[...slug].astro"
---
import { getEmDashEntry } from "emdash";
import BaseLayout from "../../layouts/Base.astro";
import { PortableText } from "emdash/ui";

const { slug } = Astro.params;

// Preview is automatic — middleware handles token verification
const { entry, isPreview, error } = await getEmDashEntry("posts", slug);

if (error) {
  return new Response("Server error", { status: 500 });
}

if (!entry) {
  return Astro.redirect("/404");
}
---

<BaseLayout title={entry.data.title}>
  {isPreview && (
    <div class="preview-banner" role="alert">
      <strong>Preview</strong> — This content is not published.
    </div>
  )}

  <article {...entry.edit}>
    <header>
      <h1 {...entry.edit.title}>{entry.data.title}</h1>
      {entry.data.publishedAt && (
        <time datetime={entry.data.publishedAt.toISOString()}>
          {entry.data.publishedAt.toLocaleDateString()}
        </time>
      )}
      {isPreview && !entry.data.publishedAt && (
        <span class="draft-indicator">Draft</span>
      )}
    </header>

    <div class="content" {...entry.edit.content}>
      <PortableText value={entry.data.content} />
    </div>
  </article>
</BaseLayout>
```

Note the `{...entry.edit}` and `{...entry.edit.title}` spreads — these add `data-emdash-ref` attributes that enable visual editing for authenticated editors. In production, they produce no output.

## API Reference

### `getPreviewUrl(options)`

Generate a preview URL with a signed token.

**Options:**

- `collection` — Collection slug (string)
- `id` — Content ID or slug (string)
- `secret` — Signing secret (string)
- `expiresIn` — Token validity duration (default: `"1h"`)
- `baseUrl` — Optional base URL for absolute links
- `pathPattern` — URL pattern with `{collection}`, `{id}` and `{locale}` placeholders (default: `"/{collection}/{id}"`)
- `locale` — Value substituted for `{locale}`. Empty string omits the locale segment (slashes are collapsed).

**Returns:** `Promise<string>`

### `verifyPreviewToken(options)`

Verify a preview token.

**Options:**

- `secret` — Verification secret (string)
- `url` — URL to extract token from, OR
- `token` — Token string directly

**Returns:** `Promise<VerifyPreviewTokenResult>`

```ts
type VerifyPreviewTokenResult =
	| { valid: true; payload: PreviewTokenPayload }
	| { valid: false; error: "invalid" | "expired" | "malformed" | "none" };
```

### `generatePreviewToken(options)`

Generate a token without building a URL.

**Options:**

- `contentId` — Content ID in format `collection:id`
- `expiresIn` — Token validity duration (default: `"1h"`)
- `secret` — Signing secret

**Returns:** `Promise<string>`
