Database Options

On this page

EmDash supports multiple database backends. Choose based on your deployment target.

Overview

DatabaseBest ForDeployment
D1Cloudflare WorkersEdge, globally distributed
HyperdrivePostgreSQL on Cloudflare WorkersEdge, existing Postgres
PostgreSQLProduction Node.jsAny platform with Postgres
libSQLRemote databasesEdge or Node.js
SQLiteNode.js, local devSingle server

Cloudflare D1

D1 is Cloudflare’s serverless SQLite database. Use it when deploying to Cloudflare Workers.

import { d1 } from "@emdash-cms/cloudflare";

export default defineConfig({
	integrations: [
		emdash({
			database: d1({ binding: "DB" }),
		}),
	],
});

Configuration

OptionTypeDefaultDescription
bindingstringD1 binding name from wrangler.jsonc
sessionstring"disabled"Read replication mode (see below)
bookmarkCookiestring"__em_d1_bookmark"Cookie name for session bookmarks

Setup

wrangler.jsonc

{
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "emdash-db"
    }
  ]
}

wrangler.toml

[[d1_databases]]
binding = "DB"
database_name = "emdash-db"

Read Replicas

D1 supports read replication to lower read latency for globally distributed sites. When enabled, read queries are routed to nearby replicas instead of always hitting the primary database.

EmDash uses the D1 Sessions API to manage this transparently. Enable it with the session option:

import { d1 } from "@emdash-cms/cloudflare";

export default defineConfig({
	integrations: [
		emdash({
			database: d1({
				binding: "DB",
				session: "auto",
			}),
		}),
	],
});

Session Modes

ModeBehavior
"disabled"No sessions. All queries go to primary. Default.
"auto"Anonymous requests read from the nearest replica. Authenticated users get read-your-writes consistency via bookmark cookies.
"primary-first"Like "auto", but the first query always goes to the primary. Use for sites with very frequent writes.

How It Works

  • Anonymous visitors get first-unconstrained — reads go to the nearest replica for the lowest latency. Since anonymous users never write, they don’t need consistency guarantees.
  • Authenticated users (editors, authors) get bookmark-based sessions. After a write, a bookmark cookie ensures the next request sees at least that state.
  • Write requests (POST, PUT, DELETE) always start at the primary database.
  • Build-time queries (Astro content collections) bypass sessions entirely and use the primary directly.

libSQL

libSQL is a fork of SQLite that supports remote connections. Use it when you need a remote database without Cloudflare D1.

import { libsql } from "emdash/db";

export default defineConfig({
	integrations: [
		emdash({
			database: libsql({
				url: process.env.LIBSQL_DATABASE_URL,
				authToken: process.env.LIBSQL_AUTH_TOKEN,
			}),
		}),
	],
});

Configuration

OptionTypeDescription
urlstringDatabase URL (libsql://... or file:...)
authTokenstringAuth token for remote databases (optional for local)

Local Development

Use a local libSQL file during development:

database: libsql({ url: "file:./data.db" });

PostgreSQL

PostgreSQL is supported for Node.js deployments that need a full relational database.

import { postgres } from "emdash/db";

export default defineConfig({
	integrations: [
		emdash({
			database: postgres({
				connectionString: process.env.DATABASE_URL,
			}),
		}),
	],
});

Configuration

You can connect with a connection string or individual parameters:

// Connection string
database: postgres({
	connectionString: "postgres://user:password@localhost:5432/emdash",
});

// Individual parameters
database: postgres({
	host: "localhost",
	port: 5432,
	database: "emdash",
	user: "emdash",
	password: process.env.DB_PASSWORD,
	ssl: true,
});
OptionTypeDescription
connectionStringstringPostgreSQL connection URL
hoststringDatabase host
portnumberDatabase port
databasestringDatabase name
userstringDatabase user
passwordstringDatabase password
sslbooleanEnable SSL
pool.minnumberMinimum pool connections (default 0)
pool.maxnumberMaximum pool connections (default 10)

Connection Pooling

The adapter uses pg.Pool under the hood. Tune pool size based on your deployment:

database: postgres({
	connectionString: process.env.DATABASE_URL,
	pool: { min: 2, max: 20 },
});

Hyperdrive

Use the hyperdrive() adapter to run EmDash on Cloudflare Workers backed by an existing PostgreSQL — or Postgres-compatible (e.g. PlanetScale Postgres) — database. Hyperdrive pools and accelerates the connection over Cloudflare’s network; EmDash’s PostgreSQL dialect runs the queries.

import { hyperdrive, r2 } from "@emdash-cms/cloudflare";

export default defineConfig({
	integrations: [
		emdash({
			database: hyperdrive({ binding: "HYPERDRIVE" }),
			storage: r2({ binding: "MEDIA" }),
		}),
	],
});

Requirements

  • pg >= 8.16.3 installed in your site (pnpm add pg)
  • compatibility_flags: ["nodejs_compat"]
  • compatibility_date >= "2024-09-23"

Setup

Create the Hyperdrive configuration and add the binding to your Wrangler config:

wrangler hyperdrive create emdash-db \
  --connection-string "postgres://user:password@host/db?sslmode=verify-full" \
  --caching-disabled

wrangler.jsonc

{
  "hyperdrive": [
    {
      "binding": "HYPERDRIVE",
      "id": "<your-hyperdrive-id>"
    }
  ]
}

wrangler.toml

[[hyperdrive]]
binding = "HYPERDRIVE"
id = "<your-hyperdrive-id>"

Configuration

OptionTypeDefaultDescription
bindingstring"HYPERDRIVE"Primary (caching-disabled) Hyperdrive binding name
cachedBindingstringOptional caching-enabled binding for anonymous reads (see below)
maxnumber5Max size of the in-Worker connection pool to Hyperdrive

Serving anonymous reads from cache

By default you disable Hyperdrive caching entirely, because the admin and writes need read-after-write consistency. But anonymous public reads — no session, no write — can tolerate a short staleness window. If that trade-off is acceptable, run two Hyperdrive configurations over the same database: one with caching off (the primary binding) and one with caching on (cachedBinding). EmDash then routes anonymous read requests through the cache-enabled binding while every authenticated request and every write stays on the uncached primary, so read-after-write consistency is preserved.

# Primary — caching OFF (used by admin, auth'd requests, writes, migrations)
wrangler hyperdrive create emdash-db \
  --connection-string "postgres://user:password@host/db?sslmode=verify-full" \
  --caching-disabled

# Cached — SAME connection string, caching ON (used by anonymous reads only)
wrangler hyperdrive create emdash-db-cached \
  --connection-string "postgres://user:password@host/db?sslmode=verify-full"
{
	"hyperdrive": [
		{ "binding": "HYPERDRIVE", "id": "<caching-disabled-id>" },
		{ "binding": "HYPERDRIVE_CACHED", "id": "<caching-enabled-id>" }
	]
}
database: hyperdrive({ binding: "HYPERDRIVE", cachedBinding: "HYPERDRIVE_CACHED" });

This is the two-configuration pattern Cloudflare documents for caching. EmDash decides which binding to use per request:

  • Anonymous reads of public-site paths (GET/HEAD, no session, not under /_emdash) → cache-enabled cachedBinding.
  • Authenticated requests (editors, authors) → uncached binding.
  • Writes (POST, PUT, DELETE, including anonymous ones) → uncached binding.
  • Any request under /_emdash (admin, setup, auth, internal APIs), even an anonymous GET → uncached binding.
  • Migrations and cold-start → always the primary binding.

SQLite

SQLite with better-sqlite3 is the simplest option for Node.js deployments.

import { sqlite } from "emdash/db";

export default defineConfig({
	integrations: [
		emdash({
			database: sqlite({ url: "file:./data.db" }),
		}),
	],
});

Configuration

OptionTypeDescription
urlstringFile path with file: prefix

File Path

The url must start with file::

// Relative path
database: sqlite({ url: "file:./data/emdash.db" });

// Absolute path
database: sqlite({ url: "file:/var/data/emdash.db" });

// From environment variable
database: sqlite({ url: `file:${process.env.DATABASE_PATH}` });

Migrations

EmDash runs migrations automatically on the first request, for every supported dialect (D1, SQLite, libSQL, PostgreSQL). Migrations are bundled with the emdash package and embedded into your build.

If the database is empty (no collections) and the setup wizard has not been completed, EmDash also applies a seed file on first boot. The seed is read from .emdash/seed.json, the path in package.json#emdash.seed, or seed/seed.json — whichever is found first — and inlined into the build at compile time. If none is present, a built-in default seed is used. Subsequent boots against an existing database leave its content alone.

Environment-Based Configuration

Use different databases per environment:

import { sqlite, libsql, postgres } from "emdash/db";
import { d1 } from "@emdash-cms/cloudflare";

const database = import.meta.env.PROD ? d1({ binding: "DB" }) : sqlite({ url: "file:./data.db" });

export default defineConfig({
	integrations: [emdash({ database })],
});

The choice can also key off an environment variable instead of the build mode:

const database = process.env.DATABASE_URL
	? postgres({ connectionString: process.env.DATABASE_URL })
	: sqlite({ url: "file:./data.db" });