EmDash is configured through two files: astro.config.mjs for the integration and src/live.config.ts for content collections.
Astro Integration
Configure EmDash as an Astro integration:
import { defineConfig } from "astro/config";
import emdash, { local, r2, s3 } from "emdash/astro";
import { sqlite, libsql, d1 } from "emdash/db";
export default defineConfig({
integrations: [
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
plugins: [],
}),
],
});
Integration Options
database
Required. Database adapter configuration.
// SQLite (Node.js)
database: sqlite({ url: "file:./data.db" });
// PostgreSQL
database: postgres({ connectionString: process.env.DATABASE_URL });
// libSQL
database: libsql({
url: process.env.LIBSQL_DATABASE_URL,
authToken: process.env.LIBSQL_AUTH_TOKEN,
});
// Cloudflare D1 (import from @emdash-cms/cloudflare)
database: d1({ binding: "DB" });
See Database Options for details.
storage
Required. Media storage adapter configuration.
// Local filesystem (development)
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
});
// R2 binding (Cloudflare Workers)
storage: r2({
binding: "MEDIA",
publicUrl: "https://pub-xxxx.r2.dev", // optional
});
// S3-compatible (any platform) — all fields from S3_* environment variables
storage: s3()
// Or with explicit values
storage: s3({
endpoint: "https://s3.amazonaws.com",
bucket: "my-bucket",
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
region: "us-east-1", // optional, default: "auto"
publicUrl: "https://cdn.example.com", // optional
});
See Storage Options for details.
plugins
Optional. Array of EmDash plugins.
import seoPlugin from "@emdash-cms/plugin-seo";
plugins: [seoPlugin()];
fonts
Optional. Admin UI font configuration.
By default, EmDash loads Noto Sans via the Astro Font API. Fonts are downloaded from Google at build time and self-hosted, so there are no runtime CDN requests. The base font covers Latin, Cyrillic, Greek, Devanagari, and Vietnamese scripts.
To add support for additional writing systems, pass script names:
emdash({
fonts: {
scripts: ["arabic", "japanese"],
},
})
Available scripts: arabic, armenian, bengali, chinese-simplified, chinese-traditional, chinese-hongkong, devanagari, ethiopic, georgian, gujarati, gurmukhi, hebrew, japanese, kannada, khmer, korean, lao, malayalam, myanmar, oriya, sinhala, tamil, telugu, thai, tibetan.
Each script maps to the corresponding Noto Sans variant on Google Fonts (e.g. "arabic" loads Noto Sans Arabic). All font faces share a single font-family name and use unicode-range so the browser only downloads the files it needs for the characters on the page.
Set to false to disable font injection entirely and use system fonts:
emdash({
fonts: false,
})
The admin CSS uses the --font-emdash CSS variable. This is set automatically by the font configuration above.
auth
Optional. Authentication configuration.
auth: {
// Self-signup configuration
selfSignup: {
domains: ["example.com"],
defaultRole: 20, // Contributor
},
// OAuth providers
oauth: {
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
},
// Session configuration
session: {
maxAge: 30 * 24 * 60 * 60, // 30 days
sliding: true, // Reset expiry on activity
},
// OR use Cloudflare Access (exclusive mode)
cloudflareAccess: {
teamDomain: "myteam.cloudflareaccess.com",
audience: "your-app-audience-tag",
autoProvision: true,
defaultRole: 30,
syncRoles: false,
roleMapping: {
"Admins": 50,
"Editors": 40,
},
},
}
auth.selfSignup
Allow users to self-register if their email domain is allowed.
| Option | Type | Default | Description |
|---|---|---|---|
domains | string[] | [] | Allowed email domains |
defaultRole | number | 20 | Role for self-signups |
selfSignup: {
domains: ["example.com", "acme.org"],
defaultRole: 20, // Contributor
}
auth.oauth
Configure OAuth login providers.
oauth: {
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
}
auth.session
Session configuration.
| Option | Type | Default | Description |
|---|---|---|---|
maxAge | number | 2592000 (30d) | Session lifetime in seconds |
sliding | boolean | true | Reset expiry on activity |
auth.cloudflareAccess
Use Cloudflare Access as the authentication provider instead of passkeys.
| Option | Type | Default | Description |
|---|---|---|---|
teamDomain | string | required | Your Access team domain |
audience | string | required | Application Audience (AUD) tag |
autoProvision | boolean | true | Create users on first login |
defaultRole | number | 30 | Default role for new users |
syncRoles | boolean | false | Update role on each login |
roleMapping | object | — | Map IdP groups to roles |
siteUrl
Optional. The public browser-facing origin for the site (scheme + host + optional port, no path).
Behind a TLS-terminating reverse proxy, Astro.url returns the internal address (http://localhost:4321) instead of the public one (https://cms.example.com). This breaks passkeys, CSRF origin matching, OAuth redirects, login redirects, MCP discovery, snapshot exports, sitemap, robots.txt, and JSON-LD structured data. Set siteUrl to fix all of these at once.
The integration validates this value at load time: it must be a valid URL with http: or https: protocol and is normalized to origin (path is stripped).
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
siteUrl: "https://cms.example.com",
});
When siteUrl is not set in config, EmDash checks environment variables in order: EMDASH_SITE_URL, then SITE_URL. This is useful for container deployments where the public URL is set at runtime.
Reverse proxy setup
Astro only reflects X-Forwarded-* when the public host is allowed. Configure security.allowedDomains for the hostname (and schemes) your users hit. In astro dev, add matching vite.server.allowedHosts so Vite accepts the proxy Host header.
Prefer fixing allowedDomains (and forwarded headers) first; use siteUrl when the reconstructed URL still diverges from the browser origin (typical when TLS is terminated in front and the upstream request stays http://).
With TLS in front, binding the dev server to loopback (astro dev --host 127.0.0.1) is often enough: the proxy connects locally while siteUrl matches the public HTTPS origin.
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
security: {
allowedDomains: [
{ hostname: "cms.example.com", protocol: "https" },
{ hostname: "cms.example.com", protocol: "http" },
],
},
vite: {
server: {
allowedHosts: ["cms.example.com"],
},
},
integrations: [
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
siteUrl: "https://cms.example.com",
}),
],
});
maxUploadSize
Optional. Maximum allowed media file upload size in bytes. Applies to both direct multipart uploads and signed-URL uploads. Defaults to 52_428_800 (50 MB).
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
maxUploadSize: 100 * 1024 * 1024, // 100 MB
});
| Value | Description |
|---|---|
number (bytes) | Must be a positive finite integer |
| omitted | Defaults to 50 MB |
Uploads that exceed the configured limit are rejected with a 413 Payload Too Large response on the direct upload path, or a 400 Validation Error on the signed-URL path.
Database Adapters
Import from emdash/db:
import { sqlite, libsql, postgres, d1 } from "emdash/db";
sqlite(config)
SQLite database using better-sqlite3.
| Option | Type | Description |
|---|---|---|
url | string | File path with file: prefix |
sqlite({ url: "file:./data.db" });
libsql(config)
libSQL database.
| Option | Type | Description |
|---|---|---|
url | string | Database URL |
authToken | string | Auth token (optional for local files) |
libsql({
url: process.env.LIBSQL_DATABASE_URL,
authToken: process.env.LIBSQL_AUTH_TOKEN,
});
postgres(config)
PostgreSQL database with connection pooling.
| Option | Type | Description |
|---|---|---|
connectionString | string | PostgreSQL connection URL |
host | string | Database host |
port | number | Database port |
database | string | Database name |
user | string | Database user |
password | string | Database password |
ssl | boolean | Enable SSL |
pool.min | number | Minimum pool size (default: 0) |
pool.max | number | Maximum pool size (default: 10) |
postgres({ connectionString: process.env.DATABASE_URL });
d1(config)
Cloudflare D1 database. Import from @emdash-cms/cloudflare.
| Option | Type | Default | Description |
|---|---|---|---|
binding | string | — | D1 binding name from wrangler.jsonc |
session | string | "disabled" | Read replication mode: "disabled", "auto", or "primary-first" |
bookmarkCookie | string | "__em_d1_bookmark" | Cookie name for session bookmarks |
// Basic
d1({ binding: "DB" });
// With read replicas
d1({ binding: "DB", session: "auto" });
When session is "auto" or "primary-first", EmDash uses the D1 Sessions API to route read queries to nearby replicas. Authenticated users get bookmark-based read-your-writes consistency. See Database Options — Read Replicas for details.
Storage Adapters
Import from emdash/astro:
import emdash, { local, r2, s3 } from "emdash/astro";
local(config)
Local filesystem storage.
| Option | Type | Description |
|---|---|---|
directory | string | Directory path |
baseUrl | string | Base URL for serving files |
local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
});
r2(config)
Cloudflare R2 binding.
| Option | Type | Description |
|---|---|---|
binding | string | R2 binding name |
publicUrl | string | Optional public URL |
r2({
binding: "MEDIA",
publicUrl: "https://pub-xxxx.r2.dev",
});
s3(config?)
S3-compatible storage. All config fields are optional: any field omitted from
s3({...}) is resolved from the matching S3_* environment variable when the
Node process starts. Explicit values always take precedence.
Prerequisite: install @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner
in your project. EmDash core does not bundle the AWS SDK. See
Storage Options → S3-Compatible Storage
for details.
| Option | Type | Description |
|---|---|---|
endpoint | string | S3 endpoint URL (S3_ENDPOINT) |
bucket | string | Bucket name (S3_BUCKET) |
accessKeyId | string | Access key (S3_ACCESS_KEY_ID) |
secretAccessKey | string | Secret key (S3_SECRET_ACCESS_KEY) |
region | string | Region, default "auto" (S3_REGION) |
publicUrl | string | Optional CDN URL (S3_PUBLIC_URL) |
// All fields from S3_* environment variables (Node container deployments)
s3()
// Mix: CDN from config, rest from environment
s3({ publicUrl: "https://cdn.example.com" })
// All explicit (unchanged from before)
s3({
endpoint: "https://xxx.r2.cloudflarestorage.com",
bucket: "media",
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
publicUrl: "https://cdn.example.com",
})
Runtime environment variable resolution is a Node-only feature. On Cloudflare
Workers, secrets and variables are exposed through the env parameter of the
fetch handler, not through process.env, so S3_* environment variables are
not picked up. Workers deployments should either use the r2(config)
adapter or pass explicit values to s3({...}). See
Storage Options for details.
Live Collections
Configure the EmDash loader in src/live.config.ts:
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({
loader: emdashLoader(),
}),
};
Loader Options
The emdashLoader() function accepts optional configuration:
emdashLoader({
// Currently no options - reserved for future use
});
Environment Variables
EmDash respects these environment variables:
| Variable | Description |
|---|---|
EMDASH_SITE_URL | Public browser-facing origin (falls back to SITE_URL) |
EMDASH_DATABASE_URL | Override database URL |
EMDASH_AUTH_SECRET | Secret for passkey authentication |
EMDASH_PREVIEW_SECRET | Secret for preview token generation |
EMDASH_URL | Remote EmDash URL for schema sync |
Generate an auth secret with:
npx emdash auth secret
package.json Configuration
Optional configuration in package.json:
{
"emdash": {
"label": "My Blog Template",
"description": "A clean, minimal blog template",
"seed": ".emdash/seed.json",
"url": "https://my-site.pages.dev",
"preview": "https://emdash-blog.pages.dev"
}
}
| Option | Description |
|---|---|
label | Template name for display |
description | Template description |
seed | Path to seed JSON file |
url | Remote URL for schema sync |
preview | Demo site URL for template preview |
TypeScript Configuration
EmDash generates types in .emdash/types.ts. Add to your tsconfig.json:
{
"compilerOptions": {
"paths": {
"@emdash-cms/types": ["./.emdash/types.ts"]
}
}
}
Generate types with:
npx emdash types