Direct API
Server-side API for querying and mutating data directly from Next.js Server Components, server actions, and route handlers. No HTTP, no serialisation overhead.
Parts of this page need the release after 0.0.2-alpha.66: the
principaloption;EMAIL_NOT_VERIFIED; and login's account checks. On 0.0.2-alpha.66,nextly.login()checks only the password, so a locked, deactivated or unverified account still gets a token, and that token lasts until it expires.
The Direct API is the recommended way to read and write Nextly data on the server. It calls into the runtime's service layer in-process, so there is no HTTP round-trip and no JSON serialisation. Use it from Server Components, server actions, route handlers, hooks — anywhere your code already runs in a trusted Node.js context.
If you need to reach the same data from the browser, a mobile app, or a third-party service, use the REST API instead.
Initialisation
Import getNextly (or the lazy nextly convenience object) from the runtime package:
import { getNextly } from "nextly";
import config from "../../nextly.config";
const nextly = await getNextly({ config });
const result = await nextly.find({
collection: "posts",
where: { status: { equals: "published" } },
sort: "-publishedAt",
limit: 10,
});
console.log(result.items); // Post[]
console.log(result.meta.total); // numberThe module-level nextly export resolves the singleton lazily on each call, so it is safe to import at module scope:
import { nextly } from "nextly";
const posts = await nextly.find({ collection: "posts", limit: 10 });getNextly({ config }) and nextly reach the same singleton. getNextly takes your loaded config and can initialise the runtime itself, so it is safe to call from anywhere; nextly is the lighter import for code that runs after initialisation, and throws if nothing has initialised the runtime yet (this happens automatically when your Next.js route handler boots through createDynamicHandlers).
TypeScript support
Run nextly generate:types after defining your collections to get full type inference on every Direct API call:
const posts = await nextly.find({ collection: "posts" });
// posts.items is typed as Post[] when generated types are present.
const invalid = await nextly.find({ collection: "nonexistent" });
// Type error: collection slug not in the generated union.The generator writes a payload-types.ts (or whatever path you set in typescript.outputFile) that augments the runtime's GeneratedTypes interface via declare module "nextly". See Configuration → typescript for the config surface.
Access control
By default, Direct API calls run with overrideAccess: true — access control is bypassed because the Direct API runs in trusted server code. If you want collection access control, field-level rules, and row-level filters to apply (for example when forwarding a user request through a server action), pass overrideAccess: false and supply a user:
const visiblePosts = await nextly.find({
collection: "posts",
overrideAccess: false,
user: { id: "user-123", role: "editor" },
});overrideAccess, user, and context flow through every hook the operation triggers. Anything you put in context is accessible inside hooks via req.context.
To run a collection or Single operation as a plugin-defined signed-in person, pass the person as principal (@experimental). Access rules always apply, so overrideAccess is not needed; the call is refused if it also sets overrideAccess: true, trusted or frameworkFilter, or names a user or an API key. A person is named per call: new Nextly({ principal }) and requireNextly({ principal }) are refused. See Signed-in people of your own kind.
Collections
These methods hang off the nextly root and operate on collection entries. The shapes shown below match the canonical Phase 4 envelopes (ListResult<T>, MutationResult<T>, etc.) — verify against packages/nextly/src/direct-api/types/shared.ts if you need the exact TypeScript definitions.
find
Find multiple documents in a collection. Returns the canonical list envelope { items, meta }.
const result = await nextly.find({
collection: "posts",
where: { status: { equals: "published" } },
sort: "-publishedAt",
limit: 10,
page: 1,
depth: 2,
});
result.items; // Post[]
result.meta.total; // total matching rows
result.meta.page; // current page (1-indexed)
result.meta.limit; // page size
result.meta.totalPages; // total pages
result.meta.hasNext; // boolean
result.meta.hasPrev; // boolean| Argument | Type | Default | Description |
|---|---|---|---|
collection | string | required | Collection slug. |
where | WhereFilter | — | Filter conditions (see Querying). |
limit | number | 10 | Page size. |
page | number | 1 | Page number, 1-indexed. |
sort | string | — | field for ascending, -field for descending. |
depth | number | 0 | Relationship population depth. |
select | Record<string, boolean> | — | Whitelist or blacklist fields. |
populate | Record<string, boolean | PopulateOptions> | — | Per-field population control. |
pagination | boolean | true | Set false to return all matching rows without paging. |
status | "published" | "draft" | "all" | — | Lifecycle scope on a collection with the status lifecycle. An untrusted read (overrideAccess: false) that states none sees public states only. |
richTextFormat | "json" | "html" | "both" | "json" | Output format for rich text fields. |
overrideAccess | boolean | true | Bypass access control. |
user | { id, role? } | — | Required when overrideAccess: false. |
context | Record<string, unknown> | — | Custom data forwarded to hooks via req.context. |
disableErrors | boolean | false | Return an empty result instead of throwing. |
findByID
Find a single document by ID. Returns the bare document, or throws NextlyError (NOT_FOUND) when no match is found.
const post = await nextly.findByID({
collection: "posts",
id: "abc-123",
depth: 2,
});Pass disableErrors: true to get null instead of an exception:
const post = await nextly.findByID({
collection: "posts",
id: "maybe-exists",
disableErrors: true,
});
if (post) {
console.log(post.title);
}| Argument | Type | Default | Description |
|---|---|---|---|
collection | string | required | Collection slug. |
id | string | required | Document ID. |
depth | number | 0 | Relationship population depth. |
select | Record<string, boolean> | — | Whitelist or blacklist fields. |
populate | Record<string, boolean | PopulateOptions> | — | Per-field population control. |
status | "published" | "draft" | "all" | — | As on find. An untrusted read that states none answers NOT_FOUND for a row that was never published, whatever draft says. |
draft | boolean | false | Overlay the pending working draft on a drafts-enabled collection. Granted outright to a trusted read; for an untrusted one, only when the caller may update the document. |
richTextFormat | "json" | "html" | "both" | "json" | Output format for rich text fields. |
disableErrors | boolean | false | Return null instead of throwing. |
overrideAccess | boolean | true | Bypass access control. |
user | { id, role? } | — | Required when overrideAccess: false. |
Returns: T | null.
create
Create a new document. Returns the canonical mutation envelope { message, item }.
const created = await nextly.create({
collection: "posts",
data: {
title: "Hello world",
content: "My first post",
status: "draft",
},
});
created.message; // e.g. "Posts created."
created.item; // the created Post
created.item.id;| Argument | Type | Default | Description |
|---|---|---|---|
collection | string | required | Collection slug. |
data | Record<string, unknown> | required | Document data. |
draft | boolean | false | Save as draft. |
disableVerificationEmail | boolean | false | Skip the verification email when creating users. |
overrideAccess | boolean | true | Bypass access control. |
user | { id, role? } | — | Required when overrideAccess: false. |
context | Record<string, unknown> | — | Forwarded to hooks. |
Returns: MutationResult<T> ({ message, item }). Throws NextlyError (VALIDATION_ERROR) when the payload fails validation; the thrown error carries field-level details on error.publicData.errors.
update
Update by ID, or update the first match of a where clause. Either id or where is required.
// By ID
const updated = await nextly.update({
collection: "posts",
id: "abc-123",
data: { status: "published", publishedAt: new Date() },
});
updated.item; // the updated Post
updated.message; // "Posts updated."
// By where (single match path — use bulk endpoints for true bulk semantics)
const archived = await nextly.update({
collection: "posts",
where: { status: { equals: "draft" } },
data: { status: "archived" },
});| Argument | Type | Default | Description |
|---|---|---|---|
collection | string | required | Collection slug. |
id | string | — | Document ID. Either id or where is required. |
where | WhereFilter | — | Where clause for the by-query path. |
data | Record<string, unknown> | required | Update payload. |
draft | boolean | false | Autosave as draft. |
overrideAccess | boolean | true | Bypass access control. |
user | { id, role? } | — | Required when overrideAccess: false. |
Returns: MutationResult<T> ({ message, item }). Throws NextlyError (NOT_FOUND) when the where clause matches nothing.
delete
Delete by ID or by where. The two paths return different shapes because a multi-row delete cannot collapse into a single mutation envelope, and the return type is the union either way — narrow it before reading a path-specific field:
// By ID — returns MutationResult<{ id }>
const deleted = await nextly.delete({
collection: "posts",
id: "abc-123",
});
if ("item" in deleted) {
deleted.message; // "Posts deleted."
deleted.item.id; // "abc-123"
}
// By where — returns DeleteResult ({ deleted: boolean, ids: string[] })
const archived = await nextly.delete({
collection: "posts",
where: { status: { equals: "archived" } },
});
if ("ids" in archived) {
console.log("Deleted IDs:", archived.ids);
}| Argument | Type | Default | Description |
|---|---|---|---|
collection | string | required | Collection slug. |
id | string | — | Document ID. Either id or where is required. |
where | WhereFilter | — | Where clause for the bulk-by-query path. |
overrideAccess | boolean | true | Bypass access control. |
user | { id, role? } | — | Required when overrideAccess: false. |
Returns: MutationResult<{ id: string }> | DeleteResult.
count
Count documents matching a query. Returns { total }.
const { total } = await nextly.count({
collection: "posts",
where: { status: { equals: "published" } },
});
console.log("Published posts:", total);Returns: CountResult ({ total: number }).
group
Count documents per distinct value of one field. Returns the buckets largest first.
const { buckets, truncated } = await nextly.group({
collection: "orders",
groupBy: "region",
where: { status: { equals: "paid" } },
bucketLimit: 10,
});
// [{ value: "emea", count: 42 }, { value: null, count: 3 }]group reaches the same rows count does, so buckets can never describe rows
a count with the same arguments would have excluded.
A null value is a bucket in its own right — "how many orders have no region"
is a real answer, and folding it into another bucket would misreport both.
Ranking and the cap happen in the database after grouping finishes, so the
buckets you get back are genuinely the largest. truncated is true when the
cap left some out; a chart drawn from a capped set is not the whole picture, so
show that rather than hiding it.
Some fields cannot be grouped, and the refusal names the reason: a field whose
read rule does not let the caller read it (or lets them read it only on some
entries), under any spelling, a password field, the owner column, a field
transformed by an afterRead hook, and anything stored as JSON. Buckets are the
distinct values of a column, so grouping publishes the whole value set in one
request where a where yields it one probe at a time.
Text buckets follow your database's collation. MySQL's default collation
ignores case, so US and us are one bucket there and two on PostgreSQL and
SQLite. Nextly does not override this: your filters use the same collation, so
grouping and filtering agree with each other — a bucket's count always matches
what a where on that value returns. The trade-off is that the same content
can chart differently on a different database.
Returns: GroupResult ({ buckets: { value: string | null; count: number }[]; truncated: boolean }).
timeseries
Count documents per interval of a recent window, for a trend line.
const { points, interval } = await nextly.timeseries({
collection: "orders",
dateField: "createdAt",
interval: "day",
intervals: 30,
});
// [{ start: "2026-03-02T00:00:00.000Z", count: 4 }, ...]interval is one of hour, day, week, month or year. Weeks start on
Monday, following ISO-8601.
intervals is how many intervals the window covers, ending with the current
one; it defaults to 30 and is capped at 366. The window bounds the read, not
just the answer: its oldest start becomes a lower bound on the date column, so a
long history is never scanned to draw a short chart.
An interval with no documents comes back with a count of 0 rather than being
left out. A GROUP BY cannot report a bucket it never grouped, so the quiet day
would simply be missing — and a line drawn through the gap reads as steady
activity rather than none. A zero here always means "no documents", never
"unknown": every interval in the window was read.
Buckets are computed in UTC. A document's interval is decided by the UTC
wall clock its date was stored with, so the same document lands in the same
interval whoever is looking. Format start in the viewer's own timezone when
you draw it.
dateField must be a field stored as a date, and it is refused on the same
terms a group key is — a field carrying a read rule, or the owner column, cannot
place documents on a timeline either.
Returns: TimeseriesResult ({ points: { start: string; count: number }[]; interval: TimeseriesInterval }).
bulkDelete
Delete many documents by IDs with first-class partial-success semantics. Use this when you need the per-item failure detail that delete({ where }) cannot give you.
const result = await nextly.bulkDelete({
collection: "posts",
ids: ["post-1", "post-2", "post-3"],
});
result.successes; // [{ id: "post-1" }, { id: "post-2" }]
result.failures; // [{ id: "post-3", code: "NOT_FOUND", message: "..." }]
result.total; // 3
result.successCount; // 2
result.failedCount; // 1Returns: BulkOperationResult<T> — { successes, failures, total, successCount, failedCount }. The HTTP request always returns 200 unless the input itself is malformed; per-item errors live in the body's failures array.
duplicate
Duplicate a document. Optionally pass field overrides — overridden fields replace the copied values.
const copy = await nextly.duplicate({
collection: "posts",
id: "abc-123",
overrides: { title: "Copy of Original Post" },
});
copy.message; // "Posts duplicated."
copy.item.id; // new idReturns: MutationResult<T> ({ message, item }).
Singles
Singles are single-document entities for site-wide settings (navigation, footer, theme, etc.). The Direct API method names still use the legacy "global" naming.
findSingle
Get a single's content by slug. Returns the bare document.
const settings = await nextly.findSingle({
slug: "site-settings",
depth: 1,
});
console.log(settings.siteName);| Argument | Type | Default | Description |
|---|---|---|---|
slug | string | required | Single slug. |
depth | number | 0 | Relationship population depth. |
select | Record<string, boolean> | — | Whitelist or blacklist fields. |
populate | Record<string, boolean | PopulateOptions> | — | Per-field population control. |
richTextFormat | "json" | "html" | "both" | "json" | Rich text output format. |
overrideAccess | boolean | true | Bypass access control. |
user | { id, role? } | — | Required when overrideAccess: false. |
Returns: the bare single document (T).
updateSingle
Update a single's content by slug. Returns the same { message, item } envelope
the collection mutations return, so every mutation reports its outcome the same
way.
const { item, warnings } = await nextly.updateSingle({
slug: "site-settings",
data: {
siteName: "My Awesome Site",
maintenanceMode: false,
},
});
item.siteName; // "My Awesome Site"
if (warnings) {
// The document was saved. A hook that runs after the write committed threw,
// so a side effect did not happen — each warning names the phase, the single
// and the error code.
}Returns: MutationResult<T> — { message, item, warnings? }.
warnings is present only when a post-commit hook failed. The write itself
succeeded: an afterChange hook cannot un-save the document, so the operation
reports success and the failed side effect is reported beside it rather than
being turned into an error that would invite a retry.
findSingles
List the actual content for every registered single. Returns the legacy list shape { docs, totalDocs, limit, offset } (this method has not yet been migrated to the canonical { items, meta } envelope).
const result = await nextly.findSingles();
result.docs.forEach(({ slug, label, data }) => {
console.log(`${label} (${slug}):`, data);
});
// Filter by source
const codeSingles = await nextly.findSingles({ source: "code" });
// Search by name or slug
const settingsLike = await nextly.findSingles({ search: "settings" });| Argument | Type | Default | Description |
|---|---|---|---|
source | "code" | "ui" | "built-in" | — | Filter by definition source. |
migrationStatus | "synced" | "pending" | "generated" | "applied" | "failed" | — | Filter by current schema state. |
locked | boolean | — | Return only locked or only unlocked singles. |
search | string | — | Substring match on slug or label. |
limit | number | — | Max results. |
offset | number | 0 | Skip count. |
Returns: SingleListResult — { docs: SingleEntry[], totalDocs, limit, offset }. Each SingleEntry is { slug, label, data }.
The nextly.email.* namespace sends transactional email through whichever provider is configured (database-default first, code-config fallback second).
email.send
Send a raw HTML email.
const result = await nextly.email.send({
to: "user@example.com",
subject: "Hello",
html: "<p>Welcome to Nextly</p>",
text: "Welcome to Nextly",
});
result.success; // boolean
result.messageId; // provider-assigned ID (when successful)
result.error; // error string (when failed)| Argument | Type | Description |
|---|---|---|
to | string | string[] | Recipient(s). |
subject | string | Subject line. |
html | string | HTML body. |
text | string | Plain-text fallback. |
from | string | Override the sender. |
providerId | string | Use a specific provider instead of the default. |
attachments | EmailAttachmentInput[] | Media-library attachments. |
Returns: SendEmailResult — { success, messageId?, error? }.
email.sendWithTemplate
Send an email rendered from a database template.
const result = await nextly.email.sendWithTemplate({
to: "user@example.com",
template: "welcome-email",
variables: { name: "Alice", productName: "Nextly" },
});| Argument | Type | Description |
|---|---|---|
to | string | string[] | Recipient(s). |
template | string | Template slug. |
variables | Record<string, string> | Substituted into the template. |
from | string | Override the sender. |
providerId | string | Use a specific provider. |
attachments | EmailAttachmentInput[] | Merged with the template's defaults at send time. |
Returns: SendEmailResult — { success, messageId?, error? }.
Auth
The Direct API exposes auth operations as methods on the root nextly object. They speak directly to the auth service and are useful in server actions and admin scripts.
login
Needs the release after 0.0.2-alpha.66: the account checks below,
EMAIL_NOT_VERIFIED, and the token ending early. On 0.0.2-alpha.66,nextly.login()checks only the password, so a locked, deactivated or unverified account still gets a token, and that token lasts until it expires.
Verify credentials and issue a signed JWT. The token expiry is 30 days, and it ends earlier, within ten seconds, if the account is deactivated or an administrator sets its password (Authentication). The Direct API does not set cookies — that responsibility belongs to your route handler / server action wrapper.
const result = await nextly.login({
email: "user@example.com",
password: "secure-password",
});
result.user; // { id, email, name, image, ... }
result.token; // string (JWT)
result.exp; // unix timestampReturns: LoginResult — { user, token, exp }. Throws NextlyError (AUTH_INVALID_CREDENTIALS) on bad credentials.
The credentials are checked the way the login endpoint checks them: a wrong password counts toward the account lockout, and an account that is locked, deactivated, or must replace an admin-set password is refused with the same AUTH_INVALID_CREDENTIALS error, so the caller cannot tell which applied. An account that has not verified its email is refused with EMAIL_NOT_VERIFIED once the password is proven correct, unless an administrator deactivated it, and with AUTH_INVALID_CREDENTIALS otherwise.
login is for trusted server code. It runs no plugin login hooks and no second-factor challenge, so do not use it to sign a person in on their behalf; send them through the admin login page, which does both.
logout
No-op for the Direct API — there is no session state to clear at this layer. Clear your auth cookies in the route handler that wraps the call.
await nextly.logout();register
Register a new user with email + password.
const result = await nextly.register({
email: "newuser@example.com",
password: "secure-password",
name: "New User",
});
result.user; // { id, email, name }Returns: { user }. Throws NextlyError (VALIDATION_ERROR, CONFLICT) on validation failure or duplicate email.
me / updateMe
Both require an explicit user.id because the Direct API has no implicit session.
const profile = await nextly.me({ user: { id: "user-123" } });
profile.user; // current user record (or null)
const updated = await nextly.updateMe({
user: { id: "user-123" },
data: { name: "New Display Name" },
});Returns: AuthResult — { user, permissions? }. Throws NextlyError (NOT_FOUND) if the user does not exist.
changePassword
await nextly.changePassword({
user: { id: "user-123" },
currentPassword: "old",
newPassword: "new-secure-password",
});Returns: { success: true }. Throws NextlyError (AUTH_INVALID_CREDENTIALS) when the current password is wrong.
forgotPassword
Initiate a password reset. Always returns success: true — even when the email is unknown — to avoid user enumeration. Pass disableEmail: true to receive the raw token instead of the runtime mailing it for you.
const result = await nextly.forgotPassword({
email: "user@example.com",
});
// With disableEmail
const tokenResult = await nextly.forgotPassword({
email: "user@example.com",
disableEmail: true,
});
console.log(tokenResult.token); // raw token, you mail it yourselfReturns: { success: true, token? }.
resetPassword
Consume a reset token and set a new password.
const result = await nextly.resetPassword({
token: "reset-token-from-email",
password: "new-password",
});
result.email; // address whose password was resetReturns: { success: true, email? }. Throws NextlyError (VALIDATION_ERROR / NOT_FOUND) when the token is invalid or expired.
verifyEmail
await nextly.verifyEmail({ token: "verification-token" });Returns: { success: true, email? }.
Other namespaces
The Direct API also exposes typed namespaces for users, media, forms, field groups, email providers + templates, RBAC, API keys, and access checks. They follow the same find / findByID / create / update / delete shape and use the canonical ListResult<T> and MutationResult<T> envelopes:
| Namespace | Purpose | Key methods |
|---|---|---|
nextly.users | User records | find, findOne, findByID, create, update, delete |
nextly.media | Media library | upload, find, findByID, update, delete, bulkDelete, folders.list, folders.create |
nextly.forms | Form-builder forms | find, findBySlug, submit, submissions |
nextly.fieldGroups | Reusable field group definitions | find, findBySlug, create, update, delete |
nextly.emailProviders | Provider CRUD | find, findByID, create, update, delete, setDefault, test |
nextly.emailTemplates | Template CRUD + preview + layout | find, findByID, findBySlug, create, update, delete, preview, getLayout, updateLayout |
nextly.userFields | Custom user field definitions | find, findByID, create, update, delete, reorder |
nextly.roles | RBAC roles | find, findByID, create, update, delete, getPermissions, setPermissions |
nextly.permissions | RBAC permissions | find, findByID, create, delete |
nextly.access | Permission checks | check, checkApiKey |
nextly.apiKeys | API keys | list, findByID, create, update, revoke |
The full TypeScript surface is generated from packages/nextly/src/direct-api/namespaces/.
Querying
where accepts a structured filter object. Conditions on the same level are AND-ed; use and and or for explicit groupings.
Operators
| Operator | Description | Example |
|---|---|---|
equals | Exact match | { status: { equals: "published" } } |
not_equals | Not equal | { status: { not_equals: "draft" } } |
greater_than | > | { price: { greater_than: 100 } } |
greater_than_equal | >= | { price: { greater_than_equal: 100 } } |
less_than | < | { price: { less_than: 50 } } |
less_than_equal | <= | { price: { less_than_equal: 50 } } |
like | SQL LIKE (case-sensitive) | { title: { like: "%hello%" } } |
contains | Substring, ignoring case on every database; % and _ are matched as typed | { title: { contains: "hello" } } |
search | Alias for contains | { title: { search: "hello" } } |
in | Value in array | { status: { in: ["draft", "published"] } } |
not_in | Value not in array | { status: { not_in: ["archived"] } } |
exists | Field is non-null when true, null when false | { image: { exists: true } } |
Point fields also support near and within geo operators; these are evaluated in the application layer rather than pushed to the database.
Combining conditions
Same-level keys are implicitly AND-ed:
where: {
status: { equals: "published" },
category: { equals: "news" },
}Use and or or to express more complex shapes:
const posts = await nextly.find({
collection: "posts",
where: {
and: [
{ status: { equals: "published" } },
{
or: [
{ category: { equals: "news" } },
{ category: { equals: "featured" } },
],
},
{ publishedAt: { less_than: new Date().toISOString() } },
],
},
});Sorting
Pass a single field name. Prefix with - for descending:
await nextly.find({ collection: "posts", sort: "-publishedAt" });
await nextly.find({ collection: "posts", sort: "title" });Selecting fields
Use select to whitelist or blacklist fields on the response. true includes, false excludes:
await nextly.find({
collection: "posts",
select: { title: true, slug: true, publishedAt: true },
});Depth
depth controls how many levels of relationship and upload fields to populate:
0(default): return relationship IDs as strings.1: populate direct relationships with their full document.2+: populate nested relationships up to the requested depth.
const posts = await nextly.find({ collection: "posts", depth: 0 });
posts.items[0].author; // "user-abc"
const populated = await nextly.find({ collection: "posts", depth: 1 });
populated.items[0].author; // { id: "user-abc", name: "John", ... }For per-field control, use populate:
await nextly.find({
collection: "posts",
depth: 1,
populate: {
author: { select: { name: true, email: true } },
category: false, // skip population for this field
},
});Rich text format
Rich text fields are stored as Lexical JSON. Pick the output shape with richTextFormat:
// JSON (default) — Lexical editor state
const a = await nextly.find({ collection: "posts", richTextFormat: "json" });
// HTML — rendered string
const b = await nextly.find({ collection: "posts", richTextFormat: "html" });
b.items[0].content; // "<p>Hello world</p>"
// Both — { json, html }
const c = await nextly.find({ collection: "posts", richTextFormat: "both" });
c.items[0].content; // { json: {...}, html: "<p>Hello world</p>" }Errors
Every Direct API failure throws a single NextlyError class. The error carries a public payload (safe to surface) and a log payload (for the server logger). Type guards let you narrow without instanceof (which can fail at package boundaries):
import { NextlyError } from "nextly";
async function getPost() {
try {
const post = await nextly.findByID({ collection: "posts", id: "missing" });
return post;
} catch (err) {
if (NextlyError.isNotFound(err)) {
// 404
return null;
}
if (NextlyError.isValidation(err)) {
// 400 — err.publicData.errors carries [{ path, code, message }]
console.log(err.publicData);
throw err;
}
if (NextlyError.isAuthRequired(err)) {
// 401
}
if (NextlyError.isForbidden(err)) {
// 403
}
throw err;
}
}Available type guards: NextlyError.is, NextlyError.isCode, NextlyError.isNotFound, NextlyError.isValidation, NextlyError.isAuthRequired, NextlyError.isForbidden, NextlyError.isConflict, NextlyError.isRateLimited.
Common code values you will see: VALIDATION_ERROR (400), INVALID_INPUT (422), AUTH_REQUIRED (401), AUTH_INVALID_CREDENTIALS (401), EMAIL_NOT_VERIFIED (403), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409), DUPLICATE (409), PAYLOAD_TOO_LARGE (413), RATE_LIMITED (429), DATABASE_ERROR (500), INTERNAL_ERROR (500), SERVICE_UNAVAILABLE (503).
The same NextlyError is what the route handler serialises into the HTTP error envelope ({ error: { code, message, requestId, ... } }) — see the REST API error response section for the wire format.
Example: blog page
import { getNextly } from "nextly";
import config from "../../nextly.config";
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page } = await searchParams;
const nextly = await getNextly({ config });
const result = await nextly.find({
collection: "posts",
where: { status: { equals: "published" } },
sort: "-publishedAt",
limit: 12,
page: Number(page) || 1,
depth: 1,
richTextFormat: "html",
});
return (
<main>
<h1>Blog</h1>
<div className="grid grid-cols-3 gap-6">
{result.items.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
))}
</div>
{result.meta.hasNext && (
<a href={`/blog?page=${result.meta.page + 1}`}>Next page</a>
)}
</main>
);
}Next steps
- REST API — HTTP endpoints for browser code, mobile clients, and third-party integrations.
- Collections — define the content types the Direct API operates on.
- Authentication — RBAC and access control for API operations.
- Configuration → typescript.outputFile — set up generated types for full inference.
Extending the schema
Add tables and indexes of your own with a dialect-neutral DSL, ordered hooks, and a per-dialect escape hatch that refuses what migrations cannot carry.
REST API
HTTP REST API for browser code, mobile apps, and third-party integrations. Served under /admin/api/ by a single Next.js catch-all route handler.