lumenpages-cms
v0.0.3
Published
A lightweight, edge-native headless CMS for Cloudflare Workers — pages, layouts, components, forms, webhooks, and a built-in MCP server.
Readme
Lumenpages CMS
A lightweight headless CMS for the edge, built on Cloudflare Workers. Powered by D1 (SQLite), R2 object storage, and a custom template engine.
Lumenpages CMS is also AI/LLM friendly — instead of using the APIs you can use the MCP with your favorite coding agent and it will be able to manage content for you.
Quick Start
npm install
npm run setup # creates tables + seeds demo content (local D1)
npm run dev # start wrangler dev serverVisit http://localhost:8787.
Scaffold a project with the CLI
The lumenpages-cms CLI scaffolds a project configured for local development first — no Cloudflare account or deploy is needed to start.
lumenpages-cms create # scaffold + generate keys + seed local D1 + admin JWT
cd my-project
lumenpages-cms dev # local dev server at http://localhost:8787create prompts for a project name, admin email, and app name. It writes everything to .dev.vars (keys, APP_URL=http://localhost:8787, an admin JWT) and seeds the local D1 so the site works immediately.
When you are ready to go live:
lumenpages-cms deploy # deploy Worker + provision remote D1 + upload secrets + set APP_URLdeploy logs in to Cloudflare (if needed), deploys the Worker (auto-provisioning remote D1 + R2), runs the schema and first-run seed on the remote DB, uploads the secrets from .dev.vars, sets APP_URL to your production URL, and offers to install the MCP. It is safe to re-run: schema is idempotent and seeding is skipped once the remote DB has content.
Push local work live
deploy ships the Worker; it never overwrites remote content. After building a site locally (via the UI, API, or MCP), push it live:
lumenpages-cms push # overwrite remote D1 with local + upload local R2 media
lumenpages-cms push --skip-media # database onlypush exports your local D1, replaces the remote database with it (pages, layouts, components, forms, settings, custom tables), and uploads every local media object to the remote R2 bucket (keeping content types). A D1 Time Travel bookmark of the remote database is captured automatically before pushing, so you can undo with lumenpages-cms db restore --remote <id>. Pass --no-snapshot to skip it, or -y to skip confirmation. Pushed content is live immediately — rendered HTML is served with Cache-Control: max-age=0, must-revalidate, so the edge never serves stale pages (transformed images keep their 1-year immutable cache).
| Command | Description |
|---------|-------------|
| lumenpages-cms create | Scaffold a project for local development |
| lumenpages-cms create --deploy | Scaffold and deploy immediately |
| lumenpages-cms dev | Start the local dev server |
| lumenpages-cms deploy | Deploy to Cloudflare and provision remote resources |
| lumenpages-cms deploy --app-url https://cms.example.com | Deploy with an explicit public URL |
| lumenpages-cms push | Push local content + media to the deployed site |
| lumenpages-cms push --skip-media | Push only the database |
| lumenpages-cms destroy | Remove a deployed project and its resources |
| lumenpages-cms token --sub admin --exp 365d | Generate a signed JWT |
| lumenpages-cms db snapshot --label "before migration" | Capture a local D1 snapshot (default) |
| lumenpages-cms db snapshot --remote --label "pre-deploy" | Capture a remote Time Travel bookmark |
| lumenpages-cms db snapshots | List stored snapshots |
| lumenpages-cms db restore | Restore the D1 database to a snapshot (interactive) |
| lumenpages-cms db restore 3f9a1c2d | Restore a specific snapshot by id |
Building Blocks
Layouts
Layouts wrap page content to avoid boilerplate. A default layout is seeded automatically.
Fields
| Field | Type | Description |
|-------|------|-------------|
| id | TEXT PK | Unique identifier |
| name | TEXT | Human-friendly name |
| data | TEXT (JSON) | Data sources to resolve and pass to the template |
| css | TEXT | CSS injected before </head> |
| js | TEXT | JS injected before </body> |
| content | TEXT | Template HTML with {{content}} placeholder for page body |
| created_at | TEXT | Timestamp |
| updated_at | TEXT | Timestamp |
Pages
Every URL is a page. The path field supports URLPattern syntax for dynamic routes (e.g. /products/:id).
Fields
| Field | Type | Description |
|-------|------|-------------|
| id | TEXT PK | Unique identifier |
| path | TEXT UNIQUE | URL path pattern (supports :param wildcards) |
| title | TEXT | Page title |
| layout | TEXT | Layout ID to use (defaults to default if NULL) |
| data | TEXT (JSON) | Data sources to resolve and pass to the template |
| content | TEXT | Template HTML |
| meta | TEXT (JSON) | Key/value pairs for <head> meta tags |
| head_html | TEXT | Raw HTML injected before </head> |
| body_html | TEXT | Raw HTML injected before </body> |
| css | TEXT | Page-specific CSS |
| js | TEXT | Page-specific JS |
| validation | TEXT (JSON) | Validation rules for params, query, and body |
| handler | TEXT | Custom JS handler code — bypasses template rendering |
| created_at | TEXT | Timestamp |
| updated_at | TEXT | Timestamp |
Components
Reusable template fragments usable inside pages, layouts, and other components.
Fields
| Field | Type | Description |
|-------|------|-------------|
| id | TEXT PK | Unique identifier |
| name | TEXT UNIQUE | Name used in {{ component name:"..." }} |
| description | TEXT | What the component does |
| json_schema | TEXT (JSON) | If present, props are validated against this schema |
| data | TEXT (JSON) | Data sources to resolve (only for referenced components) |
| content | TEXT | Template HTML |
| css | TEXT | Component CSS (deduped, injected into the page) |
| js | TEXT | Component JS (deduped, injected into the page) |
| default_props | TEXT (JSON) | Default props merged with passed-in props |
| created_at | TEXT | Timestamp |
| updated_at | TEXT | Timestamp |
Usage
{{ component name:"hero" }}
{{ component name:"feature-card" props:this }}Components are resolved recursively. If component A references component B, both their data sources are collected and resolved.
Forms
Define forms with field schemas, then collect submissions via a public endpoint.
Fields
| Field | Type | Description |
|-------|------|-------------|
| id | TEXT PK | Unique identifier |
| name | TEXT | Form name |
| description | TEXT | Description |
| fields | TEXT (JSON) | Field definitions for validation |
| created_at | TEXT | Timestamp |
| updated_at | TEXT | Timestamp |
Field Definitions
The fields column stores a JSON array of field definitions. Each definition supports:
| Property | Type | Description |
|----------|------|-------------|
| name | string | Required. Field name (key in submitted data) |
| type | string | Field type: string (default), number, boolean, email, uuid |
| required | boolean | Whether the field must be present |
| min | number | Minimum value (number type) |
| max | number | Maximum value (number type) |
| minLength | number | Minimum string length |
| maxLength | number | Maximum string length |
| pattern | string | Regex pattern the value must match (string/email type) |
| enum | array | Allowed values — value must be in the list (all types) |
Example:
[
{ "name": "email", "type": "email", "required": true },
{ "name": "subject", "type": "string", "required": true, "minLength": 3 },
{ "name": "message", "type": "string", "required": true, "minLength": 10 },
{ "name": "rating", "type": "number", "required": false, "min": 1, "max": 5 },
{ "name": "category", "type": "string", "enum": ["general", "support", "billing"] },
{ "name": "id", "type": "uuid", "required": true }
]Form Entries
Submissions are stored in the form_entries table.
| Field | Type | Description |
|-------|------|-------------|
| id | TEXT PK | Unique identifier |
| form_id | TEXT | References the form |
| data | TEXT (JSON) | Submitted field values (validated) |
| created_at | TEXT | Timestamp |
Submitting Forms
Send a POST request to /forms/:id. The endpoint accepts both JSON and form-encoded bodies:
# JSON
curl -X POST https://your-cms.dev/forms/<form-id> \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "message": "Hello!"}'
# HTML form (application/x-www-form-urlencoded)
curl -X POST https://your-cms.dev/forms/<form-id> \
-d "[email protected]" \
-d "message=Hello!"Responses:
| Status | Description |
|--------|-------------|
| 201 | Success — returns { "success": true, "id": "<entry-id>" } |
| 400 | Missing/invalid body or unsupported Content-Type |
| 404 | Form not found |
| 422 | Validation failed — returns { "error": "Validation failed", "details": [...] } |
Managing Entries (Admin)
Form entries are managed via the authenticated API:
GET /api/forms/:id/entries List all entries for a form
DELETE /api/forms/:id/entries/:entryId Delete a specific entryWebhooks
Notify external apps when events occur. Lumenpages CMS delivers webhook payloads via HTTP POST to every active webhook whose events list matches the fired event. Each delivery includes a signed JWT in the X-CMS-Signature header so the receiver can verify both the origin and the body integrity.
Fields
| Field | Type | Description |
|-------|------|-------------|
| id | TEXT PK | Unique identifier |
| name | TEXT | Webhook name |
| description | TEXT | Description |
| endpoint | TEXT | HTTP endpoint URL |
| events | TEXT (JSON) | Event types to listen for (e.g. ["page.created", "form_entry.created", "page.*"]). null, [], or ["*"] subscribes to all events. |
| is_active | INTEGER (default 1) | 0 or 1 |
| created_at | TEXT | Timestamp |
| updated_at | TEXT | Timestamp |
Events
Events are delivered automatically when content changes through the REST API, MCP, or public form submissions. Custom events can be fired from custom handlers via ctx.trigger.
| Event | Fired when | data |
|-------|------------|--------|
| page.created / page.updated / page.deleted | Page CRUD via API/MCP | The page record ({ id } on delete) |
| layout.created / layout.updated / layout.deleted | Layout CRUD | The layout record |
| component.created / component.updated / component.deleted | Component CRUD | The component record |
| form.created / form.updated / form.deleted | Form CRUD | The form record |
| webhook.created / webhook.updated / webhook.deleted | Webhook CRUD | The webhook record |
| form_entry.created | Public form submission (POST /forms/:id) | The entry record |
| form_entry.deleted | Entry deleted via API | { form_id, id } |
| setting.upserted / setting.deleted | Setting changed via API | { key, value? } |
| custom | ctx.trigger("my.event", data) in a handler | Whatever you pass |
Event subscription matching supports exact names (page.created), namespace wildcards (page.*), and a catch-all (*).
Payload
Each delivery is a JSON POST body:
{
"id": "8f3a...-delivery-id",
"event": "page.created",
"created_at": "2026-08-05T12:00:00.000Z",
"data": { "id": "...", "path": "/blog/hello", "title": "Hello" },
"app": { "url": "https://cms.example.com", "name": "My CMS" }
}Integrity Verification
Every delivery carries an X-CMS-Signature header containing a short-lived RS256 JWT. The token is scoped exclusively to integrity verification — it cannot be used to authenticate against the /api or /mcp endpoints.
JWT claims:
| Claim | Description |
|-------|-------------|
| iss | App URL (APP_URL) |
| iat / exp | Issued / expires (5-minute TTL) |
| scope | Always "webhook.verify" |
| event | The event name |
| delivery_id | Matches id in the payload |
| sig | SHA-256 hash of the raw request body (hex) |
Verifying a delivery (Node.js example):
const crypto = await import("node:crypto");
const { jwtVerify, createLocalJWKSet } = await import("jose");
// 1. Fetch the CMS public key once (cache it)
const jwks = createLocalJWKSet(
await fetch("https://<your-cms>/.well-known/jwks.json").then((r) => r.json()),
);
// 2. Verify the JWT
const { payload } = await jwtVerify(req.headers["x-cms-signature"], jwks, {
algorithms: ["RS256"],
});
if (payload.scope !== "webhook.verify") throw new Error("wrong scope");
// 3. Verify body integrity
const rawBody = await readRawBody(req); // exact bytes
const hash = crypto.createHash("sha256").update(rawBody).digest("hex");
if (hash !== payload.sig) throw new Error("body tampered");The receiver only needs the public key — no shared secret is required. The JWT signature proves origin; the sig claim (covering the JWT via RS256) proves the body is unmodified.
Settings
Simple key/value store for application configuration.
| Field | Type | Description |
|-------|------|-------------|
| key | TEXT PK | Setting key |
| value | TEXT | Setting value (string or JSON) |
Custom Tables
Custom tables cover any extra use case that needs to store and serve structured data outside the built-in content types. Any table whose name starts with __ is automatically discovered and gets full CRUD API endpoints and MCP tools — no configuration required.
Every custom table managed by Lumenpages CMS has three automatic columns:
| Column | Type | Description |
|--------|------|-------------|
| id | TEXT PK | Auto-generated UUID |
| created_at | TEXT | Timestamp |
| updated_at | TEXT | Timestamp |
Plus whatever columns you define. Tables are also queryable from templates and handlers via the db data source and ctx.db.query (use the __-prefixed name in SQL).
Creating Tables
Create a table via the API or MCP:
curl -X POST https://your-cms.dev/api/custom-tables \
-H "Authorization: Bearer <JWT>" \
-H "Content-Type: application/json" \
-d '{
"name": "__posts",
"columns": [
{ "name": "title", "type": "TEXT", "nullable": false },
{ "name": "body", "type": "TEXT" },
{ "name": "views", "type": "INTEGER" },
{ "name": "published", "type": "BOOLEAN" }
]
}'Allowed column types: TEXT, INTEGER, REAL, NUMERIC, BLOB, BOOLEAN, DATETIME, DATE, JSON. Each column supports nullable (default true) and unique.
Management Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /api/custom-tables | List all custom tables with their columns |
| GET | /api/custom-tables/:name | Describe a single table's columns |
| POST | /api/custom-tables | Create a new table |
| DELETE | /api/custom-tables/:name | Drop a table and all its data |
CRUD Endpoints
Every __-prefixed table automatically gets standard CRUD endpoints at /api/<table>:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /api/__posts | List all rows |
| GET | /api/__posts/:id | Get a single row |
| POST | /api/__posts | Create a row (auto-generates id) |
| PUT | /api/__posts/:id | Update a row |
| DELETE | /api/__posts/:id | Delete a row |
CRUD operations fire webhook events using the table name as the namespace (e.g. __posts.created, __posts.updated, __posts.deleted).
MCP Tools
Custom tables are also exposed as MCP tools, discovered dynamically at runtime:
| Tool | Description |
|------|-------------|
| list_custom_tables | List all custom tables |
| describe_custom_table | Describe a table's columns |
| create_custom_table | Create a new table |
| drop_custom_table | Drop a table |
For each table (e.g. __posts), five CRUD tools are generated automatically: list___posts, get___posts, create___posts, update___posts, delete___posts.
Data Sources
The data field on pages, layouts, and components accepts a JSON object where each key defines a named data source. At request time, all sources across all three levels are collected, deduplicated, and resolved in parallel. The results are merged into a single data object accessible in templates via {{ data.xxx }}.
Priority
When the same key is defined at multiple levels with different sources, priority is: page > layout > component.
Deduplication
Identical sources (same SQL + params, same URL, same file path) are resolved once and shared across all references. For example, if a page and a component both request SELECT * FROM categories, only one query runs.
Source Types
static
Inline data passed directly to the template.
{
"site_config": {
"type": "static",
"data": {
"name": "My Site",
"description": "A cool website"
}
}
}{{ data.site_config.name }}db
Execute a SQL query against the D1 database.
{
"posts": {
"type": "db",
"query": "SELECT * FROM posts WHERE category = ? ORDER BY created_at DESC LIMIT 5",
"params": ["query.category"]
},
"featured": {
"type": "db",
"queryOne": "SELECT * FROM posts WHERE featured = 1 LIMIT 1"
}
}queryreturns an array of rows:{{#each data.posts}}{{this.title}}{{/each}}queryOnereturns a single object (or null):{{ data.featured.title }}paramsvalues are resolved dynamically (see Param Resolution)
Only data statements (SELECT, INSERT, UPDATE, DELETE, REPLACE, WITH, VALUES) are allowed — schema operations are rejected (see SQL Guard).
url
Fetch and parse JSON from an external HTTP endpoint.
{
"weather": {
"type": "url",
"url": "https://api.weather.gov/points/40.7,-74.0",
"method": "GET",
"headers": {
"Accept": "application/json"
}
}
}{{ data.weather.temperature }}file
Fetch metadata and a time-limited signed URL for an R2 object. The file content is not loaded into the template — instead you get a signed URL that can be used in <a>, <img>, <video>, etc.
{
"brochure": {
"type": "file",
"path": "marketing/brochure.pdf",
"expires": 3600
}
}<a href="{{ data.brochure.url }}">Download ({{ data.brochure.size }} bytes)</a>Result object shape:
| Field | Description |
|-------|-------------|
| key | R2 object key |
| size | File size in bytes |
| etag | R2 etag |
| httpEtag | HTTP etag (quoted) |
| uploaded | ISO timestamp |
| url | Signed URL served via /__r2/{key}?exp=...&sig=... |
The signed URL is verified via HMAC-SHA256 and expires after expires seconds (default: 3600).
Param Resolution
DB query params values are resolved dynamically from the request:
| Param value | Resolves to |
|-------------|-------------|
| "params.id" | URL path parameter (e.g. /products/:id → id) |
| "query.q" | URL query string parameter (e.g. ?q=shoes) |
| Any other string | Passed through as a literal value |
How Resolution Works
1. Collect data configs from: page + layout + referenced components
2. Deduplicate by content (same SQL+params / same URL / same file path)
3. Resolve in parallel:
- All unique DB queries → single db.batch() round-trip
- All unique URL fetches → concurrent fetch()
- All unique file ops → concurrent R2.head() + signUrl()
4. Merge results (page > layout > component on key collisions)
5. Pass as `data` to all template render callsErrors are handled gracefully: a failed source logs the error and resolves to null without breaking the page.
Templates
Lumenpages CMS uses a custom Mustache-like template engine with {{ }} syntax.
Variables
All template variables support dotted paths, array index access ([0]), and this inside loops.
| Variable | Scope | Description |
|----------|-------|-------------|
| {{ title }} | Page + Layout | The page title |
| {{ content }} | Layout | The rendered page HTML |
| {{ head_html }} | Page + Layout | Raw HTML from the page's head_html field |
| {{ body_html }} | Page + Layout | Raw HTML from the page's body_html field |
| {{ data.* }} | Page + Layout | Resolved data sources |
| {{ params.* }} | Page + Layout | URL path parameters (e.g. /products/:id → {{ params.id }}) |
| {{ query.* }} | Page + Layout | URL query string parameters (e.g. ?q=shoes → {{ query.q }}) |
| {{ body.* }} | Page + Layout | Parsed POST body fields (available when page validation rules define body) |
| {{ request.path }} | Page + Layout | Current URL pathname (e.g. /blog/my-post) |
| {{ request.href }} | Page + Layout | Full URL (e.g. https://example.com/blog/my-post?sort=new) |
| {{ request.origin }} | Page + Layout | Scheme + host (e.g. https://example.com) |
| {{ request.hostname }} | Page + Layout | Hostname only (e.g. example.com) |
| {{ this.* }} | Inside {{#each}} | Current item in a loop |
Active Link Example
Use request.path with conditionals to mark active navigation links:
<a href="/about" class="{{#if request.path == "/about"}}active{{/if}}">About</a>
<a href="/blog" class="{{#if request.path startsWith "/blog"}}active{{/if}}">Blog</a>Conditionals
{{#if data.featured}}
<div>Featured: {{ data.featured.title }}</div>
{{else}}
<div>No featured post</div>
{{/if}}Operators: ==, !=, >, <, >=, <=, contains, startsWith, endsWith.
{{#if data.post.status == "published"}}Visible{{/if}}
{{#if data.tags contains "tech"}}Tech{{/if}}Loops
{{#each data.posts}}
<article>
<h2>{{this.title}}</h2>
<p>{{this.excerpt}}</p>
</article>
{{/each}}Components
{{ component name:"hero" }}
{{ component name:"feature-card" props:this }}Components receive the full context including data, params, query, and request. Props are merged: context → default_props → override props.
Custom Handlers
When a page has a handler field set, the normal template rendering flow is completely bypassed. Instead, the handler code runs in a sandboxed Dynamic Worker (via Cloudflare's Worker Loader API) and has full control over the request/response lifecycle.
Handlers respond to GET, POST, PUT, PATCH, and DELETE requests — use ctx.method to branch on the HTTP method. Pages without a handler only render templates on GET and POST; other methods return 405 Method Not Allowed.
The handler code is the body of an async function that receives a ctx object. It must return a Response.
ctx Object
| Property | Type | Description |
|----------|------|-------------|
| ctx.method | string | HTTP method (GET, POST, etc.) |
| ctx.url | string | Full request URL |
| ctx.params | Record<string, string> | URL path params (e.g. /posts/:id → ctx.params.id) |
| ctx.query | Record<string, string> | Query string params |
| ctx.body | Record<string, unknown> | Parsed request body (after validation, available for POST/PUT/PATCH/DELETE) |
| ctx.headers | Record<string, string> | Request headers |
| ctx.page | { id, path, title } | The matched page record |
| ctx.request | { method, url, headers } | Request summary |
Methods on ctx
| Method | Description |
|--------|-------------|
| ctx.db.query(sql, params?) | Run a SQL query → { rows, meta }. Limited to data statements (SELECT, INSERT, UPDATE, DELETE, REPLACE, WITH). |
| ctx.db.batch(statements) | Run multiple queries in a single round-trip (same restrictions as query) |
| ctx.render(template, data, params?, query?) | Render a template string using the template engine |
| ctx.sendEmail(opts) | Send an email (same options as the email service) |
| ctx.trigger(event, data) | Fire a custom webhook event (delivery runs in the background) |
| ctx.html(str, status?) | Return an HTML response |
| ctx.json(data, status?) | Return a JSON response |
| ctx.text(str, status?) | Return a plain text response |
| ctx.redirect(url, status?) | Return a redirect response (default 302) |
| ctx.fetch(input, init?) | Make outbound HTTP requests from inside the handler |
SQL Guard:
ctx.db.queryand thedbdata source are restricted to data operations —SELECT,INSERT,UPDATE,DELETE,REPLACE,WITH, andVALUES. Schema-changing statements (CREATE,DROP,ALTER,PRAGMA,ATTACH, etc.) and stacked queries are rejected. Table structure is managed through the custom tables API and setup scripts instead.
Examples
JSON API endpoint — create a page with path /api/posts/:id:
const post = await ctx.db.query(
"SELECT * FROM posts WHERE id = ?",
[ctx.params.id]
);
if (post.rows.length === 0) return ctx.json({ error: "Not found" }, 404);
return ctx.json(post.rows[0]);Custom HTML page with DB data:
const posts = await ctx.db.query("SELECT * FROM posts ORDER BY created_at DESC");
const list = posts.rows.map(p => `<li>${p.title}</li>`).join("");
return ctx.html(`<!DOCTYPE html><html><body><ul>${list}</ul></body></html>`);Send email on form submission:
await ctx.sendEmail({
to: "[email protected]",
subject: "New contact form submission",
html: `<p>Name: ${ctx.body.name}</p><p>Message: ${ctx.body.message}</p>`
});
return ctx.redirect("/thank-you");Using the template engine inside a handler:
const product = await ctx.db.query(
"SELECT * FROM products WHERE id = ?",
[ctx.params.id]
);
const html = ctx.render(
`<h1>{{ product.name }}</h1><p>$ {{ product.price }}</p>`,
{ product: product.rows[0] }
);
return ctx.html(html);How It Works
- Validation rules (if any) run before the handler — the handler only fires if validation passes.
- The handler code is hashed (SHA-256) and the hash is used as the Worker Loader cache key. If the code changes, a new isolate is spun up.
- DB, email, and render calls are proxied back to the parent worker via RPC bindings — the handler runs in an isolated sandbox with no direct access to bindings.
- The handler works for
GET,POST,PUT,PATCH, andDELETErequests on matched page routes.
Caching
Dynamic Worker isolates are cached by code hash. When the same handler code is used across requests, the existing isolate is reused. Changing the handler code creates a new isolate automatically.
Page Validation
Pages can define validation rules for URL params, query params, and POST body. Validation runs before both template rendering and custom handlers.
The validation field accepts a JSON object with optional params, query, and body arrays. Each array contains field definitions (same format as form field definitions):
{
"params": [
{ "name": "id", "type": "uuid", "required": true }
],
"query": [
{ "name": "tab", "type": "string", "enum": ["details", "reviews"] }
],
"body": [
{ "name": "rating", "type": "number", "min": 1, "max": 5 }
]
}If validation fails, the page returns 404 Not Found (intentionally — to avoid revealing route existence).
R2 File Serving
Files stored in the R2 bucket (BUCKET binding) are served via signed URLs at /__r2/{key}. The route verifies the HMAC-SHA256 signature and expiry before serving the object with proper Content-Type and Cache-Control headers.
Files are accessed through the file data source — direct access to /__r2/* without a valid signature returns 403 Forbidden.
File Uploads
Uploads use a two-step signed-URL flow so file bytes never pass through JSON or the API auth layer more than once:
- Mint an upload URL (requires your API JWT):
curl -X POST https://<worker>/api/files/upload-url \
-H "Authorization: Bearer <JWT>" \
-H "Content-Type: application/json" \
-d '{"key":"photos/cat.jpg","contentType":"image/jpeg","expiresIn":600}'{
"key": "photos/cat.jpg",
"url": "https://<worker>/__r2/photos/cat.jpg?exp=...&sig=...",
"method": "PUT",
"headers": { "Content-Type": "image/jpeg" },
"expiresIn": 600
}- Upload the raw bytes with a PUT to the signed URL (no JWT needed, the signature authorizes the upload).
expiresInis clamped to 60–3600 seconds (default 600).
curl -X PUT "https://<worker>/__r2/photos/cat.jpg?exp=...&sig=..." \
-H "Content-Type: image/jpeg" \
--data-binary @cat.jpgThe signature is bound to the PUT method, so an upload URL cannot be reused to download the file (and vice-versa). Optional per-object metadata can be attached with X-Meta-* request headers on the PUT (e.g. X-Meta-Uploader: jane).
Image Transformation
Lumenpages CMS includes on-the-fly image resizing and format conversion powered by the Cloudflare Images binding. The middleware intercepts GET requests to image URLs (both static assets in ./public/ and R2 files served via /__r2/) when transform query parameters are present.
Usage
Append any combination of transform parameters to an image URL:
<!-- Resize a static asset -->
<img src="/images/logo.png?w=300&h=200&fit=cover" />
<!-- Convert an R2 file to WebP -->
<img src="/__r2/photos/sunset.jpg?exp=...&sig=...&w=800&f=webp&q=80" />
<!-- Auto format: serves AVIF/WebP based on Accept header -->
<img src="/images/hero.avif?w=1200&f=auto" />Parameters
| Param | Alias | Description | Range |
|-------|-------|-------------|-------|
| w | width | Output width (px) | 1–4000 |
| h | height | Output height (px) | 1–4000 |
| f | format | Output format: webp, avif, jpeg, png, gif, auto | — |
| q | quality | Compression quality | 1–100 (default 85) |
| fit | — | Resize fit mode | scale-down, contain, cover, crop, pad, squeeze |
| dpr | — | Device pixel ratio multiplier | 1–3 |
| gravity | — | Focus area for cropping | auto, left, right, top, bottom, center, face, entropy |
| blur | — | Blur radius | 0–250 |
| sharpen | — | Sharpen amount | 0–10 |
| rotate | — | Rotation degrees | 0, 90, 180, 270 |
| flip | — | Flip direction | h, v, hv |
| bg | background | Background color (for pad fit) | CSS color string |
| brightness | — | Brightness adjustment | 0–2 |
| contrast | — | Contrast adjustment | 0–2 |
| gamma | — | Gamma adjustment | 0–2 |
| saturation | — | Saturation adjustment | 0–2 |
When f=auto (or omitted), the output format is negotiated from the Accept header: AVIF > WebP > JPEG.
Optimizing Images
Every image Lumenpages CMS serves — whether a static asset in ./public/ or an R2 file — can be resized and reformatted on the fly. There is no build step, no separate optimization pipeline, and no need to pre-generate variants. Transforms run at the edge and results are cached for a year, so always append transform parameters when embedding images rather than shipping the original full-resolution file.
Rules of thumb:
- Always set
f=autoso each browser gets the best format it supports (AVIF/WebP). - Always set
w(andhwhere the box is known) to avoid sending oversized pixels. - Always set
width/heightattributes (oraspect-ratio) on the<img>to prevent layout shift. - Use
loading="lazy"for below-the-fold images;fetchpriority="high"for the LCP image. - Serve multiple sizes via
srcsetwhen the image spans a range of viewport widths.
Recipes — copy these patterns when writing <img> tags:
<!-- Hero / full-width banner (LCP image) -->
<img src="/images/hero.jpg?w=1600&h=600&fit=cover&f=auto&q=80"
width="1600" height="600" alt="..." fetchpriority="high" />
<!-- Card / list thumbnail -->
<img src="/images/photo.jpg?w=400&h=300&fit=cover&f=auto&q=75"
width="400" height="300" alt="..." loading="lazy" />
<!-- Responsive via srcset (browser picks the best size) -->
<img src="/images/photo.jpg?w=800&f=auto&q=80"
srcset="/images/photo.jpg?w=400&f=auto 400w,
/images/photo.jpg?w=800&f=auto 800w,
/images/photo.jpg?w=1200&f=auto 1200w"
sizes="(max-width: 768px) 100vw, 33vw"
width="800" height="600" alt="..." loading="lazy" />
<!-- Square avatar with face-aware cropping -->
<img src="/images/me.png?w=96&h=96&fit=cover&gravity=face&f=auto"
width="96" height="96" alt="..." loading="lazy" />R2 file URLs already contain
?exp=...&sig=.... Signature verification only coversexpandsig, so append transform parameters with&— e.g.{{ data.cover.url }}&w=800&f=auto.
Caching
Transformed images are cached at the edge using the Cache API with Cache-Control: public, max-age=31536000, immutable. If the transform fails (e.g., corrupt input), the original image is served as a fallback.
Email Service
Lumenpages CMS integrates with Cloudflare Email Service for sending transactional emails. Email is available inside custom handlers via ctx.sendEmail().
Configuration
Set the EMAIL_FROM secret to define a default sender address:
# .dev.vars or wrangler secrets
EMAIL_FROM="[email protected]"If EMAIL_FROM is set, you can omit the from field when calling ctx.sendEmail().
Related optional secrets:
| Secret | Description |
|--------|-------------|
| EMAIL_REPLY_TO | Default reply-to address (used when replyTo is omitted) |
| APP_NAME | App display name, available in templates as {{app.name}} |
| APP_URL | Public base URL, available in templates as {{app.url}} (used for links in emails) |
Sending Emails
From a custom handler:
await ctx.sendEmail({
to: "[email protected]",
subject: "Welcome!",
html: "<h1>Welcome to our site</h1>",
text: "Welcome to our site"
});
return ctx.redirect("/welcome");Options
| Property | Type | Description |
|----------|------|-------------|
| to | string \| string[] | Required. Recipient address(es) |
| from | string \| { email, name? } | Sender address (defaults to EMAIL_FROM) |
| subject | string | Required. Email subject |
| html | string | HTML body |
| text | string | Plain text body |
| cc | string \| string[] | CC recipient(s) |
| bcc | string \| string[] | BCC recipient(s) |
| replyTo | string | Reply-to address (defaults to EMAIL_REPLY_TO) |
| headers | Record<string, string> | Custom email headers |
| attachments | Attachment[] | File attachments ({ filename, content, type, disposition, contentId? }) |
| template | string | A template string rendered to HTML (uses the same {{var}} engine as pages). Mutually exclusive with html. |
| data | Record<string, unknown> | Data passed to the template context |
Email Templates
Instead of passing a raw html string, you can pass a template and data. The template uses the same {{variable}} engine as page/layout templates, so it supports interpolation ({{name}}), conditionals ({{#if}}…{{/if}}), and loops ({{#each items}}…{{/each}}).
await ctx.sendEmail({
to: "[email protected]",
subject: "Welcome!",
template: `<h1>Welcome to {{app.name}}, {{name}}!</h1>
<p>Visit us at <a href="{{app.url}}">{{app.url}}</a></p>`,
data: { name: "Sam" },
});The app variable is always available in email templates:
| Variable | Description |
|----------|-------------|
| app.name | App display name (from APP_NAME) |
| app.url | App base URL (from APP_URL) |
ctx.renderEmail(template, data) renders a template string (with app injected) without sending, if you need to preview or compose a body yourself.
The
appvariable ({{app.name}},{{app.url}}) is also available in page and layout templates, not just emails.
Scripts
| Command | Description |
|---------|-------------|
| npm run dev | Start local dev server |
| npm run deploy | Deploy to Cloudflare Workers |
| npm run setup | Create tables + seed (local D1) |
| npm run setup -- --drop | Drop all tables, recreate, and seed |
| npm run setup:remote | Run setup on remote D1 |
| npm run db:apply | Apply D1 migrations (local) |
| npm run db:apply:remote | Apply D1 migrations (remote) |
| npm run test | Run vitest |
| npm run typecheck | Run tsc --noEmit |
| npm run cf:types | Regenerate worker types |
| npm run jwt:keys | Generate RS256 JWT key pair |
| npm run jwt:sign | Sign a JWT token (e.g. -- --sub admin --exp 365d) |
| npm run secrets:put | Upload secrets to Cloudflare |
Configuration
Environment Bindings (wrangler.jsonc)
| Binding | Type | Description |
|---------|------|-------------|
| DB | D1 Database | SQLite database |
| BUCKET | R2 Bucket | Object storage for files |
| EMAIL | Send Email | Email sending (Cloudflare Email Service) |
| IMAGES | Images | Image transformation/resizing |
| LOADER | Worker Loader | Dynamic Worker execution for custom handlers |
| ASSETS | Assets | Static asset serving (./public/) |
Secrets (.dev.vars)
| Secret | Description |
|--------|-------------|
| JWT_PRIVATE_JWK | RS256 private key (JWT auth + R2 URL signing) |
| JWT_PUBLIC_JWK | RS256 public key (JWT verification) |
| DB_PROVIDER | d1 (default) or turso |
| TURSO_URL | Turso endpoint (if using Turso) |
| TURSO_AUTH_TOKEN | Turso auth token (if using Turso) |
| EMAIL_FROM | Default sender address for outgoing emails |
| EMAIL_REPLY_TO | Default reply-to address for outgoing emails |
| APP_NAME | Display name of the app (available in templates as {{app.name}}) |
| APP_URL | Public base URL of the app (available in templates as {{app.url}}) |
| API_ENABLED | Enable API endpoints |
JWKS Endpoint
The public JWT key is exposed as a standard JWKS endpoint at GET /.well-known/jwks.json. This allows external services to verify JWT tokens issued by Lumenpages CMS:
{
"keys": [{ "kty": "RSA", "kid": "...", ... }]
}MCP (Model Context Protocol)
Lumenpages CMS exposes an MCP server at /mcp that lets AI agents (Claude Desktop, Cursor, etc.) manage content using the same service layer as the REST API. It uses the Streamable HTTP transport (JSON-RPC 2.0) and is protected by the same JWT authentication.
Prerequisites
- Generate JWT keys if you haven't already:
npm run jwt:keys- Sign a token for MCP usage:
npm run jwt:sign -- --sub admin --exp 365dQuick Add
Claude Code
claude mcp add --transport http lumenpages-cms http://localhost:8787/mcp \
--header "Authorization: Bearer $(npm run --silent jwt:sign -- --sub admin --exp 365d)"opencode
opencode mcp add lumenpages-cms \
--url http://localhost:8787/mcp \
--header "Authorization=Bearer $(npm run --silent jwt:sign -- --sub admin --exp 365d)"Manual Configuration
Claude Desktop (claude_desktop_config.json)
{
"mcpServers": {
"lumenpages-cms": {
"url": "http://localhost:8787/mcp",
"headers": {
"Authorization": "Bearer <your-jwt-token>"
}
}
}
}Cursor (.cursor/mcp.json)
{
"mcpServers": {
"lumenpages-cms": {
"url": "http://localhost:8787/mcp",
"headers": {
"Authorization": "Bearer <your-jwt-token>"
}
}
}
}For production, replace http://localhost:8787 with your deployed Worker URL.
Available Tools
The MCP server exposes 35+ tools covering all content types, plus dynamic tools for any custom tables:
| Entity | Tools |
|--------|-------|
| Pages | list_pages, get_page, create_page, update_page, delete_page |
| Layouts | list_layouts, get_layout, create_layout, update_layout, delete_layout |
| Components | list_components, get_component, create_component, update_component, delete_component |
| Forms | list_forms, get_form, create_form, update_form, delete_form |
| Form Entries | list_form_entries, delete_form_entry |
| Webhooks | list_webhooks, get_webhook, create_webhook, update_webhook, delete_webhook |
| Settings | list_settings, get_setting, upsert_setting, delete_setting |
| Files | list_files, create_upload_url, get_file, delete_file |
| Custom Tables | list_custom_tables, describe_custom_table, create_custom_table, drop_custom_table + 5 CRUD tools per table |
Each create/update tool accepts the same fields documented in Building Blocks. Tool results are returned as JSON.
Notes
- MCP respects the
API_ENABLEDflag — it is disabled whenAPI_ENABLED=false. - The same JWT token works for both
/apiand/mcp. - File uploads go through a signed-URL flow: call
create_upload_urlto mint aPUTURL, then upload the raw bytes directly to it (see File Uploads).
