# 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](#persistence) and [Builder entities and production](https://nextlyhq.com/docs/guides/production-migrations.md#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](https://nextlyhq.com/docs/configuration/fields.md) 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](https://nextlyhq.com/docs/configuration/fields.md).

### 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

1. Open `/admin/builder/collections` and click **New Collection**.
2. 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.
3. Pick a sidebar icon (Lucide icon name) and an optional admin group, then click **Continue** to enter the canvas.
4. Drag a **Long text** (`textarea`) field from the palette and rename it `excerpt` in the editor.
5. Drag an **Editor** (`richText`) field and name it `content`.
6. Drag a **Media** (`upload`) field and name it `featuredImage`. On the General tab, restrict the MIME pattern to `image/*`.
7. Open the **Advanced** tab and toggle **Status (Draft / Published)** on. This wires Nextly's built-in lifecycle — the runtime injects a `status` system column and the entry editor surfaces Save Draft / Publish buttons. Don't drag a `Select` field for this: declaring a user-defined `status` field would conflict with the system column. (See [Draft / Published status guide](https://nextlyhq.com/docs/guides/draft-published-status.md).)
8. Drag a **Relationship** field and name it `author`. Set the target collection to `users`.
9. Reorder the fields by dragging the grip handles.
10. 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`.

> [!WARNING]
> 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](https://nextlyhq.com/docs/guides/production-migrations.md#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 = true` in `nextly.config.ts`.
* Force hidden: `admin.branding.showBuilder = false`.

See [Branding > Schema Builder visibility](https://nextlyhq.com/docs/admin/branding.md#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](https://nextlyhq.com/docs/admin.md) — full navigation guide
* [Branding](https://nextlyhq.com/docs/admin/branding.md) — control Builder visibility with `showBuilder`
* [Collections](https://nextlyhq.com/docs/configuration/collections.md) — code-first collection definitions
* [Singles](https://nextlyhq.com/docs/configuration/singles.md) — code-first Single definitions
* [Field Groups](https://nextlyhq.com/docs/configuration/field-groups.md) — reusable field groups
* [Fields](https://nextlyhq.com/docs/configuration/fields.md) — per-type option reference and validation
