Getting started
Small project guide
Add ForgeCMS to a small Angular/Analog project — users, posts, cookie auth, and a working admin — end to end.
You have a small Angular/Analog project. You want users, a login, a protected admin, and a couple
of content types. This page is that path, start to finish — nothing here is aspirational: every
snippet is the same code proven by apps/tiny-project in this repository (spec 055), a
deliberately tiny external-style consumer built only on the public @forge-cms/* exports below.
1. Install
pnpm add @forge-cms/core @forge-cms/runtime @forge-cms/auth @forge-cms/db @forge-cms/storage \
@forge-cms/angular @forge-cms/admin
Add the adapters of your durable profile (see §9):
pnpm add @forge-cms/cloudflare # Cloudflare: D1 + R2
pnpm add @forge-cms/s3 # portable: libSQL (in @forge-cms/db) + S3
2. Define users + posts
// server/collections.ts
import { defineCollection, defineField } from '@forge-cms/core';
import { defineUsersCollection } from '@forge-cms/auth';
export const users = defineUsersCollection();
export const posts = defineCollection({
slug: 'posts',
drafts: true,
admin: { useAsTitle: 'title', defaultColumns: ['title', 'author'] },
fields: {
title: defineField.text({ required: true }),
slug: defineField.slug({
required: true,
unique: true,
autoGenerate: true,
sourceField: 'title'
}),
body: defineField.richtext(),
author: defineField.relation({ collection: 'users', required: true })
},
access: {
read: () => true,
create: ({ user }) => user?.role === 'admin' || user?.role === 'editor',
update: ({ user }) => user?.role === 'admin' || user?.role === 'editor',
delete: ({ user }) => user?.role === 'admin'
}
});
export const collections = [users, posts];
defineUsersCollection() is the recommended users shape: email, name, a role select
(admin | editor | viewer, defaulting to viewer), and passwordHash wired in already. You are
not required to use it — withAuthFields() merges the same auth-adapter fields onto a collection
you write by hand — but it is the fastest path and what this guide uses throughout.
3. Choose adapters and create the runtime
This is the development factory: in-memory adapters, nothing durable. It is what you want while learning and in tests. A production build must use a durable profile factory instead (D1 + R2, or libSQL + S3) that throws on an incomplete configuration — see §9 and Deployment.
// server/runtime.ts
import { InMemoryDatabaseAdapter } from '@forge-cms/db';
import { UsersCollectionAuthAdapter } from '@forge-cms/auth';
import { InMemoryStorageAdapter } from '@forge-cms/storage';
import { D1DatabaseAdapter, type D1Database } from '@forge-cms/cloudflare';
import { ForgeCmsRuntime } from '@forge-cms/runtime';
import { collections } from './collections';
export interface ServerEnv {
DB?: D1Database;
AUTH_SECRET?: string;
}
let runtimePromise: Promise<ForgeCmsRuntime<ServerEnv>> | undefined;
export function getServerRuntime(env?: ServerEnv): Promise<ForgeCmsRuntime<ServerEnv>> {
runtimePromise ??= buildRuntime(env);
return runtimePromise;
}
async function buildRuntime(env?: ServerEnv): Promise<ForgeCmsRuntime<ServerEnv>> {
// DEVELOPMENT ONLY — never ship this selector: a missing binding silently becomes in-memory.
const database = env?.DB ? new D1DatabaseAdapter() : new InMemoryDatabaseAdapter();
// Explicit dev mode (never inferred from a missing secret): `true` only under the dev server.
const auth = new UsersCollectionAuthAdapter({ devMode: import.meta.dev === true }).init({
...env,
userDatabase: database
});
const runtime = new ForgeCmsRuntime<ServerEnv>({
collections,
adapters: { database, auth, storage: new InMemoryStorageAdapter() },
...(env !== undefined && { env })
});
runtime.init();
await runtime.syncSchema();
return runtime;
}
Build this lazily, on first request, not at module load — a Cloudflare Worker forbids async I/O at
module scope, and this shape works identically outside Cloudflare too. In this development factory env?.DB
picks D1 when the binding exists and in-memory otherwise — which is exactly why it is not a production
selector: a missing or misnamed binding would silently lose data. Production uses the profile factory of
§9.
4. Mount the server API
Every route is a thin wrapper — the actual logic lives in the handler, not the route file. On Analog/h3:
// server/routes/api/auth/login.post.ts
import { defineEventHandler, toWebRequest } from 'h3';
import { handleLogin } from '@forge-cms/runtime';
import { getServerRuntime } from '../../../runtime';
export default defineEventHandler(async (event) => {
const runtime = await getServerRuntime(event.context.cloudflare?.env);
return handleLogin(
{ request: toWebRequest(event), env: event.context.cloudflare?.env },
{ runtime, cookie: { secure: !!event.context.cloudflare?.env } }
);
});
Repeat the same four-line pattern for signup.post.ts (handleSignup, gate it behind your own
enabled flag — off by default), logout.post.ts (handleLogout), and me.get.ts (handleMe).
Then the generic collection CRUD routes, same shape, one file per verb:
GET/POST /api/v1/[collection] (handleList/handleCreate) and
GET/PUT/DELETE /api/v1/[collection]/[id] (handleRead/handleUpdate/handleDelete). See
REST API for the full contract these produce.
5. Mount the admin
// app/admin.routes.ts
import type { Routes } from '@angular/router';
import {
ForgeAdminLayoutComponent,
ForgeUsersWorkspaceComponent,
forgeAdminAuthRoutes,
forgeAdminContentRoutes
} from '@forge-cms/admin';
import { forgeAuthGuard } from '@forge-cms/angular';
export const ADMIN_ROUTES: Routes = [
...forgeAdminAuthRoutes({ signup: true }),
{
path: '',
component: ForgeAdminLayoutComponent,
canActivate: [forgeAuthGuard()],
children: [
{ path: '', pathMatch: 'full', redirectTo: 'collections' },
...forgeAdminContentRoutes(),
{
path: 'users',
component: ForgeUsersWorkspaceComponent,
canActivate: [forgeAuthGuard({ roles: ['admin'] })]
}
]
}
];
That's the whole admin: sign-in/sign-up, a guarded shell, the reusable collections index and
document editor, and a users workspace gated to admins. No host-written CRUD page. In
app.config.ts, add provideForgeCms({ baseUrl: '/api/v1' }) — no authToken; the browser session
is the forge_session HttpOnly cookie, and CmsApiService sends it automatically.
Want it at /studio instead of /admin, or your APIs somewhere other than /api/v1 and
/api/auth? That is configuration, not code: basePath on forgeAdminAuthRoutes() and the layout
config, signInPath/forbiddenPath on the guard, baseUrl/authBaseUrl on provideForgeCms() — see
Admin UI.
One thing to watch for: @forge-cms/angular and @forge-cms/admin ship partial-Ivy code, which
needs Angular's linker at your app's build time or you'll hit a production-only
JIT compiler unavailable crash. If you're on Vite (Analog.js), add the plugin ForgeCMS ships for
exactly this (@forge-cms/admin/vite re-exports the same plugin):
// vite.config.ts
import { angularLinker } from '@forge-cms/angular/vite';
export default defineConfig({
plugins: [angularLinker() /* your other plugins, e.g. analog() */]
});
6. Create the first admin
There is no seed script and no separate bootstrap endpoint to call — the very first user ever
created in a fresh install is always promoted to admin, regardless of any role you pass:
const admin = await auth.createUser({ email: '[email protected]', password: 'a-real-password' });
// admin.user.role === 'admin', even though no role was requested
A route that guards this behind "no user exists yet" is a few lines of your own server code, not a new Forge capability:
// server/routes/api/bootstrap-admin.post.ts
export default defineEventHandler(async (event) => {
const runtime = await getServerRuntime(event.context.cloudflare?.env);
const auth = runtime.adapters.auth as UsersCollectionAuthAdapter;
if ((await auth.listUsers()).length > 0) {
throw createError({ statusCode: 409, statusMessage: 'Already initialized' });
}
const body = await readBody(event);
const created = await auth.createUser({ email: body.email, password: body.password });
if (!created.ok) throw createError({ statusCode: 400, statusMessage: created.reason });
return { data: { user: created.user } };
});
Put a small form behind it (or just curl it once) and you have a real first-run flow: zero rows,
zero users, one request, a working admin.
7. Sign in
Visit /admin/login. On success the server sets the forge_session cookie and the Angular session
(ForgeAuthSession, injected wherever you need session.user()/session.authenticated())
reflects it immediately — no page reload required, and the session survives a real reload because
it's a cookie, not localStorage.
8. Manage content
/admin/collections lists every registered collection; /admin/collections/posts is a full
workspace — search, sort, status filter, pagination, create/edit/publish/delete — generated from the
posts definition in §2. The author field renders as a searchable
picker against users, not a text box for pasting an id. Nothing here is host code.
9. Cloudflare vs. portable
Both durable profiles run the identical domain from §2; only the database and file
adapters change. Production selects exactly one complete profile and refuses to start otherwise (never an
in-memory fallback). apps/tiny-project's profile.ts is the tested reference; Deployment
has the runnable recipes.
Cloudflare — D1 + R2 (the best-supported path):
import { D1DatabaseAdapter, R2StorageAdapter } from '@forge-cms/cloudflare';
if (!env.DB || !env.BUCKET)
throw new Error('Incomplete Cloudflare profile: DB and BUCKET are required');
const database = new D1DatabaseAdapter(); // .init(env) happens inside runtime.init()
const storage = new R2StorageAdapter();
Config: a D1 binding (DB) and an R2 binding (BUCKET) in wrangler.toml, and AUTH_SECRET (at least 32
bytes) in every build; only the dev server, through an explicit devMode: true, falls back to Forge's public dev
secret. See Deployment and
Browser auth.
Portable — libSQL + S3 (no Cloudflare account, no binding of any kind):
import { LibSqlDatabaseAdapter } from '@forge-cms/db';
import { S3StorageAdapter } from '@forge-cms/s3';
// DATABASE_URL=file:/data/forge.db (a persistent volume!) or a libsql:// URL; S3_BUCKET, S3_REGION, …
const database = new LibSqlDatabaseAdapter(process.env['DATABASE_URL']!);
const storage = new S3StorageAdapter({ bucket, region, endpoint, forcePathStyle, credentials });
pnpm add @forge-cms/s3 adds it. @forge-cms/db loads libSQL on the first operation, so init() does not open
the database (a bad URL rejects the first call). The S3 bucket must already exist.
Same UsersCollectionAuthAdapter, same collections, same runtime, same admin. Schema sync, first-admin
bootstrap, sign-in, users, post CRUD, drafts, the relation and file uploads work unchanged.
Uploads and files
Add an upload: true collection (see Uploads), mount POST /api/v1/[collection] and the
file route GET /api/media/[...key] → handleFile. Both profiles store files durably (R2 or S3) and serve them
through handleFile, which applies the owning document's access rules. Keep buckets private.
Server rendering and production (S01–S03)
Public pages server-render and hydrate: set ssr: true in analog(), provide
provideForgeCmsServer({ origin }) in main.server.ts, read public content with a credentials: 'omit'
client and collectionResource(params, { transfer: 'public' }), and build with the public angularLinker()
from @forge-cms/angular/vite. SSR guide has every snippet and the two production setups
(Cloudflare Pages + D1 + R2; node-server + libSQL + S3 with nitro: { externals: { trace: false } } and
node_modules shipped beside dist/). The path in this guide — install → collections → profile factory → auth
handlers → admin → first admin → SSR → public client → hydration → upload → build → serve → restart → recover — is
exercised end to end, from packed public packages only, by pnpm test:s3 profiles (production-built Node +
on-disk libSQL + Garage S3, and the Cloudflare Pages output under local workerd + local D1 + local R2 — local
workerd evidence; the isolated remote Cloudflare rehearsal is described in Deployment) and pnpm release:ssr (the technical SSR consumer).
Next
- Quickstart — the bare Local API, no auth or admin.
- Browser auth — the cookie session, CSRF, and signup contract this guide builds on.
- Admin UI — every component
@forge-cms/adminexports. - Deployment — both durable profiles in full, plus backup and recovery.