Configuration
The complete reference for aphex.config.ts — schemas, adapters, auth, GraphQL, versioning, branding, and security.
The aphex.config.ts file is the central configuration for your Aphex project. It's created by createCMSConfig() and wires together your schemas, database, storage, authentication, and email.
import { createCMSConfig } from '@aphexcms/cms-core/server';
import { schemaTypes } from '$lib/schemaTypes/index.js';
import { db } from '$lib/server/db/index.js';
import { authProvider } from '$lib/server/auth/index.js';
import { email } from '$lib/server/email/index.js';
export default createCMSConfig({
schemaTypes,
database: db,
email,
auth: {
provider: authProvider,
loginUrl: '/login'
},
graphql: {
defaultPerspective: 'published',
path: '/api/graphql'
},
customization: {
branding: {
title: 'My CMS'
}
}
});Options
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
schemaTypes | SchemaType[] | Yes | — | Your content schemas (document and object types). |
database | DatabaseAdapter | Yes | — | Database adapter instance. |
storage | StorageAdapter | null | No | Local filesystem | Storage adapter for file uploads. |
images | object | null | No | Enabled | Responsive image derivatives. null disables them. |
upload | object | No | 10 MB, proxied | Upload size ceiling and direct-to-storage transport. |
signedDownloads | object | No | — | Serve selected assets as a signed-URL redirect instead of proxying. |
email | EmailAdapter | null | No | null | Email adapter for sending emails. |
cache | CacheAdapter | null | No | null | Cache adapter for published-perspective reads. |
auth | object | No | — | Authentication configuration. |
graphql | boolean | GraphQLConfig | No | true | GraphQL API configuration. |
versioning | object | No | — | Document version history options. |
api | (app: Hono) => void | No | — | Register custom HTTP routes / middleware on the built-in Hono app. |
aiProvider | AIProviderAdapter | null | No | null | Model backend for the in-admin agent. Omit to leave the agent off. |
agentModel | string | No | — | Model id the agent calls. Required when aiProvider is set. |
agentSystemPrompt | string | No | built-in | Replaces the default agent system prompt. |
customization | object | No | — | Branding and theme options. |
logLevel | string | No | auto | 'debug' in dev, 'warn' in prod. |
security | object | No | — | Security options (asset signing). |
schemaTypes
An array of SchemaType objects defining your content model. Each schema is either a document (top-level collection) or an object (reusable nested structure).
import { schemaTypes } from '$lib/schemaTypes/index.js';
createCMSConfig({
schemaTypes
// ...
});Schemas are registered in the database on first startup and re-synced when they change during development. The aphex() Vite plugin (from @aphexcms/cms-core/vite, included in the template's vite.config.ts) watches aphex.config.ts and src/lib/schemaTypes/** and hot-swaps the engine config in place — the dev server keeps running, only the schema map updates. See Schemas for the full schema reference.
database
A DatabaseAdapter instance that handles all content storage. This is the only required adapter. The interface is part of the ports-and-adapters architecture so additional database backends can be added later, but the only adapter shipped today is PostgreSQL via Drizzle ORM (@aphexcms/postgresql-adapter).
import { db } from '$lib/server/db/index.js';
createCMSConfig({
database: db
// ...
});The PostgreSQL adapter (@aphexcms/postgresql-adapter) is the built-in implementation. It uses Drizzle ORM and supports Row-Level Security for multi-tenant isolation.
storage
A StorageAdapter instance for file uploads and asset management. If not provided, Aphex creates a local filesystem adapter automatically:
// Default (no config needed):
// Files stored at ./storage/assets
// Served via /assets/{id}/{filename}
// S3-compatible storage:
import { storage } from '$lib/server/storage/index.js';
createCMSConfig({
storage
// ...
});Available adapters:
- Local filesystem (default) — stores files in
./storage/assets. @aphexcms/storage-s3— S3-compatible storage (AWS S3, Cloudflare R2, MinIO).
images
Responsive image derivatives, generated on first request rather than at upload.
export default createCMSConfig({
images: {
widths: [320, 640, 960, 1280, 1920],
quality: 80
}
});Enabled by default — these are the defaults, so you only need this block to change them. Set
images: null to turn the pipeline off entirely; /media then always serves the original.
Prop
Type
Variants are addressed as siblings of the original: /media/{assetId}/w960-{configHash}.webp.
That hash is derived from widths + quality, which is what earns the URLs a one-year immutable
cache — the bytes behind a given URL can never change.
Changing the ladder
Safe, and needs no migration. Edit either value and the hash moves, so every variant URL changes and the next request regenerates at the new settings. Nothing breaks in between, because a URL carrying an old hash simply serves the original.
The one cost is that superseded files stay in the bucket unreferenced until the asset is deleted
(deletion sweeps the whole {assetId}/ prefix, so it does catch them). Reordering widths is a
no-op: they're sorted and de-duplicated before hashing.
There is deliberately no per-collection or per-block size config. One ladder means adding a width
is a config edit rather than a migration, and two placements of one image share a single set of
files. The per-placement control is the sizes attribute — see
Frontend.
Widths at or above an asset's own width are skipped, so a 1600 px original's srcset stops at
1280 rather than upscaling. SVG and animated images are served as-is.
upload
export default createCMSConfig({
upload: {
maxFileSize: 100 * 1024 * 1024, // 100 MB
allowedMimeTypes: ['image/*', 'application/pdf'],
direct: true
}
});Prop
Type
maxFileSize is the only place to set the limit. It backs the request check, the direct-upload
grant, the ceiling the admin UI reads back, and — since v1 — the storage adapter's own guard, which
createCMSConfig overrides so the two can't disagree.
The one thing it can't raise is a host's own request cap. Vercel Functions reject a body over
4.5 MB before your app is invoked, and unlike responses there's no streaming escape — which is what
direct is for.
allowedMimeTypes is the installation-wide upload policy. When omitted, Aphex allows this
conservative set of common CMS formats:
const defaultAllowedMimeTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/avif',
'image/heic',
'image/heif',
'application/pdf',
'text/plain',
'text/csv',
'text/markdown',
'application/json',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.presentation',
'application/zip',
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/ogg',
'audio/aac',
'audio/flac',
'video/mp4',
'video/webm',
'video/quicktime',
'video/ogg',
'font/woff',
'font/woff2',
'font/ttf',
'font/otf'
];SVG is excluded by default because it can contain active content. Providing allowedMimeTypes
replaces the built-in list. Configure exact MIME types (application/pdf) or wildcards (image/*);
filename extensions such as .pdf are rejected because they identify a name, not its content. A
schema field's accept rule is applied in addition to this
list, so fields may narrow the global policy but cannot loosen it. Aphex's built-in dangerous-content
checks always apply, including when an explicit list is configured.
To add formats while retaining the defaults, spread the exported list:
import { createCMSConfig, DEFAULT_ALLOWED_MIME_TYPES } from '@aphexcms/cms-core/server';
export default createCMSConfig({
upload: {
allowedMimeTypes: [...DEFAULT_ALLOWED_MIME_TYPES, 'application/x-your-custom-format']
}
});To replace the defaults, provide only the formats the installation should accept:
upload: {
allowedMimeTypes: ['application/pdf', 'text/csv'];
}direct
direct: true additionally requires CORS PUT on your bucket from your site's origin, which
nothing in Aphex can detect. Without it every upload fails in the browser. It's off by default for
that reason, rather than inferred from "the adapter can sign". The grant is checked against the
declared MIME type, then confirmation reads a bounded prefix back from storage and magic-byte
checks the actual content before exposing the asset. Uploads land at a temporary key; confirmation
claims a non-servable row once, promotes and inspects the object, then exposes its final path. The
still-live signed URL and confirmation ticket therefore cannot overwrite an approved asset. A
storage adapter must support server-side copies; without ranged reads it must buffer smaller files
and rejects large direct uploads it cannot inspect safely.
It's also ignored — with a silent fallback to uploading through the app — when the storage adapter
can't sign uploads or security.secretEncryptionKey is unset. Uploading through the app works
everywhere, so the fallback is always safe.
An EmailAdapter instance for sending transactional emails (password resets, invitations, email verification). If not provided, email features are disabled.
import { email } from '$lib/server/email/index.js';
createCMSConfig({
email
// ...
});Available adapters:
@aphexcms/nodemailer-adapter— SMTP via Nodemailer. Includes acreateMailpitAdapter()shorthand for local development.@aphexcms/resend-adapter— Resend API for production.
In development, createMailpitAdapter() sends all emails to Mailpit on localhost:1025. The base template wires this up automatically — see Getting Started.
cache
An optional CacheAdapter that caches reads with perspective: 'published'. Entries are invalidated automatically when a document is published, unpublished, or deleted. Draft reads bypass the cache so the admin UI always sees the latest data.
import { InMemoryCacheAdapter } from '@aphexcms/cms-core/server';
createCMSConfig({
cache: new InMemoryCacheAdapter({ maxSize: 5000 })
});The base template exports a shared cacheAdapter from src/lib/server/cache/index.ts and passes the same instance to both the CMS config and Better Auth (for API-key lookup caching).
| Value | Behavior |
|---|---|
undefined or null | No caching (default). |
InMemoryCacheAdapter | LRU in-memory cache, shipped with @aphexcms/cms-core/server. |
Custom CacheAdapter | Implement the CacheAdapter interface for Redis, Upstash, etc. |
auth
Authentication configuration. If not provided, the CMS has no access control.
createCMSConfig({
auth: {
provider: authProvider,
loginUrl: '/login'
}
// ...
});| Option | Type | Required | Default | Description |
|---|---|---|---|---|
auth.provider | AuthProvider | Yes | — | Authentication provider instance. |
auth.loginUrl | string | No | '/login' | Redirect URL for unauthenticated users. |
The AuthProvider interface handles:
- Session auth — browser sessions for the admin UI.
- API key auth — programmatic access via
x-api-keyheader. - User management — fetching users, changing names.
- Password reset — request and confirm password resets.
The built-in integration uses Better Auth with organization support. See the scaffolded src/lib/server/auth/ directory for the full implementation.
graphql
Controls the built-in GraphQL API. Can be true (defaults), false (disabled), or a config object.
// Enabled with defaults
createCMSConfig({ graphql: true });
// Disabled
createCMSConfig({ graphql: false });
// Custom options
createCMSConfig({
graphql: {
defaultPerspective: 'published',
path: '/api/graphql',
enableGraphiQL: true,
defaultQuery: '{ allPost { id title } }'
}
});| Option | Type | Default | Description |
|---|---|---|---|
defaultPerspective | 'draft' | 'published' | 'published' | Default perspective when not specified in a query. |
path | string | '/api/graphql' | GraphQL endpoint path. |
enableGraphiQL | boolean | true | Enable the interactive GraphiQL IDE at the endpoint. |
defaultQuery | string | Built-in example | Default query shown in GraphiQL. |
See GraphQL API for the full reference.
versioning
Controls document version history. Each draft save and publish creates an entry in the cms_document_versions table. See Version History for the full reference.
createCMSConfig({
versioning: {
maxVersions: 25 // default — set 0 to disable rolling cleanup
}
});Prop
Type
api
The escape hatch for registering custom HTTP routes and middleware. Aphex's HTTP API runs on Hono — the function you pass receives the same Hono app the built-in routes mount onto, before they mount.
createCMSConfig({
api: (app) => {
// Add a brand-new endpoint
app.post('/send-email', async (c) => {
const { aphexCMS, auth } = c.var;
// ...
return c.json({ success: true });
});
// Wrap a built-in route with side effects (registration order
// matters: register before built-ins to intercept them)
app.use('/organizations/invitations', async (c, next) => {
await next();
if (c.res.status === 201) sendInviteEmail(/* ... */);
});
}
});Hono is registration-order-strict and first-match-wins. Registering before built-ins lets you wrap
them with app.use() or override them outright with app.METHOD(path, handler).
c.var exposes the same context the built-in routes use:
| Var | Type | Description |
|---|---|---|
c.var.aphexCMS | CMSInstances | Local API, services, adapters. |
c.var.auth | Auth | null | Resolved auth — session or API key. |
customization
Branding and theme options for the admin UI.
createCMSConfig({
customization: {
branding: {
title: 'My CMS',
logo: '/images/logo.png',
favicon: '/favicon.ico'
},
theme: {
colors: { primary: '#3b82f6' },
fonts: { sans: 'Inter, sans-serif' }
}
}
});Branding
| Option | Type | Default | Description |
|---|---|---|---|
title | string | 'Aphex CMS' | Display title in the admin UI. |
logo | string | — | URL to a logo image. |
favicon | string | — | URL to a favicon. |
Theme
| Option | Type | Default | Description |
|---|---|---|---|
colors | Record<string, string> | — | Custom color values (e.g. { primary: '#3b82f6' }). |
fonts | Record<string, string> | — | Custom font families (e.g. { sans: 'Inter, sans-serif' }). |
Admin sidebar
The sidebar nav is not part of aphex.config.ts. It's a SidebarData object your
app builds in src/routes/(protected)/admin/+layout.svelte and passes to <Sidebar> —
so it can read page data, feature flags, or the signed-in user.
The sidebar is organised into groups. Most projects want the three the templates ship with, so there are shorthand fields for exactly those:
import { Activity, ExternalLink, House } from '@lucide/svelte';
const sidebarData = $derived({
user: {
/* … */
},
navItems: [{ href: '/admin', label: 'Home', icon: House }],
systemNavItems: [{ href: '/admin/activity', label: 'Activity', icon: Activity }],
secondaryNavItems: [{ href: '/', label: 'View site', icon: ExternalLink, newTab: true }]
} satisfies SidebarData);| Field | Tier | Renders as |
|---|---|---|
navItems | Content | A group labelled Content — the nav an editor uses daily. |
systemNavItems | System | A group labelled System — operational views like Activity. |
secondaryNavItems | Utility | Pinned to the bottom, one size smaller. Off-site links, help, version. |
Setting none of them falls back to a single Content item pointing at /admin.
Custom groups
For any other shape, use navGroups — it defines the hierarchy outright and takes precedence over the three shorthand fields:
const sidebarData = $derived({
user: {
/* … */
},
navGroups: [
{ id: 'content', label: 'Content', items: [{ href: '/admin', label: 'Home', icon: House }] },
{
id: 'marketing',
label: 'Marketing',
items: [{ href: '/admin/campaigns', label: 'Campaigns' }]
},
{ id: 'ops', label: 'Ops', items: [{ href: '/admin/activity', label: 'Activity' }] },
{
id: 'utility',
placement: 'bottom',
items: [{ href: '/', label: 'View site', icon: ExternalLink, newTab: true }]
}
]
} satisfies SidebarData);| Option | Type | Default | Description |
|---|---|---|---|
id | string | — | Stable id. A plugin admin tool with a matching group renders inside this group. |
label | string | — | Heading above the group. Omit for an unlabelled block. |
items | SidebarNavItem[] | [] | The group's nav items. |
placement | 'top' | 'bottom' | 'top' | 'bottom' pins the group to the bottom of the sidebar and demotes it a size. |
Groups are partitioned by placement, not declaration order — a 'bottom' group declared first still renders at the bottom.
Nav items
| Option | Type | Default | Description |
|---|---|---|---|
href | string | — | Target path. Rendered as a real anchor, so cmd-click and middle-click work. |
label | string | — | Item text, and its tooltip when the sidebar is collapsed. |
icon | Component<IconProps> | — | Any Lucide icon component. |
newTab | boolean | false | Open in a new tab, and never mark the item active. |
Use newTab for anything that leaves the studio. It opens in its own tab so an editor keeps the document they were working on, and it's never highlighted as the current location — the live site isn't a studio page:
{ href: '/', label: 'View site', icon: ExternalLink, newTab: true }Where plugin tools land
A plugin admin tool with placement: 'sidebar' renders in a group labelled Tools. Give the tool a group matching one of your navGroups ids to file it under your own heading instead — see Add your own screen to the admin. A tool naming a group you haven't defined falls back to Tools rather than disappearing.
security
Security options for asset access control.
createCMSConfig({
security: {
assetSigningSecret: 'a-long-random-string-32-chars-minimum'
}
});| Option | Type | Default | Description |
|---|---|---|---|
assetSigningSecret | string | — | Secret key for HMAC-signed asset URLs. Lets a private asset be served to a viewer with no admin session — one asset, for a bounded window. Should be 32+ characters. |
Mint links with signAssetUrl from @aphexcms/cms-core/server; see
Private assets for the full flow. Without this secret, signing is a
no-op and verification always fails, so private assets stay reachable only with a session.
aiProvider
Turns on the in-admin agent — a chat panel that can read and edit content through the same agent tools exposed over MCP. Off unless you configure it. This section covers the config knobs; see AI Assistant for what the panel does, the workspace bridge into the open editor, and the per-turn audit/undo trail.
import { createOpenAIAdapter } from '@aphexcms/ai-openai';
createCMSConfig({
aiProvider: env.OPENAI_API_KEY ? createOpenAIAdapter({ apiKey: env.OPENAI_API_KEY }) : null,
agentModel: 'gpt-4.1'
});| Option | Type | Default | Description |
|---|---|---|---|
aiProvider | AIProviderAdapter | null | null | Model backend. When unset, POST /api/agent/chat returns 404 and the admin doesn't show the panel. |
agentModel | string | — | Provider-specific model id. Required when aiProvider is set — the route 501s without it. A request may override it per call. |
agentSystemPrompt | string | built-in | Injected fresh ahead of every turn, so edits apply to open conversations immediately. Replaces the default, which only carries behavioral guardrails (draft-first, confirm before broad changes) — schema and tool knowledge is self-describing via the describe_cms tool, so there's no need to restate it. Useful for giving a client's deployment its own tone or house rules. |
AIProviderAdapter is a port, same as database and storage. @aphexcms/ai-openai implements it for OpenAI and any OpenAI-compatible endpoint (OpenRouter, a local router); you can write your own for anything else.
Pointing at a different endpoint
createOpenAIAdapter takes an optional baseURL. Leave it out and requests go to https://api.openai.com/v1 — the OpenAI SDK's own default, which also means the standard OPENAI_BASE_URL environment variable keeps working. Set it to talk to anything OpenAI-compatible:
createOpenAIAdapter({
apiKey: env.OPENAI_API_KEY ?? 'local', // some local servers ignore the key
...(env.OPENAI_API_URL ? { baseURL: env.OPENAI_API_URL } : {})
});Spreading baseURL conditionally rather than passing undefined isn't required — the SDK treats both the same — but it keeps the intent obvious. createOpenRouterAdapter is a thin wrapper that just presets this value.
Every agent write goes through the same validation, permission checks, and compare-and-swap guard as a human edit — an agent can't write shapes the admin UI couldn't, and can't silently clobber a document someone else is editing. Mutating tool calls are recorded per turn and can be undone from the Activity view.
How config is consumed
The createCMSHook() function in hooks.server.ts takes your config and creates singleton instances of all adapters:
import { createCMSHook } from '@aphexcms/cms-core/server';
import config from '../aphex.config.ts';
const aphexHook = createCMSHook(config);
export const handle = sequence(authHook, aphexHook);On the first request, the hook:
- Creates the storage adapter (or uses the default local filesystem).
- Initializes the
AssetServicewith the storage adapter. - Creates the
CMSEngineand registers schemas in the database. - Creates the
LocalAPI(unified data layer). - Calls your
api(app)hook (if provided), then mounts the built-in Hono routes. - Initializes GraphQL if enabled.
On every subsequent request, the hook injects these singletons into event.locals.aphexCMS, making them available in all route handlers and load functions.
What's available on event.locals.aphexCMS
event.locals.aphexCMS.localAPI; // LocalAPI instance
event.locals.aphexCMS.databaseAdapter; // DatabaseAdapter
event.locals.aphexCMS.assetService; // AssetService
event.locals.aphexCMS.storageAdapter; // StorageAdapter
event.locals.aphexCMS.emailAdapter; // EmailAdapter (or null)
event.locals.aphexCMS.cmsEngine; // CMSEngine
event.locals.aphexCMS.rolesService; // RolesService
event.locals.aphexCMS.auth; // AuthProvider (or undefined)
event.locals.aphexCMS.config; // Your resolved CMSConfig (includes `cache`, `versioning`, etc.)
event.locals.aphexCMS.graphqlSettings; // GraphQL endpoint info (or null)Last updated on
API Keys
Create and manage API keys for programmatic access, scoped to organizations with parent–child hierarchy support.
Authentication
Set up auth, organizations, password reset, and API keys — with createAphexAuth or by wiring Better Auth yourself. The AuthProvider interface lives at the bottom for replacing it entirely.