Getting started
Angular/Analog quickstart
An Analog.js app with the ForgeCMS server routes, the typed Angular client and the reusable admin — install, linker, routes, first admin and login.
This path builds an Analog.js application that serves the ForgeCMS API from Nitro routes and mounts the reusable admin at /admin. It uses only public @forge-cms/* packages. Every file
below is the real source of an example that CI installs from the packed tarballs into an empty project, type-checks with strict TypeScript and strict Angular templates, builds for production,
and then drives the built server and a real browser (pnpm docs:verify:consumers).
It uses development adapters (in-memory database and storage), so data resets when the server restarts. Durable profiles are the Deployment guide. Server-only? Use the Server quickstart.
1. Create the app and install
Create an Analog app with its own tooling (npm create analog@latest, then switch to pnpm if you prefer) or start from an empty Vite + Analog project, then add Forge. The tested combination is Angular
21.2, Analog 2.5, Vite 7, TypeScript 5.9 (support matrix).
# ForgeCMS server packages
pnpm add @forge-cms/core @forge-cms/db @forge-cms/auth @forge-cms/storage @forge-cms/api @forge-cms/runtime h3
# Angular client and admin
pnpm add @forge-cms/angular @forge-cms/admin
# the admin's peers (the Angular packages are normally already in your app)
pnpm add @angular/cdk @voltui/components lumen-icons rxjs
# the linker's tooling and the Node types the server files use
pnpm add -D @angular/compiler-cli @babel/core vite @types/node
The admin requires Angular 21 (^21.2.0). @forge-cms/angular alone also supports Angular 22 (with @babel/core 8).
2. Add the official linker
@forge-cms/angular and @forge-cms/admin ship Angular partial-Ivy code. A Vite/Analog build must link it, or production crashes with JIT compiler unavailable. Use the plugin Forge ships — nothing else:
import analog from '@analogjs/platform';
import { defineConfig } from 'vite';
import { angularLinker } from '@forge-cms/admin/vite';
export default defineConfig({
plugins: [
// Forge ships partial-Ivy code; the official linker compiles it at build time.
angularLinker(),
analog({ ssr: false, nitro: { preset: 'node-server' } })
],
build: { target: 'es2022' }
});
ssr: false keeps this quickstart a browser app with a Nitro API; the SSR guide turns on server rendering. Analog's build still requires a server entry file:
// Analog's build requires a server entry even with `ssr: false`. This app renders in the browser;
// the SSR guide replaces this stub with a real renderer.
export default function bootstrap() {
return Promise.resolve();
}
3. Collections and the runtime (server)
Put server code under src/server. The collections define users, an upload-enabled media collection and posts with drafts:
import { defineUsersCollection } from '@forge-cms/auth';
import { defineCollection, defineField } from '@forge-cms/core';
export const users = defineUsersCollection();
export const media = defineCollection({
slug: 'media',
upload: true,
fields: {
filename: defineField.text({ required: true }),
url: defineField.text(),
contentType: defineField.text(),
filesize: defineField.number(),
alt: defineField.text()
},
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 posts = defineCollection({
slug: 'posts',
drafts: true,
admin: { useAsTitle: 'title', defaultColumns: ['title', 'author'] },
fields: {
title: defineField.text({ required: true }),
slug: defineField.slug({ unique: true, autoGenerate: true, sourceField: 'title' }),
body: defineField.richtext(),
cover: defineField.upload({ collection: 'media' }),
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, media, posts];
import { UsersCollectionAuthAdapter } from '@forge-cms/auth';
import { InMemoryDatabaseAdapter } from '@forge-cms/db';
import { ForgeCmsRuntime } from '@forge-cms/runtime';
import { InMemoryStorageAdapter } from '@forge-cms/storage';
import { collections } from './collections';
export interface ServerEnv {
/** At least 32 bytes of randomness; signs the session tokens. */
AUTH_SECRET?: string;
}
let runtimePromise: Promise<ForgeCmsRuntime<ServerEnv>> | undefined;
/** Built on the first request (not at module load) and shared afterwards. */
export function getServerRuntime(): Promise<ForgeCmsRuntime<ServerEnv>> {
runtimePromise ??= buildRuntime();
return runtimePromise;
}
async function buildRuntime(): Promise<ForgeCmsRuntime<ServerEnv>> {
const env: ServerEnv = {};
const secret = process.env['AUTH_SECRET'];
if (secret !== undefined) env.AUTH_SECRET = secret;
// DEVELOPMENT ADAPTERS: everything is lost when the server restarts. A real deployment uses a durable
// profile (D1 + R2, or libSQL + S3) — see the Deployment guide.
const database = new InMemoryDatabaseAdapter();
const auth = new UsersCollectionAuthAdapter().init({ ...env, userDatabase: database });
const runtime = new ForgeCmsRuntime<ServerEnv>({
collections,
adapters: { database, auth, storage: new InMemoryStorageAdapter() },
env
});
runtime.init();
await runtime.syncSchema();
return runtime;
}
The runtime is built lazily on the first request (Cloudflare Workers forbid async I/O at module scope, and this shape works everywhere). Set AUTH_SECRET (at least 32 bytes) in .env for development and
in the environment of the built server.
4. Mount the API routes
Every route is a thin wrapper over a runtime handler; no logic lives in the route. A small helper builds the framework-agnostic ApiContext from an h3 event:
import { getRouterParam, toWebRequest, type H3Event } from 'h3';
import type { ApiContext } from '@forge-cms/api';
/** The framework-agnostic context every Forge handler takes, built from an h3 event. */
export function apiContext(event: H3Event, ...params: string[]): ApiContext {
return {
request: toWebRequest(event),
// Ids and slugs arrive percent-encoded as one path segment.
params: Object.fromEntries(
params.map((name) => [name, decodeURIComponent(getRouterParam(event, name) ?? '')])
),
env: undefined
};
}
/** Session cookie options: `Secure` in production (serve it over HTTPS), plain for local development. */
export const sessionCookie = { secure: process.env['NODE_ENV'] === 'production' };
Auth (/api/auth/*, the client's default authBaseUrl):
import { defineEventHandler } from 'h3';
import { handleLogin } from '@forge-cms/runtime';
import { apiContext, sessionCookie } from '../../../context';
import { getServerRuntime } from '../../../runtime';
export default defineEventHandler(async (event) => {
const runtime = await getServerRuntime();
return handleLogin(apiContext(event), { runtime, cookie: sessionCookie });
});
Create logout.post.ts (handleLogout, with cookie: sessionCookie) and me.get.ts (handleMe) the same way — their sources are in the verified example
(logout.post.ts,
me.get.ts).
Content (/api/v1/*, the client's default baseUrl): GET/POST /api/v1/[collection], GET/PUT/DELETE /api/v1/[collection]/[id], and GET /api/v1/collections for the admin's schema metadata:
import { defineEventHandler } from 'h3';
import { handleUpdate } from '@forge-cms/runtime';
import { apiContext } from '../../../../context';
import { getServerRuntime } from '../../../../runtime';
export default defineEventHandler(async (event) => {
const runtime = await getServerRuntime();
return handleUpdate(apiContext(event, 'collection', 'id'), {
runtime,
allowedRoles: ['admin', 'editor']
});
});
The other content routes —
[collection].get.ts (handleList),
[collection].post.ts (handleCreate, which also accepts multipart uploads),
[id].get.ts (handleRead),
[id].delete.ts (handleDelete) and
collections.get.ts — are the same four lines with a different handler.
Media — uploaded files are served by handleFile, which applies the owning document's access rules, at the storage adapter's default URL GET /api/media/[...key]
([...key].get.ts).
First admin — the first user ever created is always an admin. This route creates it and signs it in, and refuses once any user exists:
import {
createError,
defineEventHandler,
readBody,
setResponseHeader,
setResponseStatus
} from 'h3';
import { buildSessionCookie, type UsersCollectionAuthAdapter } from '@forge-cms/auth';
import { sessionCookie } from '../../context';
import { getServerRuntime } from '../../runtime';
// Creates the FIRST user, who is always an admin, and signs them in. Refuses once any user exists.
export default defineEventHandler(async (event) => {
const runtime = await getServerRuntime();
const auth = runtime.adapters.auth as UsersCollectionAuthAdapter;
if ((await auth.listUsers()).length > 0) {
throw createError({ statusCode: 409, statusMessage: 'Already initialized' });
}
const body = (await readBody<{ email?: string; password?: string }>(event)) ?? {};
if (!body.email || !body.password) {
throw createError({ statusCode: 400, statusMessage: 'Missing email or password' });
}
const created = await auth.createUser({ email: body.email, password: body.password });
if (!created.ok) throw createError({ statusCode: 400, statusMessage: created.reason });
const login = await auth.login(body.email, body.password);
if (login.ok)
setResponseHeader(event, 'set-cookie', buildSessionCookie(login.token, sessionCookie));
setResponseStatus(event, 201);
return { data: { user: created.user } };
});
5. The Angular side: providers, routes, admin
The admin reads its route data through component inputs, so the router needs withComponentInputBinding(). provideForgeCms() is the one place that names the APIs — the values below are the defaults.
import type { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideVoltTheme } from '@voltui/components';
import { provideForgeCms } from '@forge-cms/angular';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
// The admin reads its route data through component inputs: this feature is required.
provideRouter(routes, withComponentInputBinding()),
provideVoltTheme({ color: 'volt', style: 'soft' }),
// The only place that names the APIs. These are the defaults, written out for clarity.
provideForgeCms({ baseUrl: '/api/v1', authBaseUrl: '/api/auth', credentials: 'include' })
]
};
import type { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
pathMatch: 'full',
loadComponent: () => import('./pages/home.page').then((m) => m.HomePage)
},
{
path: 'admin',
loadChildren: () => import('./admin.routes').then((m) => m.ADMIN_ROUTES)
},
{ path: '**', redirectTo: '' }
];
Mounting the admin is the whole of admin.routes.ts — sign-in, a guarded shell and the reusable collections index and document editor:
import type { Routes } from '@angular/router';
import {
ForgeAdminLayoutComponent,
forgeAdminAuthRoutes,
forgeAdminContentRoutes
} from '@forge-cms/admin';
import { forgeAuthGuard } from '@forge-cms/angular';
export const ADMIN_ROUTES: Routes = [
// /admin/login
...forgeAdminAuthRoutes(),
{
path: '',
component: ForgeAdminLayoutComponent,
data: { config: { title: 'My site' } },
canActivate: [forgeAuthGuard()],
children: [
{ path: '', pathMatch: 'full', redirectTo: 'collections' },
// /admin/collections, /admin/collections/:collection, …/new, …/:id
...forgeAdminContentRoutes()
]
}
];
forgeAuthGuard() sends an anonymous visitor to /admin/login?returnUrl=… and only honours a returnUrl under the mount root. It is UX; the server decides. To mount somewhere else, or add the Users
workspace and sign-up, see Admin UI and the small project guide. The remaining files (index.html, main.ts,
app.component.ts, a home page that lists published posts with collectionResource) are in the
example directory.
// tsconfig.json (excerpt) — strict, with Angular's strict templates
{
"compilerOptions": {
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"types": ["node"]
},
"angularCompilerOptions": { "strictTemplates": true, "strictInjectionParameters": true }
}
6. Run it and log in
echo "AUTH_SECRET=$(openssl rand -base64 32)" > .env
pnpm exec vite dev
Create the first admin once (the route refuses a second call with 409):
curl -X POST http://localhost:5173/api/bootstrap-admin \
-H 'content-type: application/json' \
-d '{"email":"[email protected]","password":"a-long-passphrase"}'
Open /admin: you are redirected to /admin/login; sign in and you land on /admin/collections, where the posts workspace lets you create, edit, publish and delete — generated from the definition above.
Anonymous visitors to / see only published posts.
For a production build, run pnpm exec vite build, then start the server with AUTH_SECRET=… NODE_ENV=production node dist/analog/server/index.mjs. NODE_ENV=production makes the session cookie
Secure, so serve it over HTTPS.
Where next
- A durable database and file storage, and deployment to Cloudflare or Node: Deployment.
- Users workspace, sign-up, drafts and the long walkthrough: Small project guide.
- Server rendering and public hydration: Server rendering. The typed client: Angular client.
- Something failing —
JIT compiler unavailable,401vs403, CSRF? Troubleshooting.