Browse documentation

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/admin exports.
  • Deployment — both durable profiles in full, plus backup and recovery.