Getting started
Quickstart
Install ForgeCMS, define a collection, and run CRUD through the Local API.
This is the smallest possible ForgeCMS: the Local API with in-memory adapters, nothing durable. For a real project follow the verified Server quickstart (libSQL, first admin, access control) or the Angular/Analog quickstart (admin included). Start from your own repository:
pnpm add @forge-cms/core @forge-cms/runtime @forge-cms/db @forge-cms/auth @forge-cms/storage
Define a collection:
import { defineCollection, defineField } from '@forge-cms/core';
export const notes = defineCollection({
slug: 'notes',
fields: {
title: defineField.text({ required: true }),
data: defineField.json()
}
});
Create the runtime:
import { InMemoryAuthAdapter } from '@forge-cms/auth';
import { InMemoryDatabaseAdapter } from '@forge-cms/db';
import { ForgeCmsRuntime } from '@forge-cms/runtime';
import { InMemoryStorageAdapter } from '@forge-cms/storage';
import { notes } from './cms.js';
export const runtime = new ForgeCmsRuntime({
collections: [notes],
adapters: {
database: new InMemoryDatabaseAdapter(),
auth: new InMemoryAuthAdapter(),
storage: new InMemoryStorageAdapter()
}
});
runtime.init();
await runtime.syncSchema();
Use the Local API:
const created = await runtime.create({
collection: 'notes',
data: { title: 'Hello ForgeCMS', data: { source: 'docs' } }
});
const { docs } = await runtime.find({ collection: 'notes' });
const note = await runtime.findOne({ collection: 'notes', where: { title: 'Hello ForgeCMS' } });
await runtime.delete({ collection: 'notes', id: created.id });
runtime.syncSchema() creates missing tables and applies safe additive changes. It never drops,
renames, retypes or backfills data, and since 0.8.0 it refuses changes that would need a data
migration (you apply those with a reviewed migration); see Schema upgrades for what happens when
you change a collection that already has rows. For framework HTTP integration, build an ApiContext
and call the handlers exported by @forge-cms/runtime.
Next, mount the admin and the Angular client: the Small project guide does both, with users and cookie sessions.
Running the repository demo
Prerequisites
- Node >= 22 (
.nvmrc) - pnpm 10.11.0 — never npm or yarn, the workspace depends on pnpm's linking
- git
1. Clone and build
git clone https://github.com/Andersseen/ForgeCMS.git
cd ForgeCMS
pnpm install
pnpm build # required once: @forge-cms/* resolves to packages/*/dist
pnpm build is not optional on a fresh clone. TypeScript path mapping points @forge-cms/* at
packages/*/dist/index.d.ts, so pnpm typecheck fails until the packages have been built once.
2. Run an app
pnpm dev:www # landing page + /admin + the CRUD API
pnpm dev:demo # a full marketing site built on the CMS (apps/demo-aesthetics)
Open the printed URL and go to /admin. Reads are public; creating, editing and deleting require a
login:
| App | Password | Role | |
|---|---|---|---|
apps/www (/admin/login) |
[email protected] |
forgecms-demo |
admin |
apps/demo-aesthetics (/login) |
[email protected] |
lumea-demo |
admin |
apps/demo-aesthetics (/login) |
[email protected] |
lumea-desk |
editor |
Local development uses in-memory adapters, so data resets on every reload. That is expected; the deployed apps persist to Cloudflare D1.
3. Add a collection
Everything lives in one file — apps/www/src/server/api/runtime.ts. The HTTP routes and the admin
UI are collection-agnostic: they read whatever is registered there, so there is no second place to
wire anything up.
const testimonials = defineCollection({
slug: 'testimonials',
fields: {
author: defineField.text({ required: true }),
quote: defineField.textarea({ required: true }),
rating: defineField.number({ min: 1, max: 5 }),
featured: defineField.boolean({ defaultValue: false })
}
});
Add it to the collections array in the same file:
const collections = [pages, posts, products, media, users, categories, testimonials];
Save. The runtime is built lazily per request, so the next request picks it up — reload
/admin/collections and "testimonials" is there, with a working create/edit/delete form generated
from those field definitions. No admin code was touched.
4. Drive it over HTTP
Reads are open. Writes need a bearer token:
# Log in
TOKEN=$(curl -s -X POST http://localhost:5173/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"forgecms-demo"}' \
| node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).data.token))")
# Create
curl -X POST http://localhost:5173/api/v1/testimonials \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"author":"Ada","quote":"It just works.","rating":5,"featured":true}'
# List, filter, sort, paginate — no token needed
curl "http://localhost:5173/api/v1/testimonials?featured=true&sort=rating&order=desc&limit=10"
Post without author and you get a 400 with a per-field validation error, because required: true
is enforced by @forge-cms/core on every write. Post without the Authorization header and you get
a 401.
5. Query it from server code
The HTTP API is for clients. From an Analog server route, skip it and call the Local API directly:
// apps/www/src/server/routes/api/site/testimonials.get.ts
import { defineEventHandler } from 'h3';
import { getServerRuntime } from '../../../api/runtime';
export default defineEventHandler(async (event) => {
const runtime = await getServerRuntime(event.context.cloudflare?.env);
const { docs } = await runtime.find({
collection: 'testimonials',
where: { featured: true },
sort: 'rating',
order: 'desc',
limit: 3
});
return docs;
});
Same pipeline — validation, hooks, access, drafts — with no HTTP hop and no Request to fabricate.
6. Query it from the browser
import { Component, inject, signal } from '@angular/core';
import { CmsApiService } from '@forge-cms/angular';
@Component({ selector: 'app-testimonials', template: '…' })
export class TestimonialsComponent {
private readonly cms = inject(CmsApiService);
readonly testimonials = signal<Record<string, unknown>[]>([]);
async ngOnInit() {
const { docs } = await this.cms.listDocuments('testimonials', {
where: { featured: true },
sort: 'rating',
order: 'desc',
limit: 3
});
this.testimonials.set(docs);
}
}
provideForgeCms({ baseUrl: '/api/v1' }) is already in app.config.ts. See
Angular client for the signal-based collectionResource version of the same
thing.
Next
- Small project guide — users, cookie auth, and a working admin, start to finish.
- Core concepts — what the runtime, adapters and Local API actually are.
- Fields — every field kind and its options.
- Deployment — shipping it to Cloudflare with D1 and R2.