@getxuui/core
v0.12.1
Published
A Studio for content that lives in your Git repository. No database.
Readme
xuui
xuui is a Git-based CMS for Next.js content websites. Users get a sleek editor experience and all the website content lives in the Git repository. There are no databases to maintain and its easy to collaborate with agents. The site is xuui.com. The documentation is at xuui.com/docs.
Getting started
xuui needs a Next.js 16 App Router project. It scaffolds into one; it does not create one.
New project
npx create-next-app@latest my-site # App Router, TypeScript
cd my-site
npm install @getxuui/core zod
npx xuui initxuui init writes xuui.config.ts, a starter home page and post, and four mount points:
| Path | What it is |
| --------------------------------------------- | -------------------------------------------------------- |
| app/(xuui)/api/xuui/[...path]/route.ts | the content API — createXuuiHandler |
| app/(xuui)/api/xuui/auth/[...path]/route.ts | passkey registration and login — createXuuiAuthHandler |
| app/(xuui)/admin/[[...path]]/page.tsx | the Studio, at /admin |
| proxy.ts | the preview gate — createXuuiMiddleware |
Then set a signing secret and invite yourself:
echo "XUUI_SECRET=$(openssl rand -base64 32)" >> .env.local
npx xuui invite you --role admin
npm run devThe invite prints a one-time link. Open it, register a passkey, and you are in. Use
--role admin for the first person: xuui grants nothing by default, so a site with no
administrator has nobody who can make one.
Existing project
The same npx xuui init. It writes only the files that are missing and never edits one you
already have — run npx xuui init --dry-run first to see exactly what it would add.
Two things it deliberately leaves to you:
- A root layout. If
app/layout.tsxalready exists, move your site underapp/(site)/so the Studio's own layout is a true root layout. Otherwise your global CSS reaches the Studio. xuui will not move your files. outputFileTracingIncludes. Serverless deploys trace only the files they can see imported. Your content is read at run time, soinitprints thenext.configsnippet that ships it.
Then run npx xuui doctor, which checks for the quiet traps — a missing secret, a repo with no
token, a gate importing the wrong entry point — before a deploy finds them for you.
Going to production
Local development reads and writes the working tree. Production reads and writes GitHub:
storage: {
repo: "owner/name",
branch: "main",
draftBranch: "xuui/draft",
}Set XUUI_GITHUB_TOKEN, XUUI_RP_ID (your domain), and XUUI_ORIGIN (your full origin) in
the deploy environment, alongside XUUI_SECRET. Run npx xuui doctor --production to check it.
The Studio
The Studio is xuui's edit interface. It mounts at /admin, ships its own scoped stylesheet, and
needs nothing from your design system.
A save is a commit on the draft branch, and it moves only the lines the editor actually changed — if xuui cannot write a file back without moving lines nobody touched, it refuses the save rather than reformatting your content. Publish merges the draft branch into the published one.
Content modelling
A content model defines what content a website has, how that content is structured, and how different pieces of content relate to each other. In xuui, the content model is defined in the project's config using two concepts:
- Content types: define the different kinds of content in a website. For example, a blog post, an event, or a page.
- Fields: define the information that can be stored for each content type. For example, a title, image, date, or author.
Content types
Content types define the kinds of content your website has and which fields each kind contains. xuui supports several kinds of content types, each suited to a different purpose.
Singles
Singles are one-off pages. They usually have a bespoke design. Tailoring an editing experience for them makes for a better editing experience. Singles are defined in code and their content can be edited in the Studio. Examples: A homepage, pricing page, and contact page.
Collections
Collections are sets of pages without hierarchy that share design. Examples: Blog posts, news entries, and event pages. A single entry in a Collection is called an Entry.
Structures
Structures are hierarchical trees of pages that can be nested. Nesting affects the URLs of the pages (/about/team lives under /about). Example: About pages, careers pages, and feature pages. A single entry in a Structure is called a Page.
Tags
Tag Groups are free-form taxonomies without hierarchy that users can assign to other entries. Examples: topics, recipe tags, etc. A single entry in a Tag Group is called a Tag.
Categories
Category Groups are hierarchical taxonomies that users can assign to other entries. Examples: news topics, geographical categories, etc. A single entry in a Category Group is called a Category.
Globals
Global Sets are groups of content that is available globally throughout the website but is not tied to a single URL. Global Sets are defined in code and their content can be edited in the Studio. Examples: header/footer content, website details, theme details.
Fields
Fields define the information that can be stored in a content type. A schema is an ordinary
Zod object, so most fields are plain Zod — xuui reads the schema and picks the editor to draw.
The rest are helpers from @getxuui/core, for the fields Zod has no shape for.
| Field | Write it as | The Studio draws |
| ------------ | ------------------------- | ------------------------------------------------------------- |
| Text | z.string() | one line |
| Textarea | z.string().max(400) | a box — a max of 160 or more means a paragraph |
| Markdown | markdown() | the rich text editor |
| Slug | slug({ from: "title" }) | the URL, proposed from another field |
| Image | image() | an upload and a picker |
| Number | z.number() | a number input |
| Boolean | z.boolean() | a switch |
| Date | z.string().date() | a date picker |
| Datetime | z.string().datetime() | a date and time picker |
| Select | z.enum(["a", "b"]) | a dropdown |
| Object | z.object({ … }) | a group of fields |
| Array | z.array(…) | a repeater |
| Blocks | blocks({ … }) | a list of mixed, named sections |
| Order | order() | nothing — it stores the drag-and-drop position of a tree page |
| Tags | tags("topics") | a multi-select from a tag group |
| Category | category("genres") | a single select from a category group |
| Relation | relation("authors") | a picker over another content type's entries |
| SEO | seo() | a title, description, and social image group |
Two more helpers shape a field rather than adding one:
generated({ compute })— a value xuui works out at read time. It is never written to the file and never drawn.withFieldMeta(schema, meta)— a label, help text, or afieldTypeoverride on any Zod schema. Every helper above is this function with a preset.
A field's Studio behaviour never changes what is stored. markdown() is a z.string();
slug() is a z.string() with a pattern.
Bodies
markdown() defaults to the body — the text after the frontmatter block — not a frontmatter
key. Pass { source: "frontmatter" } for a second markdown field that belongs in the
frontmatter. One field per type can be the body.
JSX components in a body
An .mdx body can hold the site's own React components. Declare one and the Studio draws a
form for its props instead of a plain box:
import { component, componentList, defineConfig, richText } from "@getxuui/core"
export default defineConfig({
components: {
Callout: component({
schema: z.object({
type: z.enum(["note", "tip", "warning"]),
title: z.string().optional(),
children: richText(),
}),
}),
CardGrid: component({
label: "Card grid",
schema: z.object({ children: componentList(["Card"], { min: 1 }) }),
}),
},
content: {/* … */},
})richText() takes prose, plus any component named in its allow list. componentList() takes
only the components it names and no free text, so <CardGrid> cannot be given a paragraph. The
insert menu offers only what may go where the cursor is.
One registry for the whole site, keyed by tag name, because a body resolves a tag through the
site's own mdx-components.tsx, which is global too.
A schema here is Studio chrome, not law. A prop whose value it would reject still opens and still saves. Nothing validates a body against these, so a site declares one component at a time rather than migrating all of them at once. A component you have not declared keeps the plain box, and a prop no schema names stays in the file untouched.
Layouts
Layouts define how content is presented and edited in the Studio.
Layouts are separate from the content model and can be configured and edited in the Studio.
They are stored in .xuui/layouts.yaml.
Settings → Layouts lists the declared components below the content types. Arranging one writes
a component:<Name> row — component:Callout for <Callout>. Ordering is all it does: a
component's props are one flat list, and a prop the row leaves out is drawn after the ones it
names rather than hidden, because a required prop has nowhere else to be set from.
Access control
Turn it on in the config:
auth: {
enabled: true
}It is off by default, because the disk adapter serves localhost only and a login there protects
nothing. Turn it on before you deploy — with auth.enabled false in production, xuui
refuses to start rather than serving an open API.
Access control needs XUUI_SECRET, a random string that signs sessions. Generate one with
openssl rand -base64 32. Off localhost, set XUUI_RP_ID (your domain) and XUUI_ORIGIN
(your full origin) too, or auth.rpId and auth.origin in the config. xuui never reads them
from the request: a request can lie about its own host.
Users, roles, and layouts live in .xuui/*.yaml in your repository, on the published
branch. A change to one takes effect on the next request, not after the next Publish.
Users
.xuui/users.yaml — one row per person, and passkeys only. There are no passwords to
leak, reset, or phish, and no email to send.
npx xuui invite anna --role editor --first Anna --last de Vries
npx xuui role anna admin # change what somebody may do
npx xuui revoke anna # remove them; ends their session at onceinvite prints a one-time link. There is no mail in this product — send the link yourself.
Registering a passkey on a second device replaces the first, so re-inviting is how a lost
device is handled.
A revoke takes effect immediately: every request re-reads the file, so a stale session cookie stops working on the next click rather than when it expires.
Roles
admin is defined in code. It holds every permission there is, including ones a later version
of xuui adds, and nothing can narrow or delete it.
Every other role is a row in .xuui/roles.yaml, editable on the Studio's Roles screen.
xuui init seeds one called editor — everything about content, plus uploads and Publish —
and from that moment it is an ordinary role you can rename, narrow, or delete.
An unknown role and a missing role are the same thing: no permissions at all. That is deliberate. It means a typo locks somebody out of the Studio rather than into it.
Permissions
A permission is a colon-separated id. * stands in for one whole segment.
entries:{type}:view | history | create | save | delete | savePeer | deletePeer
globalSets:{name}:view | history | save
assets:view | upload | delete
publish
layouts:manage
branding:manage
settings:docsLinks | sidebarOrder
users:view | invite | edit | setRole | revoke | reset
roles:manageSo entries:*:view reads every content type, and entries:posts:save saves posts and nothing
else. A permission list of exactly ["*"] is the administrator.
Ownership is not a separate axis. save and delete govern an entry this editor wrote;
savePeer and deletePeer govern one somebody else wrote, or one nobody owns. The two are
standalone, so "may edit only their own drafts" is a role you can actually write. Who wrote
what is recorded in .xuui/authors.yaml, in the same commit as the entry.
Reference
Entry points
| Import | Runs in | Holds |
| ------------------------------- | ----------- | -------------------------------------------------------- |
| @getxuui/core | Node | config helpers, field helpers, readers, StorageAdapter |
| @getxuui/core/next | Node | createXuuiHandler, createXuuiAuthHandler, metadata |
| @getxuui/core/next/middleware | Edge | createXuuiMiddleware — the preview gate |
| @getxuui/core/next/og | Node | createXuuiOgImage |
| @getxuui/core/studio | the browser | Studio, PreviewPane |
| @getxuui/core/live | the browser | useXuuiEntry — live preview on your own pages |
| @getxuui/core/api | anywhere | the HTTP response types, and XUUI_API_VERSION |
Everything exported from these is supported: it does not move or change shape without a version bump that says so. Anything you reach by importing a deeper path is an internal, and will move. A test holds each entry point to a list of exported names checked into the repository, so the surface cannot grow by accident.
While xuui is below 1.0, a breaking change ships as a minor release — Semantic Versioning §4. The annotated tag on each release says what to change, and so does the matching GitHub Release.
@getxuui/core/api is the exception to all of that, and says so: it describes the HTTP wire
format, which is versioned by XUUI_API_VERSION rather than by the package version. Every
response carries an x-xuui-api header, and the server refuses a request that claims a
different version — so a Studio bundle cached across a deploy fails loudly instead of misreading
a shape that moved.
Config
xuui.config.ts at the root of your project. It is TypeScript and it is executed, so it
can import your schemas and compute values.
import { z } from "zod"
import { defineConfig, defineCollection, markdown, slug } from "@getxuui/core"
export default defineConfig({
storage: {
repo: "owner/name", // GitHub, for production. Unset means the local disk.
branch: "main", // where Publish lands
draftBranch: "xuui/draft", // where a save lands
root: ".", // content root; every glob starts here
commitEmail: undefined, // pin the commit author address; rarely needed
},
auth: {
enabled: true,
rpName: "xuui", // the name a passkey prompt shows
rpId: "example.com", // or XUUI_RP_ID
origin: "https://example.com", // or XUUI_ORIGIN
},
assets: {
directory: "media", // where uploads are committed
publicPath: "/media", // the URL prefix written into content
maxBytes: 4_000_000,
formats: ["jpg", "jpeg", "png", "webp", "avif", "gif"],
},
seo: {
url: "https://example.com", // absolute, no trailing slash
name: "Example",
titleTemplate: "%s · Example",
defaultImage: "/og.png",
description: "…",
twitter: { card: "summary_large_image", site: "@example" },
generatedOgImage: true, // this site serves an opengraph-image route
imageHosts: ["images.example.com"], // hosts a generated card may fetch from
},
content: {
posts: defineCollection({
path: "content/posts/*.md",
label: "Posts",
singular: "Post",
schema: z.object({
title: z.string(),
slug: slug({ from: "title" }),
body: markdown(),
}),
}),
},
})Every key but content is optional. The six content kinds have a helper each:
defineSingle, defineCollection, defineStructure, defineTags, defineCategories, and
defineGlobalSet.
Content API
Reading takes a read context — a resolved config, and optionally a Git ref and a storage adapter. Build one once and share it:
// lib/content.ts
import { join } from "node:path"
import { headers } from "next/headers"
import { resolveConfig, type ReadContext } from "@getxuui/core"
import { readXuuiPreview } from "@getxuui/core/next"
import rawConfig from "@/xuui.config"
export const config = resolveConfig(rawConfig, join(process.cwd(), "xuui.config.ts"))
type Content = (typeof config)["content"]
export async function readContext(): Promise<ReadContext<Content> & { live: boolean }> {
// Reads the draft branch inside a signed preview, the published one otherwise.
return { config, ...(await readXuuiPreview(await headers())) }
}Then:
| Function | Returns |
| -------------------------------------- | ------------------------------------------------- |
| getEntry(ctx, name, id) | one entry, or null |
| getEntries(ctx, name) | every entry of a collection, structure, or group |
| getSingle(ctx, name) | the one entry of a single |
| getStructure(ctx, name) | a nested tree of StructureNode |
| getBreadcrumbs(ctx, name, id) | the path from the tree root to one page |
| getTags(ctx, name) / getCategories | a taxonomy group's entries |
| getGlobalSet(ctx, name) | one Global Set |
| getGlobalSets(ctx) | every Global Set |
| readContent(ctx, name) | entries and the ones that failed their schema |
The readers are typed from your own config — no codegen and no generated file. Because
defineCollection keeps the schema type all the way through resolveConfig,
getEntry(ctx, "posts", slug) gives you post.data.title with the type your schema declared.
Pass it explicitly — getEntry<PostData>(…) — when you want to override that.
An entry is { id, slug, path, data, body, raw }, plus parentId and depth inside a
structure. data is your schema's output; body is the markdown after the frontmatter; raw
is the file exactly as it is on disk.
On GitHub storage, content reads are cached for 10 seconds: the adapter is memoised per
resolved config, and it serves a cached tree until that window passes. A Publish from another
process or instance, or a direct push, can take up to 10 seconds to reach a warm instance. A
single-instance deploy never sees this lag — the same handler's merge invalidates the ref it
just wrote. .xuui/ files (users.yaml, roles.yaml, and the rest) always bypass this cache,
so xuui revoke still bites at once. Change the window with
createGithubAdapter({ repo, token, treeTtlMs }), passed as ReadContext.storage.
Live preview
The Studio shows two things beside the entry form: Preview, the page itself in an iframe, and
Live, that same page updating from the form as you type, before any save. Preview opens
/xuui-preview<path> through the gate createXuuiMiddleware mounts.
What Preview shows differs between a deploy and your own machine, and the difference matters. In production it shows the draft branch — the same branch Save writes to, so nothing public changes until Publish. On your own machine there is no draft branch: the gate reads your working tree instead, which can already hold edits nobody has committed. A screenshot of "Preview" from a teammate's laptop is not the same thing as a screenshot of Preview in production.
Either way, the gate needs XUUI_SECRET set — see the environment variable table below. It signs
the token that carries the ref (or no ref, locally) and the live flag together, and verifying
that signature is what keeps useXuuiEntry from listening to a stranger's frame. With
auth.enabled off, that secret is the only thing standing between a stranger and the gate once the
site is a real deploy, which is why the gate still refuses everyone once NODE_ENV says
"production" — auth off only opens the gate on a developer's own machine.
useXuuiEntry makes one of your own pages follow the Studio's form as an editor types:
"use client"
import type { Entry } from "@getxuui/core"
import { useXuuiEntry } from "@getxuui/core/live"
export function Article({ post, live }: { post: Entry<PostData>; live: boolean }) {
const entry = useXuuiEntry(post, { name: "posts", live })
return <h1>{entry.data.title}</h1>
}live comes from readXuuiPreview above. It is false on an ordinary request, and true only
inside a preview the middleware signed — so a page never listens for messages from whatever
frame happened to embed it.
It is a hook and nothing more. Importing it does not pull the Studio into your site's bundle:
@getxuui/core/live imports react and nothing else, and a test holds it to that.
CLI
| Command | What it does |
| ----------------------------- | --------------------------------------------------------------- |
| xuui init | scaffold the mount points into an existing Next.js app |
| xuui doctor | check the project for quiet setup traps before a deploy |
| xuui check | validate every entry against its schema |
| xuui format | rewrite every body into the editor's canonical markdown |
| xuui invite <username> | print a link that lets somebody register a passkey |
| xuui revoke <username> | remove them from .xuui/users.yaml; ends their session at once |
| xuui role <username> <role> | set what somebody may do; takes effect on their next request |
| Option | Applies to | |
| --------------------- | ------------- | ------------------------------------------------------- |
| -c, --config <path> | all | the config file; the default searches upward |
| -C, --cwd <path> | all | the directory to start from |
| --type <name> | check, format | one content type only; repeat for more |
| --no-round-trip | check | skip the read-and-write-back test |
| --no-body-check | check | skip the test for a body the editor would rewrite |
| --dry-run | init, format | print what would change; write nothing |
| --production | doctor | judge this run as the deploy |
| --base-url <url> | invite | the site the link points at |
| --role <role> | invite | the role the editor will hold; the default is editor |
| --first, --last | invite | the editor's name, for the passkey picker and git log |
xuui check in CI is the useful one: it catches an entry that no longer matches its schema, a
tree page whose parent is missing, and a relation pointing at an id that is not there.
Commands read .env.development.local, .env.local, .env.development, then .env, and
print which file they loaded.
The CLI runs your config
Every xuui command loads xuui.config.ts and runs it, as TypeScript, in the same process. That is what makes a config a config rather than a data file: it can import your schemas, call defineCollection, and compute values.
With no --config, the CLI looks for xuui.config.ts, .mts, .js, or .mjs in the working directory, then in each directory above it, up to the root of the file system. The first one it finds is the one it runs. So running xuui inside a directory tree you did not write — a cloned repository you have not read, a downloaded archive — can run code from a config file somewhere above you.
This is how next.config, ESLint, and the rest of the ecosystem behave, and the same care applies: read a project's config before you run a command in it, and pass --config when you want to name the file yourself rather than let the search pick one.
The search only ever reads local disk. No xuui code path loads a config from a Git ref, so a write to the draft branch cannot become code the CLI or the server runs.
Environment variables
| Variable | Needed when | What it is |
| ----------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| XUUI_SECRET | auth.enabled, or Preview/Live | signs sessions and preview tokens — Preview needs it even with auth.enabled off. openssl rand -base64 32 |
| XUUI_GITHUB_TOKEN | storage.repo is set | the token that reads and writes the repository |
| XUUI_GITHUB_APP_TOKEN | instead of the above | a GitHub App installation token; takes precedence |
| XUUI_RP_ID | off localhost | the WebAuthn domain, e.g. example.com. Or auth.rpId |
| XUUI_ORIGIN | off localhost | the full origin, e.g. https://example.com. Or auth.origin |
| XUUI_BASE_URL | optional | the site xuui invite points its link at |
| XUUI_GITHUB_API_URL | GitHub Enterprise | the API base. The default is https://api.github.com |
With storage.repo set and no token, xuui falls back to the disk adapter locally — which is
what keeps development working with no setup — and throws under NODE_ENV=production,
because a disk write on a serverless host resolves without error and commits nothing.
Peer dependencies
zod ^4.4, and next >=16 / react ^19 for everything but the plain readers.
The Zod range is narrow on purpose. Reading a schema to work out what form to draw uses Zod's library-author internals, which its semver promise does not cover, so the peer range names the minor line xuui is actually tested against rather than the whole major.
Contributing
See AGENTS.md for the rules this repository works to. npm run verify is the
gate: build, tsc, ESLint, Prettier, knip, the tests, then the fixtures.
