A small, code-first CMS engine for building dynamic sites behind a standard fetch handler. Stackbox is designed to be driven by AI: pages, templates, blocks, and plugins are plain TypeScript files with typed, composable APIs, so an agent can author and assemble a site without a database, admin UI, or hand-written backend.
createSite() returns an object with a fetch(request, env) method — the same shape used by Cloudflare Workers, Bun, Deno, and other runtimes that serve HTTP via the Fetch API:
export default {
fetch(req: Request): Response | Promise<Response> {
return new Response(...);
},
};GET/HEAD page renders are cached in memory by default (stale-while-revalidate, single-flight refresh). Set cache: false on the site config, a page, or any block on that page to render every request. Plugin routes and plugin assets are always uncached.
Traditional CMSes assume a human clicking around an admin panel. Stackbox inverts that: a site is TypeScript assembled into a fetch handler. Every primitive (createSiteConfig, createSite, createTemplate, createPage, createBlock) is a typed factory suited for AI to generate, edit, and validate site content as code — and the same files render dynamically at request time.
- Node.js >= 20
npm install @stackbox/cms| Primitive | Factory | Purpose |
|---|---|---|
| Site config | createSiteConfig(config) |
Definition-time settings shared by templates and pages. |
| Site | createSite(siteConfig, { pages, plugins? }) |
Runtime router with fetch(request, env) — a fetch-handler-compatible server. |
| Template | createTemplate({ siteConfig, slots, render }) |
A reusable page layout that declares named slots. |
| Page | createPage(template, { path, title, slots }) |
A single URL, built by filling a template's slots with content. |
| Block | createBlock({ name, render }) |
A self-contained content block placed into a slot at request time. |
| Plugin | createPlugin({ name, description, version, keywords, root }) |
A packaged feature. Register it on createSite({ plugins }) when you use it. |
Plugins vs blocks: Plugins package whole features (blog, newsletter) — bundled under @stackbox/cms/plugins/<name> or a third-party package with the same shape. Each plugin must export a plugin object from createPlugin() (name, description, version, keywords). Blocks are the core slot primitive via createBlock(); plugins can ship blocks alongside other exports.
Importing a plugin does not enable it. Pass plugin into createSite({ plugins }). Only registered plugins have public_assets/ served or copied.
Slots are named regions in a template. Page content — strings, HTML, or blocks — is dropped into slots, and the engine resolves and renders everything (including async blocks, concurrently) to a single HTML string.
Optional cache: { min?: number; max?: number } | false on site config, pages, and blocks (milliseconds). Omit = no opinion; false = never cache that request.
createSiteConfig({
name: "My Site",
url: "https://example.com",
cache: { min: 60_000, max: 7 * 24 * 60 * 60 * 1000 },
});
createPage(template, {
path: "/live",
title: "Live",
cache: false,
slots: { content: [...] },
});
createBlock({
name: "ticker",
cache: { max: 5 * 60 * 1000 },
render() { ... },
});TTL merges settings from the site config, the page, and on-page blocks: highest min floors the result, lowest max caps it (default 1 day, hard cap 30 days). Responses include Cache-Control. Expired entries are served immediately while one background refresh runs per key.
Extend rendering at runtime with an optional hooks object on createSite:
export default createSite(siteConfig, {
pages: [homePage],
hooks: {
shouldCache(request) {
return true;
},
renderSlotItem(html, info) {
return html;
},
afterRender(html, info) {
return html;
},
beforeResponse(response, info) {
return response;
},
},
});shouldCache(request)— returnfalseto skip the page cache for that request (page/block TTL still applies when it returnstrue)renderSlotItem(html, info)— transform each slot item after it rendersafterRender(html, info)— transform the full page HTMLbeforeResponse(response, info)— adjust page and 404 responses before they are returned
Pages and blocks may optionally set source (for example import.meta.url) so hooks can point editors or agents at the defining file.
Core types are exported as a single namespace:
import type { Stackbox } from "@stackbox/cms";
type Page = Stackbox.Page;
type SiteHooks = Stackbox.SiteHooks;my-site/
site.config.ts # createSiteConfig({ name, url, ... })
server.ts # createSite(...) — default export is your fetch handler
templates/
site-template.ts # shared createTemplate() layouts
pages/
home.ts # exports homePage
blog.ts # createBlog() + createPage() for listing and posts
content/blog/ # markdown posts (read at bundle time)
site.config.ts:
import { createSiteConfig } from "@stackbox/cms";
export default createSiteConfig({
name: "My Site",
url: "https://example.com",
});templates/site-template.ts:
import { createTemplate, html } from "@stackbox/cms";
import siteConfig from "../site.config";
export const siteTemplate = createTemplate({
siteConfig,
slots: [{ name: "content", options: { required: true, primary: true } }],
render({ slots }) {
return html`<main>${slots.content.render()}</main>`;
},
});pages/home.ts:
import { createPage } from "@stackbox/cms";
import { siteTemplate } from "../templates/site-template";
const homePage = createPage(siteTemplate, {
path: "/",
title: "Home",
slots: { content: ["<p>Welcome to my site.</p>"] },
});
export default homePage;server.ts:
import { createSite } from "@stackbox/cms";
import siteConfig from "./site.config";
import homePage from "./pages/home";
import aboutPage from "./pages/about";
export default createSite(siteConfig, {
pages: [homePage, aboutPage],
// plugins: [blogPlugin, randomQuotePlugin], // only plugins this site uses
});The default export implements fetch(request, env) and returns a Response — drop it into any runtime that speaks the fetch-handler pattern. For example:
- Cloudflare Workers — deploy with
wrangler(often asworker.ts) - Bun —
Bun.serve({ fetch: site.fetch }) - Deno —
Deno.serve(site.fetch)
createBlog() loads markdown at bundle time and returns content objects you wire into your own pages with createPage() — so you control templates, slots, and any extra content alongside blog output.
// pages/blog.ts
import { join } from "node:path";
import { createPage } from "@stackbox/cms";
import { createBlog } from "@stackbox/cms/plugins/blog";
import { siteTemplate } from "../templates/site-template";
const blog = createBlog({
contentPath: join(import.meta.dirname, "../content/blog"),
pathPrefix: "/blog",
postsPerPage: 10, // optional — omit to put all posts on one listing page
});
export const blogListingPages = blog.listings.map((listing, index) =>
createPage(siteTemplate, {
path: listing.path,
title: index === 0 ? "Blog" : `Blog — page ${index + 1}`,
slots: {
content: [...listing.content, "<p>Subscribe for updates</p>"],
},
}),
);
export const blogPostPages = blog.posts.map((post) =>
createPage(siteTemplate, {
path: post.path,
title: post.title,
meta: post.meta,
slots: { content: [...post.content] },
}),
);// server.ts
import blogPlugin from "@stackbox/cms/plugins/blog";
import { blogListingPages, blogPostPages } from "./pages/blog";
export default createSite(siteConfig, {
pages: [homePage, ...blogListingPages, ...blogPostPages],
plugins: [blogPlugin],
});Blog — content objects wired into pages:
import blogPlugin, { createBlog } from "@stackbox/cms/plugins/blog";Random quote — block-only plugin (drop into any slot):
import randomQuotePlugin, {
randomQuoteBlock,
} from "@stackbox/cms/plugins/random-quote";
import myQuotes from "../content/quotes.json" with { type: "json" };
slots: { sidebar: [randomQuoteBlock()] } // bundled quotes
slots: { sidebar: [randomQuoteBlock({ quotes: myQuotes })] } // your ownRegister every plugin you use:
export default createSite(siteConfig, {
pages: [homePage, ...blogListingPages, ...blogPostPages],
plugins: [blogPlugin, randomQuotePlugin],
});Private plugin files live in assets/ (imported by JS). Files served over HTTP live in public_assets/ and are copied or served only for registered plugins — including third-party packages that follow the same layout.
Plugins may also register routes (served by fetch()) and build hooks (run by stackbox-cms build). The sitemap plugin uses both to serve and write /sitemap.xml:
import sitemapPlugin from "@stackbox/cms/plugins/sitemap";
export default createSite(siteConfig, {
pages: [homePage, aboutPage],
plugins: [sitemapPlugin],
});Run the package build script so registered plugin public_assets/ land in the site public directory:
npx stackbox-cms build
# or: npx stackbox-cms build --site server.ts --outDir dist --publicDir dist/public"scripts": {
"build": "stackbox-cms build"
}Bundled plugins include agent playbooks. See dist/AGENTS.md (generated on npm run build) for site conventions and a plugin keyword catalog. When a user asks for a feature (e.g. "add a blog"), read only the matching plugin's AGENTS.md — do not load every plugin file.
If you are building a site that uses this package, add this to your project's AGENTS.md:
This site uses @stackbox/cms. Before adding features, read
`node_modules/@stackbox/cms/dist/AGENTS.md` and follow its plugin catalog.
Do not reimplement bundled plugins.npm run build # compile the package
npm run typecheck # type-check without emitting
npm test # build, then run the test suiteBSD-3-Clause