Stackbox is a code-first CMS that assembles TypeScript into a fetch-handler-compatible server. Use these conventions when building or extending a site.
| 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) |
| Template | createTemplate({ siteConfig, slots, render }) |
Reusable layout with named slots |
| Page | createPage(template, { path, title, slots }) |
A single URL built from a template + slot content |
| Block | createBlock({ name, render }) |
A content block placed into a slot at request time |
| Plugin | createPlugin({ name, description, version, keywords, root }) |
Packaged feature; register on the site when used |
Project layout:
site.config.ts # createSiteConfig
server.ts # createSite(...) — default export is the fetch handler
templates/ # shared createTemplate() layouts
pages/ # createPage() per route or feature
content/ # markdown or other bundle-time content (per feature)
package.json # "build": "stackbox-cms build"
The default export from server.ts implements fetch(request, env) and returns a Response. It works on Cloudflare Workers, Bun, Deno, and any runtime that speaks the fetch-handler pattern.
GET/HEAD page renders are cached in memory by default. Keys are pathname + query string (/about?preview=1 is separate from /about). Plugin routes and /_sb/plugins/ assets are never cached.
Optional cache on site config, pages, and blocks — same shape everywhere:
export type CacheConfig = { min?: number; max?: number } | false;Milliseconds. Omit = no opinion. cache: false = fully dynamic (do not 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: "random-quote",
cache: { max: 5 * 60 * 1000 },
render() { ... },
})TTL is derived from the site config, the page, and every block on that page. If any of those is false, the request is not cached. Otherwise merge { min, max } (unset fields ignored): highest min floors the result, lowest max caps it (default TTL 1 day, hard cap 30 days). Registering a plugin does not change cache times — only blocks (or pages) with cache on that page count.
Stale entries are served immediately (stale-while-revalidate); one background refresh runs per key. Concurrent misses share a single render (single-flight). Failed refreshes keep the last good HTML.
Disable caching for a whole site with createSiteConfig({ cache: false }). createSite accepts an optional cacheAdapter for tests or custom stores — do not overload cache on createSite.
Optional runtime hooks on createSite(siteConfig, { pages, plugins?, hooks? }). All hooks are optional; omitting them preserves default behavior. Page/block cache TTL is unchanged — hooks do not replace that policy.
createSite(siteConfig, {
pages: [homePage],
hooks: {
shouldCache(request) {
return !new URL(request.url).searchParams.has("preview");
},
renderSlotItem(html, { item, slot, index, page, ctx }) {
return html;
},
afterRender(html, { page, ctx }) {
return html;
},
beforeResponse(response, { request, page, ctx }) {
return response;
},
},
});| Hook | When | Signature |
|---|---|---|
shouldCache |
Before the page cache adapter runs | (request: Request) => boolean — false skips the adapter for that request; true uses page/block TTL |
renderSlotItem |
After each slot item (block or HTML string) renders | (html, info) => string |
afterRender |
After the full page HTML is assembled | (html, info) => string |
beforeResponse |
Before fetch returns a page or 404 response |
(response, info) => Response |
Plugin routes and /_sb/plugins/ assets do not run render hooks. beforeResponse does not wrap plugin route responses.
Optional source?: string on createPage and createBlock (e.g. import.meta.url) is metadata for hook consumers — the engine stores it as given.
- Plugin — a packaged use-case (bundled under
@stackbox/cms/plugins/<name>, or a third-party package with the same shape). Default-exports acreatePlugin()registration object, plus named factories, content objects, blocks, types, and helpers. - Block — a core primitive via
createBlock(). Renders HTML into a template slot. Usable inside or outside plugins.
Slot content is Block | string. Plugin content arrays can interleave blocks and HTML strings.
Register plugins when you use them. Importing a plugin module is not enough — pass its default export to createSite({ plugins }). Only registered plugins have their public_assets/ copied or served. Third-party packages are registered the same way; they are not discovered from a plugins/ folder.
import blogPlugin from "@stackbox/cms/plugins/blog";
import randomQuotePlugin from "@stackbox/cms/plugins/random-quote";
export default createSite(siteConfig, {
pages: [homePage, ...blogListingPages, ...blogPostPages],
plugins: [blogPlugin, randomQuotePlugin],
});createPlugin({ name, description, version, keywords, root }) is required. createSite throws if a registered value is missing that metadata. keywords tell agents when to reach for the plugin.
Optional hooks on createPlugin():
routes({ site })— site-level paths served byfetch()before the GET/HEAD gate, plugin assets, and pages (e.g./sitemap.xml). The route handler decides which HTTP methods are allowed. Must return synchronously. Paths must not collide with pages or other plugin routes.build({ site, outDir, publicDir })— called bystackbox-cms buildafter copying registeredpublic_assets/. Write generated files intopublicDir(or elsewhere underoutDir).
Before implementing a use-case (blog, newsletter, docs, …), check this table. If the user's request matches a row, read only that plugin's AGENTS.md and follow it. Do not invent a parallel implementation.
When adding a new plugin to this package, add a folder under src/plugins/<name>/, default-export the createPlugin() registration from index.ts, and create src/plugins/<name>/AGENTS.md using the same heading structure as the blog plugin. Run npm run build — the package scans dist/plugins/ and writes the keyword catalog to dist/AGENTS.md from each plugin's keywords. Document all export kinds (factory, types, blocks, helpers).
Every plugin — bundled or third-party — has the same shape:
index.ts # default export: createPlugin({ ... }); named exports: factories, types, blocks
AGENTS.md # agent playbook
assets/ # private files imported by JS (JSON, templates, …)
public_assets/ # files served over HTTP (images, CSS, fonts, …)
root is the directory that contains assets/ and public_assets/. Pass import.meta.dirname when those folders sit next to the plugin entry. A third-party package may pass its package root instead.
| Folder | Copied to | When |
|---|---|---|
assets/ |
{plugin.root}/assets (next to compiled JS) |
Package build of that plugin (copyPluginPrivateAssets) |
public_assets/ |
{publicDir}/_sb/plugins/{name}/ |
Site build, only for plugins registered on the site (copyRegisteredPluginAssets) |
public_assets/ is no longer mirrored into the plugin's compiled folder. Import private data from ./assets/..., not from public_assets/.
At request time, createSite.fetch order is: plugin routes → plugin public assets (/_sb/plugins/...) → pages.
For a static public/ directory, run the package build script — it loads the site, reads site.plugins, copies registered public_assets/, then runs each plugin's build hook:
npx stackbox-cms build"scripts": {
"build": "stackbox-cms build"
}import { build } from "@stackbox/cms/build";
import site from "./server.ts";
await build({
site,
outDir: "dist",
publicDir: "dist/public",
});Defaults: --site looks for server.ts (then server.js, src/server.ts, src/server.js); outDir is dist; publicDir is {outDir}/public. Flags: --site, --outDir, --publicDir.
Use pluginAssetPath() from @stackbox/cms/link for <img src>, <link href>, etc. Do not put site files under /_sb/ — that prefix is reserved for Stackbox plugin assets.
import { pluginAssetPath } from "@stackbox/cms/link";
pluginAssetPath("sb-random-quote", "widget.css");
// → "/_sb/plugins/sb-random-quote/widget.css"If you are working in a consumer site repo (not this package), read the generated agent instructions from the installed package:
node_modules/@stackbox/cms/dist/AGENTS.md
Recommended snippet for the site's own 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.When a request matches a catalog row, open only that plugin's instructions file (e.g. node_modules/@stackbox/cms/src/plugins/blog/AGENTS.md).