Local API
Query and mutate your content directly from the server using the type-safe Local API.
The Local API is the core data layer of Aphex. Both the REST API and GraphQL API are thin wrappers around it. You can use it directly in SvelteKit route handlers, server load functions, and scripts for full control over your content.
You don't run a type-generation step. Editing a schema regenerates generated-types.ts automatically (the aphex() Vite plugin does it on save), the file is committed to git, and builds/CI/prod use it as-is — so localAPI.collections.<name> just stays type-safe. The generate:types command exists only for edge cases (regenerating after a pull without starting dev, or a CI drift-check). (Content fields are stored as JSON, so a schema change needs no db:push either — only adding custom DB tables does.) See Type-safe collections below.
Accessing the Local API
The Local API is available on event.locals.aphexCMS.localAPI in any SvelteKit server context:
import { json } from '@sveltejs/kit';
import { authToContext } from '@aphexcms/cms-core/server';
export const GET = async ({ locals }) => {
const api = locals.aphexCMS.localAPI;
const context = authToContext(locals.auth);
const result = await api.collections.post.find(context, {
where: { status: { equals: 'published' } },
limit: 10
});
return json({ data: result.docs });
};Context
Every Local API operation requires a LocalAPIContext. This tells the API who is making the request and which organization the data belongs to.
From an authenticated request
Use authToContext() to convert locals.auth into a context. It handles both session
auth and API key auth — for an API key it synthesises a user (id: 'apikey:<keyId>')
and maps the key's permissions onto a role, so the permission system has something to
evaluate:
import { authToContext } from '@aphexcms/cms-core/server';
const context = authToContext(locals.auth);It throws on an unauthenticated request — locals.auth is null for an anonymous visitor,
and authToContext(null) raises Error('Authentication required') rather than returning an
anonymous context. So it is not the function to reach for in a public page's load. Use
systemContext(organizationId) there and pass public: true on the read, or guard the call and
return a 401 yourself.
System context (bypass permissions)
For seed scripts, cron jobs, or migrations where there is no user session, use systemContext(). This sets overrideAccess: true, bypassing all permission checks and row-level security:
import { systemContext } from '@aphexcms/cms-core/server';
const context = systemContext('your-organization-id');Public reads
A read that ends up in a public page's payload should pass public: true. It strips the
_meta fields that describe your tenancy rather than your content:
const result = await api.collections.post.find(context, {
perspective: 'published',
public: true
});
result.docs[0]._meta;
// { type, status, createdAt, updatedAt, publishedAt, revision }
// removed: organizationId, createdBy, updatedBy, publishedHashFour fields go: organizationId, createdBy, updatedBy and publishedHash. The
first three identify your tenant and the people in it — internal identifiers with no
business appearing in a page's serialized data, where anyone can read them in the HTML
source. publishedHash is a content digest used for change detection.
It is an option on the read, not on the context, precisely because the same document
is read both ways: the admin needs the full _meta to drive change detection and audit
display, and the public page needs it gone. So public: true belongs on the call whose
result is about to cross into a response, and it applies equally to find, findByID
and a singleton's get.
public: true is not access control. It removes metadata about a document you have already been
permitted to read — it does not decide whether you may read it, and it does not touch your own
fields. Restricting a field's visibility by role is field-level access; keeping a document out
of a public read entirely is what perspective: 'published' and your own where clause are for.
Context shape
interface LocalAPIContext {
organizationId: string; // Required for multi-tenancy
user?: CMSUser; // For permission checks and audit trails
overrideAccess?: boolean; // Bypass RLS and permissions (default: false)
auth?: Auth; // Full auth object for custom logic
}Collections
Each document type in your schema becomes a collection on localAPI.collections. Collections provide CRUD methods:
const api = locals.aphexCMS.localAPI;
api.collections.post; // CollectionAPI for the 'post' document type
api.collections.page; // CollectionAPI for the 'page' document type
api.collections.product; // etc.You can also check what collections exist:
api.getCollectionNames(); // ['post', 'page', 'product']
api.hasCollection('post'); // true
api.getCollectionSchema('post'); // SchemaType for 'post'Type-safe collections
The Local API's typesafety is powered by generated TypeScript interfaces that mirror your current schema, and keeping them in sync is automatic:
- Edit a schema in
src/lib/schemaTypes/(add a field, rename a document type, change a reference, etc.) generated-types.tsrewrites itself — theaphex()Vite plugin regenerates on save. You commit the result; CI/builds/prod use the committed file. (If the dev server wasn't running, runpnpm generate:typesonce to catch up.)- Restart the TS server in your editor if autocomplete doesn't pick up the new types immediately (in VS Code:
Cmd+Shift+P→ "TypeScript: Restart TS Server").
See Type Generation for the underlying mechanism (module augmentation, file outputs, CI considerations).
Methods
find
Find multiple documents with filtering, sorting, and pagination.
const result = await api.collections.post.find(context, {
where: { status: { equals: 'published' } },
sort: '-publishedAt',
limit: 20,
offset: 0,
perspective: 'published'
});
result.docs; // Post[]
result.totalDocs; // Total matching documents
result.totalPages; // Total pages
result.hasNextPage; // boolean
result.hasPrevPage; // booleanfindByID
Find a single document by ID.
const post = await api.collections.post.findByID(context, 'doc_123', {
perspective: 'published'
});
// Returns the document or nullcount
Count documents matching a filter.
const total = await api.collections.post.count(context, {
where: { status: { equals: 'published' } }
});create
Create a new document. Returns the document and validation results.
const result = await api.collections.post.create(context, {
title: 'My New Post',
slug: 'my-new-post',
body: 'Hello world.'
});
result.document; // The created document (draft)
result.validation; // { isValid: boolean, errors: [...] }Pass { publish: true } to publish immediately. This will throw if validation fails:
const result = await api.collections.post.create(
context,
{ title: 'Published Post', slug: 'published-post' },
{ publish: true }
);update
Update an existing document. Merges the provided data with existing fields.
const result = await api.collections.post.update(context, 'doc_123', { title: 'Updated Title' });
// Returns DocumentResult or null if not foundPublish after updating:
const result = await api.collections.post.update(
context,
'doc_123',
{ title: 'Updated Title' },
{ publish: true }
);delete
Delete a document by ID.
const deleted = await api.collections.post.delete(context, 'doc_123');
// Returns booleanpublish
Publish a document. Validates the draft data first and throws if validation fails.
const published = await api.collections.post.publish(context, 'doc_123');
// Returns the published document or nullunpublish
Revert a document to draft-only state.
const draft = await api.collections.post.unpublish(context, 'doc_123');
// Returns the draft document or nullConcurrency — expectedRevision
Every document carries a monotonic revision, returned as _meta.revision and incremented on every draft write. Pass the revision you last read as expectedRevision and the write only lands if nobody changed the document in between:
const post = await api.collections.post.findByID(context, 'doc_123');
await api.collections.post.update(
context,
'doc_123',
{ title: 'Updated Title' },
{ expectedRevision: post._meta.revision }
);If the stored revision has moved on, the write throws RevisionConflictError instead of overwriting:
import { RevisionConflictError } from '@aphexcms/cms-core/server';
try {
await api.collections.post.update(context, id, data, { expectedRevision: rev });
} catch (err) {
if (err instanceof RevisionConflictError) {
// err.documentId, err.expectedRevision, err.currentRevision
// Re-read the document and decide: merge, discard, or ask the user.
}
throw err;
}Accepted by update, publish, unpublish, and VersionService.restoreVersion.
expectedRevision is optional everywhere. Omit it and you get unconditional last-write-wins,
exactly as before — so existing code keeps working unchanged. Pass it whenever a write is based on
data you read earlier: a second browser tab, a background job, or an AI agent editing a document
someone has open.
Singletons
Schemas marked singleton: true expose a different surface — there is at most one row, so most of the collection methods don't apply. The codegen narrows the autocompletion to a SingletonCollection<T> so invalid calls fail at compile time.
// Lazy-creates the canonical row on first access
const nav = await api.collections.siteNavigation.get(context, {
perspective: 'published'
});
// Update by name — no id needed
await api.collections.siteNavigation.update(context, nav.id, {
brand: 'Aphex'
});
// Compute the deterministic id (rarely needed)
const id = api.collections.siteNavigation.getSingletonId(context);find() still works on a singleton (it returns a one-element page) so generic helpers can keep treating every schema the same way. create() and delete() throw SingletonOperationError. See Singletons for the full guide.
Version history
Each update (and every publish) writes a snapshot to cms_document_versions. Use localAPI.versionService directly for scripts and migrations:
const adapter = locals.aphexCMS.databaseAdapter;
const ctx = authToContext(locals.auth);
const { versions, total } = await api.versionService.listVersions(
adapter,
ctx.organizationId,
'doc_xyz',
{ limit: 25, offset: 0 }
);
const restored = await api.versionService.restoreVersion(
adapter,
ctx.organizationId,
'doc_xyz',
8,
ctx.user?.id
);Restoring replaces the draft with the snapshot's data and writes a new draft-event version recording the action. See Version History for the HTTP endpoints and admin UI.
Filtering
The where option accepts a structured filter object that the active adapter translates into its native query language.
Comparison operators
where: { title: { equals: 'Hello' } }
where: { title: { not_equals: 'Hello' } }
where: { status: { in: ['draft', 'published'] } }
where: { status: { not_in: ['archived'] } }
where: { image: { exists: true } }Numeric and date comparisons
where: {
price: {
greater_than: 10;
}
}
where: {
price: {
greater_than_equal: 10;
}
}
where: {
price: {
less_than: 100;
}
}
where: {
price: {
less_than_equal: 100;
}
}String operations
where: {
title: {
contains: 'blog';
}
}
where: {
title: {
starts_with: 'How';
}
}
where: {
title: {
ends_with: '?';
}
}
where: {
title: {
like: '%blog%';
}
}Logical operators
Multiple conditions at the top level are combined with AND:
where: {
status: { equals: 'published' },
title: { contains: 'blog' }
}Use or for OR logic:
where: {
or: [{ title: { contains: 'tutorial' } }, { title: { contains: 'guide' } }];
}Use and for explicit AND grouping:
where: {
and: [{ title: { contains: 'blog' } }, { body: { exists: true } }];
}Nested field filters
Use dot notation for nested fields:
where: { 'seo.metaTitle': { contains: 'blog' } }
where: { 'author.name': { equals: 'John' } }Find Options
The full set of options for find:
| Option | Type | Default | Description |
|---|---|---|---|
where | Where | - | Filter conditions. |
limit | number | 50 | Max results per page. |
offset | number | 0 | Number of results to skip. |
sort | string | string[] | - | Sort order. Prefix with - for descending (e.g. '-publishedAt'). |
depth | number | 0 | Reference resolution depth. |
select | string[] | - | Only return specified fields. |
perspective | 'draft' | 'published' | 'draft' | Which version of the document to return. |
Perspectives
Documents in Aphex have two versions: draft and published.
'draft'(default) - Returns the working copy with unpublished changes.'published'- Returns the last published version. Documents that have never been published won't appear.
// Get published content for the public site
const published = await api.collections.post.find(context, {
perspective: 'published'
});
// Get draft content for the admin UI
const drafts = await api.collections.post.find(context, {
perspective: 'draft'
});Document Shape
Documents returned by the Local API include your content fields plus a _meta object:
{
id: 'doc_123',
title: 'My Post', // Your fields
slug: 'my-post',
body: '...',
_meta: {
type: 'post',
status: 'draft',
organizationId: 'org_123',
createdAt: '2025-01-15T10:30:00Z',
updatedAt: '2025-01-15T15:45:00Z',
createdBy: 'user_123',
updatedBy: 'user_123',
publishedAt: null,
publishedHash: null
}
}Permissions
The Local API enforces the same capability-based access control as the HTTP and GraphQL APIs. Each operation is gated by a specific capability:
| Operation | Required capability |
|---|---|
find / findByID / count | document.read |
create | document.create |
update | document.update |
delete | document.delete |
publish | document.publish |
unpublish | document.unpublish |
Built-in role mapping for reference:
| Role | read | create/update/delete | publish / unpublish |
|---|---|---|---|
viewer | yes | no | no |
editor | yes | yes | yes |
admin | yes | yes | yes |
owner | yes | yes | yes |
Custom roles grant whatever capabilities you assign them. Schema-level access rules and field-level access rules apply on top of this check — see Access Control.
Set overrideAccess: true in the context to bypass all checks (for system operations only).
Examples
Public API endpoint
import { json } from '@sveltejs/kit';
import { authToContext } from '@aphexcms/cms-core/server';
export const GET = async ({ locals, url }) => {
const api = locals.aphexCMS.localAPI;
const context = authToContext(locals.auth);
const page = parseInt(url.searchParams.get('page') || '1');
const limit = 10;
const result = await api.collections.post.find(context, {
where: { status: { equals: 'published' } },
perspective: 'published',
sort: '-publishedAt',
limit,
offset: (page - 1) * limit
});
return json({
posts: result.docs,
totalPages: result.totalPages,
hasNextPage: result.hasNextPage
});
};Server load function
A public page has no session, so it reads with a system context and marks the read
public — see Public reads for what that strips:
import { error } from '@sveltejs/kit';
import { systemContext } from '@aphexcms/cms-core/server';
export const load = async ({ locals, params }) => {
const api = locals.aphexCMS.localAPI;
const context = systemContext(locals.organizationId);
const result = await api.collections.post.find(context, {
where: { slug: { equals: params.slug } },
perspective: 'published',
limit: 1,
public: true
});
const post = result.docs[0];
if (!post) throw error(404, 'Post not found');
return { post };
};Don't use authToContext(locals.auth) here. It throws Authentication required when
locals.auth is null, which is every anonymous visitor — so the page works while you're logged
into the admin in the same browser and 500s for the public.
Seed script
import { getLocalAPI, systemContext } from '@aphexcms/cms-core/server';
const api = getLocalAPI();
const context = systemContext('your-org-id');
await api.collections.post.create(
context,
{
title: 'Welcome to Aphex',
slug: 'welcome',
body: 'Your first post.'
},
{ publish: true }
);Last updated on