Nextly Config Reference
Complete reference for every option accepted by defineConfig() in nextly.config.ts.
The nextly.config.ts file is the central configuration for your Nextly application. The defineConfig() function validates your config, applies defaults, and returns a sanitized SanitizedNextlyConfig consumed by the rest of the runtime.
Source of truth: the
NextlyConfiginterface inpackages/nextly/src/shared/types/config.tsdefines the shape this page documents. ThedefineConfig()helper inpackages/nextly/src/collections/config/define-config.tswires it up.
Full example
import { defineConfig } from "nextly";
import { s3Storage } from "@nextlyhq/storage-s3";
import Posts from "./src/collections/posts";
import Media from "./src/collections/media";
import SiteSettings from "./src/singles/site-settings";
import Header from "./src/singles/header";
import Footer from "./src/singles/footer";
import { Seo, Hero } from "./src/field-groups";
export default defineConfig({
// Content model
collections: [Posts, Media],
singles: [SiteSettings, Header, Footer],
fieldGroups: [Seo, Hero],
// Built-in user model extensions
users: {
fields: [
// imported from "nextly"
// text({ name: "company", label: "Company" }),
],
},
// Output paths
typescript: {
outputFile: "./src/types/generated/nextly-types.ts",
declare: true,
},
db: {
schemasDir: "./src/db/schemas/collections",
migrationsDir: "./src/db/migrations",
},
// Cloud storage (optional — defaults to local disk under ./public/uploads/)
storage: [
s3Storage({
bucket: process.env.S3_BUCKET!,
region: process.env.AWS_REGION!,
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
collections: {
media: true,
},
}),
],
// Security
security: {
cors: {
origin: ["https://example.com"],
credentials: true,
},
sanitization: { enabled: true },
},
// Rate limiting (enabled by default — opt out with enabled: false)
rateLimit: {
enabled: true,
readLimit: 100,
writeLimit: 30,
windowMs: 60_000,
},
// Per-API-key rate limit
apiKeys: {
rateLimit: { requestsPerHour: 1000, windowMs: 3_600_000 },
},
// Auth opt-in flags
auth: {
revealRegistrationConflict: false,
},
// Email
email: {
providerConfig: {
provider: "resend",
apiKey: process.env.RESEND_API_KEY!,
},
from: "My App <noreply@example.com>",
baseUrl: "https://example.com",
},
// Admin panel branding & plugin overrides
admin: {
branding: {
logoUrl: "/logo.svg",
logoText: "My App",
favicon: "/favicon.ico",
colors: {
primary: "#6366f1",
accent: "#f59e0b",
},
},
},
});Top-level options
| Option | Type | Default | Description |
|---|---|---|---|
collections | CollectionConfig[] | [] | Content types with multiple entries. See Collections. |
singles | SingleConfig[] | [] | Single-document content (site settings, header, etc.). See Singles. |
fieldGroups | FieldGroupConfig[] | [] | Reusable field groups. See Field Groups. |
users | UserConfig | undefined | Extend the built-in user model with custom fields. |
typescript | TypeScriptConfig | See below | Generated types path and module augmentation. |
db | DatabaseConfig | See below | Where Drizzle schema and migration files are written. |
rateLimit | RateLimitingConfig | Enabled (100 read / 30 write per minute) | Global API rate limiting. |
apiKeys | ApiKeysConfig | 1000 req/hour, 1-hour window | Per-API-key rate limit (applies when Authorization: Bearer nx_live_... is used). |
auth | AuthConfig | { revealRegistrationConflict: false } | Auth-related opt-in flags. |
storage | StoragePlugin[] | [] (local disk used) | Cloud storage plugins. Default storage is local disk under ./public/uploads/. |
plugins | PluginDefinition[] | [] | Plugins extending Nextly with collections, hooks, sidebar items, etc. |
email | EmailConfig | undefined | Email provider for password resets and notifications. |
security | SecurityConfig | Secure defaults | CORS, security headers, upload restrictions, sanitization, auth-rate-limit, body-size limits. |
admin | AdminConfig | undefined | Admin panel branding and plugin sidebar overrides. |
The next sections walk through each option.
collections
collections?: CollectionConfig[];Array of collection configurations created with defineCollection(). Each collection becomes a database table, an admin panel section, and a set of REST endpoints under /admin/api/collections/[slug]/entries.
import Posts from "./src/collections/posts";
import Media from "./src/collections/media";
export default defineConfig({
collections: [Posts, Media],
});See Collections for the full CollectionConfig shape.
singles
singles?: SingleConfig[];Array of single-document configurations created with defineSingle(). A single is an auto-created, non-deletable document (site settings, header, footer, homepage). Slugs must be unique across collections, singles, and field groups.
import SiteSettings from "./src/singles/site-settings";
export default defineConfig({
singles: [SiteSettings],
});See Singles for the full SingleConfig shape.
fieldGroups
fieldGroups?: FieldGroupConfig[];Array of reusable field structures created with defineFieldGroup(). Field groups are embedded inside collections, singles, or other field groups via the component field type. Slugs must be unique across collections, singles, and field groups.
import { Seo, Hero } from "./src/field-groups";
export default defineConfig({
fieldGroups: [Seo, Hero],
});See Field Groups.
users
users?: UserConfig;Extend the built-in user model with custom fields. Custom fields are stored in a separate user_ext table with proper typed columns. Only scalar field types are accepted: text, textarea, number, email, select, radio, checkbox, date.
import { text, select, option } from "nextly";
export default defineConfig({
users: {
fields: [
text({ name: "company", label: "Company" }),
select({
name: "department",
options: [option("Engineering"), option("Sales"), option("Marketing")],
}),
],
admin: {
// Show these custom columns in the user list
listFields: ["company", "department"],
// Override the form-section group label (default: "Additional Information")
group: "Profile",
},
},
});typescript
typescript?: { outputFile?: string; declare?: boolean };Controls TypeScript type generation.
| Property | Type | Default | Description |
|---|---|---|---|
outputFile | string | "./src/types/generated/nextly-types.ts" | Path the generator writes types to. |
declare | boolean | true | Whether to add declare module blocks for runtime type inference. |
Earlier versions wrote the default file as payload-types.ts. If your config doesn't set outputFile and that file is still there, the generator writes nextly-types.ts and turns payload-types.ts into a one-line re-export of it, so existing imports keep compiling. It tells you when it does. Change imports of payload-types to nextly-types, then delete the old file: a later release stops keeping it. A payload-types.ts that Nextly didn't generate is never changed. To keep the old name, set outputFile to it.
db
db?: { schemasDir?: string; migrationsDir?: string };Where Drizzle schema files and migration files are written.
| Property | Type | Default | Description |
|---|---|---|---|
schemasDir | string | "./src/db/schemas/collections" | Per-collection Drizzle schema files. |
migrationsDir | string | "./src/db/migrations" | Generated migration files. |
See Environment variables for the runtime database connection settings.
rateLimit
rateLimit?: RateLimitingConfig;Global API rate limiting. Enabled by default with 100 read / 30 write requests per minute. Opt out with rateLimit: { enabled: false }.
| Property | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Toggle rate limiting. |
readLimit | number | 100 | Max GET requests per window. |
writeLimit | number | 30 | Max POST/PATCH/PUT/DELETE per window. |
windowMs | number | 60_000 | Window in milliseconds. |
store | RateLimitStore | In-memory | Pluggable store; use a Redis store across multi-instance deployments. |
keyGenerator | (request: Request) => string | Client IP | Custom rate-limit key. |
skip | (request: Request) => boolean | Promise<boolean> | Skips /admin/api/* | Skip rate limiting for matching requests. |
collections | Record<string, { readLimit?: number; writeLimit?: number }> | None | Per-collection overrides. |
rateLimit: {
enabled: true,
readLimit: 100,
writeLimit: 30,
collections: {
media: { readLimit: 50, writeLimit: 10 },
logs: { readLimit: 200 },
},
}The default skip exempts every request under /admin/api/, so the admin's own navigation, which fires several requests per page, never trips the per-IP limit. A scaffolded app serves its whole REST API there, though, so by default these limits apply to none of its requests. To apply them, pass skip: () => false; the admin's requests then count against the same budget, so size readLimit for it.
apiKeys
apiKeys?: { rateLimit?: { requestsPerHour?: number; windowMs?: number } };Per-key rate limit applied when a request authenticates with an API key (Authorization: Bearer nx_live_...). Session-cookie requests use the global rateLimit block instead, subject to its skip, which by default exempts everything under /admin/api/. Omitting this block falls back to built-in defaults.
| Property | Type | Default | Description |
|---|---|---|---|
rateLimit.requestsPerHour | number | 1000 | Maximum requests per sliding window. Must be a positive integer. |
rateLimit.windowMs | number | 3_600_000 | Sliding window duration in milliseconds. |
defineConfig() throws at startup if either value is non-positive.
auth
auth?: { revealRegistrationConflict?: boolean };Auth-related opt-in flags. Today this block exposes a single flag.
| Property | Type | Default | Description |
|---|---|---|---|
revealRegistrationConflict | boolean | false | When false (default), /auth/register returns the same "we've sent a confirmation link" response whether or not the email exists, to prevent account enumeration. Set to true only if your threat model genuinely doesn't care about email enumeration (e.g. closed admin tool with controlled signup). |
Authentication is custom: email + password, JWT access/refresh tokens, sessions, API keys, RBAC. There is no OAuth.
storage
storage?: StoragePlugin[];Cloud storage plugins. The default is local disk — Nextly writes uploads to ./public/uploads/ if storage is empty or omitted.
Available adapters:
@nextlyhq/storage-s3— AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces@nextlyhq/storage-vercel-blob— Vercel Blob Storage@nextlyhq/storage-uploadthing— UploadThing
Install whichever adapter you need:
pnpm add @nextlyhq/storage-s3npm install @nextlyhq/storage-s3yarn add @nextlyhq/storage-s3bun add @nextlyhq/storage-s3import { s3Storage } from "@nextlyhq/storage-s3";
export default defineConfig({
storage: [
s3Storage({
bucket: process.env.S3_BUCKET!,
region: process.env.AWS_REGION!,
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
collections: {
media: true,
"private-docs": {
prefix: "private/",
signedDownloads: true,
clientUploads: true,
},
},
}),
],
});See Environment variables — Storage for adapter-specific env vars.
plugins
plugins?: PluginDefinition[];Plugins extend Nextly with extra collections, hooks, sidebar items, and admin views.
import { formBuilder } from "@nextlyhq/plugin-form-builder";
const formBuilderPlugin = formBuilder();
export default defineConfig({
plugins: [formBuilderPlugin.plugin],
collections: [Posts],
});email
email?: EmailConfig;Email provider for password resets, email verification, and other transactional emails. The provider configured here is the code-first fallback; database-managed providers configured via the admin Settings UI take precedence at runtime.
| Property | Type | Required | Description |
|---|---|---|---|
providerConfig | SmtpConfig | ResendConfig | SendLayerConfig | Yes | Provider details. |
from | string | Yes | Default From address (e.g., "App <noreply@example.com>"). |
baseUrl | string | No | Used for password reset and verify links; falls back to NEXT_PUBLIC_APP_URL. |
resetPasswordPath | string | No (default "/admin/reset-password") | Reset-password page path appended to baseUrl. |
verifyEmailPath | string | No (default "/admin/verify-email") | Email verification page path appended to baseUrl. |
templates | { welcome?, passwordReset?, emailVerification? } | No | Override the default HTML templates. |
email: {
providerConfig: {
provider: "resend",
apiKey: process.env.RESEND_API_KEY!,
},
from: "My App <noreply@example.com>",
baseUrl: "https://example.com",
}security
security?: SecurityConfig;Security configuration. All sub-sections are optional — secure defaults are applied by middleware.
| Property | Type | Description |
|---|---|---|
headers | SecurityHeadersConfig | CSP, X-Content-Type-Options, X-Frame-Options, HSTS, Referrer-Policy, Permissions-Policy. |
cors | CorsConfig | Cross-Origin Resource Sharing. Default: same-origin only. |
uploads | UploadSecurityConfigInput | MIME-type allowlist and SVG serving behavior. |
sanitization | SanitizationConfigInput | HTML stripping for plain-text fields, CSS validation in rich text, URL protocol validation. |
limits | { json?, multipart?, fileSize?, fileCount?, fieldCount?, fieldSize? } | Body and multipart size caps. Defaults: json 1mb / multipart 50mb / fileSize 10mb / fileCount 10 / fieldCount 50 / fieldSize 100kb. Numeric values accept "1mb"-style suffixes. |
authRateLimit | { requestsPerHour?: number; windowMs?: number } | Per-IP rate limit shared across /auth/login, /auth/register, /auth/forgot-password, and /auth/reset-password. Default: 30 req/hour. Set requestsPerHour: 0 to disable (test/dev only). |
trustProxy | boolean | Trust X-Forwarded-For (filtered through the TRUSTED_PROXY_IPS env-var CIDR list) for client-IP resolution. Default: false. |
breachedPasswords | { source: "pwned-passwords" | { url: string } | { range(prefix) }; timeoutMs?: number } | @experimental Where a plugin's password check asks whether a password appeared in a breach, when the plugin asks for that check. Only a 20-bit prefix of the password's SHA-1 leaves the process. A url must be https: any other address is sent nothing, the check answers "unchecked", and boot warns. Default source: "pwned-passwords"; timeoutMs is 1 to 60000 ms, default 2000 (another value is named at boot and replaced by the default). See Security helpers. Needs the release after 0.0.2-alpha.66. |
security: {
cors: {
origin: ["https://example.com", "https://app.example.com"],
credentials: true,
},
headers: {
contentSecurityPolicy: "default-src 'self'",
},
uploads: {
additionalMimeTypes: ["application/xml"],
},
sanitization: {
enabled: true,
stripHtmlFromText: true,
},
limits: {
multipart: "20mb",
fileSize: "5mb",
},
authRateLimit: {
requestsPerHour: 30,
},
trustProxy: true,
}admin
admin?: AdminConfig;Admin panel branding and per-plugin sidebar overrides.
admin.branding
| Property | Type | Default | Description |
|---|---|---|---|
logoUrl | string | None | Logo image URL (replaces text logo when set). |
logoUrlLight | string | None | Light-mode logo URL (used when logoUrl is not set). |
logoUrlDark | string | None | Dark-mode logo URL (used when logoUrl is not set). |
logoText | string | "Nextly" | Sidebar text label and alt text when logoUrl is set. |
favicon | string | None | Custom favicon URL. |
colors.primary | string (6-digit hex) | None | Primary brand color (e.g. "#6366f1"). Foreground colors auto-calculated for WCAG AA contrast. |
colors.accent | string (6-digit hex) | None | Accent brand color (e.g. "#f59e0b"). |
showBuilder | boolean | process.env.NODE_ENV !== "production" | Show or hide the Visual Schema Builder navigation in the admin. Defaults to visible in dev/test, hidden in production. |
admin.pluginOverrides
Override any plugin's sidebar placement and appearance without modifying the plugin's source.
import { defineConfig, AdminPlacement } from "nextly";
export default defineConfig({
admin: {
pluginOverrides: {
"form-builder": {
placement: AdminPlacement.SETTINGS, // "collections" | "singles" | "users" | "settings" | "plugins" | "standalone"
order: 80,
after: "settings",
appearance: { icon: "FileText" },
},
},
},
});| Property | Type | Description |
|---|---|---|
placement | AdminPlacement | Sidebar section the plugin appears in. Values: "collections", "singles", "users", "settings", "plugins", "standalone". |
order | number | Sort order within the section. |
after | "dashboard" | "collections" | "singles" | "media" | "plugins" | "users" | "settings" | Position anchor for STANDALONE plugins (which built-in section to appear after). |
appearance | Partial<PluginAdminAppearance> | Shallow-merged onto the plugin's own appearance (icon, label, etc.). |
Validation
defineConfig() performs these checks at startup:
- Duplicate slugs. Collection, single, and field group slugs must be unique across all three.
- Cross-type conflicts. A single cannot share a slug with a collection or field group.
- Field group nesting. Circular field group references and excessive nesting (max depth 3) are rejected.
- User config. Only the allowed scalar field types are accepted.
- API-key bounds.
apiKeys.rateLimit.requestsPerHourandapiKeys.rateLimit.windowMsmust be positive.
Validation failures throw a descriptive error at startup so misconfigurations surface immediately.
Next steps
- Collections — define content types with multiple entries
- Singles — define single-document content
- Field Groups — reusable field groups
- Fields — every available field type
- Environment variables — every env var explained