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

API Reference

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.

The REST API exposes the same operations as the Direct API over HTTP. Every collection, single, user, role, permission, media file, email template, API key, and more is reachable through standard HTTP methods. Use it whenever your code does not run in a trusted Node.js context — browser apps, mobile clients, third-party services.

Setup

Create a single Next.js catch-all route handler that delegates to Nextly. The dynamic handlers cover collections, singles, auth, dashboard, schema, email, RBAC, media, API keys, and the rest of the surface:

app/api/[[...params]]/route.ts
import { createDynamicHandlers } from "nextly/runtime";
import nextlyConfig from "../../../nextly.config";

const handlers = createDynamicHandlers({ config: nextlyConfig });

export const { GET, POST, PUT, PATCH, DELETE, OPTIONS } = handlers;

The health endpoint and media-streaming endpoint can be exported at their own routes if you want to opt out of catch-all coverage:

app/api/health/route.ts
export { GET, HEAD } from "nextly/api/health";

All API endpoints are served under /api/.

Authentication

Two authentication mechanisms are supported.

When a user logs in through /api/auth/login (or completes the /admin/setup wizard), the runtime sets two HTTP-only cookies:

  • __nextly_access_token — short-lived JWT used for every authenticated request.
  • __nextly_refresh_token — long-lived token consumed only by /api/auth/refresh.

Browsers send these automatically. There is no header for you to set; just call fetch with credentials: "include" if your frontend lives on a different origin.

API key (server-to-server)

Send the API key as a Bearer token:

curl https://example.com/api/collections/posts/entries \
  -H "Authorization: Bearer sk_live_abc123..."

API keys are managed via POST /api/api-keys (admin UI). There is no X-API-Key header — Authorization: Bearer <key> is the only supported form.

Response envelopes

All responses follow the canonical Phase 4 shapes documented in packages/nextly/src/api/response-shapes.ts. Content-Type is always application/json for success responses and application/problem+json for errors. Every response carries an X-Request-Id header you can quote when filing bugs.

List

Paginated reads return { items, meta }:

{
  "items": [ /* T[] */ ],
  "meta": {
    "total": 42,
    "page": 1,
    "limit": 10,
    "totalPages": 5,
    "hasNext": true,
    "hasPrev": false
  }
}

Read

Single-document reads return the bare document:

{
  "id": "abc-123",
  "title": "Hello world",
  "status": "published",
  "createdAt": "2026-01-15T10:30:00.000Z",
  "updatedAt": "2026-01-15T10:30:00.000Z"
}

Mutation

Create / update / delete-by-id return { message, item }:

{
  "message": "Posts updated.",
  "item": {
    "id": "abc-123",
    "title": "Hello world",
    "status": "published"
  }
}

Action

Non-CRUD writes (login, forgot-password, verify-email, ...) return { message, ...result } — a server-authored toast plus any operation-specific fields:

{
  "message": "Logged in.",
  "user": { "id": "user-1", "email": "user@example.com" },
  "token": "eyJ..."
}

Bulk

Bulk-by-id and bulk-upload responses return { message, items, errors }. The HTTP status is always 200 unless the request itself is malformed; per-item failures are first-class data in errors:

{
  "message": "Bulk delete completed.",
  "items": [ { "id": "post-1" }, { "id": "post-2" } ],
  "errors": [
    { "id": "post-3", "code": "NOT_FOUND", "message": "Resource not found." }
  ]
}

Errors

Failures emit application/problem+json with status from NextlyError.statusCode and a single envelope:

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Resource not found.",
    "requestId": "req_nlwxzwtnlvqsu3lc"
  }
}

When the failure is a validation error, error.data carries field-level details:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed.",
    "requestId": "req_xxx",
    "data": {
      "errors": [
        { "path": "email", "code": "INVALID_EMAIL", "message": "Email is invalid." }
      ]
    }
  }
}

For rate-limited responses (code: "RATE_LIMITED", status 429) the runtime also sets Retry-After from error.data.retryAfterSeconds.

StatusCommon codesWhen
400VALIDATION_ERRORBody or query failed validation.
401AUTH_REQUIRED, AUTH_INVALID_CREDENTIALSMissing or bad credentials.
403FORBIDDENAuthenticated but lacks permission.
404NOT_FOUNDResource does not exist.
409CONFLICT, DUPLICATEConcurrent edit, duplicate email, etc.
413PAYLOAD_TOO_LARGEUpload bigger than configured cap.
415UNSUPPORTED_MEDIA_TYPEWrong Content-Type.
422INVALID_INPUTSemantic validation failed.
429RATE_LIMITEDPer-IP or per-key rate limit. Retry-After is set.
500INTERNAL_ERROR, DATABASE_ERRORServer-side failure.
503SERVICE_UNAVAILABLEHealth probe / dependency outage.

Endpoint inventory

The catch-all dispatches every URL under /api/ to one of these endpoint groups. Auth column shows whether a session cookie or API key is required.

MethodPathDescriptionAuth
GET / HEAD/api/healthLiveness + DB probe.Public
GET/api/auth/setup-statusWhether the setup wizard has run.Public
POST/api/auth/setupRun the first-boot setup wizard (creates super admin).Public, one-shot
GET/api/auth/sessionCurrent session info from the cookie.Cookie
GET/api/auth/csrfCSRF token (when applicable).Public
POST/api/auth/loginEmail + password login. Sets cookies.Public
POST/api/auth/logoutClear cookies.Cookie
POST/api/auth/refreshRotate the access token.Cookie
POST/api/auth/registerPublic registration.Public
POST/api/auth/forgot-passwordSend password-reset email.Public
POST/api/auth/reset-passwordConsume reset token and set new password.Public
POST/api/auth/verify-emailConsume email-verification token.Public
POST/api/auth/verify-email/resendResend the verification email.Public
PATCH/api/auth/change-passwordChange current user's password.Cookie
GET/api/meCurrent user profile.Cookie / Bearer
PATCH/api/meUpdate current user profile.Cookie / Bearer
GET/api/me/permissionsResolved permissions for current user.Cookie / Bearer
GET/api/usersList users.read-users
POST/api/usersCreate user.create-users
GET/api/users/{id}Get user.read-users
PATCH/api/users/{id}Update user.update-users
DELETE/api/users/{id}Delete user.delete-users
PATCH/api/users/{id}/passwordSet password hash directly.update-users
GET/api/users/{id}/accountsList linked accounts.read-users
DELETE/api/users/{id}/accounts/{provider}/{providerAccountId}Unlink account.update-users
GET/api/users/{id}/rolesList user roles.read-roles
POST/api/users/{id}/rolesAssign role to user.update-users
DELETE/api/users/{id}/roles/{roleId}Unassign role.update-users
GET/api/rolesList roles.read-roles
POST/api/rolesCreate role.create-roles
GET/api/roles/{id}Get role.read-roles
PATCH/api/roles/{id}Update role.update-roles
DELETE/api/roles/{id}Delete role.delete-roles
GET/api/roles/{id}/permissionsList role permissions.read-roles or read-permissions
POST/api/roles/{id}/permissionsAdd permission to role.update-roles
PATCH/api/roles/{id}/permissionsReplace all role permissions.update-roles
DELETE/api/roles/{id}/permissions/{permissionId}Remove permission from role.update-roles
GET/api/roles/{id}/childrenList child roles (inheritance).read-roles
POST/api/roles/{id}/childrenAdd child role.update-roles
DELETE/api/roles/{id}/children/{childId}Remove child role.update-roles
GET/api/roles/{id}/parentsList ancestor roles.read-roles
GET/api/permissionsList permissions.read-permissions (or read-roles)
POST/api/permissionsCreate permission.create-permissions
GET/api/permissions/{id}Get permission.read-permissions (or read-roles)
PATCH/api/permissions/{id}Update permission.update-permissions
DELETE/api/permissions/{id}Delete permission.delete-permissions
GET/api/collectionsList collection definitions.Authenticated
POST/api/collectionsCreate collection (Schema Builder).manage-settings
GET/api/collections/{slug}Get collection definition.read-{slug}
PATCH/api/collections/{slug}Update collection definition.manage-settings
DELETE/api/collections/{slug}Delete collection.manage-settings
GET/api/collections/schema/{slug}Get enriched schema (with component fields).read-{slug}
POST/api/collections/schema/{slug}/previewDry-run schema diff.manage-settings
POST/api/collections/schema/{slug}/applyApply confirmed schema changes.manage-settings
GET/api/collections/{slug}/entriesList entries.read-{slug}
POST/api/collections/{slug}/entriesCreate entry.create-{slug}
GET/api/collections/{slug}/entries/{id}Get entry by ID.read-{slug}
PATCH/api/collections/{slug}/entries/{id}Update entry.update-{slug}
DELETE/api/collections/{slug}/entries/{id}Delete entry.delete-{slug}
GET/api/collections/{slug}/entries/countCount entries matching a query.read-{slug}
POST/api/collections/{slug}/entries/{id}/duplicateDuplicate entry.create-{slug}
POST/api/collections/{slug}/entries/bulk-deleteBulk delete by IDs.delete-{slug}
POST/api/collections/{slug}/entries/bulk-updateBulk update by IDs.update-{slug}
PATCH/api/collections/{slug}/entriesBulk update by query.update-{slug}
GET/api/singlesList singles.Authenticated
POST/api/singlesCreate single (Schema Builder).manage-settings
GET/api/singles/{slug}Get single content.read-{slug}
PATCH/api/singles/{slug}Update single content.update-{slug}
DELETE/api/singles/{slug}Delete UI-created single.manage-settings
GET/api/singles/{slug}/schemaGet single schema.read-{slug}
PATCH/api/singles/{slug}/schemaUpdate single schema (Schema Builder).manage-settings
POST/api/singles/schema/{slug}/previewDry-run single schema diff.manage-settings
POST/api/singles/schema/{slug}/applyApply single schema changes.manage-settings
GET/api/componentsList component definitions.Public
POST/api/componentsCreate component.manage-settings
GET/api/components/{slug}Get component.Authenticated
PATCH/api/components/{slug}Update component.manage-settings
DELETE/api/components/{slug}Delete component.manage-settings
POST/api/components/schema/{slug}/previewDry-run component schema diff.manage-settings
POST/api/components/schema/{slug}/applyApply component schema changes.manage-settings
GET/api/formsList published forms.Public
GET/api/forms/{slug}Get form by slug.Public
POST/api/forms/{slug}/submitSubmit a form.Public
POST/api/email/sendSend a raw email.Authenticated
POST/api/email/send-with-templateSend a templated email.Authenticated
GET/api/email-providersList providers.read-email-providers
POST/api/email-providersCreate provider.create-email-providers
GET/api/email-providers/{id}Get provider.read-email-providers
PATCH/api/email-providers/{id}Update provider.update-email-providers
DELETE/api/email-providers/{id}Delete provider.delete-email-providers
PATCH/api/email-providers/{id}/defaultSet default provider.update-email-providers
POST/api/email-providers/{id}/testSend a test email.read-email-providers
GET/api/email-templatesList templates.read-email-templates
POST/api/email-templatesCreate template.create-email-templates
GET/api/email-templates/layoutGet shared header/footer.read-email-templates
PATCH/api/email-templates/layoutUpdate shared header/footer.update-email-templates
GET/api/email-templates/{id}Get template.read-email-templates
PATCH/api/email-templates/{id}Update template.update-email-templates
DELETE/api/email-templates/{id}Delete template.delete-email-templates
POST/api/email-templates/{id}/previewRender template with sample data.read-email-templates
GET/api/user-fieldsList user field definitions.read-settings
POST/api/user-fieldsCreate user field.create-settings
PATCH/api/user-fields/reorderReorder user fields.update-settings
GET/api/user-fields/{id}Get user field.read-settings
PATCH/api/user-fields/{id}Update user field.update-settings
DELETE/api/user-fields/{id}Delete user field.delete-settings
GET/api/api-keysList API keys for current user.Cookie (session-only)
POST/api/api-keysCreate API key.Cookie (session-only)
GET/api/api-keys/{id}Get API key metadata.Cookie (session-only)
PATCH/api/api-keys/{id}Update name/description.Cookie (session-only)
DELETE/api/api-keys/{id}Revoke API key.Cookie (session-only)
GET/api/dashboard/statsAdmin dashboard stats.Cookie
GET/api/dashboard/recent-entriesRecent activity.Cookie
GET/api/dashboard/activityActivity feed.Cookie
GET/api/schema/journalSchema migration journal (super-admin only).Cookie + super admin
GET / PATCH/api/general-settingsProject-wide settings.manage-settings
GET/api/image-sizesList image-size presets.Cookie
POST/api/image-sizesCreate image-size preset.manage-settings
PATCH/api/image-sizes/{id}Update preset.manage-settings
DELETE/api/image-sizes/{id}Delete preset.manage-settings

The slug-based permissions use the pattern {action}-{slug} so a posts collection requires read-posts, create-posts, etc.

Per-section detail

The endpoint inventory above is the complete list. The sections below cover the request/response specifics for the groups with non-trivial body shapes.

Health

curl -i http://localhost:3000/api/health
HTTP/1.1 200 OK
content-type: application/json
cache-control: public, max-age=60, stale-while-revalidate=30
x-request-id: req_xxx

{
  "ok": true,
  "version": "0.0.142",
  "uptime": 123,
  "timestamp": "2026-05-05T09:00:00.000Z",
  "database": { "ok": true, "dialect": "postgresql", "latency": 4 }
}

When the database probe fails the runtime emits the canonical error envelope at status 503:

{
  "error": {
    "code": "SERVICE_UNAVAILABLE",
    "message": "Service unavailable. Please try again later.",
    "requestId": "req_xxx"
  }
}

HEAD /api/health returns the same status, headers, and X-Request-Id with no body — useful for monitors that only need the status code.

Auth

# Login
curl -i -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"secure-password"}'

A successful login responds 200 with the action envelope { message, user, token, exp } and sets __nextly_access_token + __nextly_refresh_token cookies on the response. Browsers handle these automatically; for API clients you can ignore them and use the returned token as a Bearer token instead.

# Register
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"new@example.com","password":"secure-password","name":"New User"}'
# Forgot password
curl -X POST http://localhost:3000/api/auth/forgot-password \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com"}'

The forgot-password endpoint always returns 200 to avoid leaking which addresses exist. The reset email contains the token; consume it with POST /api/auth/reset-password.

# Reset password
curl -X POST http://localhost:3000/api/auth/reset-password \
  -H "Content-Type: application/json" \
  -d '{"token":"<token-from-email>","password":"new-secure-password"}'
# Change password (authenticated)
curl -X PATCH http://localhost:3000/api/auth/change-password \
  -H "Content-Type: application/json" \
  -b "__nextly_access_token=..." \
  -d '{"currentPassword":"old","newPassword":"new-secure"}'
# Verify email
curl -X POST http://localhost:3000/api/auth/verify-email \
  -H "Content-Type: application/json" \
  -d '{"token":"<token-from-email>"}'

The setup wizard is a one-shot path that runs only when no super admin exists:

curl http://localhost:3000/api/auth/setup-status
# {"ok":true,"completed":false}

curl -X POST http://localhost:3000/api/auth/setup \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","name":"Admin","password":"strong-password"}'

Re-running setup after the first super admin exists returns a 409.

Collection entries

List:

curl "http://localhost:3000/api/collections/posts/entries?limit=10&page=1&sort=-createdAt&where[status][equals]=published"
{
  "items": [
    { "id": "abc-123", "title": "Hello world", "status": "published" }
  ],
  "meta": {
    "total": 42,
    "page": 1,
    "limit": 10,
    "totalPages": 5,
    "hasNext": true,
    "hasPrev": false
  }
}

Get by ID:

curl "http://localhost:3000/api/collections/posts/entries/abc-123?depth=2"
{
  "id": "abc-123",
  "title": "Hello world",
  "author": { "id": "user-1", "name": "John Doe" },
  "createdAt": "2026-01-15T10:30:00.000Z",
  "updatedAt": "2026-01-15T10:30:00.000Z"
}

Create:

curl -X POST "http://localhost:3000/api/collections/posts/entries" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"title":"New Post","content":"Hello!","status":"draft"}'
{
  "message": "Posts created.",
  "item": { "id": "...", "title": "New Post", "status": "draft" }
}

Update (PATCH a single field set; everything not in the body is left alone):

curl -X PATCH "http://localhost:3000/api/collections/posts/entries/abc-123" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"status":"published"}'

Delete:

curl -X DELETE "http://localhost:3000/api/collections/posts/entries/abc-123" \
  -H "Authorization: Bearer sk_live_..."

Count:

curl "http://localhost:3000/api/collections/posts/entries/count?where[status][equals]=published"
# {"total": 42}

Duplicate:

curl -X POST "http://localhost:3000/api/collections/posts/entries/abc-123/duplicate" \
  -H "Authorization: Bearer sk_live_..."

Bulk delete (status 200, partial success in body):

curl -X POST "http://localhost:3000/api/collections/posts/entries/bulk-delete" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"ids":["post-1","post-2","post-3"]}'
{
  "message": "Bulk delete completed.",
  "items": [{ "id": "post-1" }, { "id": "post-2" }],
  "errors": [
    { "id": "post-3", "code": "NOT_FOUND", "message": "Resource not found." }
  ]
}

Bulk update by IDs:

curl -X POST "http://localhost:3000/api/collections/posts/entries/bulk-update" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"ids":["post-1","post-2"],"data":{"status":"archived"}}'

Bulk update by query (PATCH on the collection root with a where clause in the body):

curl -X PATCH "http://localhost:3000/api/collections/posts/entries" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"where":{"status":{"equals":"draft"}},"data":{"status":"archived"}}'

Singles

List metadata for every registered single:

curl http://localhost:3000/api/singles

Get the actual content:

curl http://localhost:3000/api/singles/site-settings

Update content:

curl -X PATCH http://localhost:3000/api/singles/site-settings \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"siteName":"My New Site","maintenanceMode":false}'

Email send

Both endpoints accept an authenticated request (cookie or API key). The body matches the Direct API arguments documented in Direct API → Email.

curl -X POST http://localhost:3000/api/email/send \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "to": "user@example.com",
    "subject": "Hello",
    "html": "<p>Welcome to Nextly</p>"
  }'

curl -X POST http://localhost:3000/api/email/send-with-template \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "to": "user@example.com",
    "template": "welcome-email",
    "variables": { "name": "Alice" }
  }'

Media

The media collection is just a normal collection (/api/collections/media/entries) with extra plumbing for binary uploads. The POST /api/collections/media/entries endpoint accepts multipart/form-data with the file under file and any other fields alongside.

Image-size presets are managed under /api/image-sizes. The regeneration-status and regenerate sub-routes return a "coming soon" placeholder today (see packages/nextly/src/routeHandler.ts).

API keys

/api/api-keys/* is session-only — these endpoints do not accept Bearer authentication, so an attacker with a stolen key cannot mint new keys with it. Manage keys from the admin UI or via cookie-authenticated requests.

curl http://localhost:3000/api/api-keys -b "__nextly_access_token=..."

POST /api/api-keys returns the new key value once at creation; it is hashed on the server and never displayed again.

Query parameters

These parameters are accepted by every list endpoint that delegates to the collection / single dispatcher.

where

Filter rows using bracket notation:

?where[status][equals]=published
?where[price][greater_than]=100
?where[title][contains]=hello
?where[category][in]=news,featured

Operators (verified against packages/nextly/src/domains/collections/query/query-operators.ts):

OperatorDescriptionQuery string example
equalsExact match.where[status][equals]=published
not_equalsNot equal.where[status][not_equals]=draft
greater_than>where[price][greater_than]=100
greater_than_equal>=where[price][greater_than_equal]=100
less_than<where[price][less_than]=50
less_than_equal<=where[price][less_than_equal]=50
likeSQL LIKE (case-sensitive).where[title][like]=%hello%
containsCase-insensitive substring (ILIKE).where[title][contains]=hello
searchAlias for contains.where[title][search]=hello
inValue in comma-separated list.where[status][in]=draft,published
not_inValue not in comma-separated list.where[status][not_in]=archived
existsField is non-null when true, null when false.where[image][exists]=true

For nested AND / OR you can either pass URL-encoded JSON via where=..., or use the index syntax:

?where[and][0][status][equals]=published&where[and][1][category][equals]=news

sort

Field name; prefix with - for descending.

?sort=-createdAt
?sort=title

Pagination

?limit=20&page=2

Maximum limit is 500 (anything higher is clamped). The response always includes the meta envelope with total, page, limit, totalPages, hasNext, and hasPrev.

depth

Relationship population depth. 0 returns only IDs; 1+ populates referenced documents.

?depth=0   # IDs only
?depth=1   # populate direct relationships
?depth=2   # populate one level deeper

select

Comma-separated whitelist of fields to include:

?select=id,title,publishedAt

richTextFormat

?richTextFormat=html       # rendered HTML
?richTextFormat=json       # Lexical JSON (default)
?richTextFormat=both       # { json, html }

Pagination

Page-based with the meta envelope. Use meta.hasNext to drive a "Next page" UI; do not assume a fixed totalPages because the underlying count can change between requests.

async function fetchPosts(page = 1) {
  const params = new URLSearchParams({
    limit: "10",
    page: String(page),
    sort: "-createdAt",
    "where[status][equals]": "published",
    depth: "1",
  });

  const response = await fetch(`/api/collections/posts/entries?${params}`, {
    credentials: "include",
  });
  const json = await response.json();
  return json; // { items, meta }
}

Security

The route handler ships with built-in defences:

  • Rate limiting on auth write endpoints (per IP) and global per-IP / per-key limits configured in nextly.config.ts.
  • CORS controlled by NEXTLY_ALLOWED_ORIGINS and the security config.
  • Cache headers (no-store) on every /api/auth/* response to keep tokens out of caches.
  • Super-admin protection so non-super-admins cannot grant the super-admin role.
  • Trusted proxy / forwarded-for filtering when trustProxy is on.

See Configuration and Authentication for the configurable surface.

Next steps

  • Direct API — server-side equivalent with no HTTP overhead.
  • Authentication — RBAC, API keys, and cookie configuration.
  • Collections — define the content types this API serves.
  • Configuration — security, rate-limit, and storage configuration.