Aphex

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 aphex

Pick 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 install

Start 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:5173

Create 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:

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]
ServicePortPurpose
Postgres5432Content database. Drizzle migrations live in ./drizzle/.
Mailpit8025Web UI at localhost:8025 — catches all dev email.
Mailpit1025SMTP 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.

.env
# --- 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

aphex.config.ts
drizzle.config.ts
docker-compose.yml
prod.docker-compose.yml
hooks.server.ts
.env

Key paths:

  • aphex.config.ts — the central config. Wires every adapter together.
  • src/hooks.server.tsauth → CMS → routing hook sequence on every request.
  • src/lib/schemaTypes/ — your content schemas. Register each new schema in index.ts.
  • src/lib/server/* — adapter singletons (auth, cache, db, email, storage). These are imported by aphex.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 no R2_* env vars are set.

aphex.config.ts

The template wires in every adapter the base setup needs:

aphex.config.ts
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:

src/hooks.server.ts (simplified)
export const handle = sequence(authHook, aphexHook, routingHook);
  1. authHook — Better Auth handles /api/auth/* and attaches the session to event.locals.
  2. aphexHook — Builds the CMS engine once, injects it into event.locals.aphexCMS, and protects /admin/* and /api/*. It watches for schema changes in dev via a __aphexSchemasDirty flag and rebuilds the engine when schemas change, so HMR picks up new fields without a restart.
  3. 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:

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:

src/lib/schemaTypes/page.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;
src/lib/schemaTypes/index.ts
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:types

This 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:

src/lib/server/email/index.ts
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_KEY and update the from address in emailConfig inside 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:

src/lib/server/storage/index.ts
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):

src/lib/server/cache/index.ts
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 run

Next steps

Edit on GitHub

Last updated on