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.
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";
const nextly = getNextly();
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() and nextly are equivalent — pick whichever reads better. Both throw if the runtime services have not yet been initialised (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.
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. |
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. |
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:
// By ID — returns MutationResult<{ id }>
const deleted = await nextly.delete({
collection: "posts",
id: "abc-123",
});
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" } },
});
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 }).
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 bare updated document.
const updated = await nextly.updateSingle({
slug: "site-settings",
data: {
siteName: "My Awesome Site",
maintenanceMode: false,
},
});Returns: T (the updated single document).
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
Verify credentials and issue a signed JWT. The token expiry is 30 days. 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.
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, components, 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.components | Reusable component 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 | Case-insensitive substring (ILIKE) | { 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";
try {
const post = await nextly.findByID({ collection: "posts", id: "missing" });
} 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), 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";
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page } = await searchParams;
const nextly = getNextly();
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.
Draft / Published Status
Enable a Draft / Published lifecycle on collections and singles, with side-by-side examples for code-first config and the Visual Schema Builder.
REST API
HTTP REST API for browser code, mobile apps, and third-party integrations. Mounted under /api/ via a single Next.js catch-all route handler.