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 (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 aphex

Pick 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--templateWhat you get
Base (default)baseA minimal starter — one example page — for building your own content model.
WebsitewebsiteA working marketing site: page builder, blog with categories, navigation, forms, SEO, search.
pnpm create aphex@latest my-site --template website

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

Start the dev server

pnpm dev          # http://localhost:5173, or the next free port

That'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:

.env
# Default: only a pending invite can create another account.
AUTH_INVITE_ONLY=true

# Public registration: anyone can create an account.
# AUTH_INVITE_ONLY=false

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

GoalConfiguration
First visitor claims a new local installDefault: openFirstUser()
Only your address can become the first admin[email protected]
Require access to server logs before promotionAPHEX_BOOTSTRAP_CLAIM_CODE=true
Keep later registration invite-onlyDefault: AUTH_INVITE_ONLY=true
Allow anyone to create an accountAUTH_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:start boots the Postgres 18 container from docker-compose.yml. See Switching to Postgres.
ServicePortPurpose
SQLiteDefault. Local file at .aphex/base.db, schema pushed on boot.
Mailpit8025Optional. Web UI — catches all dev email (pnpm mail).
Mailpit1025Optional. SMTP endpoint the dev email adapter points at.
Postgres5432Optional. 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.

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

aphex.config.ts
drizzle.config.ts
docker-compose.yml
docker-compose.prod.yml
Dockerfile
docker-entrypoint.sh
render.yaml
railway.json
.env.example
hooks.server.ts
.env

Key paths:

  • aphex.config.ts — the central config. Wires every adapter together.
  • src/hooks.server.tsauth → CMS → seed 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/(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 no S3_* env vars are set. Deliberately outside static/, so assets are reachable only through the access-controlled /media/{id}/{filename} route. Override with APHEX_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:

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, seedHook);
  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. seedHook — Seeds demo content on first run against an untouched site (set APHEX_SEED=false or delete it to opt out). There's no routing hook — / is a public page you own (see src/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:

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

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

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.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):

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                    # 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 run

Next steps

Edit on GitHub

Last updated on