Getting Started
Scaffold a new Aphex project, run it locally, and ship your first schema.
This guide walks you from zero to a running Aphex CMS with a content schema you can edit in the admin UI. Everything below uses the base template, which is the recommended way to start a new project — it ships with a full auth, storage, email, and cache setup already wired together.
Prerequisites
- Node.js 18 or later
- pnpm (
npm install -g pnpm) - Docker (optional) — only if you want Mailpit for local email, or Postgres instead of the default SQLite. The default setup needs neither.
Quick start
Scaffold
pnpm create aphex@latest
# or: npm create aphex@latest
# or: yarn create aphexPick a lowercase-hyphen project name (e.g. my-cms). The create-aphex scaffolder copies a template into a new directory with all workspace deps already pinned to published versions.
It asks which template you want, or you can name one up front:
| Template | --template | What you get |
|---|---|---|
| Base (default) | base | A minimal starter — one example page — for building your own content model. |
| Website | website | A working marketing site: page builder, blog with categories, navigation, forms, SEO, search. |
pnpm create aphex@latest my-site --template websiteIf you're evaluating Aphex rather than starting a project, take website. It boots with real
content already in the CMS, so the admin has something to edit and the front end has something to
render — which is the part that shows what "the CMS and the site are the same app" actually means.
Both templates seed example content on first run; turn it off with APHEX_SEED=false.
Install dependencies
cd my-cms
pnpm installStart the dev server
pnpm dev # http://localhost:5173, or the next free portThat's it — no database to set up. The template runs on a local SQLite file
(.aphex/base.db) and pushes the schema on boot. Prefer Postgres? See
Switching to Postgres.
Create the first user
Open /admin at the URL Vite prints. The first account that signs up becomes the super admin and gets a freshly-seeded default organization (with them as owner). Anyone else who signs up gets the editor instance role automatically, but no organization membership — they'll land on the invitations page until someone invites them.
First user and sign-up access
A fresh instance has no administrator who could send an invitation, so the sign-up gate allows
exactly one account through while the instance is empty. With the default bootstrap policy, that
account becomes super_admin, receives a default organization, and becomes its owner. Create it
immediately after deploying, then keep the default invite-only policy in production.
After that first account exists, the default behavior is invite-only. An owner invites someone from Settings → Members. The recipient signs up with the exact invited email address, accepts the invitation, and receives the organization role selected by the inviter. The policy is enforced at the sign-up endpoint, not only by hiding a button.
Choose the registration policy in .env:
# Default: only a pending invite can create another account.
AUTH_INVITE_ONLY=true
# Public registration: anyone can create an account.
# AUTH_INVITE_ONLY=falsePublic registration creates an editor account but does not grant access to an organization or
its content. Until an owner adds or invites that user, they are sent to the invitations page. If you
open registration in production, also enable AUTH_REQUIRE_EMAIL_VERIFICATION=true and configure
email delivery so people must prove that they own the address.
The registration policy and the bootstrap policy solve different problems:
| Goal | Configuration |
|---|---|
| First visitor claims a new local install | Default: openFirstUser() |
| Only your address can become the first admin | [email protected] |
| Require access to server logs before promotion | APHEX_BOOTSTRAP_CLAIM_CODE=true |
| Keep later registration invite-only | Default: AUTH_INVITE_ONLY=true |
| Allow anyone to create an account | AUTH_INVITE_ONLY=false |
APHEX_BOOTSTRAP_EMAIL and the claim code control promotion, not whether the first account
row is created. An incorrect first sign-up becomes an ordinary editor and consumes the
empty-instance window, leaving no administrator. Do not expose a fresh deployment unattended; pair
the email allowlist with verification, claim it immediately, and keep database access available
for recovery.
See Authentication → Options and Bootstrapping the first admin for custom policies and recovery details.
The root / route is a public landing page that lists your published page documents — read
through the Local API in src/routes/+page.server.ts. It's the starting point for your own
frontend; a "Go to Studio" button links to /admin.
What's running locally
The default setup needs no containers — SQLite is a local file and the schema pushes on boot. Two things are optional:
- Email — dev email goes to Mailpit. Start it with
pnpm mail(docker compose up -d mailpit); the UI is at localhost:8025 and catches everything. Email is off by default (verification is opt-in), so you can skip this until you need it. - Postgres — only if you switch off SQLite.
pnpm db:startboots the Postgres 18 container fromdocker-compose.yml. See Switching to Postgres.
| Service | Port | Purpose |
|---|---|---|
| SQLite | — | Default. Local file at .aphex/base.db, schema pushed on boot. |
| Mailpit | 8025 | Optional. Web UI — catches all dev email (pnpm mail). |
| Mailpit | 1025 | Optional. SMTP endpoint the dev email adapter points at. |
| Postgres | 5432 | Optional. Content database if you set APHEX_DATABASE=postgres. |
Environment variables
pnpm create aphex copies .env.example to .env. For local dev the defaults just work — you only need to change things for production.
# --- Database (SQLite by default — nothing required) -------
# APHEX_SQLITE_URL=file:.aphex/base.db # default; or a libsql:// Turso URL
# To use Postgres instead:
# APHEX_DATABASE=postgres
# DATABASE_URL="postgres://root:my-secret-password@localhost:5432/local"
# --- Auth --------------------------------------------------
# `pnpm create aphex` generates AUTH_SECRET for you. Generating it by hand:
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
AUTH_SECRET=
AUTH_URL=http://localhost:5173
AUTH_TRUSTED_ORIGINS=http://localhost:5173
# Invite-only after the first account. Set false for public registration.
AUTH_INVITE_ONLY=true
# --- Email -------------------------------------------------
# APHEX_EMAIL_FROM="Acme <[email protected]>" # sender identity
# RESEND_API_KEY=re_your_api_key_here # production (dev uses Mailpit)
# --- S3 / R2 storage (optional — falls back to local) ------
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET=
S3_PUBLIC_URL=AUTH_SECRET signs session cookies and API key hashes, so generate one per environment
and keep it — rotating it signs everyone out and invalidates every API key.
BETTER_AUTH_SECRET and BETTER_AUTH_URL still work as aliases, and older templates ship them.
The app reads AUTH_SECRET || BETTER_AUTH_SECRET, so setting either is enough — but prefer the
AUTH_* names in anything new.
Project structure
Key paths:
aphex.config.ts— the central config. Wires every adapter together.src/hooks.server.ts—auth → CMS → seedhook sequence on every request.src/lib/schemaTypes/— your content schemas. Register each new schema inindex.ts.src/lib/server/*— adapter singletons (auth, cache, db, email, storage). These are imported byaphex.config.ts.src/routes/(protected)/admin/— the CMS-guarded admin UI.src/routes/(site)/— your public pages, rendered by the same app.src/routes/api/— REST endpoints re-exported from@aphexcms/cms-core/server.src/routes/healthz/— readiness probe reporting adapter health, not just liveness. Every bundled deploy config points at it.uploads/— local fallback for image/file uploads when noS3_*env vars are set. Deliberately outsidestatic/, so assets are reachable only through the access-controlled/media/{id}/{filename}route. Override withAPHEX_UPLOADS_DIR; in a container it must point at a mounted volume, or uploads vanish on the next deploy.
aphex.config.ts
The template wires in every adapter the base setup needs:
import { createCMSConfig } from '@aphexcms/cms-core/server';
import { schemaTypes } from './src/lib/schemaTypes/index.js';
import { authProvider } from './src/lib/server/auth';
import { db } from './src/lib/server/db';
import { email } from './src/lib/server/email';
import { storageAdapter } from './src/lib/server/storage';
import { cacheAdapter } from './src/lib/server/cache';
export default createCMSConfig({
schemaTypes,
database: db,
storage: storageAdapter,
email,
cache: cacheAdapter,
auth: {
provider: authProvider,
loginUrl: '/login'
},
graphql: {
defaultPerspective: 'draft',
path: '/api/aphex-graphql'
},
customization: {
branding: { title: 'Aphex' }
}
});See Configuration for the full option reference.
src/hooks.server.ts
Three hooks run in order on every request:
export const handle = sequence(authHook, aphexHook, seedHook);authHook— Better Auth handles/api/auth/*and attaches the session toevent.locals.aphexHook— Builds the CMS engine once, injects it intoevent.locals.aphexCMS, and protects/admin/*and/api/*. It watches for schema changes in dev via a__aphexSchemasDirtyflag and rebuilds the engine when schemas change, so HMR picks up new fields without a restart.seedHook— Seeds demo content on first run against an untouched site (setAPHEX_SEED=falseor delete it to opt out). There's no routing hook —/is a public page you own (seesrc/routes/+page.server.ts).
Writing your first schema
The template ships with a minimal Page document at src/lib/schemaTypes/page.ts — the one the homepage lists via the Local API:
import { defineType } from '@aphexcms/cms-core';
const page = defineType({
type: 'document',
name: 'page',
title: 'Page',
fields: [
{ name: 'title', type: 'string', title: 'Title', validation: (Rule) => Rule.required() },
{
name: 'slug',
type: 'slug',
title: 'Slug',
source: 'title',
validation: (Rule) => Rule.required()
},
{ name: 'body', type: 'text', title: 'Body', rows: 8 }
]
});
export default page;Add a new type by creating another file next to it and registering it in src/lib/schemaTypes/index.ts:
import { defineType } from '@aphexcms/cms-core';
import { FileText } from '@lucide/svelte';
const post = defineType({
type: 'document',
name: 'post',
title: 'Post',
icon: FileText,
fields: [
{ name: 'title', type: 'string', title: 'Title', validation: (Rule) => Rule.required() },
{ name: 'slug', type: 'slug', title: 'Slug', source: 'title' },
{ name: 'excerpt', type: 'text', title: 'Excerpt' },
{ name: 'content', type: 'text', title: 'Content', rows: 10 },
{ name: 'coverImage', type: 'image', title: 'Cover Image' }
]
});
export default post;import page from './page.js';
import post from './post.js';
export const schemaTypes = [page, post];Save. The dev server's Vite plugin flags the schemas as dirty, the next request rebuilds the engine, and Post appears in the admin sidebar. No restart needed.
See Schema Types for every field type, validation rules, references, arrays, and objects.
TypeScript types for your schemas
You don't manage this — it's automatic. Editing a schema rewrites src/lib/generated-types.ts on save (the aphex() Vite plugin), augmenting localAPI.collections so queries stay fully typed. You commit that file; builds, CI, and prod use it as-is.
The template still exposes a pnpm generate:types script for the rare catch-up case (a schema changed while the dev server was off). See Type Generation for details.
Email in development vs production
src/lib/server/email/index.ts decides the adapter at runtime:
export const email = dev
? createMailpitAdapter()
: createResendAdapter({ apiKey: env.RESEND_API_KEY ?? '' });- Dev — all password-reset, verification, and invitation emails go to Mailpit. No setup required.
- Prod — set
RESEND_API_KEYand update thefromaddress inemailConfiginside the same file.
Swap in any other SMTP provider by replacing createMailpitAdapter() with createNodemailerAdapter({ host, port, auth }) — see the @aphexcms/nodemailer-adapter package.
Storage in development vs production
src/lib/server/storage/index.ts picks an adapter based on environment variables:
if (env.S3_BUCKET && env.S3_ENDPOINT && env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY) {
storageAdapter = s3Storage({
/* R2 / S3 config */
}).adapter;
} else {
storageAdapter = createStorageAdapter('local', {
basePath: env.APHEX_UPLOADS_DIR || './uploads',
options: { legacyBasePaths: ['./static/uploads', './uploads'] },
baseUrl: '/uploads'
});
}- Dev (default) — uploads land in
uploads/and are served through/media/{id}/{filename}, the route that applies access control. - Prod — fill in the
S3_*env vars (works with Cloudflare R2, AWS S3, MinIO, or any S3-compatible backend).
See Storage for adapter options and provider examples.
Caching (optional but shipped)
The template creates a shared InMemoryCacheAdapter and hands it to both the CMS config (for published read caching) and Better Auth (for API key lookups):
import { InMemoryCacheAdapter } from '@aphexcms/cms-core/server';
export const cacheAdapter: InMemoryCacheAdapter | null = new InMemoryCacheAdapter({
maxSize: 5000
});Set the export to null to disable caching, or swap in a Redis-backed CacheAdapter when you outgrow in-memory storage.
Common commands
# Dev
pnpm dev # loopback only; picks the next free port
pnpm dev --host # opt in to LAN/tunnel access
pnpm build # Production build
pnpm preview # Preview built app
# Database — SQLite (default) needs none of these; they're for the Postgres path
pnpm db:start # docker compose up -d (Postgres)
pnpm db:delete # docker compose down -v (wipes volume)
pnpm db:push # Push schema (dev)
pnpm db:generate # Generate migration SQL
pnpm db:migrate # Run migrations (prod)
pnpm db:studio # Drizzle Studio — localhost:4983
pnpm mail # docker compose up -d mailpit (local email)
# Schemas
pnpm generate:types # Regenerate typed collections
# Quality
pnpm check # svelte-check type check
pnpm test # vitest runNext steps
Deploy it
One container and one volume, or a button on Render or Railway. Read this before you go public — the demo seed is on by default.
Configuration
Every aphex.config.ts option — schemas, adapters, auth, GraphQL, cache, versioning.
Schemas
Every field type, validation rules, objects, references, and arrays.
Local API
Query and mutate content from route handlers with full type safety.
Authentication
Roles, organizations, password reset, and API key access.
Last updated on