Browse documentation

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

overrideAccess defaults to true. A direct call is assumed to come from trusted server code. Anything acting on a visitor's behalf must pass overrideAccess: false together with the resolved user — 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.