prelo
Docs

Embed & one process

Mount your frontend inside the CMS — one port, one URL, WordPress-style.

site: in cms.config.js names a module the CMS mounts at /. The CMS claims /api, /studio and /uploads first; everything else goes to your module — same process, same port. CommonJS and ESM both load, and a wrong export fails at boot naming the file and both valid shapes.

The contract — detected by shape

site: module
What it means
exports a function
Request handler — (req, res) or (req, res, next), Express-compatible, mounted for all methods. Mount Next, Astro, anything.
exports { handle, css }
Render module — GET-only page renderer (the starter site's shape). css is served at /style.css.
no site: at all
Fully headless — the CMS serves only the API and the studio; your frontend deploys elsewhere.

Next.js on one port

// web/site.js — the whole integration
const next = require("next");
const app = next({ dev: process.env.NODE_ENV !== "production", dir: __dirname });
const handleRequest = app.getRequestHandler();
let ready;
module.exports = async (req, res) => {
await (ready ??= app.prepare());
return handleRequest(req, res);
};

Point site: "./web/site.js" at it and prelo dev serves your Next pages at /, the studio at /studio and the API at /api — one port, one deploy. Your server code fetches content from the same port (http://localhost:3300/api/post). For Astro, build with @astrojs/node in middleware mode and export the built handler; an Express app is itself a valid handler. npx create-prelo my-site --framework scaffolds this shape.

The render module (starter shape)

module.exports = {
handle: async (url, ctx) => ({ status: 200, html: "<!doctype html>…" }),
css: "body { … }", // served at /style.css
};

GET-only. ctx carries cmsUrl (the local API base) and headers (the visitor's cookie, when present) — pass ctx.headers on every API fetch.

Draft preview

The content API serves drafts to author-and-above credentials only. In one-process mode the visitor's cookie reaches the site module, so anyone signed into the studio sees drafts on the real site — the starter renderer marks them with a "draft" pill. With your own framework, forward req.headers.cookie on CMS fetches for the same behavior. Shareable tokenized preview links don't exist yet.

The programmatic API

import { start, createApp } from "prelo";
const cms = await start({ configPath: "./cms.config.js" });
// … cms.app, cms.db, cms.server — and cms.close() when done
Export
What it does
start({ configPath, dataDir, port })
Loads and validates the config, boots, listens. Returns { app, db, config, server, close }.
createApp({ config, dataDir, configDir })
Builds the Express app without listening — embed it in an existing server, or drive it in tests. Returns { app, db, config, close }.
loadConfig · validateConfig · ConfigError
The config pipeline, exported for tooling.
renderBody (also prelo/render)
Renders a content body to HTML — usable from any frontend.

When to go fully headless instead

Keep one process unless the frontend must live on other infrastructure — Vercel, a static CDN, an existing deployment. Then omit site:, point the frontend at the CMS URL, and set webhooks: { onPublish: "https://…/api/revalidate" } — every publish POSTs { event: "publish", type, slug, id } so the frontend revalidates. Drafts and private types are fetched server-side with a key. See Deploy.