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.
How It Works
Section titled “How It Works”- The admin generates a preview URL for a draft post
- The URL contains a signed
_previewquery parameter with an expiration time - EmDash’s middleware automatically verifies the token and sets up the request context
- 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.
Setting Up Preview
Section titled “Setting Up Preview”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
# Optional: override the auto-generated secretEMDASH_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:
---import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
// No special preview handling needed — the middleware// detects _preview tokens and serves draft content automaticallyconst { 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
Section titled “Generating Preview URLs”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...Locale-aware paths
Section titled “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.
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.
Token Expiration
Section titled “Token Expiration”Control how long preview links remain valid:
// Valid for 1 hour (default)await getPreviewUrl({ ..., expiresIn: "1h" });
// Valid for 30 minutesawait getPreviewUrl({ ..., expiresIn: "30m" });
// Valid for 1 dayawait getPreviewUrl({ ..., expiresIn: "1d" });
// Valid for 2 weeksawait getPreviewUrl({ ..., expiresIn: "2w" });
// Valid for 3600 secondsawait getPreviewUrl({ ..., expiresIn: 3600 });Supported units: s (seconds), m (minutes), h (hours), d (days), w (weeks).
Verifying Tokens
Section titled “Verifying Tokens”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 directlyconst 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}Preview Indicator
Section titled “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:
{isPreview && ( <div class="preview-banner" role="alert"> <strong>Preview</strong> — You are viewing unpublished content. <a href={Astro.url.pathname}>Exit preview</a> </div>)}Helper Functions
Section titled “Helper Functions”isPreviewRequest(url)
Section titled “isPreviewRequest(url)”Check if a URL contains a preview token:
import { isPreviewRequest } from "emdash";
if (isPreviewRequest(Astro.url)) { // Handle preview request}getPreviewToken(url)
Section titled “getPreviewToken(url)”Extract the token string from a URL:
import { getPreviewToken } from "emdash";
const token = getPreviewToken(Astro.url);// Returns the token string or nullparseContentId(contentId)
Section titled “parseContentId(contentId)”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" }Token security
Section titled “Token security”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.
Complete Example
Section titled “Complete Example”The following page combines preview and visual editing support in a full blog post template:
---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 verificationconst { 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
Section titled “API Reference”getPreviewUrl(options)
Section titled “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 linkspathPattern— 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)
Section titled “verifyPreviewToken(options)”Verify a preview token.
Options:
secret— Verification secret (string)url— URL to extract token from, ORtoken— Token string directly
Returns: Promise<VerifyPreviewTokenResult>
type VerifyPreviewTokenResult = | { valid: true; payload: PreviewTokenPayload } | { valid: false; error: "invalid" | "expired" | "malformed" | "none" };generatePreviewToken(options)
Section titled “generatePreviewToken(options)”Generate a token without building a URL.
Options:
contentId— Content ID in formatcollection:idexpiresIn— Token validity duration (default:"1h")secret— Signing secret
Returns: Promise<string>