# 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 `principal` option; `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](https://nextlyhq.com/docs/api-reference/rest-api.md) instead.

## Initialisation

Import `getNextly` (or the lazy `nextly` convenience object) from the runtime package:

```typescript title="app/posts/page.tsx"
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); // number
```

The module-level `nextly` export resolves the singleton lazily on each call, so it is safe to import at module scope:

```typescript
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:

```typescript
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](https://nextlyhq.com/docs/configuration/nextly-config.md) 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`:

```typescript
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](https://nextlyhq.com/docs/plugins/signed-in-people.md#let-a-person-read-and-write-your-collections).

## 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`](https://github.com/nextlyhq/nextly/blob/main/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 }`.

```typescript
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](#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.

```typescript
const post = await nextly.findByID({
  collection: "posts",
  id: "abc-123",
  depth: 2,
});
```

Pass `disableErrors: true` to get `null` instead of an exception:

```typescript
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 }`.

```typescript
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.

```typescript
// 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:

```typescript
// 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 }`.

```typescript
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.

```typescript
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.

> [!NOTE]
> **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.

```typescript
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.

> [!NOTE]
> **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.

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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).

```typescript
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 }`.

## 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.

```typescript
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.

```typescript
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](https://nextlyhq.com/docs/guides/authentication.md)). The Direct API does **not** set cookies — that responsibility belongs to your route handler / server action wrapper.

```typescript
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.

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.

```typescript
await nextly.logout();
```

### register

Register a new user with email + password.

```typescript
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.

```typescript
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

```typescript
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.

```typescript
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.

```typescript
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

```typescript
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/`](https://github.com/nextlyhq/nextly/tree/main/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:

```typescript
where: {
  status: { equals: "published" },
  category: { equals: "news" },
}
```

Use `and` or `or` to express more complex shapes:

```typescript
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:

```typescript
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:

```typescript
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.

```typescript
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`:

```typescript
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`:

```typescript
// 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):

```typescript
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](https://nextlyhq.com/docs/api-reference/rest-api.md#errors) for the wire format.

## Example: blog page

```typescript title="app/blog/page.tsx"
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](https://nextlyhq.com/docs/api-reference/rest-api.md) — HTTP endpoints for browser code, mobile clients, and third-party integrations.
* [Collections](https://nextlyhq.com/docs/configuration/collections.md) — define the content types the Direct API operates on.
* [Authentication](https://nextlyhq.com/docs/guides/authentication.md) — RBAC and access control for API operations.
* [Configuration → typescript.outputFile](https://nextlyhq.com/docs/configuration/nextly-config.md) — set up generated types for full inference.
