# Nextly Documentation
> Open-source CMS and visual page builder for Next.js.
Nextly is an open-source CMS and visual page builder for Next.js. Model content, build pages, localize, version and release from your own stack. Define your schema in TypeScript or build it visually in the Schema Builder; both produce the same APIs and the same database schema. Supports PostgreSQL, MySQL and SQLite. MIT licensed.
This file contains the complete Nextly documentation for use by AI coding assistants.
For a lightweight index, see https://nextlyhq.com/llms.txt
---
title: Nextly Documentation
description: Nextly is an open-source CMS and visual page builder for Next.js. Model content, build pages, localize, version and release from your own stack.
url: https://nextlyhq.com/docs
---
Nextly is an open-source content platform for Next.js. Developers define the content model in TypeScript, or build it in the admin with the Visual Schema Builder; either way it is the same data model, the same type-safe API and the same admin interface. Your team then composes pages in the Visual Page Builder, translates them, keeps a version history, and schedules what goes live. It all runs inside your own Next.js app, on your own database.
## Why Nextly?
- **Next.js Native** -- Runs as part of your Next.js app. No separate server, no external API to manage.
- **Code-First + Visual** -- Define collections in TypeScript for version control, or build them visually in the admin panel. Mix both approaches freely.
- **Type-Safe** -- Full TypeScript support with auto-generated types for your content schema.
- **Direct API** -- Query content directly in Server Components with `nextly.find()` and `nextly.findByID()`. No HTTP overhead.
- **Flexible Database** -- PostgreSQL, MySQL, or SQLite via swappable Drizzle adapters.
- **Admin Panel** -- A full-featured admin UI at `/admin` with RBAC, media library, and rich text editing out of the box.
## Quick Links
- [Getting Started](https://nextlyhq.com/docs/getting-started) -- What Nextly is and core concepts
- [Installation](https://nextlyhq.com/docs/getting-started/installation) -- Install Nextly in a new or existing Next.js project
- [Quick Start](https://nextlyhq.com/docs/getting-started/quick-start) -- Build a blog in 5 minutes
- [Configuration](https://nextlyhq.com/docs/configuration) -- Configure collections, singles, fields, and plugins
- [REST API](https://nextlyhq.com/docs/api-reference/rest-api) -- HTTP endpoints, auth, response shapes
- [Direct API](https://nextlyhq.com/docs/api-reference/direct-api) -- Server-side query layer for Server Components
## Get Started
The fastest way to start is with the scaffolding CLI:
**pnpm:**
```bash
pnpm create nextly-app@alpha my-app
```
**npm:**
```bash
npx create-nextly-app@alpha my-app
```
**yarn:**
```bash
yarn create nextly-app@alpha my-app
```
**bun:**
```bash
bun create nextly-app@alpha my-app
```
Or follow the [Quick Start guide](https://nextlyhq.com/docs/getting-started/quick-start) to add Nextly to an existing Next.js project and build a blog in 5 minutes.
## Next Steps
- [Getting Started](https://nextlyhq.com/docs/getting-started) -- Core concepts and prerequisites
- [Installation](https://nextlyhq.com/docs/getting-started/installation) -- Install Nextly in your project
- [Configuration](https://nextlyhq.com/docs/configuration) -- Configure collections, singles, fields, and plugins
- [Database](https://nextlyhq.com/docs/database) -- Choose and configure PostgreSQL, MySQL, or SQLite
- [Deployment](https://nextlyhq.com/docs/guides/deployment) -- Deploy to Vercel or self-host on Node.js
---
title: Getting Started
description: Learn what Nextly is, understand its core concepts, and get up and running quickly.
url: https://nextlyhq.com/docs/getting-started
---
Nextly is an open-source content platform that embeds directly into your Next.js application. Instead of running a separate server, Nextly lives alongside your frontend code -- same repo, same deployment, same database.
## Two Ways to Build
Nextly supports two approaches to defining your content model. You can use either one, or mix them in the same project.
### Code-First
Define collections and singles in TypeScript. Your content schema lives in `nextly.config.ts`, is version-controlled, and generates types automatically.
```typescript title="nextly.config.ts"
import { defineConfig, defineCollection, text, richText } from "nextly/config";
const posts = defineCollection({
slug: "posts",
labels: { singular: "Post", plural: "Posts" },
fields: [
text({ name: "title", required: true }),
richText({ name: "content" }),
],
// Built-in Draft/Published lifecycle: injects a `status` system column
// (default 'draft') and adds Save Draft / Publish buttons in the admin.
status: true,
});
export default defineConfig({
collections: [posts],
});
```
### Visual Schema Builder
Create and modify collections visually through the admin panel at `/admin`. The Visual Schema Builder lets you add fields with drag-and-drop, configure validation, and set up relationships -- all without writing config code. Collections created this way generate database tables dynamically and are immediately available through the same API. See [Schema Builder](https://nextlyhq.com/docs/schema-builder) for the full walkthrough.
### Same API, Either Way
Regardless of how you create a collection, you query it the same way:
```typescript title="app/blog/page.tsx"
import { nextly } from "nextly";
export default async function BlogPage() {
const { items: posts } = await nextly.find({
collection: "posts",
limit: 10,
});
return (
{posts.map((post) => (
{post.title}
))}
);
}
```
## Core Concepts
### Collections
Collections are repeatable content types -- like blog posts, products, or team members. Each collection gets its own database table and admin UI for managing entries. See [Collections](https://nextlyhq.com/docs/configuration/collections) for the full reference.
### Singles
Singles store one-of-a-kind content -- site settings, navigation menus, footer content. They work like collections but hold a single document instead of many. See [Singles](https://nextlyhq.com/docs/configuration/singles).
### Fields
Fields define the shape of your content. Nextly ships eighteen field types organised by category:
- **Basic:** [`text`](https://nextlyhq.com/docs/configuration/fields#text), [`textarea`](https://nextlyhq.com/docs/configuration/fields#textarea), [`richText`](https://nextlyhq.com/docs/configuration/fields#rich-text), [`email`](https://nextlyhq.com/docs/configuration/fields#email), [`password`](https://nextlyhq.com/docs/configuration/fields#password), [`code`](https://nextlyhq.com/docs/configuration/fields#code), [`number`](https://nextlyhq.com/docs/configuration/fields#number), [`checkbox`](https://nextlyhq.com/docs/configuration/fields#checkbox), [`date`](https://nextlyhq.com/docs/configuration/fields#date)
- **Selection:** [`select`](https://nextlyhq.com/docs/configuration/fields#select), [`radio`](https://nextlyhq.com/docs/configuration/fields#radio), [`chips`](https://nextlyhq.com/docs/configuration/fields#chips)
- **Media:** [`upload`](https://nextlyhq.com/docs/configuration/fields#upload)
- **Relationship:** [`relationship`](https://nextlyhq.com/docs/configuration/fields#relationship)
- **Layout:** [`repeater`](https://nextlyhq.com/docs/configuration/fields#repeater), [`group`](https://nextlyhq.com/docs/configuration/fields#group)
- **Component:** [`component`](https://nextlyhq.com/docs/configuration/fields#component)
- **Advanced:** [`json`](https://nextlyhq.com/docs/configuration/fields#json)
- **Virtual:** [`join`](https://nextlyhq.com/docs/configuration/fields#join)
See [Fields](https://nextlyhq.com/docs/configuration/fields) for every option per field with examples.
### Field Groups
Field groups are reusable field structures that can be embedded in collections, singles, or other field groups. Define a field group once (like an SEO block or a CTA section), then use it across multiple content types. See [Field Groups](https://nextlyhq.com/docs/configuration/field-groups).
### Admin Panel
Nextly includes a full admin interface at `/admin` with role-based access control, a media library, and rich text editing. The admin panel works with both code-first and Visual Schema Builder content. See [Admin Panel](https://nextlyhq.com/docs/admin) for the full overview.
## Prerequisites
- **Node.js 20** or later
- **Next.js 16+** (App Router required)
- **React 19+**
- **TypeScript 5+**
- A supported database: **PostgreSQL** (recommended for production), **MySQL**, or **SQLite** (local demo only — see the [SQLite caveats](https://nextlyhq.com/docs/database/sqlite))
- Basic familiarity with Next.js and TypeScript
## Next Steps
- [Installation](https://nextlyhq.com/docs/getting-started/installation) -- Install Nextly in your project
- [Quick Start](https://nextlyhq.com/docs/getting-started/quick-start) -- Build a blog in 5 minutes
- [Project Structure](https://nextlyhq.com/docs/getting-started/project-structure) -- Understand how a Nextly project is organised
- [Configuration](https://nextlyhq.com/docs/configuration) -- Full configuration reference for collections, singles, and fields
---
title: Installation
description: Install Nextly in a new or existing Next.js project with step-by-step instructions.
url: https://nextlyhq.com/docs/getting-started/installation
---
There are two ways to install Nextly: scaffold a new project with the CLI, or add Nextly to an existing Next.js app manually. Both paths take a few minutes.
## Option A: Create a New Project (Recommended)
The `create-nextly-app` CLI scaffolds a complete Nextly project with your choice of database and template. Storage defaults to local disk, so you don't need to configure cloud storage to get started.
**pnpm:**
```bash
pnpm create nextly-app@alpha my-app
```
**npm:**
```bash
npx create-nextly-app@alpha my-app
```
**yarn:**
```bash
yarn create nextly-app@alpha my-app
```
**bun:**
```bash
bun create nextly-app@alpha my-app
```
The CLI will prompt you to:
1. **Project name** -- defaults to the directory name. Use `.` to scaffold into the current folder.
2. **Template** -- `Blank` (empty config) or `Blog` (a complete blog with posts, authors, categories, and frontend pages). See [Templates](https://nextlyhq.com/docs/templates) for what each one ships with.
3. **Schema approach** (only for templates that support both) -- `code-first` (default for Blog) or `visual` (Visual Schema Builder).
4. **Database** -- `SQLite` (default), `PostgreSQL`, or `MySQL`.
5. **Database connection string** -- skipped for SQLite (defaults to `file:./data/nextly.db`); required for PostgreSQL/MySQL.
Once the CLI completes, change into the project and start the dev server:
**pnpm:**
```bash
cd my-app
pnpm dev
```
**npm:**
```bash
cd my-app
npm run dev
```
**yarn:**
```bash
cd my-app
yarn dev
```
**bun:**
```bash
cd my-app
bun run dev
```
### Create the super admin
The first time you run the dev server, the database is empty -- there's no admin account yet. Open the setup page to create one:
```
http://localhost:3000/admin/setup
```
Enter an email, password, and name. The first user is bootstrapped with a `Super Admin` role that has every permission. After that, you'll be redirected to the login screen at `/admin`.
### Default storage is local disk
Out of the box, Nextly uses your project's `./public/uploads/` directory as the media store -- no env vars, no cloud account, no credentials. Files you upload through the admin's media library land there and are served by Next.js as static assets. When you're ready to move to S3, Vercel Blob, or Uploadthing, see [Media & Storage](https://nextlyhq.com/docs/guides/media-storage).
## Option B: Add to an Existing Next.js Project
### 1. Install Packages
Install the runtime, admin panel, and database adapter:
**pnpm:**
```bash
pnpm add nextly @nextlyhq/admin
```
**npm:**
```bash
npm install nextly @nextlyhq/admin
```
**yarn:**
```bash
yarn add nextly @nextlyhq/admin
```
**bun:**
```bash
bun add nextly @nextlyhq/admin
```
Then install your database driver:
**PostgreSQL:**
```bash
pnpm add @nextlyhq/adapter-postgres pg
```
**MySQL:**
```bash
pnpm add @nextlyhq/adapter-mysql mysql2
```
**SQLite:**
```bash
pnpm add @nextlyhq/adapter-sqlite better-sqlite3
```
Cloud storage is optional -- Nextly's default is local disk under `./public/uploads/`. Install a storage adapter only if you need one:
**S3 / R2 / DigitalOcean Spaces:**
```bash
pnpm add @nextlyhq/storage-s3
```
**Vercel Blob:**
```bash
pnpm add @nextlyhq/storage-vercel-blob
```
**Uploadthing:**
```bash
pnpm add @nextlyhq/storage-uploadthing
```
### 2. Create `nextly.config.ts`
Create a `nextly.config.ts` file in your project root:
```typescript title="nextly.config.ts"
import { defineConfig } from "nextly/config";
export default defineConfig({
collections: [],
singles: [],
typescript: {
outputFile: "./src/types/generated/nextly-types.ts",
},
});
```
### 3. Update `next.config.ts`
Add `serverExternalPackages` so Next.js doesn't try to bundle server-only dependencies:
```typescript title="next.config.ts"
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: [
"nextly",
"@nextlyhq/adapter-drizzle",
"@nextlyhq/adapter-postgres",
"@nextlyhq/adapter-mysql",
"@nextlyhq/adapter-sqlite",
"drizzle-orm",
"drizzle-kit",
"pg",
"mysql2",
"better-sqlite3",
"bcryptjs",
"sharp",
"esbuild",
],
};
export default nextConfig;
```
Drop adapters and drivers your project doesn't actually use.
### 4. Set Up Admin Routes
Create the admin panel page and layout:
```tsx title="src/app/admin/[[...params]]/page.tsx"
"use client";
import "@nextlyhq/admin/style.css";
import { RootLayout, QueryProvider, ErrorBoundary } from "@nextlyhq/admin";
export default function AdminPage() {
return (
{
console.error("Admin error:", error, errorInfo);
}}
>
);
}
```
```tsx title="src/app/admin/[[...params]]/layout.tsx"
import { getBrandingCss } from "nextly/config";
import config from "../../../../nextly.config";
const brandingCss = getBrandingCss(config.admin?.branding);
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{brandingCss && (
)}
{children}
>
);
}
```
Create the admin API catch-all route:
```typescript title="src/app/admin/api/[[...params]]/route.ts"
import { getNextly } from "nextly";
import { createDynamicHandlers } from "nextly/runtime";
import nextlyConfig from "../../../../../nextly.config";
const handlers = createDynamicHandlers({ config: nextlyConfig });
export const GET = handlers.GET;
export const POST = handlers.POST;
export const PUT = handlers.PUT;
export const PATCH = handlers.PATCH;
export const DELETE = handlers.DELETE;
export const OPTIONS = handlers.OPTIONS;
```
### 5. Set Up Environment Variables
Create a `.env` file with your database and auth configuration:
```bash title=".env"
# Database
DB_DIALECT=postgresql
DATABASE_URL=postgresql://user:password@localhost:5432/nextly
# Authentication (REQUIRED, min 32 chars)
# Generate with: openssl rand -base64 32
NEXTLY_SECRET=change-me-generate-a-secure-secret
# Application URL
NEXT_PUBLIC_APP_URL=http://localhost:3000
```
That's the minimum. For storage, email, and other optional settings see [Environment Variables](https://nextlyhq.com/docs/configuration/environment).
### 6. Run Migrations
Initialize the database schema. Nextly seeds default RBAC roles and permissions on first run:
**pnpm:**
```bash
pnpm nextly migrate
```
**npm:**
```bash
npx nextly migrate
```
**yarn:**
```bash
yarn nextly migrate
```
**bun:**
```bash
bunx nextly migrate
```
In development, Nextly auto-syncs schema changes when you save `nextly.config.ts`, so you usually only need `nextly migrate` for initial setup and production deploys. See [Production migrations](https://nextlyhq.com/docs/guides/production-migrations) for the production flow.
### 7. Verify
Start the development server:
**pnpm:**
```bash
pnpm dev
```
**npm:**
```bash
npm run dev
```
**yarn:**
```bash
yarn dev
```
**bun:**
```bash
bun run dev
```
Open [http://localhost:3000/admin/setup](http://localhost:3000/admin/setup) to create the first super admin account, then log in at [http://localhost:3000/admin](http://localhost:3000/admin).
## Requirements
| Requirement | Version |
| --- | --- |
| Node.js | 20 or later |
| Next.js | 16+ (App Router required) |
| React | 19+ |
| TypeScript | 5+ |
| Database | PostgreSQL (recommended for production), MySQL, or SQLite (local demo only) |
## Next Steps
- [Quick Start](https://nextlyhq.com/docs/getting-started/quick-start) -- Build a blog in 5 minutes
- [Project Structure](https://nextlyhq.com/docs/getting-started/project-structure) -- Understand the file layout
- [Configuration](https://nextlyhq.com/docs/configuration) -- Full configuration reference
- [Environment Variables](https://nextlyhq.com/docs/configuration/environment) -- All environment variables explained
---
title: Quick Start: Build a Blog in 5 Minutes
description: Create a blog with posts and categories using Nextly. Define content with code or build it visually.
url: https://nextlyhq.com/docs/getting-started/quick-start
---
This guide walks you through building a blog with posts and categories. You will define a content model, run migrations, create content in the admin panel, and query it in a Next.js page.
**Prerequisites:** Complete the [Installation](https://nextlyhq.com/docs/getting-started/installation) steps first, including creating the super admin at `/admin/setup`.
## 1. Define Your Collections
**Code-First:**
Open `nextly.config.ts` and define a `posts` collection and a `categories` collection:
```typescript title="nextly.config.ts"
import {
defineConfig,
defineCollection,
text,
richText,
relationship,
date,
} from "nextly/config";
const posts = defineCollection({
slug: "posts",
labels: { singular: "Post", plural: "Posts" },
fields: [
text({ name: "title", required: true }),
text({ name: "slug", required: true, unique: true }),
richText({ name: "content" }),
relationship({ name: "category", relationTo: "categories" }),
date({ name: "publishedAt" }),
],
// Built-in Draft/Published lifecycle: injects a NOT NULL `status`
// system column (default 'draft') and adds Save Draft / Publish
// buttons in the admin entry editor.
status: true,
});
const categories = defineCollection({
slug: "categories",
labels: { singular: "Category", plural: "Categories" },
fields: [
text({ name: "name", required: true }),
text({ name: "slug", required: true, unique: true }),
],
});
export default defineConfig({
collections: [posts, categories],
singles: [],
typescript: {
outputFile: "./src/types/generated/nextly-types.ts",
},
});
```
In development, Nextly auto-syncs schema changes when you save `nextly.config.ts`. For production, run:
```bash
pnpm nextly migrate
```
**Visual Schema Builder:**
1. Open the admin panel at [http://localhost:3000/admin](http://localhost:3000/admin)
2. Navigate to **Collections** in the sidebar
3. Click **Create Collection**
4. Name it `Posts` (slug: `posts`)
5. Add fields:
- `title` -- Text, required
- `slug` -- Text, required, unique
- `content` -- Rich Text
- `publishedAt` -- Date
6. Open the **Advanced** tab on the collection and toggle **Status (Draft / Published)** on. This adds the system status column without you having to declare a `status` field by hand.
7. Click **Save**
8. Repeat to create a `Categories` collection with fields:
- `name` -- Text, required
- `slug` -- Text, required, unique
The Visual Schema Builder creates the database tables automatically. No migration step needed in development.
## 2. Add Content
Open the admin panel at [http://localhost:3000/admin](http://localhost:3000/admin).
1. Go to **Categories** and create a few categories (e.g., "Technology", "Design")
2. Go to **Posts** and create a post:
- Set a title and slug
- Write some content in the rich text editor
- Select a category
- Set status to "Published"
- Set a publish date
## 3. Query Content in Your App
Use the Direct API to fetch posts in a Server Component. The Direct API runs on the server -- no HTTP requests, no REST endpoints needed.
```typescript title="src/app/blog/page.tsx"
import { nextly } from "nextly";
export default async function BlogPage() {
const { items: posts } = await nextly.find({
collection: "posts",
where: {
status: { equals: "published" },
},
sort: "-publishedAt",
limit: 10,
});
return (
Blog
{posts.map((post) => (
{post.title}
))}
);
}
```
### Fetch a Single Post
```typescript title="src/app/blog/[slug]/page.tsx"
import { nextly } from "nextly";
import { notFound } from "next/navigation";
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const { items } = await nextly.find({
collection: "posts",
where: {
slug: { equals: slug },
status: { equals: "published" },
},
limit: 1,
});
const post = items[0];
if (!post) notFound();
return (
{post.title}
);
}
```
## 4. Add a Single
Singles are perfect for site-wide settings. Add a blog settings single to your config:
**Code-First:**
```typescript title="nextly.config.ts"
import { defineSingle, text, number } from "nextly/config";
const blogSettings = defineSingle({
slug: "blog-settings",
label: { singular: "Blog Settings" },
fields: [
text({ name: "blogTitle", required: true, label: "Blog Title" }),
text({ name: "tagline", label: "Tagline" }),
number({ name: "postsPerPage", defaultValue: 10 }),
],
});
export default defineConfig({
collections: [posts, categories],
singles: [blogSettings],
// ... rest of config
});
```
In development, save `nextly.config.ts` and the schema syncs automatically. For production, run `pnpm nextly migrate`.
**Visual Schema Builder:**
1. In the admin panel, go to **Singles**
2. Click **Create Single**
3. Name it `Blog Settings` (slug: `blog-settings`)
4. Add fields: `blogTitle` (Text, required), `tagline` (Text), `postsPerPage` (Number, default: 10)
5. Click **Save**
Query the single in your layout:
```typescript title="src/app/blog/layout.tsx"
import { nextly } from "nextly";
export default async function BlogLayout({
children,
}: {
children: React.ReactNode;
}) {
const settings = await nextly.findSingle({
slug: "blog-settings",
});
return (
{settings?.blogTitle}
{settings?.tagline}
{children}
);
}
```
## What You Built
In 5 minutes, you have:
- Two collections (`posts` and `categories`) with typed fields
- A single (`blog-settings`) for site-wide configuration
- An admin panel to manage all content at `/admin`
- Server-rendered pages that query content directly with the Direct API
## Next Steps
- [Project Structure](https://nextlyhq.com/docs/getting-started/project-structure) -- Understand how the project is organised
- [Fields](https://nextlyhq.com/docs/configuration/fields) -- Explore all available field types
- [Direct API](https://nextlyhq.com/docs/api-reference/direct-api) -- Full API reference for querying content
- [Media Storage](https://nextlyhq.com/docs/guides/media-storage) -- Set up image and file uploads
---
title: Project Structure
description: Understand how a Nextly project is organized, where key files live, and what each directory does.
url: https://nextlyhq.com/docs/getting-started/project-structure
---
A Nextly project follows the standard Next.js App Router structure with a few additions: a `nextly.config.ts` at the root, admin routes under `src/app/admin/`, and API routes for the backend. This page explains where everything lives and what each part does.
## Directory Overview
Here is the structure of a project scaffolded with `create-nextly-app` (Blank template, with `src/` directory):
```
my-nextly-app/
├── src/
│ ├── app/
│ │ ├── admin/
│ │ │ ├── [[...params]]/
│ │ │ │ ├── page.tsx # Admin panel UI (client component)
│ │ │ │ └── layout.tsx # Admin layout — injects branding CSS
│ │ │ └── api/
│ │ │ └── [[...params]]/
│ │ │ └── route.ts # Admin API catch-all handler
│ │ ├── api/
│ │ │ ├── health/
│ │ │ │ └── route.ts # Health check endpoint
│ │ │ ├── media/
│ │ │ │ └── [[...path]]/
│ │ │ │ └── route.ts # Media upload + serving
│ │ │ ├── media-folders/
│ │ │ │ └── route.ts # Media folder management
│ │ │ └── [[...params]]/
│ │ │ └── route.ts # REST API catch-all
│ │ ├── layout.tsx # Root layout
│ │ ├── page.tsx # Home page
│ │ ├── globals.css # Global styles
│ │ └── favicon.ico
│ └── types/
│ └── generated/
│ └── nextly-types.ts # Auto-generated TypeScript types
├── public/
│ └── uploads/ # Default local-disk media storage
├── nextly.config.ts # Nextly configuration
├── next.config.ts # Next.js configuration
├── eslint.config.mjs
├── postcss.config.mjs
├── tsconfig.json
├── package.json
├── .env # Environment variables (gitignored)
└── .env.example # Environment template
```
The Blog template adds a few more folders -- `src/collections/`, `src/singles/`, `src/components/`, `src/access/`, `src/actions/`, `src/lib/`, plus a `(frontend)` route group for the public-facing blog pages. See [Templates → Blog](https://nextlyhq.com/docs/templates/blog) for the blog-specific layout.
## Key Files
### `nextly.config.ts`
The central configuration file. This is where you define code-first collections, singles, plugins, storage adapters, email configuration, and admin branding. It lives at the project root.
```typescript title="nextly.config.ts"
import { defineConfig } from "nextly/config";
export default defineConfig({
collections: [/* your collections */],
singles: [/* your singles */],
plugins: [/* your plugins */],
storage: [/* storage adapters */],
email: { /* email provider config */ },
admin: {
branding: {
logoText: "My App",
colors: { primary: "#387c26" },
},
},
typescript: {
outputFile: "./src/types/generated/nextly-types.ts",
},
});
```
See [Configuration](https://nextlyhq.com/docs/configuration) for the full reference.
### `next.config.ts`
Standard Next.js configuration. The main Nextly-specific addition is `serverExternalPackages`, which prevents Next.js from bundling server-only dependencies like database drivers:
```typescript title="next.config.ts"
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: [
"nextly",
"@nextlyhq/adapter-drizzle",
"@nextlyhq/adapter-postgres",
"@nextlyhq/adapter-mysql",
"@nextlyhq/adapter-sqlite",
"drizzle-orm",
"drizzle-kit",
"pg",
"mysql2",
"better-sqlite3",
"bcryptjs",
"sharp",
"esbuild",
],
};
export default nextConfig;
```
The Blank template ships with all dialect adapters listed; remove the ones you aren't using to keep the dev server tree clean.
### `.env`
Environment variables for database connection and authentication. The CLI generates a complete `.env` with secure defaults; the essentials are:
| Variable | Required | Purpose |
| --- | --- | --- |
| `DB_DIALECT` | Yes | Database type (`postgresql`, `mysql`, `sqlite`) |
| `DATABASE_URL` | Yes (PG/MySQL) | Database connection string. SQLite falls back to `file:./data/nextly.db` |
| `NEXTLY_SECRET` | Yes (production) | Secret for JWT signing and session encryption. Min 32 chars; the CLI auto-generates a base64 value |
| `NEXT_PUBLIC_APP_URL` | Yes (production) | Public URL of your app, used by metadata and the admin |
Storage env vars are only needed when you opt in to a cloud adapter -- the default is local disk under `public/uploads/`. See [Environment Variables](https://nextlyhq.com/docs/configuration/environment) for the full list.
## Key Directories
### `src/app/admin/`
The admin panel lives here as a standard Next.js route. The catch-all `[[...params]]` pattern lets Nextly handle all admin panel routing internally.
- **`page.tsx`** -- Renders the admin panel UI. This is a client component that imports `RootLayout`, `QueryProvider`, and `ErrorBoundary` from `@nextlyhq/admin`.
- **`layout.tsx`** -- Reads `admin.branding` from `nextly.config.ts` and injects the generated CSS so logos and colors apply on every admin page.
- **`api/[[...params]]/route.ts`** -- The admin API. Handles all CRUD operations for collections, singles, users, roles, permissions, and media.
### `src/app/api/`
Public REST API routes. The catch-all `[[...params]]/route.ts` dispatches every endpoint Nextly ships -- collections, singles, auth, image sizes, email -- through one handler that reads your config. Health, media, and media-folders are split into their own routes for clarity. See [REST API](https://nextlyhq.com/docs/api-reference/rest-api) for every endpoint.
### `public/uploads/`
Default media store when no storage adapter is configured in `nextly.config.ts`. Files uploaded through the admin's media library land here and are served directly by Next.js. To switch to S3, Vercel Blob, or Uploadthing, see [Media & Storage](https://nextlyhq.com/docs/guides/media-storage).
### `src/types/generated/`
Auto-generated TypeScript types for your content schema. The output path is configured in `nextly.config.ts` under `typescript.outputFile`. In development the types are regenerated automatically when you save `nextly.config.ts`; in production run:
```bash
pnpm nextly generate:types
```
### Schema files (Visual Schema Builder)
When you create collections through the Visual Schema Builder, Nextly writes the generated TypeScript schema files alongside your project under the directory configured in `nextly.config.ts` -- by default `./src/db/schemas/`. Those files are regular `defineCollection()` exports you can commit to version control. Code-first collections live in `nextly.config.ts` (or wherever you import them from); the two approaches coexist freely.
## Code-First vs Visual Schema Builder: Where Content Is Defined
| Approach | Where collections are defined | Schema location |
| --- | --- | --- |
| **Code-First** | `nextly.config.ts` (or files you import from it) | Generated during `pnpm nextly migrate` (production) or auto-synced on save (dev) |
| **Visual Schema Builder** | Admin panel at `/admin/builder/collections` | TypeScript files written to disk under `./src/db/schemas/` |
Both approaches produce the same result: database tables, admin UI, REST endpoints, and Direct API access. You can mix them -- define some collections in config and create others through the Visual Schema Builder.
## Next Steps
- [Configuration](https://nextlyhq.com/docs/configuration) -- Full `nextly.config.ts` reference
- [Fields](https://nextlyhq.com/docs/configuration/fields) -- All available field types
- [Admin Panel](https://nextlyhq.com/docs/admin) -- Overview of the admin panel features
- [Environment Variables](https://nextlyhq.com/docs/configuration/environment) -- All environment variables explained
---
title: Configuration
description: Configure your Nextly application with collections, singles, field groups, fields, storage, security, and more.
url: https://nextlyhq.com/docs/configuration
---
Nextly is configured through a single `nextly.config.ts` file at the root of your project. That file is the source of truth for your content model, database output paths, storage backends, security settings, email, and admin panel branding.
## The config file
Every Nextly project has a `nextly.config.ts` that exports a config built with `defineConfig()`:
```typescript title="nextly.config.ts"
import { defineConfig } from "nextly";
import Posts from "./src/collections/posts";
import Media from "./src/collections/media";
import SiteSettings from "./src/singles/site-settings";
export default defineConfig({
collections: [Posts, Media],
singles: [SiteSettings],
db: {
schemasDir: "./src/db/schemas/collections",
migrationsDir: "./src/db/migrations",
},
typescript: {
outputFile: "./src/types/generated/payload-types.ts",
},
});
```
## What you can configure
| Area | Description |
|------|-------------|
| [Nextly config](https://nextlyhq.com/docs/configuration/nextly-config) | Every option in `defineConfig()` with types and defaults |
| [Collections](https://nextlyhq.com/docs/configuration/collections) | Content types with multiple entries (blog posts, products, media) |
| [Singles](https://nextlyhq.com/docs/configuration/singles) | One-off documents (site settings, header, footer) |
| [Field Groups](https://nextlyhq.com/docs/configuration/field-groups) | Reusable field groups embedded in collections and singles |
| [Fields](https://nextlyhq.com/docs/configuration/fields) | Field types, options, validation, and helpers |
| [Environment variables](https://nextlyhq.com/docs/configuration/environment) | Every environment variable Nextly reads |
## Visual Schema Builder vs code-first
Collections, singles, and field groups can also be created visually in the Visual Schema Builder (the "Schema Builder" link inside the admin panel). The Schema Builder writes the same `CollectionConfig`, `SingleConfig`, and `FieldGroupConfig` shapes documented in this section, so anything you build visually maps 1:1 to a `defineCollection`, `defineSingle`, or `defineFieldGroup` call.
## Next steps
- [Nextly config reference](https://nextlyhq.com/docs/configuration/nextly-config) — every top-level option in `defineConfig()`
- [Collections](https://nextlyhq.com/docs/configuration/collections) — content types with multiple entries
- [Singles](https://nextlyhq.com/docs/configuration/singles) — single-document content like site settings
- [Field Groups](https://nextlyhq.com/docs/configuration/field-groups) — reusable field groups for collections and singles
- [Fields](https://nextlyhq.com/docs/configuration/fields) — every available field type
- [Environment variables](https://nextlyhq.com/docs/configuration/environment) — what to set in `.env`
---
title: Nextly Config Reference
description: Complete reference for every option accepted by defineConfig() in nextly.config.ts.
url: https://nextlyhq.com/docs/configuration/nextly-config
---
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 `NextlyConfig` interface in `packages/nextly/src/shared/types/config.ts` defines the shape this page documents. The `defineConfig()` helper in `packages/nextly/src/collections/config/define-config.ts` wires it up.
## Full example
```typescript title="nextly.config.ts"
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/payload-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 ",
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](https://nextlyhq.com/docs/configuration/collections). |
| `singles` | `SingleConfig[]` | `[]` | Single-document content (site settings, header, etc.). See [Singles](https://nextlyhq.com/docs/configuration/singles). |
| `fieldGroups` | `FieldGroupConfig[]` | `[]` | Reusable field groups. See [Field Groups](https://nextlyhq.com/docs/configuration/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`
```ts
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 `/api/[slug]`.
```typescript
import Posts from "./src/collections/posts";
import Media from "./src/collections/media";
export default defineConfig({
collections: [Posts, Media],
});
```
See [Collections](https://nextlyhq.com/docs/configuration/collections) for the full `CollectionConfig` shape.
---
### `singles`
```ts
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.
```typescript
import SiteSettings from "./src/singles/site-settings";
export default defineConfig({
singles: [SiteSettings],
});
```
See [Singles](https://nextlyhq.com/docs/configuration/singles) for the full `SingleConfig` shape.
---
### `fieldGroups`
```ts
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.
```typescript
import { Seo, Hero } from "./src/field-groups";
export default defineConfig({
fieldGroups: [Seo, Hero],
});
```
See [Field Groups](https://nextlyhq.com/docs/configuration/field-groups).
---
### `users`
```ts
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`.
```typescript
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`
```ts
typescript?: { outputFile?: string; declare?: boolean };
```
Controls TypeScript type generation.
| Property | Type | Default | Description |
|---|---|---|---|
| `outputFile` | `string` | `"./src/types/generated/payload-types.ts"` | Path the generator writes types to. |
| `declare` | `boolean` | `true` | Whether to add `declare module` blocks for runtime type inference. |
---
### `db`
```ts
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](https://nextlyhq.com/docs/configuration/environment) for the runtime database connection settings.
---
### `rateLimit`
```ts
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` | None | Skip rate limiting for matching requests. |
| `collections` | `Record` | None | Per-collection overrides. |
```typescript
rateLimit: {
enabled: true,
readLimit: 100,
writeLimit: 30,
collections: {
media: { readLimit: 50, writeLimit: 10 },
logs: { readLimit: 200 },
},
}
```
---
### `apiKeys`
```ts
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. 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`
```ts
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`
```ts
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:
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
**pnpm:**
```bash
pnpm add @nextlyhq/storage-s3
```
**npm:**
```bash
npm install @nextlyhq/storage-s3
```
**yarn:**
```bash
yarn add @nextlyhq/storage-s3
```
**bun:**
```bash
bun add @nextlyhq/storage-s3
```
```typescript
import { 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](https://nextlyhq.com/docs/configuration/environment#storage-per-adapter) for adapter-specific env vars.
---
### `plugins`
```ts
plugins?: PluginDefinition[];
```
Plugins extend Nextly with extra collections, hooks, sidebar items, and admin views.
```typescript
import { formBuilder } from "@nextlyhq/plugin-form-builder";
const formBuilderPlugin = formBuilder();
export default defineConfig({
plugins: [formBuilderPlugin.plugin],
collections: [Posts],
});
```
---
### `email`
```ts
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 "`). |
| `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. |
```typescript
email: {
providerConfig: {
provider: "resend",
apiKey: process.env.RESEND_API_KEY!,
},
from: "My App ",
baseUrl: "https://example.com",
}
```
---
### `security`
```ts
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`. |
```typescript
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`
```ts
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.
```typescript
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` | 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.requestsPerHour` and `apiKeys.rateLimit.windowMs` must be positive.
Validation failures throw a descriptive error at startup so misconfigurations surface immediately.
## Next steps
- [Collections](https://nextlyhq.com/docs/configuration/collections) — define content types with multiple entries
- [Singles](https://nextlyhq.com/docs/configuration/singles) — define single-document content
- [Field Groups](https://nextlyhq.com/docs/configuration/field-groups) — reusable field groups
- [Fields](https://nextlyhq.com/docs/configuration/fields) — every available field type
- [Environment variables](https://nextlyhq.com/docs/configuration/environment) — every env var explained
---
title: Collections
description: Define content types with multiple entries using collections.
url: https://nextlyhq.com/docs/configuration/collections
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
A collection is a content type that stores multiple entries - blog posts, products, users, media files. Each collection maps to a database table, gets automatic REST endpoints under `/api/[slug]`, and appears in the admin panel with list and edit views.
> **Source of truth:** the `CollectionConfig` interface and `defineCollection()` helper live in `packages/nextly/src/collections/config/define-collection.ts`. Hooks and access-control types are imported from `packages/nextly/src/shared/types/access.ts` and `packages/nextly/src/hooks/types.ts`.
**Code-first:**
## Defining a collection
Use `defineCollection()` from `nextly`:
```typescript title="src/collections/posts.ts"
import {
defineCollection,
text,
richText,
relationship,
date,
} from "nextly";
export default defineCollection({
slug: "posts",
labels: {
singular: "Post",
plural: "Posts",
},
fields: [
text({ name: "title", required: true }),
text({ name: "slug", unique: true }),
richText({ name: "content" }),
relationship({ name: "author", relationTo: "users" }),
date({ name: "publishedAt" }),
],
// Built-in Draft/Published lifecycle (replaces the user-defined
// `select({ name: "status" })` pattern). See the dedicated guide:
// /docs/guides/draft-published-status.
status: true,
timestamps: true,
admin: {
group: "Content",
icon: "FileText",
useAsTitle: "title",
pagination: { defaultLimit: 25 },
description: "Blog posts and articles",
},
access: {
read: true,
create: ({ roles }) => roles.includes("admin") || roles.includes("editor"),
update: ({ roles }) => roles.includes("admin") || roles.includes("editor"),
delete: ({ roles }) => roles.includes("admin"),
},
hooks: {
beforeChange: [
async ({ data, operation }) => {
if (operation === "create" && data?.title && !data.slug) {
return {
...data,
slug: data.title.toLowerCase().replace(/\s+/g, "-"),
};
}
return data;
},
],
},
});
```
Then register it in your config:
```typescript title="nextly.config.ts"
import { defineConfig } from "nextly";
import Posts from "./src/collections/posts";
export default defineConfig({
collections: [Posts],
});
```
**Visual Schema Builder:**
## Creating a collection in the Visual Schema Builder
The Nextly admin panel ships with a Visual Schema Builder for creating collections without writing code. (In subsequent prose we'll just call it the Schema Builder.)
1. Open the admin panel and navigate to **Builder > Collections**.
2. Click **Create Collection**.
3. Enter a slug (e.g. `posts`) and display labels.
4. Add fields using the drag-and-drop field editor.
5. Configure admin options, access control, and hooks through the UI.
6. Click **Save** to generate the collection.
The Schema Builder produces the same `CollectionConfig` that code-first collections use; you can export a builder-created collection to TypeScript at any time.
## System fields
Collections have three categories of fields. Knowing which is which avoids redundant declarations and column-shape surprises.
### Always auto-injected (you cannot opt out)
Every collection row has these. Never declare them yourself.
- `id` - primary key, auto-generated
- `createdAt` - set on insert (when `timestamps: true`, the default)
- `updatedAt` - refreshed on every save (when `timestamps: true`)
### Conditionally auto-injected (declared definition wins)
`defineCollection()` adds these as `text NOT NULL` columns *only if you don't declare them yourself*:
- `title`
- `slug`
If your `fields` array includes a field named `title` or `slug`, your definition replaces the auto-inject - same column, your shape. If you don't declare them, Nextly adds bare-bones columns with no validation, no uniqueness, no admin form input.
### Should you redeclare `title` and `slug`?
Almost always **yes**, even though Nextly will inject them anyway. Five reasons:
1. **Uniqueness on `slug`** - the auto-injected `slug` is a plain `text NOT NULL` column with no unique constraint. To require unique slugs (the typical case for URL-routed content), declare your own field with `unique: true`.
2. **Admin form visibility** - Nextly's admin builds the create/edit form from your `fields` array. The auto-injected columns are database-only and don't appear in the form. If you don't declare `title`, the admin shows no title input.
3. **Validation and hooks** - auto-injected columns have no field config. Declared fields can attach `required`, `minLength`, `beforeChange` hooks (e.g. `auto-slug` to derive slug from title), defaults, and so on.
4. **Custom UI** - placeholders, descriptions, field ordering, `admin: { readOnly: true }` all live on the field declaration.
5. **Self-documentation** - a contributor reading the collection file sees the full surface in one place, without having to know Nextly's reserved-column behavior.
You can legitimately skip declaring them when the collection is internal-only (e.g. an `audit-log`) and you don't need form visibility, validation, or unique slugs.
```ts
// Good: declared explicitly so slug can be unique and the admin shows inputs.
import { defineCollection, text, textarea } from "nextly/config";
export const Categories = defineCollection({
slug: "categories",
fields: [
text({ name: "title", required: true }),
text({ name: "slug", required: true, unique: true }),
textarea({ name: "description" }),
],
});
```
> **Don't mix conventions.** If you declare a field named `name` for the user-facing label and don't declare `title`, the auto-injected `title` column still exists alongside `name` - you'll have a redundant database column you have to populate via API on every create. Pick `title` (matches the auto-inject and is what most callers expect) or be intentional about why you're using `name`.
## Collection options
Only `slug` and `fields` are required.
### `slug`
| | |
|---|---|
| **Type** | `string` |
| **Required** | Yes |
Unique identifier used as the database table name (unless `dbName` is set), the API endpoint path (`/api/[slug]`), and the internal reference. Must be lowercase, URL-friendly, and not a reserved SQL keyword.
### `fields`
| | |
|---|---|
| **Type** | `FieldConfig[]` |
| **Required** | Yes |
Array of field definitions. See [Fields](https://nextlyhq.com/docs/configuration/fields) for the full inventory.
### `labels`
| | |
|---|---|
| **Type** | `{ singular?: string; plural?: string }` |
| **Default** | Auto-generated from slug |
Display names in the admin UI. If omitted, the singular label is derived from the slug (`blog-posts` → `Blog Posts`) and the plural is generated from the singular.
### `timestamps`
| | |
|---|---|
| **Type** | `boolean` |
| **Default** | `true` |
When `true`, every entry gets `createdAt` (set on insert) and `updatedAt` (refreshed on every save) columns.
### `dbName`
| | |
|---|---|
| **Type** | `string` |
| **Default** | Same as `slug` |
Custom database table name. Useful for legacy schemas or when the slug doesn't match your naming convention.
### `description`
| | |
|---|---|
| **Type** | `string` |
| **Default** | `undefined` |
Description displayed in the admin UI. Falls back to `admin.description` when reading from the type.
### `sanitize`
| | |
|---|---|
| **Type** | `boolean` |
| **Default** | `true` |
When enabled, the global sanitization hook strips HTML tags from plain-text fields (`text`, `textarea`, `email`) before storage. Set to `false` only if the collection intentionally stores HTML in those fields.
### `search`
| | |
|---|---|
| **Type** | `SearchConfig` |
| **Default** | Auto-detects `text`/`textarea`/`email` fields |
| Property | Type | Default | Description |
|---|---|---|---|
| `searchableFields` | `string[]` | All `text`/`textarea`/`email` fields | Fields included in search queries. |
| `minSearchLength` | `number` | `2` | Minimum query length before search runs. |
### `indexes`
| | |
|---|---|
| **Type** | `IndexConfig[]` |
| **Default** | `undefined` |
Compound database indexes. For single-field indexes, use `index: true` directly on the field. The `id`, `createdAt`, and `updatedAt` columns are indexed automatically.
```typescript
indexes: [
{ fields: ["authorId", "createdAt"] },
{ fields: ["slug", "locale"], unique: true, name: "slug_locale_unique" },
]
```
### `endpoints`
| | |
|---|---|
| **Type** | `CustomEndpoint[]` |
| **Default** | `undefined` |
Additional REST endpoints mounted at `/api/[slug]/[path]`. Each entry is `{ path, method, handler }` where `handler` is a Web-API `(req: Request) => Response | Promise` function.
```typescript
endpoints: [
{
path: "/publish",
method: "post",
handler: async (req) => {
const { id } = await req.json();
return Response.json({ success: true });
},
},
]
```
### `custom`
| | |
|---|---|
| **Type** | `Record` |
| **Default** | `undefined` |
Arbitrary metadata for hooks, plugins, or custom code. Not persisted to the database.
## Admin options
Configure how the collection appears in the admin panel via the `admin` property.
| Property | Type | Default | Description |
|---|---|---|---|
| `group` | `string` | None | Sidebar group name; collections sharing a group appear together. |
| `icon` | `string` | None | Lucide icon name (e.g. `"FileText"`, `"Users"`). |
| `hidden` | `boolean` | `false` | Hide from sidebar navigation (still reachable via direct URL and API). |
| `order` | `number` | `100` | Sort order within sidebar group (lower = higher). |
| `sidebarGroup` | `string` | None | Custom sidebar group slug; moves the entry from its default section to a custom group. |
| `isPlugin` | `boolean` | `false` | Render under the "Plugins" sidebar section instead of "Collections". |
| `useAsTitle` | `string` | Document ID | Field name used as the entry title in lists and breadcrumbs. |
| `pagination.defaultLimit` | `number` | `10` | Default entries per page. |
| `pagination.limits` | `number[]` | `[10, 25, 50, 100]` | Available page-size options. |
| `description` | `string` | None | Help text below the collection title. |
| `preview` | `CollectionPreviewConfig` | None | Adds a "Preview" button to the entry form. See below. |
| `components` | `CollectionAdminComponents` | None | Override default views and inject custom React components. See below. |
### Preview URLs
```typescript
admin: {
preview: {
url: (entry) => `/preview/posts/${entry.slug}`,
openInNewTab: true, // default true
label: "Preview Post", // default "Preview"
},
}
```
The `url` function receives the current entry data (which may include unsaved changes) and returns either a URL string or `null` to hide the button for that entry.
### Custom admin components
Override default admin views or inject components at specific positions. Each entry uses the `"package-name/path#ExportName"` component-path format.
```typescript
admin: {
components: {
views: {
Edit: { Component: "@nextlyhq/plugin-form-builder/admin#FormBuilderView" },
List: { Component: "@nextlyhq/plugin-form-builder/admin#FormsListView" },
},
BeforeListTable: "@nextlyhq/plugin-form-builder/admin#CreateFormButton",
AfterListTable: "@nextlyhq/plugin-form-builder/admin#FormsFooter",
BeforeEdit: "@nextlyhq/plugin-form-builder/admin#FormBuilderHeader",
AfterEdit: "@nextlyhq/plugin-form-builder/admin#FormBuilderFooter",
},
}
```
Available view overrides: `Edit`, `List`. Available injection points: `BeforeListTable`, `AfterListTable`, `BeforeEdit`, `AfterEdit`.
## Access control
Each CRUD operation accepts a function (returning `boolean | Promise`), a literal `boolean`, or can be omitted to fall back to the database role/permission system.
```typescript
access: {
create: ({ roles }) => roles.includes("admin") || roles.includes("editor"),
read: true,
update: ({ roles }) => roles.includes("admin") || roles.includes("editor"),
delete: ({ roles }) => roles.includes("admin"),
}
```
The `AccessControlContext` passed to functions has the following shape:
| Property | Type | Description |
|---|---|---|
| `user` | `MinimalUser \| null` | The authenticated user (or `null` for unauthenticated requests). |
| `roles` | `string[]` | The user's role slugs (resolved from DB, includes inherited roles). |
| `permissions` | `string[]` | Effective permissions in `"resource:action"` format. |
| `operation` | `"create" \| "read" \| "update" \| "delete"` | The operation being checked. |
| `collection` | `string` | The collection slug. |
**Rules:**
- Code-defined access always takes precedence over database role/permission checks.
- Omitting an operation falls back to the database role/permission system.
- Super-admin always bypasses all access checks.
## Hooks
Hooks let you run custom logic at specific points in a document's lifecycle. Every hook property is an array of handlers - they run in array order and can modify the data flowing through (for `before*` hooks).
### Eight available hooks
| Hook | Triggers on | Can modify |
|---|---|---|
| `beforeOperation` | Before any operation begins. | Yes (operation arguments) |
| `beforeValidate` | Before validation during create/update. | Yes (data) |
| `beforeChange` | After validation passes, before the database write during create/update. | Yes (data) |
| `afterChange` | After the database write during create/update. | No (side effects) |
| `beforeRead` | Before reading from the database. | Yes (query parameters) |
| `afterRead` | After reading from the database. | Yes (transform output) |
| `beforeDelete` | Before deletion. Throw to prevent. | No |
| `afterDelete` | After deletion. | No (side effects) |
### Execution order
1. `beforeOperation`
2. `beforeValidate` (create/update)
3. **Validation** - the schema's declared rules are enforced
4. `beforeChange` (create/update)
5. **Database write or read**
6. `afterChange` (create/update) **or** `afterRead` (reads)
7. `beforeDelete` / `afterDelete` (deletes)
The validation gate is what separates the two write hooks. Use `beforeValidate`
to supply or repair a value you want the rules applied to; use `beforeChange` to
derive the value that gets stored, knowing the document has already passed them.
What a `beforeChange` handler returns is written without being re-validated.
### Hook handler signature
Every handler receives a `HookContext` object:
```typescript
hooks: {
beforeChange: [
async ({ data, operation, collection, user, context, req }) => {
if (operation === "create" && !data.slug) {
return { ...data, slug: slugify(data.title) };
}
return data;
},
],
afterChange: [
async ({ data, req }) => {
// Use req.nextly for the Direct API inside hooks
await req?.nextly?.create({
collection: "activity-logs",
data: { action: "post_updated", postId: data.id },
});
},
],
}
```
**Return-value behavior:**
- `before*` hooks: return modified data to pass to the next hook (or the DB).
- `after*` hooks: return value is ignored; use these for side effects.
- Throwing aborts the operation and rolls back the transaction.
### When to use which hook
- `beforeOperation` - validate the request, short-circuit before reading args (rate-limit metadata, audit logging entry).
- `beforeValidate` - coerce inputs (trim strings, normalize emails, derive slugs).
- `beforeChange` - final mutations before the row is persisted (hash passwords, stamp metadata).
- `afterChange` - outbound side effects (cache busting, webhooks, queue jobs).
- `beforeRead` - modify query filters (multi-tenant scoping, soft-delete filtering).
- `afterRead` - shape the response (compute virtual fields, redact sensitive properties).
- `beforeDelete` - block deletes when there are dependents.
- `afterDelete` - cascade cleanup (delete files, revoke tokens, audit log).
## Next steps
- [Fields](https://nextlyhq.com/docs/configuration/fields) - every field type and validation option
- [Singles](https://nextlyhq.com/docs/configuration/singles) - single-document content like site settings
- [Field Groups](https://nextlyhq.com/docs/configuration/field-groups) - reusable field groups for collections and singles
- [Visual Schema Builder](https://nextlyhq.com/docs/schema-builder) - create collections visually with drag-and-drop
- [Direct API](https://nextlyhq.com/docs/api-reference/direct-api) - query collections from server-side code
---
title: Singles
description: Define single-document content like site settings, headers, and footers.
url: https://nextlyhq.com/docs/configuration/singles
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
A single is a one-off document — content that exists as exactly one instance. Site settings, navigation headers, footers, and homepage configurations are typical singles. Unlike [collections](https://nextlyhq.com/docs/configuration/collections), singles have no list view, no create/delete operations, and are auto-created on first access.
> **Source of truth:** the `SingleConfig` interface lives in `packages/nextly/src/singles/config/types.ts`. The `defineSingle()` helper is in `packages/nextly/src/singles/config/define-single.ts`.
**Code-first:**
## Defining a single
Use `defineSingle()` from `nextly`:
```typescript title="src/singles/site-settings.ts"
import {
defineSingle,
text,
upload,
group,
repeater,
} from "nextly";
export default defineSingle({
slug: "site-settings",
label: { singular: "Site Settings" },
admin: {
group: "Settings",
icon: "Settings",
description: "Global site configuration",
},
fields: [
text({ name: "siteName", required: true, label: "Site Name" }),
text({ name: "tagline", label: "Tagline" }),
upload({ name: "logo", relationTo: "media", label: "Logo" }),
upload({ name: "favicon", relationTo: "media", label: "Favicon" }),
group({
name: "seo",
label: "SEO Defaults",
fields: [
text({ name: "metaTitle", label: "Default Meta Title" }),
text({ name: "metaDescription", label: "Default Meta Description" }),
],
}),
repeater({
name: "socialLinks",
label: "Social Links",
fields: [
text({ name: "platform", required: true }),
text({ name: "url", required: true }),
],
}),
],
access: {
read: true,
update: ({ roles }) => roles.includes("admin"),
},
hooks: {
afterChange: [
async ({ data }) => {
await fetch("/api/revalidate?tag=site-settings", { method: "POST" });
},
],
},
});
```
Then register it in your config:
```typescript title="nextly.config.ts"
import { defineConfig } from "nextly";
import SiteSettings from "./src/singles/site-settings";
import Header from "./src/singles/header";
import Footer from "./src/singles/footer";
export default defineConfig({
singles: [SiteSettings, Header, Footer],
});
```
**Visual Schema Builder:**
## Creating a single in the Visual Schema Builder
The Nextly admin panel ships with a Visual Schema Builder for creating singles without writing code. (We'll just call it the Schema Builder below.)
1. Open the admin panel and navigate to **Builder > Singles**.
2. Click **Create Single**.
3. Enter a slug (e.g. `site-settings`) and a display label.
4. Add fields using the drag-and-drop field editor.
5. Configure access control and hooks through the UI.
6. Click **Save**.
The Schema Builder produces the same `SingleConfig` that code-first singles use. Builder-created singles can be exported to TypeScript at any time.
## How singles differ from collections
| | Collections | Singles |
|---|---|---|
| **Entries** | Many | Exactly one |
| **List view** | Yes | No |
| **Create/Delete** | Yes | No (auto-created on first access; cannot be deleted) |
| **Access control operations** | `create`, `read`, `update`, `delete` | `read`, `update` only |
| **Hooks** | 8 hooks | 4 hooks (`beforeRead`, `afterRead`, `beforeChange`, `afterChange`) |
| **Default table prefix** | None — uses slug as table name | `single_` (e.g. `single_site_settings`) |
| **API endpoint** | `/api/[slug]` | `/api/singles/[slug]` |
## Single options
Only `slug` and `fields` are required.
### `slug`
| | |
|---|---|
| **Type** | `string` |
| **Required** | Yes |
Unique identifier across all singles **and** collections **and** field groups. Used as the API endpoint path and database table name (with the `single_` prefix unless `dbName` is set). Must be lowercase and URL-friendly.
### `fields`
| | |
|---|---|
| **Type** | `FieldConfig[]` |
| **Required** | Yes |
Array of field definitions. Singles support the same field types as collections. See [Fields](https://nextlyhq.com/docs/configuration/fields).
### `label`
| | |
|---|---|
| **Type** | `{ singular: string }` |
| **Default** | Auto-generated from slug |
Display label in the admin sidebar, breadcrumbs, and page titles. Singles only need a singular label since there is exactly one document.
### `dbName`
| | |
|---|---|
| **Type** | `string` |
| **Default** | `single_[slug]` (e.g. `single_site_settings`) |
Custom database table name.
### `description`
| | |
|---|---|
| **Type** | `string` |
| **Default** | `undefined` |
Description displayed in the admin UI. Falls back to `admin.description`.
### `sanitize`
| | |
|---|---|
| **Type** | `boolean` |
| **Default** | `true` |
When enabled, the global sanitization hook strips HTML tags from plain-text fields (`text`, `textarea`, `email`) before storage. Set to `false` only if the single intentionally stores HTML in those fields.
### `custom`
| | |
|---|---|
| **Type** | `Record` |
| **Default** | `undefined` |
Arbitrary metadata for hooks, plugins, or custom code. Not persisted to the database.
## Admin options
Configure how the single appears in the admin panel via the `admin` property.
| Property | Type | Default | Description |
|---|---|---|---|
| `group` | `string` | None | Sidebar group name. |
| `icon` | `string` | None | Lucide icon name (e.g. `"Settings"`, `"Menu"`, `"Home"`). |
| `hidden` | `boolean` | `false` | Hide from sidebar navigation (still reachable via direct URL and API). |
| `order` | `number` | `100` | Sort order within sidebar group (lower = higher). |
| `sidebarGroup` | `string` | None | Custom sidebar group slug; moves the entry from its default section to a custom group. |
| `description` | `string` | None | Help text below the single title. |
> **Singles do not support `useAsTitle`, `pagination`, `preview`, or admin-component overrides** — those concepts are collection-specific.
## Access control
Singles only support `read` and `update` — there is no create or delete since the document is auto-created on first access and cannot be removed.
```typescript
access: {
read: true,
update: ({ roles }) => roles.includes("admin"),
}
```
Each operation accepts a function (returning `boolean | Promise`), a literal `boolean`, or can be omitted to fall back to database role/permission checks. Code-defined access takes precedence; super-admin always bypasses checks. The `AccessControlContext` shape is the same as for [collections](https://nextlyhq.com/docs/configuration/collections#access-control).
## Hooks
Singles support four lifecycle hooks — a subset of the eight available on collections.
### Five available hooks
| Hook | Triggers on | Can modify |
|---|---|---|
| `beforeRead` | Before reading from the database. | Yes (query parameters) |
| `afterRead` | After reading from the database. | Yes (transform output) |
| `beforeValidate` | Before the schema's rules are enforced on an update. | Yes (data) |
| `beforeChange` | After validation passes, before the database write. | Yes (data) |
| `afterChange` | After the database write. | No (side effects) |
### Execution order — read
1. `beforeRead`
2. **Database read**
3. `afterRead`
### Execution order — update
1. `beforeValidate`
2. **Validation** - the schema's declared rules are enforced
3. `beforeChange`
4. **Database update**
5. `afterChange`
The validation gate is what separates the two write hooks. Use `beforeValidate`
to supply or repair a value you want the rules applied to; use `beforeChange` to
derive the value that gets stored, knowing the document has already passed them.
What a `beforeChange` handler returns is written without being re-validated.
### Example: cache invalidation
```typescript title="src/singles/site-settings.ts"
hooks: {
afterChange: [
async ({ data }) => {
// Invalidate ISR / CDN caches when settings change
await fetch("/api/revalidate?tag=site-settings", { method: "POST" });
},
],
afterRead: [
async ({ data }) => {
// Add a computed field
return { ...data, fullTitle: `${data.siteName} - ${data.tagline}` };
},
],
}
```
### When to use which hook
- `beforeRead` — modify query filters (e.g., scope to the current tenant), audit logging.
- `afterRead` — compute virtual properties, redact sensitive fields.
- `beforeValidate` — coerce inputs (trim strings, normalize casing), or supply a value the rules require.
- `beforeChange` — final mutations before the row is persisted, on data already known to be valid.
- `afterChange` — cache busting, webhooks, queue jobs.
Throwing inside any hook aborts the operation and rolls back the transaction.
## Example: header navigation
```typescript title="src/singles/header.ts"
import { defineSingle, repeater, text, relationship } from "nextly";
export default defineSingle({
slug: "header",
label: { singular: "Header Navigation" },
admin: {
group: "Navigation",
icon: "Menu",
},
fields: [
repeater({
name: "navItems",
label: "Navigation Items",
fields: [
text({ name: "label", required: true }),
text({ name: "url" }),
relationship({ name: "page", relationTo: "pages" }),
],
}),
],
access: {
read: true,
update: ({ roles }) => roles.includes("admin") || roles.includes("editor"),
},
});
```
## Next steps
- [Collections](https://nextlyhq.com/docs/configuration/collections) — content types with multiple entries
- [Fields](https://nextlyhq.com/docs/configuration/fields) — every field type and validation option
- [Field Groups](https://nextlyhq.com/docs/configuration/field-groups) — reusable field groups for collections and singles
- [Visual Schema Builder](https://nextlyhq.com/docs/schema-builder) — create singles visually with drag-and-drop
---
title: Field Groups
description: Define reusable field groups that can be shared across collections and singles.
url: https://nextlyhq.com/docs/configuration/field-groups
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
Field groups are reusable field structures. Define a set of fields once as a field group, then embed it in any number of collections or singles. Each usage creates a separate data instance — field groups are schemas, not shared documents.
> **Source of truth:** the `FieldGroupConfig` interface lives in `packages/nextly/src/field-groups/config/types.ts`. The `defineFieldGroup()` helper is in `packages/nextly/src/field-groups/config/define-field-group.ts`.
Key characteristics:
- **Templates, not documents.** Field groups define a field structure; each embed creates its own data row.
- **Own database table.** Each field group gets a table derived from its slug and prefixed with `comp_` (e.g. `comp_seo`). The name is always derived; it cannot be overridden.
- **Nesting.** A field group's fields can include `component` fields referencing other field groups (max depth: 3 levels).
- **Slug uniqueness.** Field group slugs must be unique across field groups, collections, **and** singles.
- **Dual creation.** Define in code with `defineFieldGroup()` or visually in the Visual Schema Builder.
**Code-first:**
Use `defineFieldGroup()` from `nextly` to create field groups in TypeScript:
```typescript title="src/field-groups/seo.ts"
import {
defineFieldGroup,
text,
textarea,
upload,
} from "nextly";
export default defineFieldGroup({
slug: "seo",
label: { singular: "SEO Metadata" },
admin: {
category: "Shared",
icon: "Search",
description: "Search engine optimization metadata",
},
fields: [
text({ name: "metaTitle", required: true, label: "Meta Title" }),
textarea({
name: "metaDescription",
label: "Meta Description",
maxLength: 160,
}),
upload({ name: "ogImage", relationTo: "media", label: "OG Image" }),
text({ name: "canonicalUrl", label: "Canonical URL" }),
],
});
```
**Visual Schema Builder:**
Field groups can also be created visually in the admin UI:
1. Navigate to **Field Groups** in the sidebar.
2. Click **Create Field Group**.
3. Name it and add fields using the drag-and-drop Schema Builder.
4. Save — the field group is immediately available for use.
Builder-created field groups work identically to code-defined ones; both produce the same database schema and API behavior.
## Field group options
Only `slug` and `fields` are required.
| Option | Type | Required | Description |
|---|---|---|---|
| `slug` | `string` | Yes | Unique identifier across field groups, collections, and singles. Used as the DB table prefix (`comp_{slug}`). Must be URL-friendly. |
| `fields` | `FieldConfig[]` | Yes | Array of [field definitions](https://nextlyhq.com/docs/configuration/fields). |
| `label` | `{ singular: string }` | No | Display name in the admin UI. Auto-generated from slug if omitted (e.g. `social-link` → `Social Link`). |
| `admin.category` | `string` | No | Category that groups field groups in the sidebar and selector modal (e.g. `"Shared"`, `"Blocks"`). |
| `admin.icon` | `string` | No | Lucide icon name shown in sidebar and field group selector. |
| `admin.description` | `string` | No | Help text in the field group selector modal. |
| `admin.hidden` | `boolean` | No | Hide from admin navigation. Still usable in code and via API. |
| `admin.imageURL` | `string` | No | Preview image URL shown in the field group selector. |
| `description` | `string` | No | General description; falls back to `admin.description`. |
| `custom` | `Record` | No | Arbitrary metadata for plugins or custom code. Not persisted. |
> **Field groups do not have hooks or access control of their own.** They inherit hook execution and access checks from the collection or single they're embedded in.
## Using field groups in collections and singles
Once defined, embed a field group in any collection or single using the `fieldGroup()` field helper. There are three usage modes.
### Single field group (fixed type)
Embed exactly one instance of a specific field group:
```typescript title="src/collections/pages.ts"
import { defineCollection, fieldGroup, text, richText } from "nextly";
export default defineCollection({
slug: "pages",
fields: [
text({ name: "title", required: true }),
richText({ name: "content" }),
fieldGroup({ name: "seo", component: "seo" }),
],
});
```
### Dynamic zone (multiple field group types)
Let editors choose from several field group types, which suits flexible page builders:
```typescript title="src/collections/pages.ts"
import { defineCollection, fieldGroup, text } from "nextly";
export default defineCollection({
slug: "pages",
fields: [
text({ name: "title", required: true }),
fieldGroup({
name: "layout",
components: ["hero", "cta", "content-block", "image-gallery"],
repeatable: true,
}),
],
});
```
### Repeatable single field group
An array of the same field group, for example a list of feature cards:
```typescript title="src/collections/landing-pages.ts"
import { defineCollection, fieldGroup, text } from "nextly";
export default defineCollection({
slug: "landing-pages",
fields: [
text({ name: "title", required: true }),
fieldGroup({
name: "features",
component: "feature-card",
repeatable: true,
minRows: 1,
maxRows: 12,
}),
],
});
```
The `component` field's full option reference lives in [Fields → Component](https://nextlyhq.com/docs/configuration/fields#component).
## Example: hero section field group
```typescript title="src/field-groups/hero.ts"
import {
defineFieldGroup,
text,
upload,
select,
option,
} from "nextly";
export default defineFieldGroup({
slug: "hero",
label: { singular: "Hero Section" },
admin: {
category: "Blocks",
icon: "Image",
description: "Full-width hero banner with heading and CTA",
},
fields: [
text({ name: "heading", required: true, label: "Heading" }),
text({ name: "subheading", label: "Subheading" }),
upload({
name: "backgroundImage",
relationTo: "media",
label: "Background Image",
}),
text({ name: "ctaText", label: "CTA Button Text" }),
text({ name: "ctaLink", label: "CTA Button Link" }),
select({
name: "alignment",
label: "Content Alignment",
options: [option("Left"), option("Center"), option("Right")],
defaultValue: "center",
}),
],
});
```
## Field group nesting
Field groups can embed other field groups using the `component` field type, up to **3 levels deep**. `defineConfig()` rejects circular references and configurations that exceed the depth limit at startup.
```typescript title="src/field-groups/faq-item.ts"
import { defineFieldGroup, text, fieldGroup } from "nextly";
export default defineFieldGroup({
slug: "faq-item",
label: { singular: "FAQ Item" },
fields: [
text({ name: "question", required: true }),
text({ name: "answer", required: true }),
fieldGroup({ name: "cta", component: "cta" }),
],
});
```
## Next steps
- [Fields](https://nextlyhq.com/docs/configuration/fields) — all field types available inside field groups
- [Collections](https://nextlyhq.com/docs/configuration/collections) — where field groups are most commonly used
- [Singles](https://nextlyhq.com/docs/configuration/singles) — embed field groups in single-document content
---
title: Fields
description: Complete reference for every field type, their options, and usage patterns.
url: https://nextlyhq.com/docs/configuration/fields
---
Fields define the shape of your content. Every [collection](https://nextlyhq.com/docs/configuration/collections), [single](https://nextlyhq.com/docs/configuration/singles), and [field group](https://nextlyhq.com/docs/configuration/field-groups) is composed of fields.
Nextly exports field-helper functions from `nextly`. They eliminate the need to set the `type` property manually and give you full TypeScript autocomplete.
```typescript
import { text, number, select, option } from "nextly";
const fields = [
text({ name: "title", required: true }),
number({ name: "price", min: 0 }),
select({
name: "priority",
options: [option("Low"), option("Medium"), option("High")],
}),
];
```
> **Status / Draft-Published is not a `select` field.** If you want a Draft / Published lifecycle on a collection, use the built-in `status: true` flag on `defineCollection` (or the Advanced-tab toggle in the Schema Builder) -- not a hand-rolled `select` field. See [Draft / Published status](https://nextlyhq.com/docs/guides/draft-published-status).
> **Source of truth:** `packages/nextly/src/collections/fields/types/` (one file per field type) and `packages/nextly/src/collections/fields/helpers.ts` (the helper exports).
## Field categories
Eight categories partition the field inventory.
| Category | Fields |
|---|---|
| [Basic](#basic) | `text`, `textarea`, `richText`, `email`, `password`, `code`, `number`, `checkbox`, `date` |
| [Selection](#selection) | `select`, `radio`, `chips` |
| [Media](#media) | `upload` |
| [Relationship](#relationship) | `relationship` |
| [Layout](#layout) | `repeater`, `group` |
| [Component](#component-1) | `component` |
| [Advanced](#advanced) | `json` |
| [Virtual](#virtual) | `join` |
The "Common options across fields" section at the bottom lists the shared properties (name, label, required, admin, access, hooks, validate, custom).
---
## Basic
### text
Single-line text input. Supports `hasMany` mode for storing arrays of strings (tag-style input).
```typescript
text({ name: "title", required: true })
```
| Option | Type | Default | Description |
|---|---|---|---|
| `name` | `string` | — | **Required.** Unique field identifier. Lowercase, no spaces. |
| `minLength` | `number` | — | Minimum string length. |
| `maxLength` | `number` | — | Maximum string length. Also sets the DB column size. |
| `hasMany` | `boolean` | `false` | Accept an array of strings. |
| `minRows` | `number` | — | Min items when `hasMany: true`. |
| `maxRows` | `number` | — | Max items when `hasMany: true`. |
| `defaultValue` | `string \| string[] \| (data) => …` | — | Static default or function. |
| `admin.autoComplete` | `string` | — | HTML `autocomplete` attribute (e.g. `"name"`, `"tel"`). |
```typescript
text({ name: "title", required: true, maxLength: 200 });
// Multiple values (tags)
text({
name: "tags",
hasMany: true,
minRows: 1,
maxRows: 10,
});
```
**Gotchas:** `maxLength` is the database column width — pick it once and migrate carefully if you change it.
---
### textarea
Multi-line text input.
```typescript
textarea({ name: "description", maxLength: 1000 })
```
| Option | Type | Default | Description |
|---|---|---|---|
| `minLength` | `number` | — | Minimum string length. |
| `maxLength` | `number` | — | Maximum string length. |
| `defaultValue` | `string \| (data) => string` | — | Static default or function. |
| `admin.rows` | `number` | `3` | Number of visible text rows. |
| `admin.resize` | `"vertical" \| "horizontal" \| "both" \| "none"` | `"vertical"` | Resize behavior. |
```typescript
textarea({
name: "bio",
admin: { rows: 5, resize: "none" },
});
```
---
### richText
Full-featured WYSIWYG editor powered by Lexical. Content is stored as a Lexical editor-state JSON object.
```typescript
richText({ name: "content" })
```
| Option | Type | Default | Description |
|---|---|---|---|
| `features` | `RichTextFeature[]` | See "Default features" below | Enabled editor features. Pass `[]` for plain text. |
| `defaultValue` | `RichTextValue \| (data) => RichTextValue` | — | Static default or function. |
| `admin.hideToolbar` | `boolean` | `false` | Hide the editor toolbar (keyboard shortcuts still work). |
**Default features** when `features` is omitted: `bold`, `italic`, `underline`, `strikethrough`, `code`, `h1`–`h4`, `orderedList`, `unorderedList`, `indent`, `blockquote`, `link`.
**All available features** (`RichTextFeature` union):
| Category | Features |
|---|---|
| Formatting | `bold`, `italic`, `underline`, `strikethrough`, `code`, `subscript`, `superscript` |
| Text styling | `fontFamily`, `fontSize`, `fontColor`, `bgColor` |
| Headings | `h1`, `h2`, `h3`, `h4`, `h5`, `h6` |
| Lists | `orderedList`, `unorderedList`, `checkList`, `indent` |
| Links and media | `link`, `upload`, `relationship` |
| Advanced | `table`, `horizontalRule`, `codeBlock`, `align` |
| Rich media | `video`, `buttonLink`, `collapsible`, `gallery` |
```typescript
// Simple blog editor
richText({
name: "content",
features: ["bold", "italic", "link", "h2", "h3", "orderedList", "unorderedList"],
});
// Full-featured editor
richText({
name: "body",
features: [
"bold", "italic", "underline", "strikethrough", "code",
"h1", "h2", "h3", "blockquote",
"orderedList", "unorderedList", "checkList",
"link", "upload", "table", "codeBlock",
],
});
```
**Gotchas:** the value is a JSON object (`{ root: { children: […] } }`) — not an HTML string. Use the REST endpoint's `?richTextFormat=html` query param if you want pre-rendered HTML.
---
### email
Specialised text input with built-in email-format validation.
```typescript
email({ name: "email", required: true, unique: true })
```
| Option | Type | Default | Description |
|---|---|---|---|
| `defaultValue` | `string \| (data) => string` | — | Static default or function. |
| `admin.autoComplete` | `string` | `"email"` | HTML `autocomplete` attribute. |
Custom `validate` runs **in addition to** the built-in email-format check, so you can layer on domain restrictions without losing format validation.
```typescript
email({
name: "workEmail",
validate: (value) =>
!value || value.endsWith("@company.com")
? true
: "Must be a company email address",
});
```
---
### password
Masked text input for passwords. Values should be hashed before storage using a `beforeChange` hook, and `read` access should return `false` so hashed values don't escape the database.
```typescript
password({
name: "password",
required: true,
minLength: 8,
})
```
| Option | Type | Default | Description |
|---|---|---|---|
| `minLength` | `number` | — | Minimum password length. (Helper-level default is none; documented recommended minimum is 8.) |
| `maxLength` | `number` | — | Maximum password length. Most hashing algorithms cap around 72–128 bytes. |
| `defaultValue` | `string \| (data) => string` | — | Static default. Setting one is rarely a good idea security-wise. |
| `admin.autoComplete` | `"new-password" \| "current-password" \| "off"` | `"new-password"` | HTML `autocomplete` attribute. |
| `admin.showStrengthIndicator` | `boolean` | `false` | Show a visual password-strength meter. |
Full working example:
```typescript
password({
name: "password",
required: true,
minLength: 8,
access: { read: () => false },
hooks: {
beforeChange: [
async ({ value }) => (value ? await bcrypt.hash(value, 10) : value),
],
},
});
```
**Gotchas:** there is no built-in hashing — the field type stores whatever you give it. Always pair with a hashing hook and a `read: () => false` field-level access rule.
---
### code
Code editor with syntax highlighting. Supports 29 languages.
```typescript
code({
name: "snippet",
admin: { language: "javascript" },
})
```
| Option | Type | Default | Description |
|---|---|---|---|
| `defaultValue` | `string \| (data) => string` | — | Static default or function. |
| `admin.language` | `CodeLanguage` | `"plaintext"` | Syntax-highlighting language. |
| `admin.editorOptions.lineNumbers` | `boolean` | `true` | Show line numbers. |
| `admin.editorOptions.wordWrap` | `boolean` | `false` | Enable word wrapping. |
| `admin.editorOptions.tabSize` | `number` | `2` | Tab width in spaces. |
| `admin.editorOptions.useTabs` | `boolean` | `false` | Use tabs instead of spaces. |
| `admin.editorOptions.minHeight` | `number` | `200` | Minimum editor height in px. |
| `admin.editorOptions.maxHeight` | `number` | — | Maximum editor height in px. |
| `admin.editorOptions.fontSize` | `number` | `14` | Font size in px. |
| `admin.editorOptions.fontFamily` | `string` | `"monospace"` | Editor font family. |
| `admin.editorOptions.folding` | `boolean` | `true` | Enable code folding. |
| `admin.editorOptions.matchBrackets` | `boolean` | `true` | Highlight matching brackets. |
| `admin.editorOptions.autoCloseBrackets` | `boolean` | `true` | Auto-close brackets and quotes. |
**Supported `CodeLanguage` values:** `javascript`, `typescript`, `jsx`, `tsx`, `html`, `css`, `scss`, `less`, `json`, `markdown`, `yaml`, `xml`, `sql`, `graphql`, `python`, `ruby`, `php`, `java`, `c`, `cpp`, `csharp`, `go`, `rust`, `swift`, `kotlin`, `shell`, `bash`, `powershell`, `dockerfile`, `plaintext`.
```typescript
code({
name: "snippet",
admin: {
language: "javascript",
editorOptions: { lineNumbers: true, minHeight: 300 },
},
});
```
---
### number
Numeric input for integers or decimals. Supports `hasMany` mode for arrays of numbers.
```typescript
number({ name: "price", required: true, min: 0, admin: { step: 0.01 } })
```
| Option | Type | Default | Description |
|---|---|---|---|
| `min` | `number` | — | Minimum allowed value. |
| `max` | `number` | — | Maximum allowed value. |
| `hasMany` | `boolean` | `false` | Accept an array of numbers. |
| `minRows` | `number` | — | Min items when `hasMany: true`. |
| `maxRows` | `number` | — | Max items when `hasMany: true`. |
| `defaultValue` | `number \| number[] \| (data) => …` | — | Static default or function. |
| `admin.step` | `number` | `1` | Increment step for spinner buttons. |
| `admin.placeholder` | `string` | — | Placeholder text. |
```typescript
number({ name: "rating", min: 1, max: 5, admin: { step: 1 } });
```
---
### checkbox
Boolean true/false toggle.
```typescript
checkbox({ name: "published", defaultValue: false })
```
| Option | Type | Default | Description |
|---|---|---|---|
| `defaultValue` | `boolean \| (data) => boolean` | — | Initial value. If `required: true` and unset, defaults to `false`. |
```typescript
checkbox({
name: "featured",
label: "Featured Post",
admin: { position: "sidebar" },
});
// Required terms-acceptance
checkbox({
name: "termsAccepted",
required: true,
validate: (value) =>
value === true ? true : "You must accept the terms to continue",
});
```
---
### date
Date and/or time picker. Dates are stored in UTC as ISO 8601 strings.
```typescript
date({ name: "publishedAt" })
```
| Option | Type | Default | Description |
|---|---|---|---|
| `defaultValue` | `string \| Date \| (data) => string \| Date` | — | Static default or function. |
| `admin.date.pickerAppearance` | `"dayOnly" \| "dayAndTime" \| "timeOnly" \| "monthOnly"` | `"dayOnly"` | Picker mode. |
| `admin.date.displayFormat` | `string` | — | Display format string (date-fns format). |
| `admin.date.monthsToShow` | `1 \| 2` | `1` | Months visible in the picker (max 2). |
| `admin.date.minDate` | `Date \| string` | — | Earliest selectable date. |
| `admin.date.maxDate` | `Date \| string` | — | Latest selectable date. |
| `admin.date.minTime` | `Date \| string` | — | Earliest selectable time (when picker includes time). |
| `admin.date.maxTime` | `Date \| string` | — | Latest selectable time. |
| `admin.date.timeIntervals` | `number` | `30` | Time step in minutes. |
| `admin.date.timeFormat` | `string` | `"h:mm aa"` | Time display format. |
```typescript
// Date and time
date({
name: "eventStart",
admin: {
date: { pickerAppearance: "dayAndTime", timeIntervals: 15 },
},
});
// Time only
date({
name: "openingTime",
admin: {
date: { pickerAppearance: "timeOnly", timeFormat: "HH:mm" },
},
});
// Month only (e.g., card expiration)
date({
name: "expirationMonth",
admin: {
date: { pickerAppearance: "monthOnly", displayFormat: "MM/yyyy" },
},
});
```
**Gotchas:** values are stored in UTC; format display in the client when displaying to users.
---
## Selection
### select
Dropdown for choosing from predefined options. Supports single or multi-select with searchable input.
```typescript
import { select, option } from "nextly";
select({
name: "priority",
options: [option("Low"), option("Medium"), option("High")],
})
```
> Use `select` for arbitrary categorical options (priority, region, theme, etc.). For a collection's Draft / Published lifecycle, prefer the built-in `status: true` flag on `defineCollection` -- see [Draft / Published status](https://nextlyhq.com/docs/guides/draft-published-status).
| Option | Type | Default | Description |
|---|---|---|---|
| `options` | `SelectOption[]` | — | **Required.** Array of `{ label, value }` objects. |
| `hasMany` | `boolean` | `false` | Allow multiple selections. |
| `enumName` | `string` | Auto | Custom SQL enum name. |
| `interfaceName` | `string` | — | TypeScript interface name for code generation. |
| `filterOptions` | `(args) => SelectOption[]` | — | Dynamically filter options based on data, sibling data, and user. |
| `defaultValue` | `string \| string[] \| (data) => …` | — | Default value(s). |
| `admin.isClearable` | `boolean` | `false` | Show a clear button. |
| `admin.isSortable` | `boolean` | `false` | Enable drag-and-drop reorder (multi-select only). |
The `option()` helper auto-generates the value from the label by lowercasing it and replacing spaces with underscores. Pass an explicit second arg if you want a different value.
```typescript
option("Draft"); // { label: "Draft", value: "draft" }
option("In Review"); // { label: "In Review", value: "in_review" }
option("Active", "active");
```
```typescript
// Multi-select
select({
name: "categories",
hasMany: true,
options: [option("Tech"), option("Business"), option("Design")],
admin: { isClearable: true, isSortable: true },
});
// Cascading dropdown
select({
name: "subcategory",
options: allSubcategories,
filterOptions: ({ data }) =>
allSubcategories.filter((s) => s.parentId === data.category),
});
```
**Gotchas:** option `value` strings should not contain hyphens or special characters — that's a GraphQL enum constraint. Underscores are fine.
---
### radio
Radio button group for single selection. Best when all options should be visible at once.
```typescript
radio({
name: "priority",
options: [option("Low"), option("Medium"), option("High")],
})
```
| Option | Type | Default | Description |
|---|---|---|---|
| `options` | `SelectOption[]` | — | **Required.** Array of `{ label, value }` objects. |
| `enumName` | `string` | Auto | Custom SQL enum name. |
| `interfaceName` | `string` | — | TypeScript interface name for code generation. |
| `defaultValue` | `string \| (data) => string` | — | Default value (must match an option). |
| `admin.layout` | `"horizontal" \| "vertical"` | `"horizontal"` | Button layout direction. |
```typescript
radio({
name: "size",
options: [option("S"), option("M"), option("L"), option("XL")],
admin: { layout: "horizontal" },
});
```
---
### chips
Free-form multi-value string field that stores an array of unique strings. Renders as interactive chips/tags. Duplicate entries are automatically prevented.
```typescript
chips({ name: "tags" })
```
| Option | Type | Default | Description |
|---|---|---|---|
| `defaultValue` | `string[] \| (data) => string[]` | — | Initial chips. |
| `maxChips` | `number` | — | Maximum number of chips allowed. The add input is hidden once reached. |
| `minChips` | `number` | — | Minimum number of chips required (validation). |
| `admin.placeholder` | `string` | `"Type and press Enter to add"` | Placeholder text for the input. |
```typescript
chips({
name: "keywords",
required: true,
minChips: 1,
maxChips: 10,
});
```
---
## Media
### upload
Reference files from upload-enabled collections. Works like `relationship` but with media-specific UI (thumbnails) and a richer `filterOptions` query schema.
```typescript
upload({ name: "featuredImage", relationTo: "media" })
```
| Option | Type | Default | Description |
|---|---|---|---|
| `relationTo` | `string \| string[]` | — | **Required.** Upload-collection slug or array of slugs (polymorphic). |
| `hasMany` | `boolean` | `false` | Allow multiple file selections. |
| `minRows` | `number` | — | Min uploads when `hasMany: true`. |
| `maxRows` | `number` | — | Max uploads when `hasMany: true`. |
| `maxDepth` | `number` | `1` | Population depth for the related document. |
| `maxFileSize` | `number` | — | Max file size in bytes (rejected before upload). |
| `mimeTypes` | `string` | — | Comma-separated allowed MIME types (e.g. `"image/*"` or `"image/png,application/pdf"`). |
| `filterOptions` | `UploadFilterQuery \| (args) => boolean \| UploadFilterQuery \| Promise<…>` | — | Filter available uploads. Static query or dynamic function. |
| `defaultValue` | See type union | — | Static default ID, array of IDs, polymorphic ref, or function. |
| `admin.allowCreate` | `boolean` | `true` | Allow uploading new files from the field. |
| `admin.allowEdit` | `boolean` | `true` | Allow editing upload metadata. |
| `admin.isSortable` | `boolean` | `true` | Drag-and-drop reorder when `hasMany: true`. |
| `admin.displayPreview` | `boolean` | inherited from collection | Show thumbnail preview. |
`filterOptions` static-query operators: `equals`, `not_equals`, `contains`, `in`, `not_in`, `exists` (strings); `equals`, `not_equals`, `greater_than`, `greater_than_equal`, `less_than`, `less_than_equal`, `exists` (numbers). Available filterable keys: `mimeType`, `filesize`, `width`, `height`, `filename`, `alt`, plus any custom field on the upload collection.
```typescript
// Gallery with constraints
upload({
name: "gallery",
relationTo: "media",
hasMany: true,
maxRows: 10,
filterOptions: { mimeType: { contains: "image" } },
});
// Polymorphic uploads
upload({
name: "attachment",
relationTo: ["media", "documents"],
});
// Dynamic filter — require HD images for hero
upload({
name: "heroImage",
relationTo: "media",
filterOptions: () => ({
mimeType: { contains: "image" },
width: { greater_than_equal: 1920 },
height: { greater_than_equal: 1080 },
}),
});
```
---
## Relationship
### relationship
Reference documents from other collections. Supports single, multi, and polymorphic relationships.
```typescript
relationship({ name: "author", relationTo: "users", required: true })
```
| Option | Type | Default | Description |
|---|---|---|---|
| `relationTo` | `string \| string[]` | — | **Required.** Target collection slug or array of slugs (polymorphic). |
| `hasMany` | `boolean` | `false` | Allow multiple document references. |
| `minRows` | `number` | — | Min selections when `hasMany: true`. |
| `maxRows` | `number` | — | Max selections when `hasMany: true`. |
| `maxDepth` | `number` | `1` | Population depth for nested relationships. |
| `filterOptions` | `RelationshipFilterQuery \| (args) => boolean \| RelationshipFilterQuery \| Promise<…>` | — | Filter available documents. Static query or dynamic function. |
| `defaultValue` | See type union | — | Static default ID, array of IDs, polymorphic ref, or function. |
| `admin.allowCreate` | `boolean` | `true` | Allow creating new documents from the field. |
| `admin.allowEdit` | `boolean` | `true` | Allow editing related documents. |
| `admin.isSortable` | `boolean` | `true` | Enable drag-and-drop reorder (multi only). |
| `admin.sortOptions` | `string \| Record` | — | Default sort. Prefix with `-` for descending. Per-collection mapping for polymorphic. |
| `admin.appearance` | `"select" \| "drawer"` | `"select"` | Picker UI style. Use `"drawer"` for large lists. |
```typescript
// Has many
relationship({
name: "categories",
relationTo: "categories",
hasMany: true,
maxRows: 5,
});
// Polymorphic
relationship({
name: "relatedContent",
relationTo: ["posts", "pages", "products"],
hasMany: true,
});
// Self-reference, exclude self
relationship({
name: "parent",
relationTo: "pages",
filterOptions: ({ id }) =>
id ? { id: { not_equals: id } } : true,
});
// Drawer picker for big lists
relationship({
name: "featuredProducts",
relationTo: "products",
hasMany: true,
admin: { appearance: "drawer", sortOptions: "-sales" },
});
```
**Gotchas:** when both `filterOptions` and `validate` are set, the filter is **not** automatically re-validated server-side — include the same constraint in `validate` if you need it enforced.
---
## Layout
### repeater
Repeatable sets of fields. Each row contains the same field structure. Rows can be added, removed, and reordered.
```typescript
repeater({
name: "links",
labels: { singular: "Link", plural: "Links" },
fields: [
text({ name: "label", required: true }),
text({ name: "url", required: true }),
],
})
```
| Option | Type | Default | Description |
|---|---|---|---|
| `fields` | `FieldConfig[]` | — | **Required.** Field definitions for each row. |
| `minRows` | `number` | — | Minimum rows. |
| `maxRows` | `number` | — | Maximum rows. The Add button is disabled when reached. |
| `labels` | `{ singular?: string; plural?: string }` | — | Custom row labels (e.g. `"Link"` / `"Links"`). |
| `interfaceName` | `string` | — | TypeScript interface name for code generation. |
| `dbName` | `string` | Auto | Custom database table name. |
| `virtual` | `boolean` | `false` | Skip database storage; field exists only in API. |
| `defaultValue` | `RepeaterRowValue[] \| (data) => RepeaterRowValue[]` | — | Static default rows. |
| `admin.initCollapsed` | `boolean` | `false` | Render rows initially collapsed. |
| `admin.isSortable` | `boolean` | `true` | Enable drag-and-drop reorder. |
| `admin.components.RowLabel` | `React.ComponentType` | — | Custom row-label component. |
```typescript
// FAQ with custom row labels
repeater({
name: "faq",
labels: { singular: "Question", plural: "Questions" },
fields: [
text({ name: "question", required: true }),
richText({ name: "answer", required: true }),
],
admin: {
initCollapsed: true,
// RowLabel component must be provided as a path string when using
// a server-rendered config; React component in client contexts.
},
});
```
> **Note:** Use `repeater()` to define this field. The internal `type` is `"repeater"`. (Some other CMSes call this concept "array".)
**Cross-links:** [Field Groups](https://nextlyhq.com/docs/configuration/field-groups#repeatable-single-field-group) covers the alternative repeatable field-group pattern when you want a typed schema instead of inline fields.
---
### group
Organize related fields together. Named groups create nested data; groups without a `name` are presentational only.
```typescript
group({
name: "seo",
fields: [
text({ name: "metaTitle", maxLength: 60 }),
textarea({ name: "metaDescription", maxLength: 160 }),
],
})
// Stored data: { seo: { metaTitle: "...", metaDescription: "..." } }
```
| Option | Type | Default | Description |
|---|---|---|---|
| `name` | `string` | — | If provided, fields are nested under this property. If omitted, the group is purely visual. |
| `fields` | `FieldConfig[]` | — | **Required.** Nested field definitions. |
| `interfaceName` | `string` | — | TypeScript interface name. |
| `dbName` | `string` | Auto | Custom database column/table name. |
| `virtual` | `boolean` | `false` | Skip database storage. |
| `defaultValue` | `Record \| (data) => …` | — | Default values for nested fields (named groups only). |
| `admin.hideGutter` | `boolean` | `false` | Remove the left-side visual gutter. |
```typescript
// Presentational group — no data nesting; fields stored at parent level
group({
label: "Display Settings",
fields: [
checkbox({ name: "showTitle", defaultValue: true }),
checkbox({ name: "showDate", defaultValue: true }),
],
admin: { hideGutter: true },
});
```
**Gotchas:** named groups create real nested objects in your data — keep that in mind for queries and migrations. Presentational groups are pure UI; their fields share the parent's data namespace.
---
## Component
### component
Embed reusable [field groups](https://nextlyhq.com/docs/configuration/field-groups) inside collections, singles, or other field groups. Three usage modes are supported.
```typescript
// Single fixed type
fieldGroup({ name: "seo", component: "seo" })
// Dynamic zone
fieldGroup({
name: "layout",
components: ["hero", "cta", "content-block"],
repeatable: true,
})
// Repeatable single type
fieldGroup({
name: "features",
component: "feature-card",
repeatable: true,
minRows: 1,
maxRows: 12,
})
```
| Option | Type | Default | Description |
|---|---|---|---|
| `component` | `string` | — | Single mode: one specific component slug. Mutually exclusive with `components`. |
| `components` | `string[]` | — | Dynamic-zone mode: array of allowed component slugs. Mutually exclusive with `component`. |
| `repeatable` | `boolean` | `false` | Allow multiple instances (array mode). |
| `minRows` | `number` | — | Min instances when `repeatable: true`. |
| `maxRows` | `number` | — | Max instances when `repeatable: true`. |
| `admin.initCollapsed` | `boolean` | `false` | Start instances collapsed. |
| `admin.isSortable` | `boolean` | `true` | Drag-and-drop reorder when `repeatable: true`. |
**Gotchas:** `component` and `components` are mutually exclusive; you'll get a startup error if both are set. Component nesting is capped at depth 3.
**Cross-links:** see [Field Groups](https://nextlyhq.com/docs/configuration/field-groups) for the `defineFieldGroup()` reference and [the three modes](https://nextlyhq.com/docs/configuration/field-groups#using-field-groups-in-collections-and-singles) explained side-by-side.
---
## Advanced
### json
Store arbitrary JSON data with an in-browser code editor. Supports JSON Schema validation.
```typescript
json({ name: "metadata" })
```
| Option | Type | Default | Description |
|---|---|---|---|
| `jsonSchema` | `JSONSchemaDefinition` | — | Inline JSON Schema for validation and editor hints. |
| `defaultValue` | Any valid JSON value or function | — | Default value. |
| `admin.editorOptions.height` | `number \| string` | `300` | Editor height. |
| `admin.editorOptions.minHeight` | `number` | `100` | Minimum height. |
| `admin.editorOptions.maxHeight` | `number` | — | Maximum height. |
| `admin.editorOptions.lineNumbers` | `boolean` | `true` | Show line numbers. |
| `admin.editorOptions.folding` | `boolean` | `true` | Enable code folding. |
| `admin.editorOptions.wordWrap` | `boolean` | `false` | Enable word wrapping. |
| `admin.editorOptions.minimap` | `boolean` | `false` | Show code minimap. |
| `admin.editorOptions.tabSize` | `number` | `2` | Tab size in spaces. |
| `admin.editorOptions.formatOnBlur` | `boolean` | `true` | Auto-prettify on blur. |
| `admin.editorOptions.validateOnChange` | `boolean` | `true` | Real-time syntax validation. |
**Database storage:** PostgreSQL uses `JSONB`, MySQL uses `JSON`, SQLite uses `TEXT`. Schema validation happens at the application level so behavior is consistent across adapters.
```typescript
json({
name: "settings",
jsonSchema: {
type: "object",
properties: {
theme: { type: "string", enum: ["light", "dark", "system"] },
maxItems: { type: "number", minimum: 1 },
},
required: ["theme"],
},
});
```
---
## Virtual
### join
Display reverse relationships — entries from another collection that reference the current document. Join fields are read-only and do **not** store data; they query at read time.
```typescript
{
name: "posts",
type: "join",
collection: "posts",
on: "category",
}
```
There is no helper function for `join` — declare it as a plain object with `type: "join"`.
| Option | Type | Default | Description |
|---|---|---|---|
| `type` | `"join"` | — | **Required.** Field type literal. |
| `name` | `string` | — | **Required.** Field identifier (no DB column is created). |
| `collection` | `string` | — | **Required.** Collection containing the referencing field. |
| `on` | `string` | — | **Required.** Field name in `collection` that references this document. Supports dot notation (e.g. `"metadata.author"`). |
| `where` | `Record` | — | Additional filter for joined entries (Nextly Where syntax). |
| `defaultLimit` | `number` | `10` | Max entries to display. Set `0` to display all. |
| `defaultSort` | `string` | — | Sort field. Prefix with `-` for descending. |
| `maxDepth` | `number` | `1` | Population depth for relationships in joined entries. |
| `label` | `string` | Auto | Display label. |
| `admin.allowNavigation` | `boolean` | `true` | Make entries clickable links. |
| `admin.allowCreate` | `boolean` | `false` | Show a "Create New" button (pre-fills the relationship). |
| `admin.defaultColumns` | `string[]` | — | Columns to display in the list. |
```typescript
// On a Categories collection — show all posts in this category
{
name: "posts",
type: "join",
label: "Posts in this Category",
collection: "posts",
on: "category",
defaultSort: "-createdAt",
admin: {
defaultColumns: ["title", "status", "createdAt"],
},
}
```
**Gotchas:** join fields are **read-only**; the related entries are still edited from their own collection's edit page. Only `name`, `label`, and `admin` are common with other field types — `required`, `unique`, `defaultValue`, `validate`, `access`, and `hooks` do not apply.
---
## Common options across fields
Every field type (except `join`, which is virtual) inherits the following from `BaseFieldConfig`. Where a specific field's reference table above doesn't repeat them, they still apply.
### Identity
| Option | Type | Default | Description |
|---|---|---|---|
| `name` | `string` | — | **Required.** Unique identifier. Lowercase, underscores or numbers — no hyphens, no spaces, no SQL reserved words. |
| `type` | `FieldType` | Set by helper | Field type literal. The helper functions (`text`, `number`, etc.) set this for you. |
| `label` | `string` | Auto from `name` | Display label in the admin UI (e.g. `user_name` → `User Name`). |
| `required` | `boolean` | `false` | Whether the field must have a non-null, non-empty value. |
| `unique` | `boolean` | `false` | Enforce database-level uniqueness. |
| `index` | `boolean` | `false` | Create a single-field database index. |
| `localized` | `boolean` | `false` | **Reserved for a future release.** When implemented, will store separate values per locale. |
| `custom` | `Record` | — | Arbitrary metadata for plugins or hooks. Not persisted. |
### `admin`
Shared UI options:
| Option | Type | Default | Description |
|---|---|---|---|
| `position` | `"sidebar"` | — | Place the field in the sidebar instead of the main content area. |
| `width` | `"25%" \| "33%" \| "50%" \| "66%" \| "75%" \| "100%"` | `"100%"` | Field width in the form layout grid. |
| `description` | `string` | — | Help text below the field label. |
| `placeholder` | `string` | — | Placeholder text when empty. |
| `readOnly` | `boolean` | `false` | Make the field read-only in the UI. |
| `disabled` | `boolean` | `false` | Disable the input. |
| `hidden` | `boolean` | `false` | Hide the field from the UI entirely (still settable via API). |
| `condition` | `FieldCondition` | — | Conditionally show/hide based on another field's value. |
| `className` | `string` | — | Custom CSS class on the wrapper. |
| `style` | `Record` | — | Inline styles on the wrapper. |
| `components.Field` | `React.ComponentType` | — | Custom form-field renderer. |
| `components.Cell` | `React.ComponentType` | — | Custom list/table cell renderer. |
| `components.Filter` | `React.ComponentType` | — | Custom list-view filter UI. |
#### Conditional logic
```typescript
text({
name: "externalUrl",
admin: {
condition: {
field: "linkType",
equals: "external",
},
},
});
```
Available `FieldCondition` operators: `equals`, `notEquals`, `contains`, `exists`.
### `access`
Field-level access control — granular permissions per CRUD operation.
```typescript
text({
name: "internalNotes",
access: {
create: ({ req }) => req.user?.role === "admin",
read: ({ req }) => req.user?.role === "admin",
update: ({ req }) => req.user?.role === "admin",
},
});
```
Each function receives `{ req, id?, data? }` and returns `boolean | Promise`. Field-level access is checked **in addition to** collection/single access.
### `hooks`
Field-level lifecycle hooks. Each hook is an array of handlers run in order.
| Hook | When it runs |
|---|---|
| `beforeValidate` | Before validation. Can transform the value. |
| `beforeChange` | Before the database write. |
| `afterChange` | After the database write. |
| `afterRead` | After reading from the database. Can transform the returned value. |
```typescript
text({
name: "slug",
hooks: {
beforeValidate: [
async ({ value, data }) => {
if (!value && data?.title) {
return data.title.toLowerCase().replace(/\s+/g, "-");
}
return value;
},
],
},
});
```
### `validate`
Custom validation function. Receives the typed field value plus `{ data, req }` and returns either `true` (valid) or an error-message string. Runs **after** the field's built-in validation.
```typescript
text({
name: "username",
validate: (value) => {
if (value && !/^[a-z0-9_]+$/.test(String(value))) {
return "Lowercase letters, numbers, and underscores only";
}
return true;
},
});
```
### `defaultValue`
Static value or `(data) => value` function. The function form receives the document data being created so you can compute defaults from sibling fields. Each field type's signature accepts the value type appropriate for that field.
---
## Helper utilities
### `option(label, value?)`
Creates a `{ label, value }` option for `select` and `radio` fields. Auto-generates the value from the label by lowercasing and replacing spaces with underscores.
```typescript
import { option } from "nextly";
option("Draft"); // { label: "Draft", value: "draft" }
option("In Progress"); // { label: "In Progress", value: "in_progress" }
option("Active", "active");
```
## Next steps
- [Collections](https://nextlyhq.com/docs/configuration/collections) — define content types using fields
- [Singles](https://nextlyhq.com/docs/configuration/singles) — define single-document content using fields
- [Field Groups](https://nextlyhq.com/docs/configuration/field-groups) — create reusable field groups
- [Visual Schema Builder](https://nextlyhq.com/docs/schema-builder) — build collections, singles, and field groups visually
---
title: Environment Variables
description: Every environment variable Nextly reads, organized by category with required vs. optional indicators.
url: https://nextlyhq.com/docs/configuration/environment
---
Nextly reads environment variables for database connections, authentication, storage, email, and other runtime settings. Most live in the central env schema (`packages/nextly/src/shared/lib/env.ts`); storage adapter env vars are per-adapter and read by the adapter packages directly.
Copy `.env.example` to `.env` and fill in values:
```bash
cp .env.example .env
```
## Canonical `.env.example`
The base template `templates/base/.env.example` ships the minimum required variables:
```bash title=".env.example"
# Nextly Configuration
# Generated by create-nextly-app
# Database Configuration
DB_DIALECT=postgresql
DATABASE_URL=postgresql://user:password@localhost:5432/nextly_dev
# Authentication (REQUIRED)
# Generate with: openssl rand -base64 32
NEXTLY_SECRET=change-me-generate-a-secure-secret
# Application URL
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Development diagnostics (opt-in — see below)
# NEXTLY_DEV_DIAGNOSTICS=1
# Storage (per-adapter — see "Storage" section below)
```
The `create-nextly-app` scaffolder fills in `DB_DIALECT`, `DATABASE_URL`, and an auto-generated 32-byte base64 `NEXTLY_SECRET` for you. For a production deployment, regenerate `NEXTLY_SECRET` with:
```bash
openssl rand -base64 32
```
---
## Database
Source: `packages/nextly/src/shared/lib/env.ts`.
| Variable | Required | Default | Description |
|---|---|---|---|
| `DB_DIALECT` | Yes | `postgresql` | Database dialect: `postgresql`, `mysql`, or `sqlite`. |
| `DATABASE_URL` | Yes (PostgreSQL/MySQL) | — | Full database connection URL. Required for `postgresql` and `mysql`; optional for `sqlite` (paired with `SQLITE_PATH`). Validated as a URL. |
| `SQLITE_PATH` | Optional (SQLite only) | `file:./data/nextly.db` (factory default) | SQLite file path; alternative to `DATABASE_URL` for the `sqlite` dialect. |
| `DB_POOL_MAX` | No | `20` | Max connections in the pool. Min: 1. |
| `DB_POOL_MIN` | No | `2` | Min connections in the pool. Min: 0. |
| `DB_POOL_IDLE_TIMEOUT` | No | `30000` | Idle connection timeout in milliseconds. Min: 1000. |
| `DB_QUERY_TIMEOUT` | No | `15000` | Query timeout in milliseconds. Min: 1000. |
| `DB_HEALTHCHECK_INTERVAL_MS` | No | `30000` | Health-check interval in milliseconds. Min: 1000. |
| `DB_SNAKE_CASE` | No | `false` | Use `snake_case` for database column names instead of `camelCase`. |
### Connection-string formats
```bash
# PostgreSQL (recommended for production)
DATABASE_URL=postgresql://user:password@localhost:5432/nextly_dev
# MySQL
DATABASE_URL=mysql://root:root@localhost:3306/nextly_dev
# SQLite (development / single-instance only)
DATABASE_URL=file:./dev.db
# or
SQLITE_PATH=./dev.db
```
---
## Runtime
| Variable | Required | Default | Description |
|---|---|---|---|
| `NODE_ENV` | No | `development` | Runtime environment: `development`, `production`, or `test`. Affects production-only checks below. |
---
## Development diagnostics
| Variable | Required | Default | Description |
|---|---|---|---|
| `NEXTLY_DEV_DIAGNOSTICS` | No | — | Set to `1` to add a `_devDiagnostics` field to error responses, carrying the error's log context and the message of its underlying cause. Ignored unless `NODE_ENV` is `development`. |
An error response is deliberately generic: it carries a code, a public message
and a request id, and withholds the log context and the underlying cause so a
response cannot disclose driver output, table names or internal paths. That is
right for a deployed app and unhelpful while you are building, where the detail
you need is exactly what was withheld.
With this set, an error in development also carries:
```json
{
"error": {
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred.",
"requestId": "req_01HQ...",
"_devDiagnostics": {
"logContext": { "collectionName": "posts", "entryId": "abc123" },
"cause": "duplicate key value violates unique constraint posts_slug_key",
"flattened": [{ "code": "CONFLICT", "logContext": { "field": "slug" } }]
}
}
}
```
`flattened` lists errors that were rebuilt on the way to the boundary, which is
what makes an unexpected failure name itself instead of looking like every
other one.
**Why it is opt-in rather than on by default.** The gate requires two
independent signals: `NODE_ENV` must be `development` **and** this flag must be
`1`. The second exists precisely because the first can be wrong — `nextly` ships
pre-built and stays external to your build, so `NODE_ENV` is a runtime value a
deployment can carry by mistake.
A default that shipped in `.env` would be true in exactly that case, which is
the one the second signal guards against, collapsing two independent signals
back into one. So `create-nextly-app` writes the setting **commented out** with
this explanation: discoverable, and never enabled by a file that travels with
your app.
Uncomment it in your local `.env` — not in a file you deploy — and keep it out
of any environment where `NODE_ENV` might not be what you expect.
---
## Authentication
Nextly uses a custom JWT auth system (access + refresh tokens, sessions, API keys, RBAC). There is **no** OAuth.
| Variable | Required | Default | Description |
|---|---|---|---|
| `NEXTLY_SECRET` | Yes (production: ≥ 32 chars) | — | Secret used to sign JWTs and other crypto. Production startup throws if it is missing or shorter than 32 characters. Generate with `openssl rand -base64 32`. |
| `NEXTLY_SECRET_PREVIOUS` | No | — | Secrets you have RETIRED, newest first. Kept for READING only — nothing new is ever keyed with one. See "Rotating `NEXTLY_SECRET`" below. |
| `NEXTLY_ALLOWED_ORIGINS` | No | — | Comma-separated list of additional origins allowed for CSRF validation (e.g. `https://admin.example.com,https://staging.example.com`). The app's own origin is always allowed. |
### Rotating `NEXTLY_SECRET`
Rotating the secret re-keys everything derived from it, and the values already
written do not move. For most of them that fails loudly and is fine — a webhook
signing secret that no longer decrypts is one an operator re-enters.
The email delivery log is the exception, because it fails **quietly**. Each row
identifies its recipient by an HMAC of their address, so after a rotation an
erasure request computes a digest none of the older rows carry, matches nothing,
and reports success. "No rows matched" is also what a person with no mail looks
like, so nothing distinguishes the two.
List the old secret in `NEXTLY_SECRET_PREVIOUS` and those rows stay reachable —
for lookups and for erasure — without becoming writable:
```bash
NEXTLY_SECRET=the-new-one
NEXTLY_SECRET_PREVIOUS=the-old-one,an-older-one
```
**Two spellings, because one of them cannot express every legal secret.** The
comma-separated form above is the ordinary one. Use the JSON-array form when a
retired secret contains a comma, or when leading or trailing whitespace is part
of the key — splitting or trimming those produces a key that was never used,
which matches nothing and fails in the silent direction:
```bash
# Single quotes are REQUIRED. Without them the shell strips the inner double
# quotes and the value becomes `[old,with,commas, spaced ]`, which is not
# JSON — it falls back to comma splitting and derives four keys that were never
# used, so lookups and erasure silently miss the rows this is meant to reach.
NEXTLY_SECRET_PREVIOUS='["old,with,commas"," spaced "]'
```
The JSON form also names two generations no string can:
- `null` is the **unkeyed** generation — what an install writes when it has no
`NEXTLY_SECRET` at all, which is only possible outside production. Rows
written then carry a plain SHA-256 digest that no HMAC reproduces, so
enabling a secret later strands them unless you name that generation.
- `""` is a secret that really was the empty string, which is used as an HMAC
key like any other.
```bash
# was unkeyed in development, now has a secret
NEXTLY_SECRET_PREVIOUS='[null]'
```
An empty entry in the **comma** form is dropped rather than rejected: a trailing
comma is the likeliest way to write the list, and nothing about `older,` says a
second key was meant. Write `'[""]'` when you mean it — with the outer single quotes, for the same
reason.
---
## Application URLs
| Variable | Required | Default | Description |
|---|---|---|---|
| `NEXT_PUBLIC_APP_URL` | Yes (production) | — | Public-facing app URL. Used in client-side code and as the fallback for email-link `baseUrl`. Production startup throws if missing. |
| `API_BASE_URL` | No | `http://localhost:3000/api` | Base URL for API routes. Validated as a URL. |
---
## Storage (per-adapter)
The default storage backend is **local disk** under `./public/uploads/`. No env vars are needed for default storage.
Cloud adapters are opt-in. The variables below are read **by the adapter packages**, not by the central env schema, so they only need to exist when you actually configure that adapter in `nextly.config.ts`. Nothing else (env-validation, etc.) checks for them.
### S3 / S3-compatible — `@nextlyhq/storage-s3`
Works with AWS S3, Cloudflare R2, MinIO, and DigitalOcean Spaces. The plugin accepts these values directly; the env-var names below are the conventional ones used in our examples (and what your `nextly.config.ts` typically references with `process.env.X`).
| Variable | Required | Description |
|---|---|---|
| `S3_BUCKET` | Yes | Bucket name. |
| `AWS_REGION` | Yes | AWS region (e.g. `us-east-1`). Use `auto` for Cloudflare R2. |
| `AWS_ACCESS_KEY_ID` | Yes | Access key ID. |
| `AWS_SECRET_ACCESS_KEY` | Yes | Secret access key. |
| `S3_ENDPOINT` | Sometimes | Custom endpoint URL. Required for R2 and MinIO. |
| `S3_PUBLIC_URL` | No | Public URL prefix (e.g. `https://pub-xxxx.r2.dev` for R2 or a CDN domain). |
| `S3_FORCE_PATH_STYLE` | No | Set `true` for MinIO and other path-style providers. |
```typescript title="nextly.config.ts"
import { s3Storage } from "@nextlyhq/storage-s3";
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!,
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
publicUrl: process.env.S3_PUBLIC_URL,
collections: { media: true },
}),
];
```
### Vercel Blob — `@nextlyhq/storage-vercel-blob`
Recommended for Vercel deployments.
| Variable | Required | Description |
|---|---|---|
| `BLOB_READ_WRITE_TOKEN` | Yes | Vercel Blob token from the Vercel dashboard. The adapter reads this directly when the `token` config option is omitted. |
### UploadThing — `@nextlyhq/storage-uploadthing`
| Variable | Required | Description |
|---|---|---|
| `UPLOADTHING_TOKEN` | Yes | UploadThing API token. The adapter reads this directly when the `token` config option is omitted. |
---
## Email / SMTP
Source: `packages/nextly/src/shared/lib/env.ts`. Required only if you send emails (password resets, notifications). In production, if **any** SMTP variable is set, **all four** of `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM` must be set or the env validator throws.
| Variable | Required | Default | Description |
|---|---|---|---|
| `SMTP_HOST` | No | — | SMTP server hostname (e.g. `smtp.gmail.com`). |
| `SMTP_PORT` | No | `587` | SMTP port. 587 for TLS, 465 for SSL. Range: 1–65535. |
| `SMTP_USER` | No | — | SMTP authentication username. |
| `SMTP_PASS` | No | — | SMTP authentication password. |
| `SMTP_FROM` | No | — | From address for outgoing emails. Validated as an email. |
For local development, the repo ships a Mailpit profile (`docker compose --profile with-mailpit up -d mailpit`) on host ports 1025 (SMTP) and 8025 (web UI) — see [Email guide](https://nextlyhq.com/docs/guides/email) for details.
---
## Security
These env vars are read by middleware and security helpers. None are part of the central env schema; they are read directly by the modules that use them.
| Variable | Required | Default | Description | Read by |
|---|---|---|---|---|
| `TRUSTED_PROXY_IPS` | No | empty | Comma-separated CIDR list of trusted reverse-proxy IPs. Combined with `security.trustProxy: true`, controls which `X-Forwarded-For` values are honoured for client-IP resolution (rate limiting, refresh-token binding, audit logging). | `packages/nextly/src/utils/get-trusted-client-ip.ts` |
---
## Performance — permission cache
Nextly's RBAC layer ships a hybrid (in-memory LRU + database) permission cache. These env vars tune it; none are part of the central env schema — they are read directly by the permission service.
| Variable | Required | Default | Description | Read by |
|---|---|---|---|---|
| `PERMISSION_CACHE_ENABLED` | No | `true` | Set to `false` or `0` to disable the hybrid permission cache. | `packages/nextly/src/services/lib/permissions.ts` |
| `PERMISSION_CACHE_TTL_SECONDS` | No | `86400` | Time-to-live for database cache entries (seconds). | `packages/nextly/src/services/lib/permissions.ts` |
| `PERMISSION_CACHE_MEMORY_SIZE` | No | `10000` | In-memory LRU cache size (number of entries). | `packages/nextly/src/services/lib/permissions.ts` |
The database permission cache benefits from periodic cleanup. A typical daily cron:
```bash
0 2 * * * curl -X POST http://localhost:3000/api/auth/cache/cleanup
```
---
## Debug
| Variable | Required | Default | Description | Read by |
|---|---|---|---|---|
| `DEBUG_RBAC` | No | — | Set `1` to log detailed RBAC permission decisions. | `packages/nextly/src/services/lib/permissions.ts` |
| `DEBUG_CACHE` | No | — | Set `1` to log permission-cache hit/miss/eviction details. | `packages/nextly/src/domains/auth/services/permission-cache-service.ts` |
---
## Docker development (optional)
Used by the bundled `docker-compose.yml` for local Postgres / Redis / Adminer / Drizzle Studio / Mailpit setup. Not needed when connecting to an existing database.
| Variable | Default | Description |
|---|---|---|
| `DB_NAME` | `nextly_dev` | PostgreSQL database name. |
| `DB_USER` | `postgres` | PostgreSQL user. |
| `DB_PASSWORD` | — | PostgreSQL password. Override in production. |
| `DB_PORT` | `5432` | PostgreSQL host port. |
| `ADMINER_PORT` | `8080` | Adminer (database browser) UI port. |
| `REDIS_PORT` | `6379` | Redis cache port. |
| `DRIZZLE_STUDIO_PORT` | `4983` | Drizzle Studio port for the database GUI. |
---
## Example `.env` files
### Minimal production
```bash
# Runtime
NODE_ENV=production
# Database
DB_DIALECT=postgresql
DATABASE_URL=postgresql://user:password@db.example.com:5432/nextly_prod
# Auth
NEXTLY_SECRET=your-generated-secret-at-least-32-characters-long
NEXT_PUBLIC_APP_URL=https://your-domain.com
# Storage (Vercel Blob example)
BLOB_READ_WRITE_TOKEN=vercel_blob_rw_xxxxxxxxxxxx
```
### Development with S3-compatible storage (MinIO)
```bash
# Database
DB_DIALECT=postgresql
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/nextly_dev
# Auth
NEXTLY_SECRET=dev-secret-minimum-32-characters-long-replace-in-production
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Storage (MinIO)
S3_BUCKET=nextly-dev
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=minioadmin
AWS_SECRET_ACCESS_KEY=minioadmin
S3_ENDPOINT=http://localhost:9000
S3_FORCE_PATH_STYLE=true
```
## Next steps
- [Nextly config](https://nextlyhq.com/docs/configuration/nextly-config) — configure storage and security from `nextly.config.ts`
- [Database](https://nextlyhq.com/docs/database) — choose and configure PostgreSQL, MySQL, or SQLite
- [Authentication guide](https://nextlyhq.com/docs/guides/authentication) — auth-related env vars in depth
- [Deployment guide](https://nextlyhq.com/docs/guides/deployment) — production environment-variable checklist
---
title: Media Upload Security
description: How Nextly validates and sanitizes uploaded media — MIME allowlist, SVG sanitization, polyglot defenses, and per-adapter Content-Disposition trade-offs.
url: https://nextlyhq.com/docs/configuration/media-upload-security
---
Every file uploaded through `/api/media` or `/admin/api/collections/[slug]/uploads` passes through a unified validation pipeline at `services/upload-validation/`. This page explains what gets checked, what gets stripped, and how to tune the policy for your deployment.
## The validation pipeline
Each upload runs through six checks, in order. The first failure short-circuits the request with a `NextlyError.validation` carrying a stable machine code.
| Step | Code on failure | Notes |
| ---- | ------------------------- | --------------------------------------------------------------------------------------------- |
| 1 | `FILENAME_INVALID` | empty, > 255 chars, null byte, path separator, all-dots |
| 2 | `EXTENSION_BLOCKED` | `.html`, `.js`, `.php`, `.exe`, etc. — rejected regardless of MIME |
| 3 | `MIME_BLOCKED` | `text/html`, `application/javascript` (hard-block, overrides allowlist) |
| 4 | `MIME_NOT_ALLOWED` | type not in resolved allowlist |
| 5 | `SIZE_EXCEEDED` (overall) | per-file cap — `security.limits.fileSize` (default 10MB), applies to every upload |
| 5b | `SIZE_EXCEEDED` (SVG) | additional 2MB cap, applied only when the upload claims `image/svg+xml` (XML parser DoS guard) |
| 6 | `MAGIC_BYTE_MISMATCH` | sniffed bytes disagree with claimed MIME |
| 7 | `SVG_SANITIZATION_FAILED` | sanitizer threw or output was empty |
For SVG uploads, the bytes that hit storage are the **sanitized** output, never the input — the validator owns that step so call sites can't accidentally persist unsanitized content.
## Customizing the allowlist
```ts
import { defineConfig } from "nextly/config";
export default defineConfig({
security: {
uploads: {
// Replace the default allowlist entirely.
allowedMimeTypes: ["image/png", "image/webp", "application/pdf"],
// OR: add to the default allowlist.
additionalMimeTypes: ["application/zip"],
// Set Content-Disposition: attachment on SVG uploads (default: true).
// The mitigation prevents direct-URL navigation from rendering SVG
// inline. Browser rendering is unaffected (already sandboxed).
svgCsp: true,
},
limits: {
// Per-file cap applied to ALL upload paths (default: "10mb").
fileSize: "20mb",
},
},
});
```
`text/html`, `application/javascript`, `application/xhtml+xml`, and `text/ecmascript` are **unconditionally blocked** even if explicitly listed in `allowedMimeTypes` — they get stripped at boot with a `console.warn`. The same applies to extensions: `.html`, `.js`, `.php`, `.exe`, etc. are blocked at the extension layer regardless of claimed MIME, so renaming `evil.html` to `evil.png` with `Content-Type: image/png` doesn't help an attacker.
## SVG support and what gets stripped
SVG files are allowed by default but **sanitized** via DOMPurify with an explicit policy on top of `USE_PROFILES: { svg, svgFilters }`. The sanitizer removes anything that can execute, fetch external resources, or trigger script-like behavior in a browser.
| Removed | Kept |
| ---------------------------------------- | ----------------------------------------------- |
| `