Skip to content

Repository files navigation

AutoAdmin

Automatic CRUD interfaces, generated from your Drizzle schema.

AutoAdmin generates a complete admin panel from your existing Drizzle schema - list views with search, filters, sorting, and pagination; create and update forms with validation; relation dropdowns; file and image uploads; and a rich text editor. A single registration is enough for a model:

registry.register(posts)

There is no code generation step, no separate admin service, and no UI to build by hand. AutoAdmin runs inside your Nuxt application as a layer, so it deploys wherever your application deploys and can integrate with your existing authentication. This makes it well suited for back-offices, dashboards, and internal tools.

Features

  • Schema-driven CRUD - list, create, update, and delete views inferred from your Drizzle tables, including column types, enums, and defaults. Any field can be overridden (rich text, image, file, textarea, and more).
  • Relationship support - foreign keys, many-to-many, and one-to-many relations render as searchable dropdowns in forms and filters.
  • Complete list views - search, filters, column sorting, pagination, bulk actions, aggregates, drag-and-drop ordering, and custom cell rendering.
  • Files and media - image and file uploads to any S3-compatible storage, and a WYSIWYG editor with image uploads, embeds, and float layouts.
  • Extensibility - lifecycle hooks, persistent row filters (baseWhere), role-based access, automatic slug generation, reusable CRUD services for custom API routes, and opt-in audit logs.
  • JSON admin - manage JSON-backed settings and lists (stored in local files, a GitHub repository, or S3/ S3-compatible object storage / R2) with the same auto-generated forms, useful for site configuration, feature flags, or content that does not belong in the database. See docs/json-admin.md.
  • SQLite, PostgreSQL, D1, libsql - SQLite and its variants (including Cloudflare D1 and libsql), plus PostgreSQL.

Built with Nuxt, Drizzle, Nuxt UI, and Zod.

Quick Start

Requirements: a Nuxt project with Drizzle configured and a supported Drizzle schema. (Drizzle v1 RC support is being worked in drizzlev1 branch.)

1. Add AutoAdmin as a layer in your nuxt.config.ts:

exportdefaultdefineNuxtConfig({extends: [['github:awecode/autoadmin',{install: true}],],})

Alternatively, download the layer into your project's layers directory:

npx -y giget gh:awecode/autoadmin layers/autoadmin
rm -rf layers/autoadmin/examples
npx -y nypm add @awecode/autoadmin@file:layers/autoadmin
npx -y nypm install

2. Configure the database connection with the NUXT_DATABASE_URL environment variable:

NUXT_DATABASE_URL=file:server/db/db.sqlite
# OR for Postgres
NUXT_DATABASE_URL=postgresql://postgres:password@localhost:5432/dbname

A Cloudflare D1 binding named DB is detected automatically.

3. Register your models in a Nitro plugin:

// server/plugins/admin.tsimport{posts,users}from'~~/server/db/schema'exportdefaultdefineNitroPlugin(()=>{constregistry=useAdminRegistry()registry.register(users)registry.register(posts)})

4. Open/admin to access the generated admin interfaces for the registered tables.

What Gets Inferred

AutoAdmin derives form controls directly from column types: text and number inputs, boolean toggles, date and datetime pickers, enum selects, and foreign-key dropdowns are all generated automatically. Here is an example schema for SQLite demonstrating various column types.

// server/db/schema.tsimport{sql}from'drizzle-orm'import{integer,sqliteTable,text}from'drizzle-orm/sqlite-core'// An enum-like definition for the 'status' columnexportconstpostStatusEnum=['Draft','Published','Archived']asconstexportconstusers=sqliteTable('users',{id: integer().primaryKey({autoIncrement: true}),name: text().notNull(),email: text().notNull().unique(),})exportconstposts=sqliteTable('posts',{id: integer().primaryKey({autoIncrement: true}),// Text fieldtitle: text().notNull(),content: text(),featuredImage: text(),attachment: text(),// Number fieldviews: integer().default(0),// Boolean fieldisPublished: integer({mode: 'boolean'}).default(false),// Date field (as timestamp for sqlite)publishedOn: integer({mode: 'timestamp'}).notNull().default(sql`(unixepoch())`),// Datetime field (as timestamp_ms for sqlite)createdAt: integer({mode: 'timestamp_ms'}).notNull().default(sql`(unixepoch()*1000)`),// Enum fieldstatus: text({enum: postStatusEnum}).default('Draft'),// Foreign key relationshipauthorId: integer().references(()=>users.id),})

Here is the equivalent PostgreSQL version.

// server/db/schema.tsimport{boolean,date,integer,pgEnum,pgTable,serial,text,timestamp}from'drizzle-orm/pg-core'constpostStatusEnum=pgEnum('post_status',['Draft','Published','Archived'])exportconstusers=pgTable('users',{id: serial().primaryKey(),name: text().notNull(),email: text().notNull().unique(),})exportconstposts=pgTable('posts',{id: serial().primaryKey(),title: text().notNull(),content: text(),featuredImage: text(),views: integer().default(0),isPublished: boolean().default(false),publishedOn: date({mode: 'date'}).notNull(),createdAt: timestamp({withTimezone: true,mode: 'date'}).notNull().defaultNow(),status: postStatusEnum().default('Draft'),authorId: integer().references(()=>users.id),})

Register both models as shown in the Quick Start and open /admin to get list views and forms for users and posts.

JSON Admin

In addition to database tables, AutoAdmin can manage JSON files with the same auto-generated forms and list views, for site settings, feature flags, navigation menus, or small content collections. Files can be stored locally, in a GitHub repository (where each edit becomes a commit), or in object storage (R2 binding or S3-compatible API). Resources are defined with a Zod schema and registered in the same manner as database models. More here:docs/json-admin.md.

Audit Logs

Opt-in activity logging for admin writes. Guide:docs/audit-log.md.

Specifying Custom Field Types

While AutoAdmin infers types from your Drizzle schema, you can override them for more control over the UI. For example, you may want to change a text field to a textarea, a rich-text editor, or an image uploader. Use the fields option during registration.

// server/plugins/admin.tsimport{posts,users}from'~~/server/db/schema'exportdefaultdefineNitroPlugin(()=>{constregistry=useAdminRegistry()registry.register(users)registry.register(posts,{fields: [{name: 'content',type: 'rich-text',},{name: 'featuredImage',type: 'image',},{name: 'attachment',type: 'file',},// other fields will be auto-inferred]})})

Table/Model Registration Options

This is the main configuration object passed to registry.register(model, options). All options are optional.

Role-based access for registered models is configured with the roles option. See the role access guide.

KeyTypeDefaultDescription
labelstringTable nameDisplay name for the model (e.g., in the sidebar).
keystringtableNameUnique identifier for the model. Used in URLs and API endpoints. Pass if you have two models with the same table name.
iconstringundefinedIconify icon name. Auto-detected for common names.
ordernumber0Display order in the sidebar and dashboard index. Lower numbers come first; negatives float to the top, positives push down. Ties preserve registration order.
labelColumnNamestringname, title, etc.Column used for display labels in relationships and select options.
lookupColumnNamestringidThe primary or unique key used to fetch single records.
slugFieldsRecord<string, string[]>undefinedAuto-generate URL-friendly slugs from other fields. Reference ↗
warnOnUnsavedChangesbooleanfalsePrompt user before leaving a form with unsaved changes.
listPartial<ListOptions>{}Configuration for the list/table view. Reference ↗
createPartial<CreateOptions>{}Create form configuration. Reference ↗
updatePartial<UpdateOptions>{}Edit form configuration. Reference ↗
deletePartial<DeleteOptions>{}Configuration for the delete action. Reference ↗
fieldsFieldSpec[]undefinedOverwrite how columns are handled in the UI. Reference ↗
sortFieldstringundefinedColumn name (integer) used for drag-drop ordering. Reference ↗
baseWherefunctionundefinedPersistent row filter (Drizzle SQL) on list/detail/update/delete. Reference ↗
auditboolean or objectundefinedPer-model audit override. Inherit global configureAudit({ enabled: true }), or set true / options / false to opt out. Guide ↗
formFields(string | FieldSpec)[]undefinedForm field configuration. Reference ↗
m2mRecord<string, Table>undefinedDefines many-to-many relationships to enable on form and detail view. Reference ↗
o2mRecord<string, Table>undefinedDefines one-to-many relationships to enable on form and detail view. Reference ↗
rolesstring[] or objectundefinedOptional per-model role allowlists (string[] = full access for those roles). Guide ↗

Overriding Field Behavior with fields

The fields option allows you to customize the appearance and behavior of a model's columns across the entire admin interface, affecting list, detail, and form views, wherever applicable. It takes an array of FieldSpec objects.

If a column from your schema is not included in this array, its settings will be inferred automatically.

Example:

registry.register(posts,{fields: [// Customize the 'content' field to use a rich-text editor{name: 'content',type: 'rich-text',label: 'Post Body',inputAttrs: {placeholder: 'Start writing your masterpiece...',disabledHeadingLevels: [1,2],}},// Customize the 'featuredImage' to be an image uploader{name: 'featuredImage',type: 'image',help: 'Upload an image with a 16:9 aspect ratio.',fileConfig: {prefix: 'post-images/',// Subdirectory in your storage bucketaccept: ['.jpeg','.png'],maxSize: 5*1024*1024// 5MB},fieldAttrs: {class: 'w-1/2'// uses half width in forms}}]})

fields object definition (FieldSpec)

typeFieldType='text'|'email'|'number'|'boolean'|'date'|'datetime-local'|'select'|'json'|'file'|'blob'|'image'interfaceFieldSpec{// The column name from your Drizzle schemaname: string// The display label for the field. Defaults to a capitalized version of the namelabel?: string// The UI component type to use for this fieldtype: FieldType// HTML attributes to apply to the field's wrapper element (Nuxt UI's UFormField)fieldAttrs?: Record<string,any>// HTML attributes to apply directly to the form input element (Nuxt UI's UInput, UCheckbox, etc.)inputAttrs?: Record<string,any>// Help text displayed below the input.help?: string// Description for form fielddescription?: string// Hint for form fieldhint?: string// Configuration for file or image uploads.fileConfig?: {// Storage path prefix for object storage bucketprefix?: string// Allowed file extensions as array of strings starting with a period `.` - client-side only validationaccept?: `.${string}`[]// Maximum file size in bytes - client-side only validationmaxSize?: number}// Automatically inferred using drizzle-zod, if not specified; defines if the field is requiredrequired?: boolean// Automatically inferred using drizzle-zod; defines the validation rules for the fieldrules?: Record<string,unknown>// Automatically inferred for enums and relations; defines the options for the fieldoptions?: (string|number|{label?: string,value: string|number,count?: number})[]}

Field Types

The type property in FieldSpec determines which form input component is rendered.

Field Types:

  • text: Standard text input. Auto-inferred for text type database columns.
  • number: A number input. Auto-inferred for integer, real, numeric, bigint type database columns.
  • boolean: A checkbox. Auto-inferred for boolean type database columns.
  • select: A dropdown menu. Auto-inferred for enums.
  • date: A date picker. Auto-inferred for sqlite integer with mode timestamp.
  • datetime-local: A date and time picker. Auto-inferred for sqlite integer with mode timestamp_ms.
  • json: A text area for JSON input. Auto-inferred for sqlite text with mode json.
  • textarea: A multi-line text input.
  • rich-text: A WYSIWYG editor with tiptap editor.
  • image: An image uploader with preview. Text with path to the image in object storage is saved to the database.
  • file: A generic file uploader. Text with path to the file in object storage is saved to the database.
  • blob: A binary data uploader saved to the database.

File & Image Uploads (fileConfig)

When using the image or file field type, you can optionally provide a fileConfig object to specify upload constraints.

By default, image type fields accept files with extensions - .jpg, .jpeg, .png, .svg.

By default,file type fields accept files with all extensions.

A preview dialog is implemented for images, and files with extensions - .pdf, .txt, .md.

// Example for an image field
{
name: 'featuredImage',
type: 'image',
help: 'Upload a JPG or PNG, max 2MB.',
fileConfig: {
// A prefix for the storage path in your bucket
prefix: 'post-images/',
// List of allowed file extensions
accept: ['.jpg', '.svg'],
// Maximum file size in bytes
maxSize: 2 * 1024 * 1024 // 2MB
}
}

When using file and image uploads, you need to configure object storage as described in the Object Storage Configuration section.

List View Configuration

The list option allows you to customize the data table view for a model. If neither fields nor columns are defined in list option, fields are automatically inferred. Automatic inference includes all fields except primary autoincrement columns, timestamp columns with default values, and foreign keys.

asyncfunctionpopularity(db: AdminDbType,obj: typeofposts.$inferSelect){return`${obj.views} views`}asyncfunctionisArchived(db: AdminDbType,obj: typeofposts.$inferSelect){returnobj.status==='Archived'}registry.register(posts,{list: {// Only show these specific columns in the tablefields: ['title',// Access a related field from the 'users' table'authorId.email',// Column with custom labels{field: 'isPublished',label: 'Published?'},// A custom function{field: isArchived,type: 'boolean',},// A custom function with sort key{field: popularity,sortKey: 'views',},// Related column with sorting on foreign table{field: 'authorId.name',// label is auto inferred as `Author Name`sortKey: 'authorId.email',}],// Search by title and author's email, else automatically searches on `title` as inferred `lookupColumnName`searchFields: ['title','authorId.email'],// Filter by publication status and author, foreign key relations are automatically detectedfilterFields: ['isPublished','authorId'],// Add a custom action to perform on selected rowsbulkActions: [{label: 'Publish Selected',icon: 'i-lucide-check-circle',action: async(db,rowIds)=>{awaitdb.update(posts).set({isPublished: true}).where(inArray(posts.id,rowIdsasnumber[]))return{message: `${rowIds.length} posts published.`,refresh: true}},}],// Customize the search bar placeholdersearchPlaceholder: 'Search by title or author email...'}})

list options

fields: (string | function | ListFieldDef)[] - An array defining the columns to display. An item can be:

  • A string representing a column name (e.g., 'title').
  • A dot-notation string for a related field (e.g., 'authorId.email').
  • A function that returns a value for the column in list view. See isArchived example above.
  • An object (ListFieldDef) for more control, allowing you to set a custom label, type hint, sortKey, or a custom rendering field function. See examples above. field value in this object can be a string (column name or dot-notation relation string), or a function.

enableSearch: boolean (Default: true) - Toggles search functionality.

enableSort: boolean (Default: true) - Toggles sorting functionality. See List Sorting for more details.

defaultOrdering: string (Default: undefined) - Initial sort when the URL has no ?sort= (e.g. 'publishedAt:desc'). See Default ordering.

searchFields: string[] (Default: [labelColumnName]) - An array of column names (including relational fields in dot-notation) to search against.

searchPlaceholder: string (Default: 'Search ...') - Placeholder text for the search input.

enableFilter: boolean (Default: true) - Toggles the visibility of the filter controls. Setting to false disables default filters as well.

filterFields: (string | FilterFieldDef)[] - An array of fields to generate filters for. If not specified, filters are automatically generated for enums, date fields (as date ranges), and boolean fields unless enableFilter is false. You can also define custom filters. See List Filters for more details.

bulkActions: object[] - See List Bulk Actions for more details.

showCreateButton: boolean (Default: true) - Toggles the visibility of the "Create New" button on the list page.

title: string (Default: Table Name as Title Case) - Page heading and document title for list page.

Form Configuration (create, update, formFields)

Control the fields and behavior of create and update forms using the create, update, and formFields options.

The create and update objects allow you to enable/disable forms or specify a unique set of fields for each.

  • create: Partial<CreateOptions> - Configuration for the "Create New" form.
  • update: Partial<UpdateOptions> - Configuration for the "Edit" form.

Both options share these properties:

  • enabled: boolean (Default: true) - Toggles the create/update functionality.
  • warnOnUnsavedChanges: boolean (Default: false) - Prompts the user before navigating away from a form with unsaved changes. Top-level warnOnUnsavedChanges configuration can be used instead of defining separately for create and update
  • formFields: (string | FieldSpec)[] - An array defining the specific fields for that form.
  • before?: async (db, ctx) => ctx.data | void - Runs on the server before validation and persistence. Return a replacement payload to modify what gets validated and written.
  • after?: async (db, ctx) => void - Runs on the server after the record and its configured relations have been saved successfully.

The top-level formFields option is a convenient shortcut to apply the same field configuration to both create and update forms.

formFields is an array of field spec as defined in Overriding Field Behavior with fields.

Automatic Slug Generation (slugFields)

The slugFields option enables automatic generation of URL-friendly slugs from other form fields. This is particularly useful for creating SEO-friendly URLs from titles, names, or other text fields.

registry.register(posts,{slugFields: {slug: ['title','publishedOn']// You can also use a single field like `'slug': ['title']`}})

Auto-Unique Slugs

By default, AutoAdmin ensures slug uniqueness automatically. When a record is saved and the slug collides with an existing one, a numeric suffix is appended (-1, -2, etc.) until the value is unique. This uses an optimistic approach; no extra database query is made unless the insert/update actually fails with a unique constraint violation.

For example, if my-post already exists, saving another record with the same slug produces my-post-1. If my-post-1 also exists, it becomes my-post-2, and so on.

This behavior is enabled by default and can be disabled via environment variable or Nuxt runtime config:

# .env
NUXT_AUTOADMIN_AUTO_UNIQUE_SLUGS=false

Or in nuxt.config.ts:

exportdefaultdefineNuxtConfig({runtimeConfig: {autoadmin: {autoUniqueSlugs: false,},},})

When disabled, duplicate slugs will result in a standard unique constraint validation error returned to the user.

Rich Text Editor Configuration

The rich-text field type renders a full-featured Tiptap editor. Configuration options can be provided in two ways:

  • Server-side via inputAttrs in the admin registry -- for serializable options like placeholder and disabledHeadingLevels.
  • Client-side via useAdminClient -- for non-serializable options like Tiptap extensions, toolbar items, and render functions.

When both are provided, they are deep-merged. Server-side inputAttrs take priority for scalar values, while array options (extensions, extraFixedToolbarItems, extraBubbleToolbarItems) are concatenated from both sources.

Server-side configuration (inputAttrs)

Use inputAttrs in the server-side admin registry for serializable options:

registry.register(posts,{fields: [{name: 'content',type: 'rich-text',inputAttrs: {placeholder: 'Start writing...',disabledHeadingLevels: [1],allowedMimeTypes: ['image/png','image/jpeg','image/webp'],},},],})

Client-side configuration (useAdminClient)

Use useAdminClient in a Nuxt client plugin for non-serializable options like Tiptap extensions. Configuration can be set globally (for all rich-text fields) or per-field (for a specific field on a specific model).

// plugins/admin-client.tsimport{useAdminClient}from'#layers/autoadmin/composables/adminClient'import{Highlight}from'@tiptap/extension-highlight'exportdefaultdefineNuxtPlugin(()=>{const{ register, setGlobalRichText }=useAdminClient()// Global: applies to ALL rich-text fields across all modelssetGlobalRichText({allowedMimeTypes: ['image/png','image/jpeg','image/webp'],})// Per-field: applies only to the 'content' field on the 'posts' modelregister('posts',{richText: {content: {extensions: [Highlight],extraFixedToolbarItems: [[{kind: 'mark',mark: 'highlight',icon: 'i-lucide-highlighter',tooltip: {text: 'Highlight'},}]],extraBubbleToolbarItems: [[{kind: 'mark',mark: 'highlight',icon: 'i-lucide-highlighter',tooltip: {text: 'Highlight'},}]],},},})})

Styling rich text output on the frontend

If you use float images, media text, or embeds in the rich text editor, the stored HTML may rely on styles that are not bundled in your public-facing app by default. Import the layer stylesheet where you render that HTML (for example in a global CSS file or layout):

@import'#layers/autoadmin/assets/css/rich-text.css';

Available options

All options below work in both inputAttrs (server-side) and client-side configuration:

OptionTypeDefaultDescription
placeholderstring'Write, type / for commands...'Placeholder text shown when the editor is empty.
disabledHeadingLevelsnumber[][]Heading levels (1-4) to disable.
allowedMimeTypesstring[]['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'application/pdf']MIME types accepted for file drag-drop and paste.
textAlignTypesstring[]['heading', 'paragraph']Node types that support text alignment.
baseClassstring'p-8 sm:px-16 py-13.5 prose dark:prose-invert max-w-none'CSS class for the editor content area.
toolbarClassstring'border-b border-muted sticky top-0 inset-x-0 px-8 sm:px-16 py-2 z-50 bg-default overflow-x-auto'CSS class for the fixed toolbar.
extraFixedToolbarItemsEditorToolbarItem[][][]Additional toolbar item groups appended to the fixed toolbar.
extraBubbleToolbarItemsEditorToolbarItem[][][]Additional toolbar item groups appended to the bubble (selection) toolbar.
extensionsExtension[][]Additional Tiptap extensions to load alongside the built-in ones.
embedTypesEmbedType[]all typesEmbed types to show in the embed popover. Available: 'youtube', 'facebook', 'linkedin', 'pdf', 'video', 'audio', 'iframe'.

Options like extensions, extraFixedToolbarItems, and extraBubbleToolbarItems are not serializable and should be configured via the client-side approach. When the same option is set in multiple places, the merge order from lowest to highest priority is:

global (setGlobalRichText) -> per-field (client-side plugin) -> server inputAttrs

Relationship Configuration

Foreign Keys

Foreign keys are automatically detected and a dropdown selection is provided.

exportconstposts=sqliteTable('posts',{id: integer().primaryKey({autoIncrement: true}),title: text().notNull(),// Foreign key relationshipauthorId: integer().references(()=>users.id),})

When the posts model is registered, a dropdown selection of users will be provided in form for selecting an author.

Many-to-Many (m2m)

A many-to-many relationship requires a third "join" table that connects two other tables. To configure it, you provide the m2m option with an object where the key is the label for the related model and the value is the Drizzle schema for the join table.

// server/db/schema.ts// Posts table (already defined)exportconstposts=sqliteTable('posts',{id: integer('id').primaryKey(),title: text('title').notNull(),// ... other fields})// Tags tableexportconsttags=sqliteTable('tags',{id: integer('id').primaryKey(),name: text('name').notNull().unique(),})// Join table connecting posts and tagsexportconstpostsToTags=sqliteTable('posts_to_tags',{postId: integer('post_id').notNull().references(()=>posts.id),tagId: integer('tag_id').notNull().references(()=>tags.id),})

When registering the posts model, use the m2m option to declare its relationship with tags.

// server/plugins/admin.tsimport{posts,postsToTags,tags}from'~~/server/db/schema'exportdefaultdefineNitroPlugin(()=>{constregistry=useAdminRegistry()registry.register(tags)registry.register(posts,{m2m: {// 'tags' is the form label, postsToTags is the join/junction table schematags: postsToTags}})})

This will render a multi-select component on the posts form, allowing you to associate multiple tags with a post.

For long labels or many selections, enable a per-line selected list with fieldAttrs.selectedList on the relation field (field name is ___<relationName>___<columnName>, e.g. ___tags___tagId):

registry.register(posts,{m2m: {tags: postsToTags},fields: [{name: '___tags___tagId',fieldAttrs: {selectedList: true},}],})

One-to-Many (o2m)

While this is not usually required, autoadmin allows rendering one to many relation in a form, a reverse relation of foreign keys.

// server/db/schema.tsexportconstusers=sqliteTable('users',{id: integer().primaryKey(),email: text().notNull().unique(),})exportconstposts=sqliteTable('posts',{id: integer().primaryKey(),title: text().notNull(),// Foreign key linking each post to a userauthorId: integer().references(()=>users.id),})

When registering the users model, use the o2m option to declare that a user can have many posts.

// server/plugins/admin.tsimport{posts,users}from'~~/server/db/schema'exportdefaultdefineNitroPlugin(()=>{constregistry=useAdminRegistry()registry.register(users,{o2m: {
posts
// Or to pass custom label - 'Authored Posts' will be the label allowing to select posts from dropdown. Or simpl// authoredPosts: posts}})})

This will add a dropdown selection of posts on users form.

Delete Configuration (delete)

The delete option controls the deletion functionality for a model's records. By default, deletion is enabled.

Delete configuration also supports lifecycle hooks:

  • before?: async (db, ctx) => void - Runs on the server before the delete query.
  • after?: async (db, ctx) => void - Runs on the server after the row has been deleted successfully.

To disable the delete action for a model, set the enabled property to false. This will remove delete functionality, including delete button on list row and bulk delete from list data table.

// server/plugins/admin.tsimport{users}from'~~/server/db/schema'exportdefaultdefineNitroPlugin(()=>{constregistry=useAdminRegistry()// Disable the delete action for the 'users' model.registry.register(users,{delete: {enabled: false}})})

CRUD Lifecycle Hooks

AutoAdmin supports server-side lifecycle hooks for create, update, and delete. These are useful when a model needs more than simple CRUD, such as normalizing input, sending notifications, synchronizing external systems, or writing audit metadata.

Hooks are defined inside the existing operation config objects:

registry.register(posts,{create: {before: async(db,ctx)=>{return{
...ctx.data,title: ctx.data.title?.trim(),}},after: async(db,ctx)=>{console.log('Created post',ctx.record.id)},},update: {before: async(db,ctx)=>{return{
...ctx.data,updatedAt: newDate(),}},after: async(db,ctx)=>{console.log(`Updated post ${ctx.lookupValue}`)},},delete: {before: async(_db,ctx)=>{console.log(`Deleting post ${ctx.lookupValue}`)},after: async(_db,ctx)=>{console.log('Deleted post row',ctx.record)},},})

Hook Contexts

  • create.before receives { config, data }
  • create.after receives { config, data, validatedData, record }
  • update.before receives { config, lookupValue, data }
  • update.after receives { config, lookupValue, data, validatedData, record }
  • delete.before receives { config, lookupValue }
  • delete.after receives { config, lookupValue, record }

Hook Behavior Notes

  • Hooks are server-side only and must be defined in your server registration plugin.
  • before hooks can throw to stop the request.
  • create.before and update.before may return a replacement payload. That returned payload is then validated and persisted.
  • after hooks run only after the main write succeeds.
  • Hooks are not wrapped in an explicit transaction. If an after hook throws, the database write has already happened.

List Sorting

Sorting is enabled by default but can be controlled through the list.enableSort option. Sorting can be done by clicking on a column header. Sorting is persisted in the URL just like filtering and searching.

Default ordering (list.defaultOrdering)

When the list URL has no ?sort= parameter, you can set an initial sort with list.defaultOrdering. Use the same format as the URL: accessorKey:asc or accessorKey:desc.

registry.register(posts,{list: {defaultOrdering: 'publishedAt:desc',fields: ['title','publishedAt','status'],},})

Without defaultOrdering, the list falls back to primary key descending (or to sortField ascending when drag-drop ordering is enabled). defaultOrdering cannot be combined with sortField.

Record filter (baseWhere)

This enables exposing a subset of records for admin interfaces and actions. baseWhere adds persistentWHEREconditions on list queries (and matching count), detail, update, delete, bulk delete, bulk actions (lookup checks), filter option queries, and reorder. Example use-cases: tenant scoping, soft-delete exclusions, or “hide archived rows” rules, showing "Draft" posts in a different list.

import{ne}from'drizzle-orm'registry.register(posts,{baseWhere: async(_db,ctx)=>{if(ctx.event?.context.auth?.user?.role==='admin'){returnundefined}return[ne(posts.status,'archived')]},list: {fields: ['title','status']},})

Return undefined or [] for no extra filter. Return one SQL fragment or an array of fragments (combined with AND).

ctx.action is one of: list, detail, update, delete, bulkDelete, reorder. On list, ctx.query has the request query string params. For lookup operations, ctx.lookupValue or ctx.lookupValues is set. Use ctx.event for auth/session (see roles guide).

asyncfunctiondisplayTitle(db: AdminDbType,obj: typeofposts.$inferSelect){return`-> ${obj.title}`}registry.register(posts,{list: {enableSort: true,// Enable/disable sorting (default: true)fields: [// Custom sort key{field: displayTitle,sortKey: 'title'// Sort by 'name' column when clicking 'displayName'},// Simple column with automatically inferred sorting'views',// Relation sorting{field: 'authorId.email',sortKey: 'authorId.name'// Sort by author name when clicking email},// // Disable sorting for specific field{field: 'status',sortKey: false// No sorting available}]}})

Sort Key Options

The sortKey property determines how a column can be sorted:

Direct Column Sorting

Sort by the same column that's displayed:

{
field: 'createdAt',
sortKey: 'createdAt' // or just omit sortKey, defaults to field name
}

Custom Sort Key

Sort by a different column than what's displayed:

{
field: popularity, // Custom function showing view count
sortKey: 'views' // Sort by the actual views column
}

Relation Sorting

Sort by columns in related tables using dot notation:

{
field: 'authorId.email',
sortKey: 'authorId.name' // Sort by author name, not email
}

Disable Sorting

Prevent sorting on specific columns:

{
field: 'status',
sortKey: false // No sorting for status
}

Automatic Sort Key Detection

When using simple field definitions, sort keys are automatically assigned:

registry.register(posts,{list: {fields: ['title',// Automatically gets sortKey: 'title''authorId.name',// Automatically gets sortKey: 'authorId.name']}})

Drag-Drop Ordering (sortField)

Enable drag-and-drop row ordering on the list view by specifying an integer column as the sortField.

exportconstcategories=pgTable('categories',{id: serial('id').primaryKey(),name: text('name').notNull(),order: integer('order').notNull().default(0),})registry.register(categories,{sortField: 'order',})

If the sort field has a default value or is nullable, it is automatically hidden from create and edit forms since its value is managed by the reordering UI. To keep it visible in the form, explicitly mark it as required in the field configuration:

registry.register(categories,{sortField: 'order',fields: [{name: 'order',required: true},],})

Cross-Page Ordering

When the list is paginated (more than one page), a reorder menu (↕ icon) appears in each row's actions column with contextual options:

ActionVisible WhenEffect
Move to topNot on first pageMoves the item to the very first position globally
Move up one pageNot on first pageMoves the item up by one page worth of positions
Move down one pageNot on last pageMoves the item down by one page worth of positions
Move to bottomNot on last pageMoves the item to the very last position globally

Performance

  • Within-page reorder: If sort values are already unique, only the affected page rows are updated (fast path - 1 SELECT + 1 UPDATE). If duplicates exist (e.g., all zeros on first use), the entire table is resequenced once.
  • Cross-page move: Fetches all rows, repositions the item, and batch-updates only the rows whose sort value changed.
  • All updates use a batched UPDATE ... SET = CASE statement, chunked to stay within database parameter limits (e.g., Cloudflare D1's 100-parameter cap). -->

List Filters

You can filter data in list using a table column, a relation column, or a custom filter.

  • enableFilter: Enable/disable filtering (default: true)
  • filterFields: Array of filter definitions (optional - auto-detected if not provided)
registry.register(posts,{list: {enableFilter: true,// Enable filtering functionality, is true by default// if filterFields is not passed, filters are automatically generated for enums, date fields (with date range), and boolean fieldsfilterFields: [// Simple column filter'isPublished',// Relation column filter, automatically detected as foreign key relation'authorId',// Detailed filter configuration{field: 'status',type: 'select',options: [// if not provided, a dropdown is rendered from enum or relation{label: 'Draft',value: 'Draft'},{label: 'Published',value: 'Published'}]},// Custom filter with filter for m2m relation{parameterName: 'tags',label: 'Tags',type: 'select',options: async(db,query)=>{constallTags=awaitdb.select().from(tags)returnallTags.map(tag=>({label: tag.name,value: tag.id}))},queryConditions: async(db,value)=>{constpostIds=awaitdb.select({postId: postsToTags.postId}).from(postsToTags).where(eq(postsToTags.tagId,value))return[// Filter posts by matching post IDsinArray(posts.id,postIds.map(p=>p.postId))]}},// Another custom filter with boolean type{parameterName: 'hasViews',label: 'Has Views',type: 'boolean',queryConditions: async(db,value)=>{if(value){return[gt(posts.views,0)]}else{return[lte(posts.views,0)]}}}]}})

Filter Types

Boolean Filters

Automatically created for boolean columns. Provides Yes/No/All options.

{
field: 'isPublished',
type: 'boolean'
}

Text Filters

For string columns. You can provide a list of options for the filter. If not provided, the filter will be a dropdown with all unique values for the column in the database.

{
field: 'status',
type: 'text',
options: [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' }
]
}

Date Filters

Support single date or date range filtering. By default, if not provided, the filter will be a date range picker.

{
field: 'createdAt',
type: 'date' // Single date picker
}
{
field: 'createdAt',
type: 'daterange' // Date range picker
}

Relation Filters

For foreign key relationships. Automatically provides choices from the related table.

{
field: 'categoryId',
type: 'relation',
}

Custom Filters

Create advanced filters with custom logic:

exportconstpostEngagementFilter: CustomFilter={label: 'Post Engagement',parameterName: 'post_engagement_filter',options: async()=>['High Engagement','Published but No Views','Draft with Views','Archived Posts',// { label: 'High Engagement', value: 'high_engagement' },// { label: 'Published but No Views', value: 'published_no_views' },// { label: 'Draft with Views', value: 'draft_with_views' },// { label: 'Archived Posts', value: 'archived_posts' },],queryConditions: async(db: any,value: any): Promise<SQL<unknown>[]>=>{switch(value){case'High Engagement':
return[eq(posts.status,'published'),gte(posts.views,100),]case'Published but No Views':
return[eq(posts.status,'published'),eq(posts.views,0),]case'Draft with Views':
return[eq(posts.status,'draft'),gt(posts.views,0),]case'Archived Posts':
return[eq(posts.status,'archived')]default:
return[]}},}// Registrationregistry.register(posts,{list: {filterFields: [postEngagementFilter]}})

See List Filters for more examples.

Automatic Filter Detection

If no filterFields are specified, the system automatically creates filters for:

  • Boolean columns: Yes/No filters
  • Enum/Select columns: Dropdown with available options
  • Date columns: Date range filters

Filter Field Definition Types

typeFilterFieldDef<TextendsTable>=|ColField<T>// Simple column name|{field: ColField<T>label?: stringtype?: FilterTypeoptions?: {label?: string,value: string|number}[]choicesEndpoint?: string}|CustomFilter// Advanced custom filter

List Bulk Actions

You can add bulk actions to the list view which show up in the top right corner of the list view when one or more rows are selected.

bulkActions in list option is an array of actions that can be performed on selected rows. Each action object needs a label, an optional icon, and an action function that receives an array of selected rowIds on the server side using a REST API request. The function should return an object with an optional message string and refresh boolean. message is shown on toast and refresh instructs if the list view is to be refreshed after successful action completion. An example:

registry.register(platforms,{list: {bulkActions: [{label: 'Email',icon: 'i-lucide-mail',action: async(db: AdminDbType,rowIds: string[]|number[])=>{constemails=awaitdb.select({email: users.email}).from(users).where(inArray(users.id,rowIds))// send email logic herereturn{message: `Emails sent to ${emails.map(e=>e.email).join(', ')}`}},}],bulkActions: [{label: 'Something else',icon: 'i-lucide-check',action: async(db: AdminDbType,rowIds: string[]|number[])=>{// Do something with the selected rowsreturn{message: `Something else done`,refresh: true}// refresh: true instructs the list view to be refreshed after successful action completion},}],}})

If delete.enabled is not set to false in registration option, a bulk action for delete is automatically added to the list view.

Custom Selections

Custom selections allow you to define custom SQL expressions that are computed and displayed in the list view. These can be used for calculated fields, concatenated values, or aggregate statistics.

registry.register(posts,{list: {customSelections: {// Simple computed fieldslugWithId: {sql: sql<string>`(${posts.slug} || '-' || ${posts.id})`,label: 'Slug w/ ID',},// Aggregate that shows in statistics cardstotalViews: {sql: sql<number>`sum(${posts.views}) OVER ()`,isAggregate: true,},// Another aggregate exampleavgRating: {sql: sql<number>`avg(${posts.rating}) OVER ()`,isAggregate: true,label: 'Average Rating',},}}})

Custom Selection Properties

sql: SQL (Required) - The SQL expression using Drizzle's sql template literal.

isAggregate?: boolean (Default: false) - When true, the selection is treated as an aggregate statistic and displayed in the statistics cards below the table. Non-aggregate selections can be included in table columns using the fields or columns configuration.

label?: string (Default: Capitalized key name) - Display label for the custom selection.

Aggregates

Aggregates provide built-in statistical functions that are automatically computed and displayed in cards below the data table. This is a simpler alternative to custom selections for common aggregate operations.

registry.register(posts,{list: {aggregates: {totalViews: {function: 'sum',column: 'views',},averageView: {function: 'avg',column: 'views',},postsWithViews: {function: 'count',column: 'views',// counts non-null valueslabel: 'Posts with Views',},minView: {function: 'min',column: 'views',label: 'Minimum View in a Post',},maxView: {function: 'max',column: 'views',label: 'Maximum View in a Post',},}}})

Aggregate Properties

function: 'sum' | 'avg' | 'count' | 'min' | 'max' (Required) - The aggregate function to apply. count counts truthy values for a column using CASE WHEN expression.

column: string (Required) - The column name to aggregate over.

label?: string (Default: Capitalized key name) - Display label for the aggregate statistic.

Runtime Config

AutoAdmin can be configured using environment variables:

VariableDescriptionDefault
NUXT_DATABASE_URLDatabase connection URL (e.g. file:server/db/db.sqlite or postgres://user:pass@localhost:5432/db)undefined
NUXT_PUBLIC_AUTOADMIN_TITLEThe title displayed in the admin interfaceAutoAdmin
NUXT_PUBLIC_AUTOADMIN_PATH_PREFIXThe URL/path prefix for the admin interface. Required to be set during build./admin
NUXT_PUBLIC_PAGINATION_DEFAULT_SIZEThe default page size for the list view20
NUXT_PUBLIC_PAGINATION_MAX_SIZEThe maximum page size for the list view200
NUXT_AUTOADMIN_FILE_UPLOAD_ROLESArray of role strings (example: ["admin","editor"]) allowed to upload files. Omit or empty → no global restriction on uploads.(empty)

Object Storage Configuration

AutoAdmin can use any S3-compatible object storage (supported by aws4fetch) to store files and images. You can configure the object storage with environment variables.

NUXT_S3_ACCESS_KEY=<your-access-key>
NUXT_S3_SECRET_KEY=<your-secret-key>
NUXT_S3_BUCKET_NAME=<your-bucket-name>
NUXT_S3_REGION=<your-region>
NUXT_S3_ENDPOINT_URL=<your-endpoint-url>
NUXT_S3_PUBLIC_URL=<your-public-url>

Declaring DB Dialect for type-safety

You can optionally declare database dialect for resolving correct database types for db (returned by internal useAdminDb() as AdminDbType) in admin registration plugin. Add a .d.ts file and declare the dialect once:

declare module '#layers/autoadmin/server/utils/db'{interfaceAutoAdminDbTypes{dialect: 'postgresql'// or 'sqlite' / 'd1'}}

About

Automatic admin interface for Drizzle Schema using Nuxt

Topics

Resources

Stars

34 stars

Watchers

0 watching

Forks

Contributors

Languages