A modern, extensible Content Management System built with SvelteKit V2 (Svelte 5), featuring a portable core package, database/storage agnostic adapters, and a Sanity-inspired admin interface.
ā ļø Early Development: This project is in very early stages. Expect breaking changes, incomplete features, and rough edges. Not recommended for production use yet.
AphexCMS follows a monorepo architecture with clear separation between framework-agnostic CMS logic (@aphex/cms-core
) and application-specific concerns (auth, database connections, schemas). This design enables:
@aphex/cms-core
- Core CMS PackagePortable, framework-agnostic CMS logic:
@aphex/ui
- Shared UI Componentsshadcn-svelte component library:
@lib
alias)cn()
, tailwind-variants@aphex/studio
- Example ApplicationReference implementation showing how to use the CMS:
/admin
nvm
)# Clone the repository
git clone https://github.com/IcelandicIcecream/aphex.git
cd aphex
# Install dependencies
pnpm install
# Set up environment variables
cd apps/studio
cp .env.example .env
# Edit .env with your configuration (default values work for local development)
cd ../..
# Start PostgreSQL database
pnpm db:start
# Push database schema
pnpm db:push
# Start development server
pnpm dev
The admin interface will be available at http://localhost:5173/admin
# 1. Start PostgreSQL container
pnpm db:start
# This runs: docker-compose up -d
# Default: postgres://postgres:password@localhost:5432/aphexcms
# 2. Push schema to database (development)
pnpm db:push
# Or generate migrations (production)
pnpm db:generate
pnpm db:migrate
# 3. (Optional) Open Drizzle Studio to view data
pnpm db:studio
aphex/
āāā apps/
ā āāā studio/ # Example app (@aphex/studio)
ā āāā src/
ā ā āāā lib/
ā ā ā āāā schemaTypes/ # Content schemas (YOUR MODELS)
ā ā ā āāā server/db/ # Database connection (YOUR CONFIG)
ā ā ā āāā api/ # API client wrapper
ā ā āāā routes/
ā ā ā āāā api/ # Re-exports CMS handlers
ā ā ā ā āāā documents/+server.ts # export { GET, POST } from '@aphex/cms-core/server'
ā ā ā ā āāā schemas/[type]/+server.ts # Uses YOUR schemaTypes
ā ā ā āāā (protected)/admin/ # Admin UI pages
ā ā āāā hooks.server.ts # Initialize CMS
ā āāā aphex.config.ts # CMS configuration
ā
āāā packages/
āāā cms-core/ # @aphex/cms-core (THE PORTABLE CORE)
ā āāā src/
ā ā āāā components/ # Admin UI components
ā ā āāā db/ # Database adapters & interfaces
ā ā āāā storage/ # Storage adapters & interfaces
ā ā āāā routes/ # API handlers (re-exportable)
ā ā āāā services/ # Business logic
ā ā āāā types.ts # Shared types
ā āāā package.json # Exports: . (client), ./server
ā
āāā ui/ # @aphex/ui (SHARED COMPONENTS)
āāā src/lib/components/ui/ # shadcn components
āāā app.css # Shared Tailwind theme
Interfaces define contracts, adapters implement them:
// packages/cms-core/src/db/interfaces/document.ts
export interface DocumentAdapter {
findMany(filters?: DocumentFilters): Promise<Document[]>;
findById(id: string, depth?: number): Promise<Document | null>;
create(data: CreateDocumentData): Promise<Document>;
// ...
}
// packages/cms-core/src/db/adapters/postgresql/document-adapter.ts
export class PostgreSQLDocumentAdapter implements DocumentAdapter {
// Implementation for PostgreSQL
}
// Want MongoDB? Create MongoDBDocumentAdapter implementing the same interface!
Package never manages global state. Apps initialize via hooks:
// apps/studio/src/hooks.server.ts
import { createCMSHook } from '@aphex/cms-core/server';
import cmsConfig from '../aphex.config';
const aphexHook = createCMSHook(cmsConfig);
export const handle = sequence(aphexHook, yourAuthHook);
Most routes simply re-export package handlers:
// apps/studio/src/routes/api/documents/+server.ts
export { GET, POST } from '@aphex/cms-core/server';
Exception: Routes needing app-specific data (like schemas):
// apps/studio/src/routes/api/schemas/[type]/+server.ts
import { createSchemaByTypeHandler } from '@aphex/cms-core/server';
import { schemaTypes } from '$lib/schemaTypes/index.js';
export const GET = createSchemaByTypeHandler(schemaTypes);
Content models live in your app, not the package:
// apps/studio/src/lib/schemaTypes/page.ts
import { defineType } from '@aphex/cms-core';
export default defineType({
name: 'page',
title: 'Page',
type: 'document',
fields: [
{
name: 'title',
type: 'string',
title: 'Title',
validation: (Rule) => Rule.required()
},
{
name: 'slug',
type: 'slug',
title: 'Slug',
options: { source: 'title' }
},
{
name: 'content',
type: 'array',
title: 'Content',
of: [
{ type: 'hero' },
{ type: 'catalogBlock' }
]
}
]
});
Resolve nested document references with the depth
query parameter:
# No resolution (default) - references are just IDs
GET /api/documents?docType=page
# Depth 1 - resolve first-level references
GET /api/documents/123?depth=1
# Depth 2 - resolve references within references
GET /api/documents/123?depth=2
# Max depth 5 (clamped for performance)
GET /api/documents/123?depth=10 # Treated as depth=5
Circular reference protection: Visited documents are tracked to prevent infinite loops.
Documents use content hashing to detect changes:
hasChanges
flag shows draft differs from publishedIncluded field types:
string
, text
(textarea), number
, boolean
slug
(auto-generate from source field)image
(with asset upload & metadata)array
(flexible list with multiple types)object
(nested structures)reference
(link to other documents)Extend with custom field components!
# Development
pnpm dev # Start all packages in watch mode
pnpm dev:studio # Start studio app only
pnpm dev:package # Start cms-core package only
# Building
pnpm build # Build all packages (Turborepo)
pnpm preview # Preview production build
# Database
pnpm db:start # Start PostgreSQL (Docker)
pnpm db:push # Push schema changes (dev)
pnpm db:generate # Generate migrations
pnpm db:migrate # Run migrations (prod)
pnpm db:studio # Open Drizzle Studio
# Code Quality
pnpm lint # Prettier + ESLint check
pnpm format # Format code with Prettier
pnpm check # Type-check all packages
# UI Components
pnpm shadcn button # Add shadcn component to packages/ui
AphexCMS comes batteries-included with a complete authentication system powered by Better Auth:
/admin/settings
x-api-key
header for API requests# Create document with API key
curl -X POST http://localhost:5173/api/documents \
-H "x-api-key: your-api-key-here" \
-H "Content-Type: application/json" \
-d '{"type": "page", "draftData": {"title": "New Page"}}'
# List documents with API key
curl http://localhost:5173/api/documents?docType=page \
-H "x-api-key: your-api-key-here"
The auth system is configured in apps/studio/src/lib/server/auth/index.ts
using Better Auth with:
No additional setup required - it works out of the box!
We welcome contributions! Please follow these guidelines:
pnpm format
pnpm check
pnpm lint
$state
, $derived
, $effect
)Database adapters follow the same pattern: interfaces, adapters, and providers.
// packages/cms-core/src/db/interfaces/asset.ts
export interface AssetAdapter {
createAsset(data: CreateAssetData): Promise
// packages/cms-core/src/db/interfaces/index.ts
export interface DatabaseAdapter extends DocumentAdapter, AssetAdapter {
disconnect?(): Promise
2. **Create adapter implementations**:
```typescript
// packages/cms-core/src/db/adapters/mongodb/document-adapter.ts
import type { DocumentAdapter } from '../../interfaces/document.js';
export class MongoDBDocumentAdapter implements DocumentAdapter {
private db: any; // MongoDB client
constructor(client: any) {
this.db = client;
}
async findMany(filters?: DocumentFilters): Promise<Document[]> {
const collection = this.db.collection('documents');
const query = filters?.type ? { type: filters.type } : {};
return await collection.find(query).toArray();
}
async findById(id: string, depth?: number): Promise<Document | null> {
const collection = this.db.collection('documents');
const doc = await collection.findOne({ _id: id });
// Resolve references if depth > 0
return doc;
}
// ... implement all interface methods
}
// packages/cms-core/src/db/adapters/mongodb/asset-adapter.ts
export class MongoDBAssetAdapter implements AssetAdapter {
// Similar implementation for assets
}
// packages/cms-core/src/db/adapters/mongodb/index.ts
import { MongoClient } from 'mongodb';
import type { DatabaseAdapter, DatabaseConfig } from '../../interfaces/index.js';
import { MongoDBDocumentAdapter } from './document-adapter.js';
import { MongoDBAssetAdapter } from './asset-adapter.js';
export class MongoDBAdapter implements DatabaseAdapter {
private client: MongoClient;
private documentAdapter: MongoDBDocumentAdapter;
private assetAdapter: MongoDBAssetAdapter;
constructor(config: DatabaseConfig) {
this.client = new MongoClient(config.connectionString, config.options);
const db = this.client.db();
this.documentAdapter = new MongoDBDocumentAdapter(db);
this.assetAdapter = new MongoDBAssetAdapter(db);
}
// Delegate document operations
async findMany(filters?: any) {
return this.documentAdapter.findMany(filters);
}
async findById(id: string) {
return this.documentAdapter.findById(id);
}
// ... delegate all methods
// Delegate asset operations
async createAsset(data: any) {
return this.assetAdapter.createAsset(data);
}
// ... delegate all methods
async disconnect() {
await this.client.close();
}
async isHealthy(): Promise<boolean> {
try {
await this.client.db().admin().ping();
return true;
} catch {
return false;
}
}
}
export class MongoDBProvider implements DatabaseProvider { name = 'mongodb';
createAdapter(config: DatabaseConfig): DatabaseAdapter { return new MongoDBAdapter(config); } }
// Register the provider databaseProviders.register(new MongoDBProvider());
// Convenience factory function export function createMongoDBAdapter( connectionString: string, options?: any ): DatabaseAdapter { return createDatabaseAdapter('mongodb', { connectionString, options }); }
4. **Export from package**:
```typescript
// packages/cms-core/src/db/adapters/index.ts
export * from './mongodb/index.js';
// aphex.config.ts
export default createCMSConfig({
schemas,
database: {
adapter: 'mongodb',
connectionString: 'mongodb://localhost:27017/aphexcms'
}
});
Storage adapters follow the same pattern: interfaces, adapters, and providers.
Interface (already defined):
// packages/cms-core/src/storage/interfaces/storage.ts
export interface StorageAdapter {
store(data: UploadFileData): Promise<StorageFile>;
delete(path: string): Promise<boolean>;
exists(path: string): Promise<boolean>;
getUrl(path: string): string;
getStorageInfo(): Promise<{ totalSize: number; availableSpace?: number }>;
isHealthy(): Promise<boolean>;
}
Create adapter implementation: ```typescript // packages/cms-core/src/storage/adapters/s3-storage-adapter.ts import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; import type { StorageAdapter, UploadFileData, StorageFile, StorageConfig } from '../interfaces/storage.js';
export class S3StorageAdapter implements StorageAdapter { private client: S3Client; private bucket: string; private baseUrl: string;
constructor(config: StorageConfig) { this.client = new S3Client({ region: config.options?.region, credentials: config.options?.credentials }); this.bucket = config.options?.bucket || ''; this.baseUrl = config.baseUrl || ''; }
async store(data: UploadFileData): Promiseuploads/${Date.now()}-${data.filename}
;
await this.client.send(new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: data.buffer,
ContentType: data.mimeType
}));
return {
path: key,
url: `${this.baseUrl}/${key}`,
size: data.size
};
}
async delete(path: string): Promise
async exists(path: string): Promise${this.baseUrl}/${path}
; }
async getStorageInfo() { return { totalSize: 0 }; }
async isHealthy(): Promise
3. **Create provider**:
```typescript
// packages/cms-core/src/storage/providers/storage.ts
import { S3StorageAdapter } from '../adapters/s3-storage-adapter.js';
export class S3StorageProvider implements StorageProvider {
name = 's3';
createAdapter(config: StorageConfig): StorageAdapter {
return new S3StorageAdapter(config);
}
}
// Register the provider
storageProviders.register(new S3StorageProvider());
Export from package:
// packages/cms-core/src/storage/adapters/index.ts
export * from './s3-storage-adapter.js';
Use in your app:
// aphex.config.ts
export default createCMSConfig({
storage: {
adapter: 's3',
baseUrl: 'https://cdn.example.com',
options: {
bucket: 'my-bucket',
region: 'us-east-1',
credentials: { /* ... */ }
}
}
});
2. Add type to schema:
```typescript
// packages/cms-core/src/types.ts
export type FieldType = 'string' | 'number' | ... | 'yourFieldType';
export interface YourField extends BaseField {
type: 'yourFieldType';
options?: YourFieldOptions;
}
SchemaField.svelte
to render your field typefeature/your-feature
or fix/bug-description
feat:
, fix:
, docs:
, etc.)pnpm build
and pnpm check
passWhen reporting bugs, include:
Inspired by Sanity.io
Built with:
Questions? Check CLAUDE.md
for detailed architecture docs, or open an issue!