Server APIs
Local API
find, findOne, findByID, create, update, delete and count — the way to use ForgeCMS from server code.
The Local API is ForgeCmsRuntime's seven methods. Each runs the whole pipeline — access control,
hooks, drafts, relation population, validation — as a plain function call, with no HTTP hop and no
Request to fabricate.
This is the intended way to use ForgeCMS from server code: an Analog server route, a Nitro handler, a seed script, a scheduled job. The HTTP handlers are a thin transport layer over exactly these methods.
const runtime = await getServerRuntime(env);
const { docs, totalDocs, hasNextPage } = await runtime.find({
collection: 'posts',
where: { published: true, views: { gte: 100 } },
sort: 'publishedAt',
order: 'desc',
limit: 10,
depth: 1
});
Typed inference
defineCollection's schema feeds straight into every Local API method's types — no codegen, no
as const required:
const posts = defineCollection({
slug: 'posts',
fields: {
title: defineField.text({ required: true }),
views: defineField.number(),
metadata: defineField.json<{ featured: boolean }>()
}
});
const runtime = new ForgeCmsRuntime({ collections: [posts, authors], adapters });
const page = await runtime.find({ collection: 'posts' });
page.docs[0].title; // string
page.docs[0].metadata.featured; // boolean
const created = await runtime.create({
collection: 'posts',
data: { title: 'Hello', views: 1 } // unknown fields/wrong value types are a compile error
});
created.title; // string
runtime.find({ collection: 'does-not-exist' }); // compile error: not a registered collection
defineField.json<T>() is a compile-time annotation only — T flows through find/create/
update's inferred types, but nothing validates that the stored JSON actually matches T at
runtime. defineField.json() (no type argument) still infers unknown, exactly as before; keep
validating untrusted JSON yourself. A runtime built from a widened/dynamic CollectionDefinition[]
(or a ForgeCmsRuntime<TEnv> given only its environment type) still compiles and still works —
every method just accepts a plain string and returns a loosely-typed document, the same shape the
Local API always returned.
Shared arguments
Every method accepts these:
| Argument | Type | Default | Meaning |
|---|---|---|---|
collection |
string |
— | The collection slug (required) |
user |
CmsUser | null |
null |
Who the operation runs as |
overrideAccess |
boolean |
true |
Skip access checks — see the warning below |
depth |
0 | 1 |
0 |
1 replaces relation/upload ids with the documents |
overrideAccessdefaults totrue. A direct call is assumed to come from trusted server code. Anything acting on a visitor's behalf must passoverrideAccess: falsetogether with the resolveduser— that is exactly what the HTTP layer does. See Access control.
find
const page = await runtime.find({
collection: 'services',
where: { category: 'facial', price: { lte: 200 } },
sort: 'order',
order: 'asc',
limit: 20,
offset: 0,
status: 'published'
});
Returns PaginatedDocs:
interface PaginatedDocs {
docs: Record<string, unknown>[];
/** Total matching the query, ignoring limit/offset. */
totalDocs: number;
limit: number | undefined;
offset: number;
/** 1-based, derived from limit/offset. */
page: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
where supports eq (bare value), ne, gt, gte, lt, lte, in, contains, plus containsValue
for relation({ many: true }) array-membership:
where: {
category: 'facial', // equality
price: { gte: 50, lte: 200 }, // range
id: { in: ['a', 'b', 'c'] },
title: { contains: 'laser' }, // case-insensitive
specialties: { containsValue: serviceId } // relation({ many: true }) array membership
}
Nested and/or
and/or compose recursively and combine with the field operators above — an implicit and across
top-level keys still works exactly as before:
const result = await runtime.find({
collection: 'posts',
where: {
and: [
{ status: 'published' },
{
or: [{ featured: true }, { views: { gte: 1000 } }]
}
]
},
sort: [
{ field: 'featured', order: 'desc' },
{ field: 'created_at', order: 'desc' }
]
});
sort accepts a single field name (unchanged) or a { field, order }[] for a multi-field, stable
sort — the first field decides, ties fall through to the next. Behavior is identical across the
InMemory, libSQL, and D1 adapters. Querying inside group/array/blocks JSON values is still not
supported (they are JSON in a single column) — containsValue only reaches relation arrays.
An access-rule constraint (access.read returning a query) is always AND-composed around whatever
where the caller supplies — a nested or in the caller's query can never widen out of a row-level
access constraint; see Access control.
findByID
const post = await runtime.findByID({ collection: 'posts', id, depth: 1 });
Throws NotFoundError if it does not exist — or if an access constraint excludes it, so ids are not
leaked by a 403.
findOne
The same read pipeline as find (access, hooks, drafts, locale, relation population), narrowed to at
most one document — null, not a thrown error, when nothing matches:
const post = await runtime.findOne({ collection: 'posts', where: { slug: 'hello-world' } });
post?.title; // string, typed from the collection definition — or undefined if post is null
Replaces the old find({ where, limit: 1 }).docs[0] workaround: no pagination metadata is computed,
so it goes straight to the database with a real LIMIT 1.
count
const pending = await runtime.count({ collection: 'bookings', where: { status: 'pending' } });
count shares the exact same query-preparation path as find — including nested and/or and
access-rule composition — so a filtered count and a filtered find never disagree about how many
documents match.
create
const post = await runtime.create({
collection: 'posts',
data: { title: 'Hello', body: [] },
user,
overrideAccess: false
});
Applies defaults and auto-slugs, runs beforeValidate → validation → beforeChange → the write →
afterChange, and returns the stored document.
Trusted server code (the default overrideAccess: true) may pass a deterministic data.id — a
non-empty string — for seeds, imports and sync. With overrideAccess: false (and over HTTP) a
caller-chosen id is refused. See System metadata for the other keys.
update
const post = await runtime.update({ collection: 'posts', id, data: { title: 'New title' } });
Partial by design: fields you omit keep their stored values, and a required field already present on
the record does not have to be repeated. Sending back the whole document you read is fine: id, the
timestamps and _storageKey are dropped when they equal the stored values, and the adapter stamps a
fresh updated_at.
System metadata
id, created_at, updated_at and _storageKey belong to Forge. The CMS mutation API (create,
update, restoreVersion, preview, updateGlobalDocument and every HTTP handler) never lets a
caller change them — whether or not overrideAccess is set. overrideAccess: true skips
authorization, not the integrity of Forge's own metadata.
| Key | create |
update / preview of an existing document |
|---|---|---|
id |
trusted: optional non-empty string; untrusted: 400 | unchanged echo dropped, otherwise 400 |
created_at |
400 | unchanged echo dropped, otherwise 400 |
updated_at |
400 | unchanged echo dropped, otherwise 400 |
_storageKey |
400 — set only by the multipart upload pipeline | unchanged echo dropped, otherwise 400 |
_status |
writable on a drafts: true collection |
writable on a drafts: true collection |
A refused key is an InvalidInputError (400, INVALID_INPUT): Field '_storageKey' is managed
by Forge and cannot be written. Nothing is written. A null value counts as "not set" and is
dropped like an echo. Hooks follow the same rule: they may change
content and _status, and a hook that sets one of these keys fails the operation with a 500 (it is a
server-code bug, not a bad request). Hooks never see a trusted create's explicit id in data; it is
on the returned doc.
Code that genuinely needs raw persistence — importing rows with their original timestamps, attaching
an object that is already in storage — writes through runtime.adapters.database. That layer bypasses
access, hooks, validation and this boundary by design.
delete
const removed = await runtime.delete({ collection: 'posts', id });
Returns the deleted document, having run beforeDelete and afterDelete. On an upload: true
collection, it then deletes the storage object recorded in the document's _storageKey, and only that
object. A document without one (created from JSON, or a record from before storage keys existed)
deletes no object. The url field is never used to choose what to delete. Relation rules
(onDelete) run as documented in Collections.
Version history
On a versions: true collection (see Collections → Version history):
const history = await runtime.listVersions({ collection: 'posts', documentId: id, limit: 20 });
const version = await runtime.getVersion({ collection: 'posts', versionId });
const restored = await runtime.restoreVersion({
collection: 'posts',
versionId,
user,
overrideAccess: false
});
await runtime.createVersion({
collection: 'posts',
documentId: id,
data: snapshot,
label: 'Launch'
});
create/update/restoreVersion save the document and its snapshot as one atomic write. When another
write reaches the same document first, update/restoreVersion throw ConcurrentModificationError
and save nothing. Hooks run in this order: before* hooks run first, then the atomic write, then
afterChange and afterOperation. When the write fails, the before* hooks have already run (a
database rollback can't undo side effects they caused), and the after* hooks don't run. Forge does
not retry: re-read the document and submit again.
Errors
Typed, each carrying its HTTP status — so the transport layer maps them without a translation table:
| Error | Status | Thrown when |
|---|---|---|
NotFoundError |
404 | Unknown collection, unknown id, invisible document |
InvalidInputError |
400 | Malformed query, bad JSON, bad multipart body, writing Forge-owned metadata (id, timestamps, _storageKey) |
UnknownFieldError |
400 | where/sort names a field the collection doesn't have |
InvalidQueryError |
400 | Malformed and/or group (empty, wrong shape), bad operator/sort direction, containsValue on a non-relation field |
ValidationFailedError |
400 | Field validation failed — carries details |
UnauthorizedError |
401 | Authentication required |
AccessDeniedError |
403 | Authenticated but not permitted |
UniqueConstraintError |
409 | A unique field/index (or a duplicate id) already exists — carries details.fields |
ConcurrentModificationError |
409 | A versioned document changed after this update read it; nothing was saved — reload and retry |
import { isForgeError, ValidationFailedError } from '@forge-cms/runtime';
try {
await runtime.create({ collection: 'bookings', data, user, overrideAccess: false });
} catch (err) {
if (err instanceof ValidationFailedError) return { errors: err.details };
if (isForgeError(err)) return { error: err.message, status: err.status };
throw err;
}
A complete public endpoint
The pattern the demo app uses for every public route — the site runs under real anonymous access rules, so a rule that hides drafts hides them here too:
// apps/<app>/src/server/routes/api/site/services.get.ts
import { defineEventHandler } from 'h3';
import { getServerRuntime } from '../../../api/runtime';
export default defineEventHandler(async (event) => {
const runtime = await getServerRuntime(event.context.cloudflare?.env);
const asVisitor = { overrideAccess: false, user: null } as const;
const [services, categories] = await Promise.all([
runtime.find({ collection: 'services', sort: 'order', limit: 50, depth: 1, ...asVisitor }),
runtime.find({ collection: 'service_categories', sort: 'order', limit: 20, ...asVisitor })
]);
return { services: services.docs, categories: categories.docs };
});
Returning a purpose-built payload rather than raw documents is worth the extra file: the browser downloads what the page needs, not the whole content model.