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.
- 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.
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 install2. 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.
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.
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.
Opt-in activity logging for admin writes. Guide:docs/audit-log.md.
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]})})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.
| Key | Type | Default | Description |
|---|---|---|---|
label | string | Table name | Display name for the model (e.g., in the sidebar). |
key | string | tableName | Unique identifier for the model. Used in URLs and API endpoints. Pass if you have two models with the same table name. |
icon | string | undefined | Iconify icon name. Auto-detected for common names. |
order | number | 0 | Display order in the sidebar and dashboard index. Lower numbers come first; negatives float to the top, positives push down. Ties preserve registration order. |
labelColumnName | string | name, title, etc. | Column used for display labels in relationships and select options. |
lookupColumnName | string | id | The primary or unique key used to fetch single records. |
slugFields | Record<string, string[]> | undefined | Auto-generate URL-friendly slugs from other fields. Reference ↗ |
warnOnUnsavedChanges | boolean | false | Prompt user before leaving a form with unsaved changes. |
list | Partial<ListOptions> | {} | Configuration for the list/table view. Reference ↗ |
create | Partial<CreateOptions> | {} | Create form configuration. Reference ↗ |
update | Partial<UpdateOptions> | {} | Edit form configuration. Reference ↗ |
delete | Partial<DeleteOptions> | {} | Configuration for the delete action. Reference ↗ |
fields | FieldSpec[] | undefined | Overwrite how columns are handled in the UI. Reference ↗ |
sortField | string | undefined | Column name (integer) used for drag-drop ordering. Reference ↗ |
baseWhere | function | undefined | Persistent row filter (Drizzle SQL) on list/detail/update/delete. Reference ↗ |
audit | boolean or object | undefined | Per-model audit override. Inherit global configureAudit({ enabled: true }), or set true / options / false to opt out. Guide ↗ |
formFields | (string | FieldSpec)[] | undefined | Form field configuration. Reference ↗ |
m2m | Record<string, Table> | undefined | Defines many-to-many relationships to enable on form and detail view. Reference ↗ |
o2m | Record<string, Table> | undefined | Defines one-to-many relationships to enable on form and detail view. Reference ↗ |
roles | string[] or object | undefined | Optional per-model role allowlists (string[] = full access for those roles). Guide ↗ |
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}}]})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})[]}The type property in FieldSpec determines which form input component is rendered.
Field Types:
text: Standard text input. Auto-inferred fortexttype database columns.number: A number input. Auto-inferred forinteger,real,numeric,biginttype database columns.boolean: A checkbox. Auto-inferred forbooleantype database columns.select: A dropdown menu. Auto-inferred for enums.date: A date picker. Auto-inferred for sqlite integer with modetimestamp.datetime-local: A date and time picker. Auto-inferred for sqliteintegerwith modetimestamp_ms.json: A text area for JSON input. Auto-inferred for sqlitetextwith modejson.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.
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.
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...'}})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
isArchivedexample 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.
fieldvalue 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.
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-levelwarnOnUnsavedChangesconfiguration can be used instead of defining separately forcreateandupdateformFields: (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.
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']`}})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=falseOr 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.
The rich-text field type renders a full-featured Tiptap editor. Configuration options can be provided in two ways:
- Server-side via
inputAttrsin the admin registry -- for serializable options likeplaceholderanddisabledHeadingLevels. - 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.
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'],},},],})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'},}]],},},})})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';All options below work in both inputAttrs (server-side) and client-side configuration:
| Option | Type | Default | Description |
|---|---|---|---|
placeholder | string | 'Write, type / for commands...' | Placeholder text shown when the editor is empty. |
disabledHeadingLevels | number[] | [] | Heading levels (1-4) to disable. |
allowedMimeTypes | string[] | ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'application/pdf'] | MIME types accepted for file drag-drop and paste. |
textAlignTypes | string[] | ['heading', 'paragraph'] | Node types that support text alignment. |
baseClass | string | 'p-8 sm:px-16 py-13.5 prose dark:prose-invert max-w-none' | CSS class for the editor content area. |
toolbarClass | string | '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. |
extraFixedToolbarItems | EditorToolbarItem[][] | [] | Additional toolbar item groups appended to the fixed toolbar. |
extraBubbleToolbarItems | EditorToolbarItem[][] | [] | Additional toolbar item groups appended to the bubble (selection) toolbar. |
extensions | Extension[] | [] | Additional Tiptap extensions to load alongside the built-in ones. |
embedTypes | EmbedType[] | all types | Embed 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
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.
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},}],})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.
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}})})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)},},})create.beforereceives{ config, data }create.afterreceives{ config, data, validatedData, record }update.beforereceives{ config, lookupValue, data }update.afterreceives{ config, lookupValue, data, validatedData, record }delete.beforereceives{ config, lookupValue }delete.afterreceives{ config, lookupValue, record }
- Hooks are server-side only and must be defined in your server registration plugin.
beforehooks can throw to stop the request.create.beforeandupdate.beforemay return a replacement payload. That returned payload is then validated and persisted.afterhooks run only after the main write succeeds.- Hooks are not wrapped in an explicit transaction. If an
afterhook throws, the database write has already happened.
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.
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.
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}]}})The sortKey property determines how a column can be sorted:
Sort by the same column that's displayed:
{
field: 'createdAt',
sortKey: 'createdAt' // or just omit sortKey, defaults to field name
}
Sort by a different column than what's displayed:
{
field: popularity, // Custom function showing view count
sortKey: 'views' // Sort by the actual views column
}
Sort by columns in related tables using dot notation:
{
field: 'authorId.email',
sortKey: 'authorId.name' // Sort by author name, not email
}
Prevent sorting on specific columns:
{
field: 'status',
sortKey: false // No sorting for status
}
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']}})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},],})When the list is paginated (more than one page), a reorder menu (↕ icon) appears in each row's actions column with contextual options:
| Action | Visible When | Effect |
|---|---|---|
| Move to top | Not on first page | Moves the item to the very first position globally |
| Move up one page | Not on first page | Moves the item up by one page worth of positions |
| Move down one page | Not on last page | Moves the item down by one page worth of positions |
| Move to bottom | Not on last page | Moves the item to the very last position globally |
- 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 = CASEstatement, chunked to stay within database parameter limits (e.g., Cloudflare D1's 100-parameter cap). -->
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)]}}}]}})Automatically created for boolean columns. Provides Yes/No/All options.
{
field: 'isPublished',
type: 'boolean'
}
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' }
]
}
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
}
For foreign key relationships. Automatically provides choices from the related table.
{
field: 'categoryId',
type: 'relation',
}
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.
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
typeFilterFieldDef<TextendsTable>=|ColField<T>// Simple column name|{field: ColField<T>label?: stringtype?: FilterTypeoptions?: {label?: string,value: string|number}[]choicesEndpoint?: string}|CustomFilter// Advanced custom filterYou 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 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',},}}})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 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',},}}})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.
AutoAdmin can be configured using environment variables:
| Variable | Description | Default |
|---|---|---|
NUXT_DATABASE_URL | Database connection URL (e.g. file:server/db/db.sqlite or postgres://user:pass@localhost:5432/db) | undefined |
NUXT_PUBLIC_AUTOADMIN_TITLE | The title displayed in the admin interface | AutoAdmin |
NUXT_PUBLIC_AUTOADMIN_PATH_PREFIX | The URL/path prefix for the admin interface. Required to be set during build. | /admin |
NUXT_PUBLIC_PAGINATION_DEFAULT_SIZE | The default page size for the list view | 20 |
NUXT_PUBLIC_PAGINATION_MAX_SIZE | The maximum page size for the list view | 200 |
NUXT_AUTOADMIN_FILE_UPLOAD_ROLES | Array of role strings (example: ["admin","editor"]) allowed to upload files. Omit or empty → no global restriction on uploads. | (empty) |
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>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'}}