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
# 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 dev server

pnpm dev          # http://localhost:5173

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 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 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 --------------------------------------------------
BETTER_AUTH_SECRET=your-secret-key-here-change-in-production
BETTER_AUTH_URL=http://localhost:5173
AUTH_TRUSTED_ORIGINS=http://localhost:5173

# --- 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) ------
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 → 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/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, 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 type { SchemaType } from '@aphexcms/cms-core';

const page: SchemaType = {
	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 type { SchemaType } from '@aphexcms/cms-core';
import { FileText } from '@lucide/svelte';

const post: SchemaType = {
	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.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 — 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