Singles
Define single-document content like site settings, headers, and footers.
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, singles have no list view, no create/delete operations, and are auto-created on first access.
Source of truth: the
SingleConfiginterface lives inpackages/nextly/src/singles/config/types.ts. ThedefineSingle()helper is inpackages/nextly/src/singles/config/define-single.ts.
Defining a single
Use defineSingle() from nextly:
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: ({ permissions }) => permissions.includes("site-settings:update"),
},
hooks: {
afterChange: [
async ({ data }) => {
// Nextly invalidates its own cache tags on every write once the cache
// revalidator is registered (see ISR caching). Use hooks for side
// effects of your own.
console.info("site settings updated", data);
},
],
},
});Then register it in your config:
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],
});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.)
- Open the admin panel and navigate to Builder > Singles.
- Click Create Single.
- Enter a slug (e.g.
site-settings) and a display label. - Add fields using the drag-and-drop field editor.
- Configure access control and hooks through the UI.
- Click Save.
The Schema Builder produces the same SingleConfig that code-first singles use. Unlike a collection, a builder-created single has no command that moves it into code.
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 | /admin/api/collections/[slug]/entries | /admin/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.
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<string, unknown> |
| 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.
access: {
read: true,
update: ({ permissions, collection }) =>
permissions.includes(`${collection}:update`),
}Each operation accepts a function (returning boolean | Promise<boolean>), a literal boolean, or can be omitted to fall back to database role/permission checks. A declared rule is what a signed-in user is judged by, so it checks the permission itself and narrows from there; don't decide by a role's name. Super-admin always bypasses checks. The AccessControlContext shape is the same as for collections.
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
beforeRead- Database read
afterRead
Execution order — update
beforeValidate- Validation - the schema's declared rules are enforced
beforeChange- Database update
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
hooks: {
afterChange: [
async ({ data }) => {
// Nextly's own cache tags are already invalidated on every write once
// the cache revalidator is registered (see ISR caching). Notify a
// system of your own here, with an absolute URL: a relative fetch
// throws on the server.
await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/my-webhook`, {
method: "POST",
body: JSON.stringify(data),
});
},
],
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
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: ({ permissions }) => permissions.includes("header:update"),
},
});Next steps
- Collections — content types with multiple entries
- Fields — every field type and validation option
- Field Groups — reusable field groups for collections and singles
- Visual Schema Builder — create singles visually with drag-and-drop