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 and Docker Compose — used to run PostgreSQL and Mailpit locally
Quick start
Scaffold
pnpm create aphex
# or: npm create aphex@latest
# or: yarn create aphexPick a lowercase-hyphen project name (e.g. my-cms). The create-aphex scaffolder copies the base template into a new directory with all workspace deps already pinned to published versions.
Install dependencies
cd my-cms
pnpm installStart the database and dev server
pnpm db:start # docker compose up -d (Postgres + Mailpit)
pnpm db:push # push the schema to the database
pnpm dev # http://localhost:5173Create the first user
Open http://localhost:5173/admin. 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.
The root / route redirects to /admin. A routingHook in src/hooks.server.ts handles that —
change it if you want a public landing page instead.
What's running locally
pnpm db:start boots two containers from docker-compose.yml:
services:
db:
image: postgres:18
env_file: [.env]
ports: ['${PG_PORT:-5432}:5432']
environment:
POSTGRES_USER: ${PG_USER:-root}
POSTGRES_PASSWORD: ${PG_PASSWORD:-mysecretpassword}
POSTGRES_DB: ${PG_DATABASE:-local}
volumes: [pgdata:/var/lib/postgresql]
mailpit:
image: axllent/mailpit
ports: ['8025:8025', '1025:1025']
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
volumes: [mailpit_data:/data]
volumes: [pgdata, mailpit_data]| Service | Port | Purpose |
|---|---|---|
| Postgres | 5432 | Content database. Drizzle migrations live in ./drizzle/. |
| Mailpit | 8025 | Web UI at localhost:8025 — catches all dev email. |
| Mailpit | 1025 | SMTP endpoint. The template's email adapter points here automatically. |
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 ----------------------------------------------
DATABASE_URL="postgres://root:mysecretpassword@localhost:5432/local"
# Or, instead of DATABASE_URL:
# PG_HOST=localhost
# PG_PORT=5432
# PG_USER=root
# PG_PASSWORD=mysecretpassword
# PG_DATABASE=local
# --- Auth --------------------------------------------------
BETTER_AUTH_SECRET=your-secret-key-here-change-in-production
BETTER_AUTH_URL=http://localhost:5173
AUTH_TRUSTED_ORIGINS=http://localhost:5173
# --- Email (production only — dev uses Mailpit) ------------
RESEND_API_KEY=re_your_api_key_here
# --- S3 / R2 storage (optional — falls back to local) ------
R2_ENDPOINT=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=
R2_PUBLIC_URL=Rotate BETTER_AUTH_SECRET before deploying — it signs session cookies and API key hashes.
Project structure
Key paths:
aphex.config.ts— the central config. Wires every adapter together.src/hooks.server.ts—auth → CMS → routinghook 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/api/— REST endpoints re-exported from@aphexcms/cms-core/server.static/uploads/— local fallback for image/file uploads when noR2_*env vars are set.
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, routingHook);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.routingHook— Redirects/to/admin. Remove it or change the target for a public home page.
Writing your first schema
The template ships with a Post document at src/lib/schemaTypes/post.ts:
import type { SchemaType } from '@aphexcms/cms-core';
const post: SchemaType = {
type: 'document',
name: 'post',
title: 'Post',
description: 'Blog posts',
fields: [
{ name: 'title', type: 'string', title: 'Title', validation: (Rule) => Rule.required() },
{
name: 'slug',
type: 'slug',
title: 'Slug',
source: 'title',
validation: (Rule) => Rule.required()
},
{ name: 'excerpt', type: 'text', title: 'Excerpt', description: 'A short summary of the post' },
{ name: 'content', type: 'text', title: 'Content' },
{ name: 'coverImage', type: 'image', title: 'Cover Image' },
{ name: 'published', type: 'boolean', title: 'Published', initialValue: false }
]
};
export default post;Add a new type by creating another file next to it and registering it in src/lib/schemaTypes/index.ts:
import type { SchemaType } from '@aphexcms/cms-core';
import { FileText } from '@lucide/svelte';
const page: SchemaType = {
type: 'document',
name: 'page',
title: 'Page',
icon: FileText,
fields: [
{ name: 'title', type: 'string', title: 'Title', validation: (Rule) => Rule.required() },
{ name: 'slug', type: 'slug', title: 'Slug', source: 'title' },
{ name: 'body', type: 'text', title: 'Body', rows: 10 }
]
};
export default page;import post from './post.js';
import page from './page.js';
export const schemaTypes = [post, page];Save. The dev server's Vite plugin flags the schemas as dirty, the next request rebuilds the engine, and Page appears in the admin sidebar. No restart needed.
See Schema Types for every field type, validation rules, references, arrays, and objects.
Generating TypeScript types for your schemas
The template exposes a generate:types script that produces strongly-typed interfaces from your schemas:
pnpm generate:typesThis writes to src/lib/generated-types.ts and augments localAPI.collections so queries are fully typed. Re-run it whenever you change a schema. 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.R2_BUCKET && env.R2_ENDPOINT && env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY) {
storageAdapter = s3Storage({
/* R2 / S3 config */
}).adapter;
} else {
storageAdapter = createStorageAdapter('local', {
basePath: './static/uploads',
baseUrl: '/uploads'
});
}- Dev (default) — uploads land in
static/uploads/and are served by SvelteKit's static handler. - Prod — fill in the
R2_*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 # vite dev --host
pnpm build # Production build
pnpm preview # Preview built app
# Database
pnpm db:start # docker compose up -d
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
# Schemas
pnpm generate:types # Regenerate typed collections
# Quality
pnpm check # svelte-check type check
pnpm test # vitest runNext steps
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