Skip to content

Preview Mode

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.

  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. The middleware verifies the token and the query functions read it through AsyncLocalStorage, so the same template code serves draft content during a preview and published content otherwise.

Preview works as soon as EmDash is installed. On first use, EmDash generates a per-site preview secret and stores it in the database, so the common case needs no configuration.

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
.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.

Existing templates work with preview automatically, as in the following page:

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.

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

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.

Pass baseUrl to generate an absolute URL:

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...

Pass pathPattern to generate a URL with a custom path:

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...

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.

The following example builds a locale-prefixed preview URL:

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.

Control how long preview links remain valid:

// 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).

Use verifyPreviewToken() to validate incoming preview requests:

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:

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
}

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:

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

Check if a URL contains a preview token:

import { isPreviewRequest } from "emdash";
if (isPreviewRequest(Astro.url)) {
// Handle preview request
}

Extract the token string from a URL:

import { getPreviewToken } from "emdash";
const token = getPreviewToken(Astro.url);
// Returns the token string or null

Parse a content ID into collection and ID:

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

Preview tokens are signed and time-limited. The CLI and the helper functions generate and verify them for you; you do not construct or parse them by hand. A token identifies one entry and stops working after it expires.

The following page combines preview and visual editing support in a full blog post template:

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.

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>

Verify a preview token.

Options:

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

Returns: Promise<VerifyPreviewTokenResult>

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

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>