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. 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
Section titled “Setting Up Preview”Add a preview secret to your environment:
EMDASH_PREVIEW_SECRET="your-random-secret-key-here"Generate a secure random string. This secret signs and verifies preview tokens.
That’s it. Your existing templates work with preview automatically:
---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:
import { getPreviewUrl } from "emdash";
const previewUrl = await getPreviewUrl({ collection: "posts", id: "my-draft-post", secret: process.env.PREVIEW_SECRET!, expiresIn: "1h",});// Returns: /posts/my-draft-post?_preview=eyJjaWQ...With a base URL for absolute links:
const fullUrl = await getPreviewUrl({ collection: "posts", id: "my-draft-post", secret: process.env.PREVIEW_SECRET!, baseUrl: "https://example.com",});// Returns: https://example.com/posts/my-draft-post?_preview=eyJjaWQ...With a custom path pattern:
const blogUrl = await getPreviewUrl({ collection: "posts", id: "my-draft-post", secret: process.env.PREVIEW_SECRET!, pathPattern: "/blog/{id}",});// Returns: /blog/my-draft-post?_preview=eyJjaWQ...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.PREVIEW_SECRET,});
// Or with a token directlyconst result = await verifyPreviewToken({ token: someTokenString, secret: import.meta.env.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 Format
Section titled “Token Format”Preview tokens use a compact format: base64url(payload).base64url(signature)
The payload contains:
cid— Content ID in formatcollection:idexp— Expiry timestamp (seconds since epoch)iat— Issued-at timestamp (seconds since epoch)
Tokens are signed with HMAC-SHA256 using your preview secret.
Complete Example
Section titled “Complete Example”A full blog post page with preview and visual editing support:
---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}and{id}placeholders (default:"/{collection}/{id}")
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>