npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 server

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

create 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_URL

deploy 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 only

push 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 entry

Webhooks

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"
  }
}
  • query returns an array of rows: {{#each data.posts}}{{this.title}}{{/each}}
  • queryOne returns a single object (or null): {{ data.featured.title }}
  • params values 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 calls

Errors 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.query and the db data source are restricted to data operations — SELECT, INSERT, UPDATE, DELETE, REPLACE, WITH, and VALUES. 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

  1. Validation rules (if any) run before the handler — the handler only fires if validation passes.
  2. 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.
  3. 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.
  4. The handler works for GET, POST, PUT, PATCH, and DELETE requests 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:

  1. 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
}
  1. Upload the raw bytes with a PUT to the signed URL (no JWT needed, the signature authorizes the upload). expiresIn is 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.jpg

The 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=auto so each browser gets the best format it supports (AVIF/WebP).
  • Always set w (and h where the box is known) to avoid sending oversized pixels.
  • Always set width/height attributes (or aspect-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 srcset when 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 covers exp and sig, 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 app variable ({{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

  1. Generate JWT keys if you haven't already:
npm run jwt:keys
  1. Sign a token for MCP usage:
npm run jwt:sign -- --sub admin --exp 365d

Quick 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_ENABLED flag — it is disabled when API_ENABLED=false.
  • The same JWT token works for both /api and /mcp.
  • File uploads go through a signed-URL flow: call create_upload_url to mint a PUT URL, then upload the raw bytes directly to it (see File Uploads).