nuxt-auto-crud
v3.1.1
Published
Dynamic RESTful CRUD APIs for Nuxt without code generation, fully schema-driven.
Maintainers
Readme
nuxt-auto-crud (nac 3.x)
A Nuxt.js module providing dynamic RESTful CRUD APIs derived directly from your Drizzle schemas, without writing any code for CRUD operations.
🚀 Core Features
- Zero-Codegen Dynamic RESTful CRUD APIs: nuxt-auto-crud leverages Drizzle ORM, Zod, Nuxt, and Nitro to eliminate the need for manual CRUD coding.
- Single Source of Truth (SSOT): Your Drizzle schemas (
server/db/schema) define the entire API structure and validation. - Constant Bundle Size: Since no code is generated, the bundle size remains virtually identical whether you have one table or one hundred (scaling only with your schema definitions).
⚠️ Upgrading to 3.0.0 — Breaking Change
GET list endpoints (/api/_nac/:model) no longer return a bare array. They now return:
interface NacPaginatedResponse<T> {
data: T[]
meta: {
mode: 'offset' | 'simple' | 'cursor'
perPage: number
total?: number // only present when the request includes ?total=true
page?: number // present in 'offset' and 'simple' modes
nextCursor?: string // present in 'cursor' mode, when more results exist
hasMore: boolean
}
}Migration: anywhere your frontend does const items = await $fetch('/api/_nac/products'), change it to:
const { data: items } = await $fetch('/api/_nac/products')This does not affect GET /:model/:id, POST, PATCH, or DELETE — those still return a single record object directly, unchanged.
Why: a bare array gave clients no way to know if more results existed without over-fetching, and no way to page reliably.
Fixes:
- CRUD authorization (
create/read/update/delete, and their_ownvariants) was previously enforced only on list operations —nacGetRow/nacCreateRow/nacUpdateRow/nacDeleteRownow consistently check permissions too. - The
list_activepermission code has been renamed tolist, matching the intended vocabulary (list_all/list/list_own) — update anyresourcePermissionsarrays your app supplies.
See 🛡 Filtering & Performance Optimization for the full CRUD permission model, and 📄 Pagination & Filtering for the new response shape's fields.
Supported Databases
- SQLite (libSQL)
- MySQL
Installation Guide (SQLite)
Option A: Starter Template
npx nuxi init -t gh:clifordpereira/nac-starter my-app
cd my-app
nuxt db generate
nuxt dev
Option B: Manual Installation
bun create nuxt@latest my-app
cd my-app
npx nuxi module add hub
bun add drizzle-orm@rc @libsql/client nuxt-auto-crud
bun add -D drizzle-kit@rc typescript
Configuration
Update nuxt.config.ts:
export default defineNuxtConfig({
modules: [
'@nuxthub/core',
'nuxt-auto-crud'
],
hub: {
db: 'sqlite'
}
})
Schema Definition
Define your schema in server/db/schema.ts:
import { snakeCase, text, integer, numeric } from 'drizzle-orm/sqlite-core'
export const products = snakeCase.table('products', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
sku: text('sku').notNull(),
price: numeric('price', { mode: 'number' }).notNull(),
stock: integer('stock').notNull(),
createdAt: integer({ mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer({ mode: 'timestamp' }).notNull().$onUpdate(() => new Date()),
})
Generate Migrations and Start Dev Server
nuxt db generate
nuxt dev
For MySQL installation instructions, visit INSTALLATION.md.
Authentication & Sensitive Fields
NAC automatically filters out password fields across all dynamic endpoints, as encryption and authentication logic are deferred to the implementing application.
Consequently, defining a password field as notNull() in your schema will trigger a database NOT NULL constraint failed error during record creation via NAC.
- Production: Handle authentication and password persistence in your application-level endpoints, keeping the field outside of NAC's automated scope.
- Testing/Checkouts: If you are testing the core module directly against a schema containing a password, mark the field as optional (nullable). NAC will then leave the column blank without throwing constraint errors.
Conventions
NAC relies on specific naming conventions to enable zero-config relations and dynamic rendering:
- Field Naming Case: All database columns must use
snake_case. - Dynamic Field Labels: For parent-child relationship resolution, NAC dynamically falls back through a specific priority chain to resolve the text label of a record:
name||title||num||id. - Custom Identifiers (e.g., Orders): While most parent tables naturally expose a
nameortitlecolumn, tables utilizing tracking numbers (likeorders) often useorder_number. To leverage NAC's automatic label mapping for these schemas, name the column exactlynuminstead oforder_number.
🌐 Data APIs (Dynamic RESTful CRUD)
Note: All endpoints follow the pattern ${apiBase}/:model. By default, this is /api/_nac/:model.
| Method | Endpoint | Action |
| --- | --- | --- |
| GET | /api/_nac/:model | List records |
| POST | /api/_nac/:model | Create record with Zod validation |
| GET | /api/_nac/:model/:id | Fetch single record |
| PATCH | /api/_nac/:model/:id | Partial update with validation |
| DELETE | /api/_nac/:model/:id | Delete record |
Example (products table):
| Action | HTTP Method | Endpoint | Example Result |
| --- | --- | --- | --- |
| Fetch All | GET | /api/_nac/products | List of all products |
| Create | POST | /api/_nac/products | New product record added |
| Fetch One | GET | /api/_nac/products/1 | Details of product with id: 1 |
| Update | PATCH | /api/_nac/products/1 | Partial update to product 1 |
| Delete | DELETE | /api/_nac/products/1 | Product 1 removed from DB |
📄 Pagination & Filtering
GET /api/_nac/:model accepts query-string params for pagination and simple equality filtering.
Pagination modes
| Mode | Triggered by | meta fields |
| --- | --- | --- |
| simple | No pagination params at all | perPage, hasMore, page |
| offset | ?page= or ?offset= present | perPage, hasMore, page |
| cursor | ?cursor= present (and the table has an id column) | perPage, hasMore, nextCursor |
Examples:
GET /api/_nac/products?limit=20 → mode: 'simple'
GET /api/_nac/products?page=2&limit=20 → mode: 'offset'
GET /api/_nac/products?offset=40&limit=20 → mode: 'offset'
GET /api/_nac/products?cursor=118&limit=20 → mode: 'cursor'
Fields:
limit— defaults to 50, capped at 200.hasMore— always present; computed by fetching one row pastlimit, at no extra query cost.total— aCOUNT(*)query, so it's opt-in only: add?total=trueto any request, regardless of mode, to include it.- Cursor pagination returns
id-descending results by default and only supports that default ordering — a resource whosenacTableQueryConfigsets a customorderByshould useoffset/pageinstead, since a cursor built against a different sort order would return a mismatched page. - Cursor mode currently returns
nextCursoronly (forward paging). Backward navigation is left to the client keeping its own history of visited cursors — the same pattern used by Stripe and GitHub's REST APIs.
Equality filtering
Any other query param matching a visible column name is applied as an equality filter, e.g. ?status=active. Filters are resolved only against fields the caller can actually see (post hidden-field / public-resource narrowing) — an unrecognized or hidden key is silently ignored, never partially honored, so filtering can't be used to infer a hidden field's value.
🛠 Introspection & Metadata APIs
Use these endpoints to build dynamic UI components (like menus and forms) or provide context to AI agents. These use the _schemas and _meta reserved paths.
1. Discovery Endpoints
- List Resource Names:
GET /api/_nac/_schemas - Returns an array of all available table names. Useful for generating dynamic navigation menus.
- Resource Metadata:
GET /api/_nac/_schemas/:resource - Returns field definitions, validation rules, and
readonlystatus for a specific table. - Example:
GET /api/_nac/_schemas/productsreturns the schema for the products table.
Schema Interface
export interface Field {
name: string
type: string
required?: boolean
selectOptions?: string[]
references?: string
readonly?: boolean
}
export interface SchemaDefinition {
resource: string
labelField: string
fields: Field[]
}
Example Response
GET /api/_nac/_schemas/products
{
"resource": "products",
"labelField": "name",
"fields": [
{ "name": "id", "type": "number", "required": true, "readonly": true },
{ "name": "name", "type": "string", "required": true, "readonly": false },
{ "name": "sku", "type": "string", "required": true, "readonly": false },
{ "name": "price", "type": "number", "required": true, "readonly": false },
{ "name": "stock", "type": "number", "required": true, "readonly": false }
]
}
_schemasendpoints are always accessible, regardless ofauth.authentication— they return field metadata (names, types, required/readonly flags), never row data._metais separately gated by its ownagenticToken, independent of session auth.
2. Agentic Discovery
- Manifest:
GET /api/_nac/_meta?format=md - Returns a token-efficient Markdown manifest for LLM context injection.
- Security: Requires
NUXT_AUTO_CRUD_AGENTIC_TOKEN(min 16 characters) in your.env.
🛡 Security & Configuration
Enabling authentication in the autoCrud config protects all nac routes (/api/_nac/*), except those explicitly defined in publicResources.
🔒 Access Control & Data Safety
apiHiddenFields: Globally hides sensitive columns from all API responses (read). Default: ['password', 'secret', 'token', 'resetToken', 'resetExpires', 'githubId', 'googleId'].apiWriteProtectedFields: Server-enforced — fields a client can never set viaPOST/PATCHbody, regardless of the frontend (mass-assignment protection).auth.ownerKeyis always protected automatically, even if not listed explicitly.formHiddenFields: Columns excluded from the_schemasmetadata response, as a UI hint only. This does not block writes — useapiWriteProtectedFieldsfor that.formReadOnlyFields(deprecated): Was intended as a UI-only hint and was never enforced server-side. Manage read-only state in your frontend instead (e.g.nuxt-crud-table).
⚙️ Configuration Reference
| Key | Default | Description |
| --- | --- | --- |
| statusFiltering | false | Enables/disables automatic filtering of records based on the status column. |
| realtime | false | Enables real-time broadcasting of all Create, Update, and Delete (CUD) operations via SSE. |
| auth.authentication | false | Requires a valid session for all NAC routes. |
| auth.authorization | false | Enables role/owner-based access checks. |
| auth.ownerKey | 'createdBy' | The column name used to identify the record creator. |
| auth.useNacSchema | false | Injects NAC's built-in authz schema (users, roles, resources, permissions, role_resource_permissions) into your database automatically. See 🔐 Authorization Schema & Seeding. |
| db.dialect | 'sqlite' | Target database dialect. Supported: 'sqlite' (libSQL) and 'mysql'. |
| publicResources | {} | Defines tables and specific columns accessible without auth. |
| apiHiddenFields | NAC_API_HIDDEN_FIELDS | Arrays of keys to exclude from all API responses. |
| apiWriteProtectedFields | NAC_API_WRITE_PROTECTED_FIELDS | Server-enforced fields a client can never set on create/update. auth.ownerKey is always included automatically. |
| formHiddenFields | NAC_FORM_HIDDEN_FIELDS | Arrays of keys to exclude from dynamic forms. |
| formReadOnlyFields | NAC_FORM_READ_ONLY_FIELDS | @deprecated — unenforced server-side. Configure in your frontend instead. |
| agenticToken | '' | Secret key used to secure the /_meta endpoint, preventing unauthorized AI agents from introspecting your schema. |
| nacEndpointPrefix | '/api/_nac' | @deprecated Use apiBase instead. |
| apiBase | '/api/_nac' | The base path for NAC routes. Access via useRuntimeConfig().public.autoCrud. |
| schemaPath | 'server/db/schema' | Location of your Drizzle schema files. |
Example nuxt.config.ts
autoCrud: {
statusFiltering: false,
realtime: false,
auth: {
authentication: false,
authorization: false,
ownerKey: 'createdBy', // change it if you want to use another column eg: ownerId
useNacSchema: false, // set true to let NAC provide the roles/permissions tables for you
},
db: {
dialect: 'sqlite', // 'sqlite' | 'mysql' (default is 'sqlite')
},
publicResources: {
// guest users can access these tables and fields without authentication, even when auth.athentication is true
products: ['id', 'name', 'sku', 'price'],
},
apiHiddenFields: ['createdBy'], // these fields are hidden from all API responses globally
apiWriteProtectedFields: ['createdBy', 'updatedBy'], // mass-assignment protection; ownerKey is always included automatically
formHiddenFields: ['createdAt', 'updatedAt'], // @deprecated: instead configure at frontend
formReadOnlyFields: ['sku'], // @deprecated: instead configure at frontend
agenticToken: '', // OPTIONAL. required to secure the /_meta endpoint
nacEndpointPrefix: '/api/_nac', // @deprecated: instead use apiBase
apiBase: '/api/_nac',
schemaPath: 'server/db/schema',
}
Note: Modify
apiBaseorschemaPathonly if the Nuxt/Nitro conventions change.
Per-Resource Field Overrides
apiHiddenFields, formHiddenFields, and apiWriteProtectedFields each accept either a flat array (applies to every table) or a scoped object for per-table control:
apiWriteProtectedFields: {
default: ['createdBy', 'updatedBy'], // replaces the built-in default list; omit to keep it
resources: {
users: ['roleId'], // additionally protected, users table only
},
},Casing flexibility
Both the resources object's keys and its field-name values accept either casing:
- Table keys — the physical snake_case table name (e.g.
role_resource_permissions) or the camelCase schema export name (e.g.roleResourcePermissions) both resolve to the same table. - Field names — snake_case (e.g.
role_id) or camelCase (e.g.roleId) both resolve to the same column, regardless of how that column happens to be declared in your schema.
Both of these are equally valid — different casing, same table, same fields:
apiWriteProtectedFields: {
resources: {
role_resource_permissions: ['roleId', 'resourceId'],
},
},
// or, identically:
apiWriteProtectedFields: {
resources: {
roleResourcePermissions: ['role_id', 'resource_id'],
},
},publicResources follows the same rule for both its keys and values.
🛡 Filtering & Performance Optimization
CRUD Authorization
When auth.authorization is enabled, every operation is gated by event.context.nac.resourcePermissions — a flat array of codes scoped to the current resource, supplied by your app's own middleware (see Authorization Middleware below).
| Code | Grants |
| --- | --- |
| create | Create records |
| read / read_own | Read any record / only records you own |
| update / update_own | Update any record / only records you own |
| delete / delete_own | Delete any record / only records you own |
| list_all | List all records, full bypass |
| list | List records, respecting statusFiltering if enabled |
| list_own | List only records you own |
A caller with neither the full nor _own code for an operation is rejected with 403. For _own-scoped read/update/delete, a request against a record the caller doesn't own returns 404, not 403 — this is deliberate: it never confirms a not-owned record's existence to a caller who isn't authorized to see it.
Automatic Status Filtering
If statusFiltering is enabled, nac applies global visibility constraints. When a status column exists, queries are automatically restricted to active records. When both statusFiltering and the list permission apply together, hybrid OR logic is used: a caller sees all active records OR any record they own, regardless of its status.
Authorization Middleware
Populate event.context.nac from your own app's session/permission logic, before NAC's own guard runs. nacGetModelFromPath (exported from nuxt-auto-crud) resolves the :model a request targets, so your middleware never needs to re-implement NAC's route parsing:
// server/middleware/00.nac-permissions.ts
export default defineEventHandler(async (event) => {
const model = nacGetModelFromPath(event.path)
if (!model) return // not a NAC route
const user = await resolveSessionUser(event) // your own session logic, eg: getUserSession() from nuxt-auth-utils
if (!user) return
// Merge, don't overwrite — NAC's own guard may already have set
// event.context.nac.isPublic before or after this runs.
event.context.nac = {
...event.context.nac,
userId: user.id,
resourcePermissions: await resolvePermissionsForModel(user, model), // eg: user.permissions[model] (store permissions in user on login)
}
})Optimization: Skip Redundant Fetches
If your middleware has already fetched the record, pass it to event.context.nac.record — nac will use this object instead of executing an additional database query.
🔐 Authorization Schema & Seeding
NAC can also provide the IAM (Identity and Access Management) tables your app's own authorization middleware relies on, plus helpers to fetch a user's permissions and to seed roles/permissions/users in one go.
1. Built-in Authz Schema (auth.useNacSchema)
Set auth.useNacSchema: true and NAC injects its own roles, resources, permissions, and role_resource_permissions tables straight into your Drizzle schema — you don't need to hand-write them (the users table must be provided by you and it should have a role_id column):
autoCrud: {
auth: {
authentication: true,
authorization: true,
useNacSchema: true,
},
}Leave this
falseif you already manage your own authz tables — NAC's authz helpers (below) work with any schema shaped likeNacAuthzSchema(users,roles,resources,permissions,role_resource_permissions), whether NAC provided them or you defined them yourself.
If you use relations, add your relations path in relationsPath. Then add your relations and nacAuthzTableQueryConfig as shown below:
// server/db/relations.ts
import { defineRelations } from 'drizzle-orm'
import { nacAuthzRelationsConfig, nacAuthzTableQueryConfig } from '#nac/authz-relations'
export const relations = defineRelations(schema, r => ({
...nacAuthzRelationsConfig(r),
// your app's own tables, added alongside
}))
export const nacTableQueryConfig = {
...nacAuthzTableQueryConfig,
// your app's own tableQueryConfig, added alongside
}Please note that you may use schemas (schema.ts) as usual.
2. Fetching a User's Permissions (nacGetPermissionsForUser)
Your Authorization Middleware just needs event.context.nac.resourcePermissions populated for the current user — how you resolve that is entirely up to you. nacGetPermissionsForUser is an auto-imported convenience helper for a quick start.
// server/api/login.post.ts
export default defineEventHandler(async (event) => {
const { email, password } = await readBody(event)
const user = await verifyCredentials(email, password) // your own logic
const permissions = await nacGetPermissionsForUser(db, schema, user.id) // Then you can pass permissions[model] to event.context.nac.resourcePermissions in your auth middleware
await setUserSession(event, { user, permissions })
return { ok: true }
})permissions resolves to a map of resource name → permission codes, e.g.:
{
"products": ["list", "read"],
"orders": ["list_own", "read_own", "update_own"]
}3. Seeding Roles, Permissions & Users (defineAuthzSeed + nacSeedAuthz)
Declare your app's roles, resources, presets, and the users to seed with the auto-imported defineAuthzSeed helper — it's fully type-safe, so your editor will flag unknown roles or malformed permission codes as you write:
// server/config/authz-seed.ts - you can choose where this config lives, just import it in seed.ts
import { defineAuthzSeed } from 'nuxt-auto-crud'
export default defineAuthzSeed({
usersToSeed: [
{ name: 'Admin User', email: '[email protected]', password: '$1Password', role: 'admin' },
{ name: 'Customer User', email: '[email protected]', password: '$1Password', role: 'user' },
],
// Additional app tables beyond NAC's own authz tables (users, roles, resources, permissions, role_resource_permissions)
resources: ['posts', 'comments'],
// Optional shorthand groups, expanded into concrete codes below
presets: {
readOnly: ['list', 'read'],
},
roles: {
admin: {
permissions: 'all', // full access to every resource/permission combination
},
user: {
permissions: {
posts: ['readOnly'],
comments: ['create', 'read_own', 'update_own'],
},
},
},
})Then run it from a Nitro task — nacSeedAuthz is auto-imported, so no extra import is needed (requires nitro.experimental.tasks: true in nuxt.config.ts):
// server/tasks/seed.ts
import seedConfig from '../config/authz-seed' // import from where you defined the config
export default defineTask({
meta: {
name: 'db:seed',
description: 'Seed database with initial data',
},
async run() {
console.log('Seeding database...')
await nacSeedAuthz({
db,
schema,
config: seedConfig,
hashPassword, // your own hashing function, eg: hashPassword() from nuxt-auth-utils
})
return { result: 'Database seeded successfully' }
},
})nacSeedAuthz seeds permissions, resources, roles, one row per entry in usersToSeed, and then expands roles into the full role_resource_permissions grid — resolving any presets shorthand into concrete permission codes along the way.
nacSeedAuthzwrites directly tousers,roles,resources,permissions, androle_resource_permissions, so yourschemaneeds those five tables — either fromauth.useNacSchema: trueor your own equivalent definitions.
📡 Real-time Synchronization (SSE)
When realtime is enabled, all create, update, and delete operations are automatically broadcasted:
if (realtime) {
void broadcast({
table: model,
action: 'create',
primaryKey: newRecord.id,
data: newRecord,
})
}
Frontend Usage
NAC provides a useNacAutoCrudSSE composable to listen for these changes in your frontend:
useNacAutoCrudSSE(({ table, action, data: sseData, primaryKey }) => {
// Optional: Filter by specific table
if (table !== 'products') return
if (action === 'update') {
// updateRow(primaryKey, sseData)
}
if (action === 'create') {
// addRow(sseData)
}
if (action === 'delete') {
// removeRow(primaryKey)
}
})
🔗 Relations Support
nuxt-auto-crud natively supports Drizzle ORM relations, allowing you to fetch nested relational graphs, exclude or pick specific columns, and apply relational ordering automatically.
1. Update nuxt.config.ts
Define the path to your relations file in the module configuration options:
export default defineNuxtConfig({
modules: ['nuxt-auto-crud'],
autoCrud: {
relationsPath: 'server/db/relations', // Specify the path to your relations configuration
},
})
2. Define Relations & Query Configurations
Create your relations file (e.g., server/db/relations.ts).
⚠️ Important Notes:
nuxt-auto-crudutilizes the Drizzle RC API, so you must define relations usingdefineRelations()instead of the olderrelations()wrapper.- Your relations config map and query setup must be strictly exported as
relationsandnacTableQueryConfigrespectively.- Any valid Drizzle
DBQueryConfigparameter is supported innacTableQueryConfig.
example for nacTableQueryConfig
// in relations.ts after your normal relations code
export const nacTableQueryConfig: Record<string, DBQueryConfig> = {
users: {
with: {
role: { columns: { name: true } },
},
},
roleResourcePermissions: {
columns: {
roleId: false,
resourceId: false,
permissionId: false,
},
with: {
role: { columns: { name: true } },
resource: { columns: { name: true } },
permission: { columns: { code: true } },
},
},
}A full example of relations.ts
⚠️ Casing note:
nacTableQueryConfigkeys are matched primarily by the camelCase schema export name (e.g.roleResourcePermissions), matching Drizzle'sdb.query[...]API — but NAC also falls back to the physical snake_case table name (e.g.role_resource_permissions) if no camelCase entry is found, so either form works here too. This matches the same casing flexibility asapiHiddenFields/apiWriteProtectedFields'sresourceskeys (see Per-Resource Field Overrides above) — you no longer need to track which casing rule applies to which config surface.
Troubleshooting
Known Issues
drizzle-kit's RC releases (^1.0.0-rc.4) can resolve to an internal drizzle-orm snapshot pairing that's incompatible with the independently-resolved drizzle-orm version packet manager (eg: bun) installs for your app — this shows up as nuxt db generate throwing SQLiteSyncDialect is not a constructor, or an untagged rc.4 throwing event.req.text is not a function at runtime instead. nac-migrate-fresh (below) works around this by temporarily switching to the migration-capable build to generate migrations, then reinstalling to restore the app-compatible pairing.
For apps using nuxt-auto-crud
If you hit the same drizzle-kit/drizzle-orm version mismatch in your own project, a bun-based fixer script ships with this package, exposed via its package.json bin field:
"bin": {
"nac-migrate-fresh": "./bin/migrate-fresh.sh"
}Run it from your app's root (where your bun.lock lives):
npx nac-migrate-freshFor more details — see the script's own header comment (node_modules/.bin/nac-migrate-fresh after install, or view it on GitHub)
🎨 Companion Frontend: nuxt-crud-table (nct)
nuxt-crud-table is a Nuxt UI component that renders CRUD tables and forms from an API's schema metadata. It is backend-agnostic: it reads whatever _schemas-shaped response the API provides and builds the table/form from that, so it works with NAC, Laravel, FastAPI, or any backend that returns metadata matching the expected format (see Schema Interface above).
Installing both nuxt-auto-crud and nuxt-crud-table in the same Nuxt project gives you generated CRUD APIs on the backend and a matching CRUD UI on the frontend, without hand-writing either.
