Preview Mode

On this page

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

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

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

With a base URL for absolute links:

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:

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

Token Expiration

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

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

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

isPreviewRequest(url)

Check if a URL contains a preview token:

import { isPreviewRequest } from "emdash";

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

getPreviewToken(url)

Extract the token string from a URL:

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:

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.

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 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} and {id} placeholders (default: "/{collection}/{id}")

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>

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>