Visual Schema Builder
Design Collections, Singles, and Field Groups visually with drag-and-drop instead of writing code.
The Visual Schema Builder is Nextly's drag-and-drop schema designer. It produces the same registered Collections, Singles, and Field Groups that the code-first approach produces. It writes no TypeScript: nextly generate:types reads your config only, so entities made here get no generated types. Every entity it saves is also written into the UI-schema manifest (db.uiSchemaFile, default ./ui-schema.json), which you commit — that is what nextly migrate:create reads, and for Singles and Field Groups it is the only artifact carrying them, since neither writes a migration file of its own. Only the Collections Builder writes one. See Persistence and Builder entities and production. You can switch between the Visual Schema Builder and code-first definitions at any time — they are interchangeable per entity.
The Schema Builder has three variants that share the same canvas, palette, and field editor:
| Variant | Route | Output |
|---|---|---|
| Collections Builder | /admin/builder/collections | Persists the schema row, writes the entity into the UI-schema manifest (db.uiSchemaFile), and writes a Drizzle migration under db.migrationsDir (default ./src/db/migrations/). No TypeScript schema file is written: the runtime builds its Drizzle table from the dynamic_collections row instead. |
| Singles Builder | /admin/builder/singles | Persists the schema in the dynamic_singles database table and writes the entity into the UI-schema manifest (db.uiSchemaFile). No migration file. |
| Field Groups Builder | /admin/builder/field-groups | Persists the field group definition in the database and writes the entity into the UI-schema manifest (db.uiSchemaFile). No migration file. |
Create a new entity from any of the three list pages above. Edit an existing entity by appending its slug, e.g. /admin/builder/collections/posts.
Layout
Each Schema Builder uses a two-column layout:
- Canvas (main area) — a sortable list of the fields in your schema. Each row shows an icon, the field name, the type badge, and a "Required" indicator. Drag the grip handle to reorder. Click a row to edit it. Container types (Repeater, Group) display their child fields inline with indentation and dedicated drop zones.
- Right panel. The field palette and the field editor. Use the toolbar at the top of the canvas to open the Settings dialog (entity-level config like description, sidebar icon, admin group) or to preview/save changes.
Field palette
The field palette groups field types into five categories, in this order:
| Category | Field types in the picker |
|---|---|
| Basic | Text, Long text, Editor (rich text), Email, Password, Number |
| Advanced | Code, Date, Select, Radio, Checkbox, Toggle, JSON, Tags (chips) |
| Media | Media (upload) |
| Relational | Relationship |
| Structured | Repeater, Group, Component |
This is the picker grouping inside the admin UI. The reference docs in Fields group fields slightly differently (Basic / Selection / Media / Relationship / Layout / Component / Advanced) so the prose can talk about families that don't all map to a single picker tab.
Field editor
Click a field on the canvas to slide the field editor over the palette. The editor has four tabs:
| Tab | What you configure |
|---|---|
| General | Name (auto-derived slug), label, description, default value, and the field's type-specific options (e.g. Select options, Relationship target, Upload MIME pattern). |
| Validation | required, plus type-specific rules: minLength / maxLength, min / max, minRows / maxRows, minChips / maxChips, regex pattern, custom message. |
| Display | Layout width (25% / 33% / 50% / 66% / 75% / 100%), position (Main content or Sidebar), readOnly, hidden, conditional visibility (a per-field condition keyed off another field's value), description, placeholder. |
| Advanced | unique constraint, database index, and a reserved localized flag (not yet wired through to runtime localization). |
Conditional visibility supports type-aware operators (equals, notEquals, contains, startsWith, endsWith, isEmpty, isNotEmpty, numeric comparisons, between, date before / after, and boolean isTrue / isNotTrue).
Settings dialog
The toolbar's Settings button opens a Basics / Advanced modal that edits entity-level metadata: name, slug, description, sidebar icon, admin group (Collections) or category (Components), sort order, and an i18n flag. Hooks written as functions belong to collections defined in code, in the hooks object on defineCollection(), with an array of functions per phase, such as hooks: { beforeChange: [...] }: a function cannot be stored with a collection made here. A collection made here can carry Nextly's pre-built hooks (auto-slug, audit fields and unique validation) as stored settings, and they run on every write, but the Visual Schema Builder has no editor for them today.
Field types
The palette ships every field type Nextly supports through code-first definitions. The label in the picker can differ from the canonical type name (the picker shows "Long text" for textarea, "Editor" for richText, "Media" for upload, "Tags" for chips).
For the full per-type option reference, see Fields.
Relationships and foreign keys
Needs the release after 0.0.2-alpha.66.
A one-to-one or many-to-one Relationship field stores the target's id in a column of its own, and that column carries a foreign key to the target collection, with the field's on-delete and on-update actions. The key is created with the collection, or by the save that adds the field to an existing one, on PostgreSQL, MySQL and SQLite alike. A one-to-one field created with its collection also makes its column unique, so two entries cannot point at the same target, and the column stays unique through every later save; one added to an existing collection is not made unique yet.
On SQLite, where a key can only be created by rebuilding the table, a save that adds a relationship or changes a field's type rebuilds the table with every key it holds, and the rows of other collections that point at it are kept: no delete action runs during the rebuild.
A rebuild cannot keep a trigger your app or a plugin migration put on the
table, so on SQLite a save that would rebuild a table with triggers on it is
refused, naming the table. Make that change with a migration that recreates
the table with its triggers (nextly migrate:create --blank).
A relationship field added to an existing collection before Nextly created keys this way has none, and a later save does not add one, so a database that already holds rows pointing at missing entries is never asked to accept a key those rows would break.
Container fields and nesting
Repeater, Group, and Component fields can themselves contain fields. Nesting is capped at 4 levels deep — MAX_NESTING_DEPTH = 4 in packages/admin/src/components/features/schema-builder/types.ts. Each level renders with a progressively darker background to make the hierarchy visible.
A Component field embeds a reusable field group definition (created via the Field Groups Builder or defineFieldGroup()). It can target a single field group or a list of allowed field groups, and can be marked repeatable to act as an inline content zone.
Walkthrough: create a Blog Posts collection
- Open
/admin/builder/collectionsand click New Collection. - Fill in the singular and plural names (e.g. "Post" and "Posts"). The slug is generated from the singular name (
post); you can override it. - Pick a sidebar icon (Lucide icon name) and an optional admin group, then click Continue to enter the canvas.
- Drag a Long text (
textarea) field from the palette and rename itexcerptin the editor. - Drag an Editor (
richText) field and name itcontent. - Drag a Media (
upload) field and name itfeaturedImage. On the General tab, restrict the MIME pattern toimage/*. - Open the Advanced tab and toggle Status (Draft / Published) on. This wires Nextly's built-in lifecycle — the runtime injects a
statussystem column and the entry editor surfaces Save Draft / Publish buttons. Don't drag aSelectfield for this: declaring a user-definedstatusfield would conflict with the system column. (See Draft / Published status guide.) - Drag a Relationship field and name it
author. Set the target collection tousers. - Reorder the fields by dragging the grip handles.
- Click Save. For Collections, the toolbar previews the schema diff before applying it; for Singles and Components the change applies immediately.
After save, the new collection appears in the admin sidebar under Collections. The Drizzle migration is written to your db.migrationsDir.
The Builder writes the .sql file without the paired
meta/*.snapshot.json that nextly migrate:create produces. nextly migrate:check reports an unpaired .sql as MISSING_SNAPSHOT and exits
non-zero, so committing the Builder's migration as-is fails that check.
Regenerate the pair with nextly migrate:create before committing, and commit
the .sql and its snapshot together.
Persistence
| Entity | Where it is stored | What gets generated |
|---|---|---|
| Collection | dynamic_collections table + a migration file on disk | A Drizzle migration in db.migrationsDir. No TypeScript schema file: the runtime builds the Drizzle table from the dynamic_collections row, so a generated file would only drift from it. |
| Single | dynamic_singles table + the UI-schema manifest (db.uiSchemaFile, default ./ui-schema.json) | No migration file. The schema is regenerated from the database row on each request; the manifest entry is what migrate:create reads to carry the entity into a migration. |
| Component | Database row + the UI-schema manifest (db.uiSchemaFile, default ./ui-schema.json) | No migration file. Stored alongside dynamic singles; the manifest entry is what migrate:create reads. |
When you edit and re-save a collection in a way that changes its data table, Nextly emits an additive migration (or, when destructive, prompts for confirmation in the toolbar).
Status and Internationalization are schema changes, not settings. Turning Status on or off adds or drops the lifecycle's system columns, and turning Internationalization on or off moves translatable columns between the table and its _locales companion. Both produce a migration; treat them like any other column change.
An edit that changes only registry metadata — a label, a description, or the Versions, Revalidate or Webhooks switches — changes no column, so no migration file is written for it on its own, and it does not reach another environment by itself. nextly migrate never reads the manifest, and migrate:create emits a registry upsert only for an entity whose table a migration actually changes. The new value sits in the manifest until some later migration to that entity carries it. See Builder entities and production.
Applied migrations are not re-run: nextly migrate records a file_apply event for every file it applies in the nextly_schema_events ledger and skips what is already recorded there. That ledger belongs to the migration runner, not to the Builder's file writer.
Permissions and visibility
The Visual Schema Builder is reached through the Builders top-level icon. That icon is gated by the admin.branding.showBuilder toggle:
- Default behaviour: visible when
process.env.NODE_ENV !== "production", hidden in production. - Force visible:
admin.branding.showBuilder = trueinnextly.config.ts. - Force hidden:
admin.branding.showBuilder = false.
See Branding > Schema Builder visibility for the full toggle reference.
Builder vs code-first
Both approaches produce the same registered entities. Pick by workflow:
| Aspect | Visual Schema Builder | Code-first |
|---|---|---|
| Speed to prototype | Drag-and-drop | Write config, run nextly db:sync |
| Source of truth | DB row (Singles / Components) or generated file (Collections) | Hand-written TypeScript file |
| Lifecycle hooks | Pre-built hooks only, stored with the collection; no editor | Any hook function, in the hooks object |
| Team workflow | Editors and non-developers can iterate | Developers own the schema |
You can mix both — define core collections in code and let editors prototype new ones in the Schema Builder, or vice versa.
Next steps
- Admin Panel Overview — full navigation guide
- Branding — control Builder visibility with
showBuilder - Collections — code-first collection definitions
- Singles — code-first Single definitions
- Field Groups — reusable field groups
- Fields — per-type option reference and validation