Aphex CMS is an open-source CMS that lives inside your SvelteKit app. Define your schemas in TypeScript. Bring your own Postgres. Extend anything.
Types on the left, documents in the middle, the editor on the right. On mobile, it collapses into breadcrumbs.
Editing is a thousand small judgments an hour. Aphex spends its detail budget on the ones that actually slow you down — versioning, validation, autosave, references.
Aphex keeps a rolling 25 versions per document. Compare, restore, or branch without leaving the editor. Republishing identical content is a no-op.
Every document has both streams. Edit the draft freely; readers see only what you publish. Switch perspectives in a click — queries return whichever you ask for.
Validation runs on every keystroke, on save, and at publish. Errors block the publish button — not your front-end at 2am.
Click a referenced doc and it slides open as a modal — same studio, deeper. Save bubbles up; the parent stays exactly where you left it.
Drafts persist after two seconds of typing-rest — debounced, never on every keystroke. Nothing gets lost; publishing stays a deliberate action.
One schema becomes a validated form in the studio, a typed REST payload, a GraphQL type with resolvers, and a database migration. You write the shape; Aphex writes the plumbing.
No codegen step. No build artifacts. Save the file, the studio reflects it.
// schemaTypes/page.ts — your page document type
import type { SchemaType } from '@aphexcms/cms-core';
export const page: SchemaType = {
type: 'document',
name: 'page',
title: 'Page',
fields: [
{
name: 'title',
type: 'string',
validation: (Rule) => Rule.required().max(100),
},
{
name: 'slug',
type: 'slug',
source: 'title', // auto-generate
},
{
name: 'content',
type: 'array',
of: [
{ type: 'textBlock' },
{ type: 'imageBlock' },
{ type: 'catalogBlock' },
],
},
{
name: 'author',
type: 'reference',
to: [{ type: 'author' }],
},
],
};Same types, same validation, same access control. Pick the dialect that fits the problem in front of you — switch on Tuesday without rewriting Wednesday.
GraphiQL mounts at /api/graphql by default.. Schema changes propagate live — no restart, no stale queries.
// src/routes/+page.server.ts — type-safe local API
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const { localAPI } = locals.aphexCMS;
const ctx = { organizationId: locals.org.id };
const { docs } = await localAPI.collections.page.find(ctx, {
where: { slug: { equals: 'home' } },
perspective: 'published',
limit: 1,
});
return { page: docs[0] ?? null };
};Every external boundary is an adapter you wire in. Start on Postgres + local disk in dev, ship on Neon + S3 + Resend in prod — without touching a line of CMS code.
Adapters are plain modules — no decorators, no DI container. Import. Configure. Pass to createCMSConfig.
// src/lib/server/db/index.ts — Postgres today, more on the way
import postgres from 'postgres';
import { env } from '$env/dynamic/private';
import {
createPostgreSQLProvider,
pgConnectionUrl,
} from '@aphexcms/postgresql-adapter';
import type { DatabaseAdapter } from '@aphexcms/cms-core/server';
export const client = postgres(pgConnectionUrl(env), {
max: 50,
idle_timeout: 20,
});
const provider = createPostgreSQLProvider({
client,
multiTenancy: { enableRLS: true, enableHierarchy: true },
});
export const db = provider.createAdapter() as DatabaseAdapter;Six decisions that feel like gravity once you're inside a real codebase.
$state, $derived, $effect. No stores, no Svelte 4 leftovers. The studio reads like an app from next year.
Local-first setup. pnpm create aphex, install, db:start, migrate, dev. Postgres and email run in Docker — no cloud accounts, no signup flow, no credit card to see the studio.
Everything ships as npm packages — cms-core, postgresql-adapter, storage-s3, ui. No forked monorepo, no vendor lock.
Run aphex generate:types once. Every collection in localAPI.collections.<name> becomes typed end-to-end — rename a field, every consumer lights up red. No any left when you stop.
Your +page.server.ts calls the same localAPI.collections.<name>.find() the studio uses internally. One mental model, zero RPC layer between editing and reading.
Mount custom routes through api(app) — a Hono instance, 30-line learning curve. Custom storage is a plain class. Custom email is a function. Nothing decorator-heavy, nothing reflection-driven.
No quarters, no JIRA. The repo is the roadmap; this is a rough shape of where things are pointed — not a promise. Order is loose; priority shifts with what bites first.
Occasional dispatches when a feature lands, a breaking change ships, or a template drops. No noise. Unsubscribe anytime.