kilo-cms
v0.3.1
Published
An installable, config-driven CMS engine for Next.js — admin panel, RBAC, a generic field-type engine, and an API, mounted into your own app. Define your content schema via config, not a hand-maintained registry.
Maintainers
Readme
Kilo CMS
A schema-driven, config-driven CMS engine, built specifically for Next.js. Instead of generating code or hand-editing a central registry, you describe your content model as plain config — fields, relations, validation, list views — and Kilo CMS turns that into a working admin panel, RBAC, and API, mounted directly inside your own app's routes.
Status: pre-1.0, proven end-to-end against one real production app (
kilostudio.id'sapps/site), not yet used by a second, independent project. API surface may still change.Looking for collaborators. The repo is currently private; reach out if you want in as a collaborator.
What you get
- A generic field-type engine (
text,richtext,image,relation,array,blocks, and more) driving CRUD, validation, list rendering, and filtering from a plain config object — no schema-generation step, no code-gen. - Role-based access control, per content type and per field.
- Media library, review/approval workflow, scheduled publishing, localization, webhooks, an AI writing assistant, and a dashboard builder — all included, all config-driven.
- Auth via better-auth, mounted the same one-line way
better-authitself mounts into a Next.js route (export const { GET, POST } = toNextJsHandler(auth)). - A CLI (
npx kilo-cms) for scaffolding new collections without hand-editing a central registry.
Install
npm install kilo-cmsnext, react, and react-dom are peer dependencies — Kilo CMS uses whatever versions your
own project already has.
Add transpilePackages: ['kilo-cms'] to your next.config.ts — Kilo CMS ships raw TypeScript
source (including its 'use client' admin components), not a prebuilt dist, so Next's own
compiler needs to process it directly.
Quickstart
Define a collection — split into a fields file (client-safe, no database import) and a table file (server-only, the actual drizzle table):
// src/collections/projects/fields.ts
import { defineCollectionFields } from 'kilo-cms/collections'
export const projectsFields = defineCollectionFields({
kind: 'collection',
slug: 'projects',
label: 'Projects',
labelSingular: 'Project',
titleField: 'title',
defaultSort: { field: 'sortOrder', dir: 'asc' },
groups: [{ id: 'overview', label: 'Overview' }],
fields: [
{ key: 'title', type: 'text', label: 'Title', group: 'overview', required: true, listColumn: true, listPrimary: true },
],
})// src/collections/projects/table.ts
import 'server-only'
import { pgTable, text } from 'drizzle-orm/pg-core'
import { timestamps } from 'kilo-cms/schema'
import { defineCollectionTable } from 'kilo-cms/collections/table'
export const projects = defineCollectionTable('projects', pgTable('projects', {
id: text('id').primaryKey(),
title: text('title').notNull(),
...timestamps,
}))Wire it up — one config file, and a place that creates your database connection (both plain data/setup, no magic):
// kilo.config.ts — the one file you edit to add/list collections
import { projectsFields } from './src/collections/projects/fields'
import './src/collections/projects/table' // side-effecting: registers the table
export const collections = [projectsFields]
export const locales = { codes: ['en'], default: 'en', labels: { en: 'English' } }
export const currency = { locale: 'en-US', currency: 'USD' }// src/lib/db.ts — creates your db connection AND registers Kilo CMS with it
import postgres from 'postgres'
import { drizzle } from 'drizzle-orm/postgres-js'
import { defineKiloConfig } from 'kilo-cms/config'
import { createKiloAuth } from 'kilo-cms/auth'
import { schema } from './schema' // your own merged schema — see below
import { collections, currency, locales } from '../../kilo.config'
const client = postgres(process.env.DATABASE_URL!)
export const db = drizzle({ client, schema })
defineKiloConfig({ db, collections, locales, currency })
export const auth = createKiloAuth({
db,
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
})// src/lib/schema.ts — merges Kilo CMS's own tables (auth, RBAC, media, settings, ...) with yours
export * from 'kilo-cms/schema'
export * from '../collections/projects/table'
import { mergeSchema } from 'kilo-cms/schema'
import { projects } from '../collections/projects/table'
export const schema = mergeSchema({ projects })mergeSchema({ ...yourTables }) is just { ...kiloSchema, ...yourTables } — it exists so
adding a content table only touches the object you pass in, never a second copy of kiloSchema
hand-spread somewhere else. The export * from '../collections/projects/table' line above it
still has to stay, though: drizzle-kit finds tables by scanning a schema file's top-level
exports, not by evaluating a merged object at its own config-load time. If you'd rather not
maintain that re-export line per collection either, point drizzle-kit's own schema option at
a glob instead — it accepts an array of paths/globs natively:
// drizzle.config.ts
export default defineConfig({
schema: ['./src/lib/schema.ts', './src/collections/*/table.ts'],
// ...
})That way src/lib/schema.ts only needs to exist for export * from 'kilo-cms/schema' and the
mergeSchema(...) runtime object — every host content table is picked up by the glob
automatically the moment its table.ts exists, with no per-collection edit to this file at all.
Registration: instrumentation.ts
Call defineKiloConfig() once, as early as possible, from Next.js's
instrumentation.ts register() hook —
it runs once per server process, before any request is handled:
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('@/lib/db')
}
}This is enough for every dynamic route (every admin page/route, since none of them are
statically generated) — the process instrumentation ran in is the same one that later serves
those requests. It is not enough on its own for anything reachable from
generateStaticParams on your public-facing pages: Next's build-time static generation spawns
separate worker processes, and instrumentation.ts isn't guaranteed to have run in all of
them before a page module evaluates. For those pages, importing @/lib/db (directly, or
transitively through whatever data-fetching module they already import) is what actually
guarantees registration in that worker — instrumentation.ts is a convenience for the admin
side, not a replacement for that.
Middleware
Kilo CMS ships an Edge-safe, cookie-presence-only pre-filter that redirects unauthenticated
visitors away from /admin/* before a page even renders — a fast filter, not the real
security boundary (every admin page/route still validates the session and RBAC permissions
for real). Your own middleware.ts re-exports it and defines matcher itself (Next.js
statically analyzes matcher in the file it scans — it can't be re-exported through another
module):
// middleware.ts
export { kiloAdminMiddleware as middleware } from 'kilo-cms/middleware'
export const config = {
matcher: ['/admin/:path*'],
}Admin pages use requireAdminSession() from kilo-cms/admin/require-session instead of
hand-rolling getSessionWithPermissions() + redirect():
import { requireAdminSession } from 'kilo-cms/admin/require-session'
const result = await requireAdminSession((r) => r.can('projects', 'read'), '/admin/dashboard')CLI
Run from your own app's directory:
npx kilo-cms add-collection <slug> [--fields "..."] # scaffold a new collection's fields.ts + table.ts
npx kilo-cms sync # regenerate .kilo/types.gen.ts from src/collections/*
npx kilo-cms migrate # apply Kilo CMS's own package-owned migrations
npx kilo-cms init # fill in missing .env secrets, migrate, create the first admin useradd-collection scaffolds the two files and prints the exact lines to paste into
kilo.config.ts — deliberately not an auto-codemod (a script that edits your config file
wrong is worse than a 10-second copy-paste). Without --fields, you get the same
title/slug/sortOrder starter as before. With --fields, it builds exactly the fields you list
instead — a plain-text shorthand for the common case, not a substitute for hand-editing
fields.ts afterward for anything it doesn't cover:
npx kilo-cms add-collection testimonials \
--fields "quote:textarea(required,rows=4),author:text(required,listPrimary,maxLength=80),rating:rating(max=5),featured:boolean,logo:image"Each entry is key:type or key:type(opt,opt2=value). Supported types: text, textarea,
richtext, number, boolean, select, multiselect, tags, image, file, date,
datetime, color, rating. Options: required, nullable, listColumn, listPrimary,
searchable, sortable, filterable, readOnly, maxLength=N, rows=N,
format=slug|email|url|password|tel, options=a|b|c (select/multiselect). Relations, joins,
arrays, blocks, groups, and JSON fields aren't part of this shorthand — add those by hand, same
as you'd tune anything else the generator scaffolds.
migrate applies Kilo CMS's own schema (auth, RBAC, media, settings, dev-tools, admin-ui,
locales — see src/schema/) against your DATABASE_URL, tracked in its own
kilo_cms_migrations table, independent of your own app's migration history for its content
tables.
init fills in BETTER_AUTH_SECRET and CMS_SETUP_TOKEN in your .env if either is missing
(generated, never overwriting a value that's already there), runs migrate, then creates your
first admin user via the existing /api/setup/admin route — it needs your app already running
(npm run dev in another terminal) since that route executes inside your app's own Next.js
runtime:
npx kilo-cms init --name "Your Name" --email [email protected] --password "at least 12 characters" [--url http://localhost:3000]The only variable you're still expected to set yourself is DATABASE_URL. If init had to
generate a fresh CMS_SETUP_TOKEN, it stops after migrate and asks you to restart your dev
server first — the already-running process only read .env once, at its own startup, so it
can't see a token that didn't exist yet. Run the same init command again after restarting.
Package layout
| Export | What it is |
|---|---|
| kilo-cms/config | defineKiloConfig() — the one call that wires db + collections + locale/currency together |
| kilo-cms/collections | Client-safe: field types, defineCollectionFields(), the registry, value helpers |
| kilo-cms/collections/table | Server-only: defineCollectionTable() |
| kilo-cms/collections/server | Server-only: validation, workflow, filters, the query engine |
| kilo-cms/schema | Kilo CMS's own drizzle tables (auth, RBAC, media, settings, dev-tools, admin-ui, locales) + mergeSchema() |
| kilo-cms/auth, kilo-cms/auth-client | createKiloAuth() factory + the better-auth React client |
| kilo-cms/admin/* | Admin UI pages and shared components |
| kilo-cms/admin/require-session | requireAdminSession() — the shared session/RBAC guard for admin pages |
| kilo-cms/middleware | kiloAdminMiddleware — Edge-safe cookie-presence pre-filter for /admin/* |
| kilo-cms/routes/* | Route Handler logic for the admin API, public API helpers, auth, cron |
Status / roadmap
Built by extracting a working, in-production CMS out of the app it was originally embedded
in — see kilostudio.id's WORKLOG.md for the
full history of that extraction. Not yet done:
- A single catch-all admin route instead of one thin file per page (deliberately skipped for now — today's thin per-page files are already minimal; a hand-rolled catch-all router would trade that for real complexity with no proven need yet).
- A second, independent consumer project to prove the install story beyond the one app this was extracted from.
Versioning
Semantic versioning, while the major version stays 0: a minor bump (0.x.0) means new or
changed public API surface (a new export, a new CLI command); a patch bump (0.x.y) means
a bug fix with no public API change. Every release is documented in
CHANGELOG.md and tagged in git (v0.1.0, v0.1.1, v0.2.0, ...) — check
the changelog before upgrading a consumer's dependency range, especially across a minor bump.
License
Proprietary — all rights reserved. This is not an open-source project: the repo is private, and access is by invitation as a collaborator, not by public license. Contact the author before using this outside of an explicitly authorized project.
