You're reading docs for Nextly Alpha. APIs may change between releases.

API Reference

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:

app/posts/page.tsx
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); // number

The 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
ArgumentTypeDefaultDescription
collectionstringrequiredCollection slug.
whereWhereFilterFilter conditions (see Querying).
limitnumber10Page size.
pagenumber1Page number, 1-indexed.
sortstringfield for ascending, -field for descending.
depthnumber0Relationship population depth.
selectRecord<string, boolean>Whitelist or blacklist fields.
populateRecord<string, boolean | PopulateOptions>Per-field population control.
paginationbooleantrueSet false to return all matching rows without paging.
richTextFormat"json" | "html" | "both""json"Output format for rich text fields.
overrideAccessbooleantrueBypass access control.
user{ id, role? }Required when overrideAccess: false.
contextRecord<string, unknown>Custom data forwarded to hooks via req.context.
disableErrorsbooleanfalseReturn 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);
}
ArgumentTypeDefaultDescription
collectionstringrequiredCollection slug.
idstringrequiredDocument ID.
depthnumber0Relationship population depth.
selectRecord<string, boolean>Whitelist or blacklist fields.
populateRecord<string, boolean | PopulateOptions>Per-field population control.
richTextFormat"json" | "html" | "both""json"Output format for rich text fields.
disableErrorsbooleanfalseReturn null instead of throwing.
overrideAccessbooleantrueBypass 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;
ArgumentTypeDefaultDescription
collectionstringrequiredCollection slug.
dataRecord<string, unknown>requiredDocument data.
draftbooleanfalseSave as draft.
disableVerificationEmailbooleanfalseSkip the verification email when creating users.
overrideAccessbooleantrueBypass access control.
user{ id, role? }Required when overrideAccess: false.
contextRecord<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" },
});
ArgumentTypeDefaultDescription
collectionstringrequiredCollection slug.
idstringDocument ID. Either id or where is required.
whereWhereFilterWhere clause for the by-query path.
dataRecord<string, unknown>requiredUpdate payload.
draftbooleanfalseAutosave as draft.
overrideAccessbooleantrueBypass 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);
ArgumentTypeDefaultDescription
collectionstringrequiredCollection slug.
idstringDocument ID. Either id or where is required.
whereWhereFilterWhere clause for the bulk-by-query path.
overrideAccessbooleantrueBypass 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;  // 1

Returns: 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 id

Returns: 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);
ArgumentTypeDefaultDescription
slugstringrequiredSingle slug.
depthnumber0Relationship population depth.
selectRecord<string, boolean>Whitelist or blacklist fields.
populateRecord<string, boolean | PopulateOptions>Per-field population control.
richTextFormat"json" | "html" | "both""json"Rich text output format.
overrideAccessbooleantrueBypass 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" });
ArgumentTypeDefaultDescription
source"code" | "ui" | "built-in"Filter by definition source.
migrationStatus"synced" | "pending" | "generated" | "applied" | "failed"Filter by current schema state.
lockedbooleanReturn only locked or only unlocked singles.
searchstringSubstring match on slug or label.
limitnumberMax results.
offsetnumber0Skip count.

Returns: SingleListResult{ docs: SingleEntry[], totalDocs, limit, offset }. Each SingleEntry is { slug, label, data }.

Email

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)
ArgumentTypeDescription
tostring | string[]Recipient(s).
subjectstringSubject line.
htmlstringHTML body.
textstringPlain-text fallback.
fromstringOverride the sender.
providerIdstringUse a specific provider instead of the default.
attachmentsEmailAttachmentInput[]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" },
});
ArgumentTypeDescription
tostring | string[]Recipient(s).
templatestringTemplate slug.
variablesRecord<string, string>Substituted into the template.
fromstringOverride the sender.
providerIdstringUse a specific provider.
attachmentsEmailAttachmentInput[]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 timestamp

Returns: 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 yourself

Returns: { 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 reset

Returns: { 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:

NamespacePurposeKey methods
nextly.usersUser recordsfind, findOne, findByID, create, update, delete
nextly.mediaMedia libraryupload, find, findByID, update, delete, bulkDelete, folders.list, folders.create
nextly.formsForm-builder formsfind, findBySlug, submit, submissions
nextly.componentsReusable component definitionsfind, findBySlug, create, update, delete
nextly.emailProvidersProvider CRUDfind, findByID, create, update, delete, setDefault, test
nextly.emailTemplatesTemplate CRUD + preview + layoutfind, findByID, findBySlug, create, update, delete, preview, getLayout, updateLayout
nextly.userFieldsCustom user field definitionsfind, findByID, create, update, delete, reorder
nextly.rolesRBAC rolesfind, findByID, create, update, delete, getPermissions, setPermissions
nextly.permissionsRBAC permissionsfind, findByID, create, delete
nextly.accessPermission checkscheck, checkApiKey
nextly.apiKeysAPI keyslist, 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

OperatorDescriptionExample
equalsExact match{ status: { equals: "published" } }
not_equalsNot 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 } }
likeSQL LIKE (case-sensitive){ title: { like: "%hello%" } }
containsCase-insensitive substring (ILIKE){ title: { contains: "hello" } }
searchAlias for contains{ title: { search: "hello" } }
inValue in array{ status: { in: ["draft", "published"] } }
not_inValue not in array{ status: { not_in: ["archived"] } }
existsField 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

app/blog/page.tsx
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