Client & deploy
Server rendering
Analog SSR with ForgeCMS — Local API in server routes, a request-scoped CmsApiService in Angular renders, how identity is forwarded, and opt-in public hydration transfer.
Server code has two ways to read CMS content. Pick per call site; neither is "the consistent one".
| Where the code runs | Use | Why |
|---|---|---|
A server route, loader or script that already has the ForgeCmsRuntime |
Local API | Same access/hooks/drafts pipeline, no HTTP hop, no fabricated Request. You state the identity. |
| An Angular component or resource that renders in the browser and on the server | CmsApiService + provideForgeCmsServer |
One client for both platforms; components never import the runtime (it would end up in the browser bundle). |
S01 = request-safe SSR. S02 = optional, anonymous public result transfer. Server rendering works with request-scoped identity (S01). On top of it, a public read can opt in to hydrate the browser from the server's result without repeating the request (below). Nothing is transferred unless you ask.
Server routes: the Local API
// src/server/routes/api/site/my-notes.get.ts
import { defineEventHandler, toWebRequest } from 'h3';
import { getServerRuntime } from '../../../api/runtime';
export default defineEventHandler(async (event) => {
const runtime = await getServerRuntime(event.context.cloudflare?.env);
// Public content: say so explicitly. Personalized: resolve the user from THIS request.
const user = await runtime.adapters.auth.requireAuth(toWebRequest(event)).catch(() => null);
const result = await runtime.find({
collection: 'notes',
overrideAccess: false, // run the collection's access rules for `user`
user,
depth: 1
});
return { data: result.docs };
});
overrideAccess defaults to true on the Local API (trusted code). Pass false plus the user
whenever the result goes back to a visitor. user: null is an anonymous read.
Angular SSR: CmsApiService on the server
During SSR there is no window.location to resolve /api/v1 against, and no browser cookie jar.
@forge-cms/angular/server supplies both explicitly, per render:
pnpm add @angular/platform-server
// vite.config.ts
import analog from '@analogjs/platform';
import { angularLinker } from '@forge-cms/angular/vite';
export default defineConfig({
plugins: [
angularLinker(), // links Forge in the browser AND the server build — nothing else to configure
analog({ ssr: true, prerender: { routes: [] } })
]
});
prerender.routes: [] stops Analog from prerendering / at build time (it does so by default when
ssr: true), which would render against your build machine's database.
// src/app/app.config.server.ts
import { mergeApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
export const serverConfig = mergeApplicationConfig(appConfig, {
providers: [provideServerRendering()]
});
// src/main.server.ts
import '@angular/platform-server/init';
import { REQUEST } from '@angular/core';
import { bootstrapApplication, type BootstrapContext } from '@angular/platform-browser';
import { renderApplication } from '@angular/platform-server';
import { provideForgeCmsServer } from '@forge-cms/angular/server';
import { AppComponent } from './app/app.component';
import { serverConfig } from './app/app.config.server';
type IncomingHeaders = Record<string, string | string[] | undefined>;
/** Only this request's headers, as Angular's standard `REQUEST` (a Web `Request`). */
function toWebRequest(url: string, headers: IncomingHeaders): Request {
const copy = new Headers();
for (const [name, value] of Object.entries(headers)) {
if (typeof value === 'string') copy.set(name, value);
else if (Array.isArray(value)) copy.set(name, value.join(', '));
}
return new Request(new URL(url, 'http://ssr.invalid'), { headers: copy });
}
function bootstrap(context: BootstrapContext) {
return bootstrapApplication(AppComponent, serverConfig, context);
}
// Analog calls this once per request with Nitro's `event.node.req`.
export default async function render(
url: string,
document: string,
{ req }: { req: { headers: IncomingHeaders } }
) {
return renderApplication(bootstrap, {
document,
url,
platformProviders: [
{ provide: REQUEST, useValue: toWebRequest(url, req.headers) },
provideForgeCmsServer({ origin: process.env['FORGE_SSR_ORIGIN']! })
]
});
}
The browser config is unchanged: provideForgeCms() keeps its relative /api/v1, /api/auth and
cookie defaults and needs no SSR option. provideForgeCmsServer is ignored on a browser platform.
Resources render their resolved state on the server — the render waits for every Forge request.
provideForgeCmsServer(config)
| Option | Default | Meaning |
|---|---|---|
origin |
(required) | Absolute http(s) origin this server reaches Forge at, e.g. 'http://127.0.0.1:3000' or 'https://site.example' |
forwardCookies |
[] |
Incoming cookies forwarded to Forge, e.g. ['forge_session'] to render as the visiting signed-in user |
forwardAuthorization |
false |
Forward the incoming Authorization header |
Where the origin comes from. Configuration only — an environment variable, a constant. It is never
derived from the request's Host, Forwarded or X-Forwarded-* headers, which the client controls.
An origin with a path, query, credentials or another scheme throws, and the render fails rather than
guessing. Relative baseUrl/authBaseUrl resolve against it; absolute ones are used as given.
Identity is anonymous by default. With no forwardCookies/forwardAuthorization, the server
renders as an anonymous visitor even if the browser sent a session cookie — so a cached SSR page can
never embed someone's private content by accident. To render as the visitor:
provideForgeCmsServer({ origin, forwardCookies: ['forge_session'] }); // cookie session (spec 053)
provideForgeCmsServer({ origin, forwardAuthorization: true }); // Bearer clients
Only the listed cookies are forwarded (other cookies on the request are dropped), and only to the
configured origin — a cookie belongs to this site, so a server never sends it anywhere a browser
wouldn't. A forwarded Authorization header follows the authToken rule: the origin and origins
listed in provideForgeCms({ trustedOrigins }). Any other absolute URL — another host, port or scheme,
a protocol-relative //host URL, a malformed URL — gets the request with neither. The default server
transport never follows redirects; a custom transport receives the forwarded headers too and must
not follow a redirect with them. Document ids and slugs are encoded path segments, so content cannot
move a request to another origin.
App credentials are separate. A server-owned key (provideForgeCms({ authToken })) is sent as
Bearer to credential targets. Combining it with forwardCookies/forwardAuthorization throws at
startup: an app credential and a visitor identity are never sent together. A client configured with
credentials: 'omit' never receives forwarded cookies.
Without provideForgeCmsServer, a server render's calls to a relative URL reject with
ForgeApiError({ kind: 'network', code: 'SERVER_ORIGIN_REQUIRED' }) without sending anything; resources
show it as error() and ForgeAuthSession goes to 'error'. An absolute URL is requested without
forwarded identity; only trustedOrigins may receive a configured authToken. A custom transport (provideForgeCms({ transport })) is still called, and receives the forwarded cookie header like the default one.
A public site that stays anonymous for signed-in editors
On the server the default is anonymous; in the browser the app-wide client sends the cookie. To keep
public pages anonymous on both (drafts and restricted relations never shown to an editor browsing the
public site), give those routes their own client — it also works next to forwardCookies, since a
credentials: 'omit' client never receives forwarded cookies:
// app.routes.ts
{
path: '',
providers: [provideForgeCms({ credentials: 'omit' }), CmsApiService],
children: [/* public pages using collectionResource / documentResource */]
}
Hydration and public transfer
Add Angular's hydration to the shared app config so the browser reuses the server's DOM. Forge uses
its own fetch transport, not HttpClient, so Angular's HttpClient transfer cache has nothing to do:
// src/app/app.config.ts
import { provideClientHydration, withNoHttpTransferCache } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [provideClientHydration(withNoHttpTransferCache()) /* … */]
};
By default the browser still reads the content again after it starts (and the list re-renders from empty). A public read can opt in to transfer instead:
// the route's own anonymous client (see above): credentials 'omit', no authToken
providers: [provideForgeCms({ credentials: 'omit' }), CmsApiService],
// in a component under it
readonly posts = collectionResource(() => ({ collection: 'posts', limit: 10 }), { transfer: 'public' });
readonly post = documentResource(() => ({ collection: 'posts', id: this.id() }), { transfer: 'public' });
The server serializes the successful result with Angular's own transfer state (escaped by Angular, so
content like </script> is safe); the first browser render of the same logical read uses it with no
request, and the DOM is hydrated, not replaced.
- Opt-in and anonymous only. The client must be anonymous by construction:
credentials: 'omit', noauthToken, and (on the server) no forwardedAuthorization. Anything else throws aTypeErrorwhen the resource is created (a server render withoutprovideForgeCmsServeris refused too) — Forge never serializes a signed-in visitor's response. A customtransportthat attaches its own identity is outside this guarantee: opt in only when your transport sends none. Keep the public routes on their own client, as in the previous section. - Never transferred:
ForgeAuthSession,/me, users, login/logout, previews, anything from a resource that did not opt in, errors, cookies, tokens and request/credential state. Authenticated SSR stays request-safe (S01) but has no transfer. - Not a cache. The result belongs to one server-rendered document and its first hydration. It is
dropped when the app first becomes stable.
reload(), any change of page, offset, sort,where,depth,localeor document id, a later route, and a full page load all make real requests (a full page load gets fresh server data). Identical resources in the first render share one transferred result, so they don't each refetch. - Errors are never transferred. A failed server read renders the normal error state and writes nothing; the browser fetches after it starts and recovers (that subtree re-renders).
- Keys are the logical read — the configured
baseUrl, collection or document, and the full query — not the server's absolute origin, so server and browser agree. - Local API server routes are separate: their results are not bridged into Angular state.
What is request-scoped
Angular's renderApplication creates a new platform and application per request, and Forge keeps
everything it knows about a request inside that application's injectors:
| Scope | State |
|---|---|
| Per render | REQUEST, the forwarded cookie/authorization, CmsApiService, ForgeAuthSession (user, status), credential revision |
| Per component / resource | Resource values, errors, AbortControllers |
| Process / isolate (safe) | The ForgeCmsRuntime (schema, adapters, connections) — identity is passed per operation |
There is no global "current user" or "current request": two concurrent renders can never read each
other's cookie or result. When a render's application is destroyed, its in-flight Forge requests are
aborted. ForgeAuthSession runs its /me bootstrap per render: anonymous → 'anonymous', forwarded
session → 'authenticated', outage → 'error'. The admin UI is not meant to be server rendered; its
guard simply resolves on the server as anonymous and the browser takes over.
Deployment notes: two proven production profiles
pnpm test:s3 profiles (specs 081 and 084; needs Docker for Garage) installs a consumer from packed tarballs only and walks the
same journey on built production servers — never a dev server: first-admin bootstrap → sign in → create a
draft in the admin → draft invisible to anonymous SSR and to a signed-in browser's public page → publish →
no-JS HTML with the real title and body → hydration with one server read and zero duplicate browser
reads → edit (the loaded page is not changed) → SPA navigation reads normally → full reload is a fresh SSR
carrying the edit → server restart (content persists) → back to draft (hidden everywhere). Restricted relation
data (author -> users: email, id, hashes, _sessionVersion, roles), cookies, tokens and AUTH_SECRET never
appear in the HTML, the transfer state, the hydrated DOM or any browser bundle.
Both profiles use provideForgeCmsServer({ origin }), a public client with credentials: 'omit', Angular
hydration and { transfer: 'public' } exactly as above.
Both are complete durable profiles (database and file storage; a production build refuses to start
otherwise — see Deployment). After the SSR journey each profile uploads a file through the
multipart handler, loads it in a browser from /api/media/…, restarts the server, still serves the same bytes,
and deletes it (document, object and storage intents).
Cloudflare Pages + D1 + R2
Analog with the cloudflare-pages Nitro preset, DB (D1) and BUCKET (R2) bindings, AUTH_SECRET (≥ 32 bytes) and
FORGE_SSR_ORIGIN. D1DatabaseAdapter needs no native module and no extra bundler configuration. Evidence is
local: the Pages output served by wrangler pages dev (workerd) with a real local D1 database and a real local R2 bucket persisted on
disk. It is not a remote Cloudflare deployment and S03 does not certify one. With nodejs_compat,
process.env carries your bindings (compatibility date 2026-05-15); rendering fetches your own origin (a
subrequest). Server routes that only need public data can use the Local API instead.
Node + libSQL + S3
Analog with the node-server preset and the portable database:
// runtime (durable profile): DATABASE_URL and the S3_* settings are required — see Deployment
const database = new LibSqlDatabaseAdapter(env.DATABASE_URL); // e.g. file:/data/forge.db on a persistent volume
const storage = new S3StorageAdapter({ bucket, region, endpoint, forcePathStyle, credentials });
Configure Nitro so native libSQL stays installed instead of being traced into the server output (Nitro's
externals.trace option: when false, externalized dependencies are referenced from node_modules instead
of being traced and copied; checked against nitropack 2.13):
// vite.config.ts
analog({ ssr: true, nitro: { preset: 'node-server', externals: { trace: false } } });
libSQL ships a per-platform native package that Nitro's tracer cannot follow, so production must ship
node_modules beside dist/ (pnpm install --prod on the target, then node dist/analog/server/index.mjs
from the project directory with AUTH_SECRET, DATABASE_URL and FORGE_SSR_ORIGIN set). Apps that only use
InMemoryDatabaseAdapter or D1DatabaseAdapter need none of this: @forge-cms/db loads libSQL on the first
database operation, so merely importing the package no longer pulls the native module in. Content survives
process restarts (proven against a real on-disk file).
libSQL is the durable database and
@forge-cms/s3the durable file storage of the portable profile.InMemoryStorageAdapteris not durable and is for development only; a production build of the tiny-project fixture refuses to start without a complete profile. An on-disk libSQL file must live on persistent storage (a mounted volume, not an ephemeral container filesystem). Setup, environment variables and recovery: Deployment.