Client & deploy
Angular client
provideForgeCms, the typed and untyped CmsApiService, query options and signal-based resources.
@forge-cms/angular is the browser-side client: a fetch-based service (zero runtime
dependencies), signal-based resources over it, and typed errors.
Setup
// app.config.ts
import { provideForgeCms } from '@forge-cms/angular';
export const appConfig: ApplicationConfig = {
providers: [provideForgeCms()] // same-origin /api/v1 and /api/auth, cookie session
};
provideForgeCms() with no options is a complete same-origin setup. Every option is optional:
| Option | Default | Meaning |
|---|---|---|
baseUrl |
'/api/v1' |
Content API: collections, documents, globals, preview |
authBaseUrl |
'/api/auth' |
login, signup, logout, me, users* |
credentials |
'include' |
Browser cookies for credential targets; 'omit' for Bearer-only apps |
authToken |
none | Authorization: Bearer … (string, or function re-read per request) |
trustedOrigins |
[] |
Other origins allowed to receive cookies and the Bearer token |
transport |
native fetch | (request) => Promise<Response> — tests, in-process routing |
URLs. A relative base (/cms/api) resolves against the page's origin; an absolute one
(https://cms.example.com/api) is used as given. A trailing slash is ignored, and every collection
slug, document id, user id and global slug is encoded as one path segment
(getDocument('posts', 'a/b c') → /api/v1/posts/a%2Fb%20c). Empty, . and .. ids are refused.
Credentials. Cookies and the Bearer token go only to credential targets: relative URLs, the
page's own origin, and origins listed in trustedOrigins. Any other absolute origin receives a
request with credentials: 'omit' and no Authorization header — a token is never forwarded to an
origin you did not name. A cross-origin setup also needs the server to allow it (CORS with
credentials). login/signup/logout never attach the Bearer token.
provideForgeCms({
baseUrl: 'https://cms.example.com/content-api',
authBaseUrl: 'https://cms.example.com/account-api',
trustedOrigins: ['https://cms.example.com']
});
Use authToken only for machine/API-key clients; a browser app uses the cookie session of
the reusable auth UI.
Server rendering. In an SSR render there is no page origin or cookie jar: add
provideForgeCmsServer({ origin }) from @forge-cms/angular/server to the server render. It sets
the origin relative URLs resolve against and which of the incoming request's credentials may be
forwarded (none by default). See Server rendering.
No retries. Each method sends exactly one request. A failed POST/PUT/DELETE, upload, login,
signup or logout is never retried: a lost connection leaves the server-side outcome unknown. Every
method takes a last { signal } argument; aborting rejects with kind: 'aborted'.
CmsApiService
import { CmsApiService } from '@forge-cms/angular';
const cms = inject(CmsApiService); // untyped: any slug, UntypedDocument results
inject(CmsApiService) is the untyped client — the right choice for code that discovers
collections at runtime (the admin UI does). For application code, bind it to your content model with
injectForgeClient<Schema>(): same instance, same configuration, checked
types.
| Method | Returns (untyped / typed) |
|---|---|
getDocuments(collection, options?) |
documents — just the docs |
listDocuments(collection, options?) |
{ docs, meta } — with pagination |
getDocument(collection, id, { depth?, locale? }) |
one document |
findOne(collection, where?, options?) |
document | null — first match, via limit: 1 |
createDocument(collection, data, { locale? }) |
the document, or { id } when you may not read it |
updateDocument(collection, id, data, { locale? }) |
the document, or { id } when you may not read it |
setDocumentStatus(collection, id, status) |
as updateDocument (drafts collections) |
deleteDocument(collection, id) |
void |
uploadFile(collection, file, fields?) |
multipart create, as createDocument |
getGlobal(slug) / updateGlobal(slug, data) |
global document | null / write result |
getCollections() |
CollectionMeta[] — schema metadata |
login(email, password) / signup(input) / logout() |
{ token, user } / void — see below |
getCurrentUser() |
AuthUser | null |
getUsers() / createUser / updateUser / deleteUser |
user management (admin) |
Untyped results are UntypedDocument ({ id: string; [field: string]: unknown }) and payloads are
Record<string, unknown>.
Bearer/authToken requests send the token on reads as well as writes, which is what makes drafts
and field-level read rules work for a signed-in editor over that path.
login/signup/logout are the low-level primitives — they exist so ForgeAuthSession (below) can
be built on CmsApiService alone, with zero extra dependencies. A real app should use
ForgeAuthSession, not these three directly; see Browser auth.
Typed client (schema-aware)
Define the content model once on the server and share only its type with the browser. No code
generation: import type is erased from the compiled JavaScript, so hooks, access functions, adapters
and secrets never reach the bundle.
// src/server/content.ts — server code (hooks, access rules, …)
export const posts = defineCollection({
slug: 'posts',
drafts: true,
locales: ['en', 'es'],
fields: {
title: defineField.text({ required: true, localized: true }),
slug: defineField.slug({ required: true, autoGenerate: true, sourceField: 'title' }),
publishedAt: defineField.date(),
author: defineField.relation({ collection: 'users', required: true }),
internalNote: defineField.textarea({ access: { read: ['admin'] } })
},
hooks: { beforeChange: [stampEditor] }
});
export const users = defineUsersCollection();
export const collections = [users, posts];
export const siteSettings = defineGlobal({ slug: 'site', fields: { name: defineField.text() } });
// src/app/forge-schema.ts — browser code, types only
import type { ForgeSchema } from '@forge-cms/angular';
import type { collections, siteSettings } from '../server/content';
export type SiteSchema = ForgeSchema<typeof collections, [typeof siteSettings]>;
// app.config.ts: provideForgeCms() as above. Then, in any component or service:
import { injectForgeClient } from '@forge-cms/angular';
import type { SiteSchema } from './forge-schema';
export class JournalPage {
private readonly cms = injectForgeClient<SiteSchema>();
async load() {
// Typed query: unknown slugs and fields do not compile.
const { docs } = await this.cms.listDocuments('posts', {
where: { _status: 'published' },
sort: [{ field: 'publishedAt', order: 'desc' }],
depth: 1,
locale: 'es'
});
docs[0]?.title; // string (resolved to 'es')
docs[0]?.publishedAt; // string | null | undefined — an ISO string, not a Date
docs[0]?.author; // populated user document | null
// Typed create: `title` and `author` are required, `slug` is generated, `id` is refused.
const created = await this.cms.createDocument(
'posts',
{ title: 'Hola', author: 'user-1', publishedAt: new Date() },
{ locale: 'es' }
);
// Typed update: any subset; a value of the wrong type does not compile.
await this.cms.updateDocument('posts', created.id, { _status: 'published' });
}
}
Every slug needs to be a literal: one collection typed with slug string makes every slug valid.
defineUsersCollection() keeps its 'users' slug; inline withAuthFields(defineCollection({ … }))
widens it, so define the collection first and wrap it second.
What the types promise
The types describe the JSON the server sends, which is not the same as the server-side value:
| In the schema | In a read (ForgeDocument) |
|---|---|
id, created_at, updated_at |
always present (string); _status too on drafts: true collections |
required: true |
present |
| not required | optional and nullable — absent on in-memory, null on SQL |
access: { read: … } (any rule) |
optional: the server omits the field for callers the rule denies |
access: { read: [] } |
not in the type (never sent, e.g. passwordHash) |
date |
string (ISO-8601, toISOString() form) |
relation / upload at depth: 0 |
the id (string), or string[] for many: true |
relation / upload at depth: 1 |
target document or null (missing, unreadable, a hidden draft) |
relation({ many: true }) at depth: 1 |
array of the readable targets (others are dropped) |
localized: true without locale |
Record<string, string> — one value per locale |
localized: true with locale |
string, resolved with fallback |
select({ options: ['a', 'b'] }) |
'a' | 'b' |
A populated target is the target's own depth-0 projection (not populated further; its localized fields stay per-locale maps) and is filtered by its own read rules.
Writes. ForgeCreateInput requires exactly the fields Forge requires from the caller — required
fields without a defaultValue that are not auto-generated slugs — and refuses id, timestamps and
fields nobody may write. ForgeUpdateInput makes everything optional; only non-required fields accept
null. A date input is Date | string (a Date is sent as its ISO string). With { locale } a
localized field takes a plain string; without it, a per-locale map.
Write results. A create or update answers with the document, or only { id } when the writer may
not read it back (a public form posting to a staff-only collection). Narrow with
if ('created_at' in result) ….
Globals. getGlobal reads at depth 0 without a locale (localized fields are maps);
updateGlobal takes a partial body.
Limits. Responses are not validated at runtime: the types are as true as the schema type you
share. afterRead hooks that reshape documents are not visible to the types — read such a collection
with the untyped client. where values stay loosely typed (field names are checked); the server
validates them. blocks rows are { blockType: string; [field: string]: unknown }.
Typed resources
protected readonly posts = collectionResource<SiteSchema, 'posts', 1>(() => ({
collection: 'posts',
depth: 1,
limit: this.pageSize()
}));
Pass the schema and the slug (plus a depth/locale literal when you use one). Without type
arguments a resource stays untyped.
Migrating from the <T> pattern (C02)
The per-method response generic is gone: getDocument<Post>('posts', id) no longer compiles, because
it let a caller assert any shape.
- Typed code:
injectForgeClient<SiteSchema>().getDocument('posts', id)— the result is derived from the schema. View models built server-side (a/api/site/*payload) keep their own hand-written types; they are not raw CMS documents. - Dynamic code: keep
inject(CmsApiService); results areUntypedDocument. collectionResource<Post>(…)→collectionResource<SiteSchema, 'posts'>(…), or no type argument.- Typed create/update results are
document | { id }(see above). - Date fields are ISO strings in every response — they always were on the wire; the Local API now
returns them as strings too instead of a
Dateon libSQL/D1.
Query options
QueryOptions mirrors what the API parses:
const { docs, meta } = await cms.listDocuments('products', {
where: {
category: 'facial', // equality
price: { gte: 50, lte: 200 }, // operators
id: { in: ['a', 'b'] },
name: { contains: 'laser' }
},
sort: 'price',
order: 'desc',
limit: 12,
page: 2, // converted to offset when limit is set
depth: 1,
status: 'published'
});
buildQueryString(options) is exported too — use it when you build links yourself (a paginator
writing ?page=2, a filter chip) so the strings match exactly.
Nested where and multi-field sort
where also accepts nested and/or groups, and sort accepts a { field, order }[] — both
serialize through the same buildQueryString helper every method above already uses, so nothing else
changes:
const { docs } = await cms.listDocuments('posts', {
where: {
and: [{ status: 'published' }, { or: [{ featured: true }, { views: { gte: 1000 } }] }]
},
sort: [
{ field: 'featured', order: 'desc' },
{ field: 'created_at', order: 'desc' }
]
});
const post = await cms.findOne('posts', { slug: 'hello-world' });
Signal-based resources
The idiomatic way to read in a component. The resource re-runs whenever the params signal changes,
cancels the request it supersedes, and stays idle while params return undefined:
import { Component, computed, inject, input, signal } from '@angular/core';
import { collectionResource, documentResource } from '@forge-cms/angular';
@Component({
selector: 'app-products',
template: `
@if (products.isLoading()) {
<p>Loading…</p>
} @else if (products.error(); as error) {
<p>{{ error.message }}</p>
} @else {
@for (product of products.value()?.docs ?? []; track product['id']) {
<article>{{ product['name'] }}</article>
}
<button [disabled]="!products.value()?.meta?.hasNextPage" (click)="page.set(page() + 1)">
Next
</button>
}
`
})
export class ProductsComponent {
readonly category = input<string>('');
protected readonly page = signal(1);
protected readonly products = collectionResource(() => ({
collection: 'products',
where: { category: this.category() },
limit: 12,
page: this.page()
}));
}
One document, e.g. from a route param:
protected readonly product = documentResource(() => {
const id = this.id();
return id ? { collection: 'products', id, depth: 1 } : undefined;
});
Every resource exposes value(), isLoading(), error() and reload().
Resource contract
A resource sends at most one request at a time and never retries. value() is undefined or the
result of the current request under the current credentials — never a previous query, never
another user's data. value() and error() are never both set. Two requests are "the same" when they
produce the same URL (a page without limit, for example, is not sent and does not change it).
| What happens | value() |
isLoading() |
error() |
|---|---|---|---|
Params return undefined (idle) |
undefined |
false |
null |
| First request / a different request | undefined |
true |
null |
| Success | the result | false |
null |
| Failure (HTTP, network, invalid response) | undefined |
false |
the ForgeApiError |
reload(), or the same request rebuilt |
kept until the new result | true |
null |
| Sign-in, sign-out, session expiry | undefined immediately |
true |
null |
| The owning component is destroyed | frozen | frozen | frozen |
- Cancellation. Each request gets its own
AbortSignal(passed to yourtransporttoo). It is aborted when params change, onreload(), when params becomeundefined, on a credential change, and when the component (injection context) that created the resource is destroyed. A cancelled request never becomes anerror(). Even a custom transport that ignores the signal cannot overwrite newer state: only the current request may write the signals. reload()cancels the active request and runs the current params once (several calls before Angular re-runs the resource collapse into one); it does nothing while idle.- Credentials.
ForgeAuthSessionmarks every identity change it makes — successful login or signup, every logout (even a failed one), the first401while signed in, a refresh that finds another user — and every resource immediately hides its value and reloads under the new identity. A request started as user A can never fill a resource after the switch to user B. The first/mecheck on page load is not a change. WithauthToken, a function that reads a signal is observed the same way (authToken: () => this.token()); a function reading non-reactive storage is re-read per request but cannot trigger a reload. A static API key never changes identity. - Errors are the same
ForgeApiErrorinstance the service threw (kind,status,code,details).
Errors
Every failure is a ForgeApiError: kind says what happened, status and the server's Forge
code/details are kept. ApiValidationError, ApiAuthError and ApiAuthActionError are
subclasses, so existing instanceof checks still work.
| Scenario | kind |
status |
code (example) |
Class | Session effect |
|---|---|---|---|---|---|
| 400 with field errors | http |
400 | VALIDATION_ERROR |
ApiValidationError |
none |
| 401 | http |
401 | UNAUTHORIZED |
ApiAuthError |
signed-in → anonymous |
| 403 | http |
403 | FORBIDDEN |
ForgeApiError |
none — still signed in |
| 404 / 409 / 413 / 429 / 500 | http |
as sent | server code, details kept |
ForgeApiError |
none |
| Non-JSON error page (proxy) | http |
as sent | HTTP_ERROR (body dropped) |
ForgeApiError |
none |
| 2xx that is not valid JSON | invalid-response |
as sent | INVALID_RESPONSE |
ForgeApiError |
none |
| Offline / DNS / CORS refused | network |
undefined | NETWORK_ERROR |
ForgeApiError |
none |
AbortSignal fired |
aborted |
undefined | ABORTED |
ForgeApiError |
none |
| Login/signup/logout HTTP | http |
as sent | server code | ApiAuthActionError |
see below |
import { ApiValidationError, ForgeApiError, isForgeApiError } from '@forge-cms/angular';
try {
await cms.createDocument('bookings', form);
} catch (err) {
if (err instanceof ApiValidationError) {
// err.details → [{ field: 'email', message: '…', code: 'type_email' }]
} else if (err instanceof ForgeApiError && err.status === 409) {
// err.code === 'UNIQUE_CONSTRAINT', err.details → which value conflicted
} else if (isForgeApiError(err, 'network')) {
// no response: show "offline", keep the form
}
}
A non-JSON body is never copied into the error. For readable UI copy, @forge-cms/admin exports
describeAdminError(err) and describeSessionError(err).
Session semantics
ForgeAuthSession bootstraps from GET {authBaseUrl}/me:
401→status() === 'anonymous',error() === null.200→'authenticated'.403,5xx, network failure or a malformed response →'error', with theForgeApiErrorinerror(). An outage is not presented as "signed out";forgeAuthGuardstill denies access.
logout() always clears the local user (status() === 'anonymous'). If the request failed,
error() keeps the ForgeApiError: the server session or its cookie may still exist, so do not tell
the user they signed out cleanly.
Migrating from 0.8.x
Nothing is required: defaults and method signatures are unchanged, and every method gained only an
optional last { signal } argument.
baseUrlis now optional (was required);provideForgeCms()works with no argument.getCurrentUser()used to resolvenullfor any failure. It now resolvesnullonly for a 401 and throwsForgeApiErrorfor a 403, 5xx, network failure or malformed response.- A failed
ForgeAuthSession.logout()used to clearerror(); it now keeps the failure. - Other failures used to be a plain
Errorwhose message ended in: <status>; they are nowForgeApiErrorwithstatus/code/details(the message is unchanged when the server sent none). - An absolute
baseUrl/authBaseUrlon another origin no longer receives cookies or the Bearer token unless its origin is listed intrustedOrigins.
Role helpers
Re-exported so the UI can hide what the backend would refuse anyway — they are convenience, not security:
import { canManageUsers, canWriteContent, isAdmin, userRole } from '@forge-cms/angular';
Compatibility
Peer ranges are the versions proven by pnpm release:compat: external Vite +
@analogjs/vite-plugin-angular apps installed only from the packed tarballs with
strict-peer-dependencies=true and auto-install-peers=false, each checked for a single Angular
copy, tsc + ngc with strictTemplates, and a fully linked production build.
| Package | Angular | Other peers |
|---|---|---|
@forge-cms/angular |
^21.0.0 || ^22.0.0 |
optional, for /vite: @angular/compiler-cli (same as Angular), @babel/core ^7.28.0 || ^8.0.0, vite ^7 || ^8 |
@forge-cms/admin |
^21.2.0 |
rxjs ^7.8.0, @voltui/components ^1.0.1, lumen-icons ^0.2.0; optional @babel/core ^7.28.0, vite ^7 || ^8 |
Tested combinations (2026-10-04):
| Combination | Packages | Angular | @angular/build |
TypeScript | rxjs | Vite | vite-plugin-angular | Babel | VoltUI |
|---|---|---|---|---|---|---|---|---|---|
angular-min |
angular | 21.0.0 | 21.0.0 | 5.9.2 | 7.8.0 | 7.0.0 | 2.4.8 | 7.28.0 | — |
admin-min |
angular + admin | 21.2.0 | 21.2.0 | 5.9.2 | 7.8.0 | 7.0.0 | 2.4.8 | 7.28.0 | 1.0.1 |
current |
angular + admin | 21.2.10 | 21.2.10 | 5.9.2 | 7.8.2 | 7.1.4 | 2.4.8 | 7.29.0 | 1.1.0 |
latest-21 |
angular + admin | 21.2.25 | 21.2.24 | 5.9.3 | 7.8.2 | 8.3.2 | 2.8.0 | 7.29.7 | 1.1.0 |
angular-22 |
angular | 22.2.1 | 22.2.1 | 6.0.3 | 7.8.2 | 8.3.2 | 2.8.0 | 8.0.6 | — |
@forge-cms/adminstops at Angular 21 because its UI library, VoltUI 1.x, peers@angular/* ^21.2.0; VoltUI's own dependency also needs@angular/cdkinstalled by the app.- Angular 22's linker requires Babel 8; Angular 21 was tested with Babel 7.
- Angular 19/20 are not supported.
- Vite/Analog apps need the linker — for
@forge-cms/angularalone too. Forge's packages are partial-Ivyngcoutput, which Analog does not link (it only linksfesm2022packages); without it a production build crashes withJIT compiler unavailable. The Angular CLI links them itself. - SSR: the same plugin marks Forge's Angular packages
ssr.noExternal, so the server build is linked too (an external, unlinked copy would fail every render withJIT compiler unavailable).pnpm release:ssrproves a packed Analog SSR consumer (Angular 21.2.10, Analog 2.5.2, Vite 7.1.4, Nitronode-server) with nothing butangularLinker().
// vite.config.ts
import { angularLinker } from '@forge-cms/angular/vite'; // `@forge-cms/admin/vite` is the same plugin
export default defineConfig({ plugins: [angularLinker(), analog()] });
Limits
- No SSR-safe fetch or transfer state. The base URL is relative and the service is browser-first. For a content site that needs SSR, call the Local API from a server route and hand the page a purpose-built payload — better for payload size anyway.
- Documents are
UntypedDocumenton the untyped client. UseinjectForgeClient<Schema>()for schema-derived types. - No caching, normalised store or retries — a failed read stays failed until
reload().