pnpm create emdash@latest potluck --pm pnpm --install --yes, per https://docs.emdashcms.com/ ("Create your first EmDash site"); the --yes defaults are cloudflare + blog. Replaces the old madmode/ starter (never committed). Generated-by: create-emdash 1.2.0, run by Claude Opus 5.5
6.1 KiB
Configuration
astro.config.mjs
Node.js (local development / self-hosted)
import node from "@astrojs/node";
import react from "@astrojs/react";
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
image: {
layout: "constrained",
responsiveStyles: true,
},
integrations: [
react(),
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
}),
],
devToolbar: { enabled: false },
});
Reverse proxy
When behind a TLS-terminating reverse proxy, Astro.url returns the internal address (e.g. http://localhost:4321) instead of the public one (https://mysite.example.com). This breaks passkeys, CSRF, OAuth, redirects, and more.
Step 1: Declare allowed public hosts via security.allowedDomains so Astro reconstructs the URL from X-Forwarded-* headers. In dev, add matching vite.server.allowedHosts or Vite rejects the proxy Host.
Step 2: If the reconstructed URL still disagrees with the browser (common with TLS termination), set siteUrl:
emdash({
siteUrl: "https://mysite.example.com",
// ...
});
Or via environment variable (useful for container deployments):
EMDASH_SITE_URL=https://mysite.example.com
# or: SITE_URL=https://mysite.example.com
siteUrl replaces passkeyPublicOrigin (which only fixed passkeys). It applies to passkeys, CSRF origin matching, OAuth redirects, login redirects, MCP discovery, snapshot exports, sitemap, robots.txt, and JSON-LD structured data.
With TLS terminated in front, astro dev --host 127.0.0.1 (loopback) is usually enough: the proxy reaches the dev server locally while siteUrl matches the browser’s HTTPS origin -- without opening the Node port on the LAN.
Cloudflare (D1 + R2)
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import { d1, r2 } from "@emdash-cms/cloudflare";
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
export default defineConfig({
output: "server",
adapter: cloudflare(),
image: {
layout: "constrained",
responsiveStyles: true,
},
integrations: [
react(),
emdash({
database: d1({ binding: "DB", session: "auto" }),
storage: r2({ binding: "MEDIA" }),
}),
],
devToolbar: { enabled: false },
});
Requires a wrangler.jsonc with D1 and R2 bindings:
{
"name": "my-site",
"main": "./src/worker.ts",
"compatibility_date": "2026-02-24",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "my-site",
},
],
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "my-site-media",
},
],
"triggers": {
"crons": ["* * * * *"],
},
}
Use the EmDash Worker entry point so scheduled publishing and maintenance run through the same deployment:
import handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker";
export { PluginBridge };
export default {
...handler,
scheduled: createScheduledHandler(),
} satisfies ExportedHandler;
Plugins
Register plugins in astro.config.mjs:
import auditLog from "@emdash-cms/plugin-audit-log";
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }),
plugins: [auditLog],
}),
live.config.ts
Every EmDash site needs this file at src/live.config.ts. It's boilerplate -- the same in every project:
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};
This registers EmDash's live content collections with Astro. All content types are served through the single _emdash collection -- you query specific types using getEmDashCollection("posts") etc.
emdash-env.d.ts
Auto-generated at the project root when the dev server starts. Provides TypeScript types for your collections. This is the file your tsconfig.json includes.
/// <reference types="emdash/locals" />
import type { PortableTextBlock } from "emdash";
export interface Post {
id: string;
slug: string | null;
status: string;
title: string;
featured_image?: {
id: string;
src?: string;
alt?: string;
width?: number;
height?: number;
};
content?: PortableTextBlock[];
excerpt?: string;
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
}
declare module "emdash" {
interface EmDashCollections {
posts: Post;
}
}
The dev server regenerates this file automatically when schema changes. You can also generate it manually:
Type Generation
# From local dev server (writes emdash-env.d.ts at project root)
npx emdash types
# From remote instance
npx emdash types --url https://my-site.pages.dev
# Custom output path
npx emdash types --output src/types/cms.ts
The CLI also writes .emdash/schema.json with the raw schema for tooling.
package.json
Key dependencies for a Node.js site:
{
"dependencies": {
"astro": "^7.0.0",
"emdash": "workspace:*",
"@astrojs/node": "^11.0.0",
"@astrojs/react": "^4.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
For Cloudflare, replace @astrojs/node with @astrojs/cloudflare and add @emdash-cms/cloudflare.
Dev Server
pnpm dev # Start the Astro dev server
npx emdash types # Refresh types from the running site
The runtime runs pending migrations on the first request and applies the bundled seed when the database is empty and setup has not been completed. The Astro integration generates emdash-env.d.ts when the server starts. The admin UI is at http://localhost:4321/_emdash/admin. On first run, you'll go through setup to create an admin account.