next-headless-crud
v0.1.1
Published
Headless, URL-native CRUD resources for the Next.js App Router and Drizzle ORM.
Maintainers
Readme
next-headless-crud
Headless, URL-native CRUD resources for the Next.js App Router and Drizzle ORM.
next-headless-crud owns safe database operations, URL query state, serializable page models, Server Action navigation, and catch-all route dispatch. It deliberately renders no UI and ships no CSS. Every application must provide its own list, create, detail, and update page components.
Why this shape
- Application-owned UI: markup, controls, copy, accessibility decisions, and styling stay in your app.
- RSC-first: reads happen on the server; URL navigation reruns the Server Component.
- URL-native: search, filters, sorting, page, and page size are shareable query parameters.
- Allowlisted: only declared fields can be read, queried, or mutated.
- Tenant-aware: one scope protects list, find, update, and delete operations.
- Visible action boundaries: your app owns the
"use server"files. - One route: a catch-all page can serve any number of CRUD resources.
Drizzle table
-> CRUD resource
-> scoped SQL + URL query
-> serializable page model + bound actions
-> application-owned UIRequirements
- Node.js 20 or newer
- Next.js 15 or newer
- React 19 or newer
- Drizzle ORM with PostgreSQL or MySQL
Install
npm install next-headless-crud drizzle-ormInstall your database driver and a Standard Schema validator separately.
Quick start
1. Define a resource
Keep resources outside the app route tree. Drizzle column types flow into the resource row type.
// users/resource.ts
import "server-only";
import { eq } from "drizzle-orm";
import { defineCrudResource, field } from "next-headless-crud/drizzle";
import { z } from "zod";
import { getSession } from "@/auth";
import { db } from "@/db/database";
import { users } from "@/db/schema";
const userInput = z.object({
name: z.string().trim().min(2),
email: z.email(),
role: z.enum(["admin", "member"]),
});
export const usersResource = defineCrudResource({
name: "users",
label: "Users",
path: "/users",
table: users,
primaryKey: users.id,
database: () => db,
context: async () => {
const session = await getSession();
return {
user: session.user,
organizationId: session.organizationId,
};
},
fields: {
id: field(users.id, { readOnly: true }),
name: field(users.name, {
searchable: true,
sortable: true,
filter: {
operators: ["contains"],
values: async ({ db: database, context, limit }) =>
database.select({ value: users.name, label: users.name })
.from(users)
.where(eq(users.organizationId, context.organizationId))
.limit(limit),
},
}),
email: field(users.email, {
inputType: "email",
searchable: true,
sortable: true,
}),
role: field(users.role, {
inputType: "select",
filter: {
operators: ["eq"],
values: async () => [
{ label: "Administrator", value: "admin" },
{ label: "Member", value: "member" },
],
},
}),
createdAt: field(users.createdAt, { sortable: true, readOnly: true }),
},
views: {
list: ["name", "email", "role", "createdAt"],
detail: ["id", "name", "email", "role", "createdAt"],
create: ["name", "email", "role"],
update: ["name", "email", "role"],
},
pagination: { pageSize: 20, maxPageSize: 100 },
defaultSort: [{ field: "createdAt", direction: "desc" }],
validation: { create: userInput, update: userInput },
createValues: ({ context }) => ({
organizationId: context.organizationId,
}),
scope: ({ table, context }) =>
eq(table.organizationId, context.organizationId),
authorize: {
list: ({ context }) => context.user.role === "admin",
find: ({ context }) => context.user.role === "admin",
create: ({ context }) => context.user.role === "admin",
update: ({ context }) => context.user.role === "admin",
delete: ({ context }) => context.user.role === "admin",
},
});fields and views are security allowlists. createValues runs after validation and wins over submitted input, so client data cannot replace protected values such as a tenant ID.
2. Add application-owned Server Actions
// users/actions.ts
"use server";
import type { MutationResult } from "next-headless-crud/core";
import {
createResourceAction,
deleteResourceAction,
updateResourceAction,
} from "next-headless-crud/next";
import { usersResource } from "@/users/resource";
export async function createUser(
_previous: MutationResult | undefined,
formData: FormData,
) {
return createResourceAction(usersResource, formData);
}
export async function updateUser(
id: string,
_previous: MutationResult | undefined,
formData: FormData,
) {
return updateResourceAction(usersResource, id, formData);
}
export async function deleteUser(
id: string,
_previous: MutationResult | undefined,
_formData: FormData,
) {
return deleteResourceAction(usersResource, id);
}Navigation defaults are centralized:
| Mutation | Revalidate | Redirect | | --- | --- | --- | | create | list | list | | update | list and detail | detail | | delete | list | list |
Pass a MutationNavigation object to override the defaults. Passing {} performs no revalidation or redirect.
3. Write all four pages
The package does not have fallback pages. Each route definition requires application components for every route kind.
// users/pages.tsx
import { notFound } from "next/navigation";
import type {
CrudRouteCreatePageProps,
CrudRouteDetailPageProps,
CrudRouteListPageProps,
CrudRouteUpdatePageProps,
} from "next-headless-crud/next";
import { usersResource } from "@/users/resource";
import { UserForm } from "./user-form";
type Users = typeof usersResource;
export async function UsersListPage({
resource,
searchParams,
}: CrudRouteListPageProps<Users>) {
const model = await resource.list(await searchParams);
return <UsersTable model={model} />;
}
export async function UsersCreatePage({
resource,
action,
}: CrudRouteCreatePageProps<Users>) {
return <UserForm model={await resource.getCreateForm()} action={action} />;
}
export async function UsersDetailPage({
resource,
id,
action,
}: CrudRouteDetailPageProps<Users>) {
const model = await resource.getDetail(id);
if (!model) notFound();
return <UserDetails model={model} deleteAction={action} />;
}
export async function UsersUpdatePage({
resource,
id,
action,
}: CrudRouteUpdatePageProps<Users>) {
const model = await resource.getUpdateForm(id);
if (!model) notFound();
return <UserForm model={model} action={action} />;
}UsersTable, UserDetails, and UserForm above are your components. Use any CSS strategy or component library you want.
4. Register resources in one catch-all page
// app/[...path]/page.tsx
import {
createCrudRoutePage,
defineCrudRoute,
} from "next-headless-crud/next";
import { createUser, deleteUser, updateUser } from "../actions";
import {
UsersCreatePage,
UsersDetailPage,
UsersListPage,
UsersUpdatePage,
} from "@/users/pages";
import { usersResource } from "@/users/resource";
const routes = [
defineCrudRoute({
resource: usersResource,
actions: { create: createUser, update: updateUser, delete: deleteUser },
pages: {
list: UsersListPage,
create: UsersCreatePage,
detail: UsersDetailPage,
update: UsersUpdatePage,
},
}),
];
export default createCrudRoutePage(routes);This handles /users, /users/new, /users/:id, and /users/:id/edit. Add another route entry for posts or any other resource; no app/users or app/posts folders are needed. Nested resource paths are supported, and the longest matching resource path wins.
defineCrudRoute() accepts only routing concerns: resource, actions, and pages. Page titles, descriptions, layout, and all other presentation data belong inside your components.
Headless resource API
| Method | Result |
| --- | --- |
| list(searchParams) | rows, normalized query, pagination, ready-to-render columns, and typed filters |
| find(id) | scoped row or null |
| getDetail(id) | serializable detail model or null |
| getCreateForm() | serializable create form model |
| getUpdateForm(id) | serializable update form model or null |
| create(input) | MutationResult |
| update(id, input) | MutationResult |
| delete(id) | MutationResult |
The browser-safe next-headless-crud/query entrypoint exports tableHref, parseTableQuery, and stringifyTableQuery. Filter form names and sort links are supplied by list() because they depend on resource capabilities and the current query state. List columns already combine field metadata with optional sort state, so UI code does not need to join views.list, fields, and sort data itself.
URL query format
/users?page=3&pageSize=20&sort=role.asc,createdAt.desc&filter.role.eq=admin&q=kim- Unknown fields and unsupported operators are ignored.
- Search, sorting, or filter changes reset the page to 1.
- The primary key is appended to SQL ordering for stable offset pagination.
Fields from Drizzle relations
Use an existing Drizzle one() relation when a list or detail model needs data from a related row. The resource reads the relation's fields and references, so the join condition is not declared twice.
// db/schema.ts
import { relations } from "drizzle-orm";
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.userId],
references: [users.id],
}),
}));Map the related column to a flat resource field with relationField():
import {
defineCrudResource,
field,
relationField,
} from "next-headless-crud/drizzle";
export const postsResource = defineCrudResource({
table: posts,
primaryKey: posts.id,
fields: {
id: field(posts.id, { readOnly: true }),
userId: field(posts.userId, {
readOnly: true,
filter: {
operators: ["eq"],
values: async ({ db, context, search, limit }) =>
db.select({ value: users.id, label: users.name })
.from(users)
.where(and(
eq(users.organizationId, context.organizationId),
search ? ilike(users.name, `%${search}%`) : undefined,
))
.limit(limit),
},
}),
title: field(posts.title, { searchable: true }),
userName: relationField(postsRelations, "author", users.name, {
searchable: true,
sortable: true,
}),
},
views: {
list: ["title", "userName"],
detail: ["id", "title", "userName"],
create: ["title"],
update: ["title"],
},
// database, path, name, and other options omitted
});Each list item contains the flat selected data (item.data.userName), its primary-key id, and canonical href and editHref links. list() also resolves the declared filter targets and preserves the configured field names in its return type:
const model = await postsResource.list(searchParams);
const authorFilter = model.filters.userId;
authorFilter.name; // "filter.userId"
authorFilter.values; // [{ value: 1, label: "Kim" }, ...]/posts?q=kim
/posts?filter.userId=1
/posts?sort=userName.ascRelation fields are automatically read-only and cannot be added to views.create or views.update. A nullable one() relation produces a nullable field. Current relation support is intentionally limited to directly configured one() relations; collection relations and multiple joins to the same target table are not supported because they require different pagination and aliasing rules.
Every declared filter supplies a server-only values() loader. list() runs all declared loaders and returns their serializable { value, label } targets under model.filters. The loader can use Drizzle queries, another resource, or an external service, and must apply any tenant or visibility rules specific to the target collection. Select fields receive the same values automatically in their create and update form models.
Validation and forms
Validation accepts any Standard Schema implementation. Form values are normalized from Drizzle column types before validation: numbers, booleans, dates, nullable empty values, and omitted checkboxes are handled automatically.
Mutation errors are serializable and include optional fieldErrors, making them suitable for React useActionState. See the custom UI guide for a complete form pattern.
Security model
fieldsis the public field allowlist.views.createandviews.updateare mutation allowlists.scopeapplies to list, find, update, and delete SQL.createValuesinjects trusted server-owned values.authorizecan reject each operation using context and, where applicable, the scoped row.- Database and resource modules should import
server-only.
Never put tenant IDs or ownership fields in client-controlled hidden inputs.
Package entrypoints
| Import | Environment | Purpose |
| --- | --- | --- |
| next-headless-crud/core | universal | resource contracts, models, actions, mutation results |
| next-headless-crud/query | browser-safe | URL query parsing and link generation |
| next-headless-crud/drizzle | server-only | Drizzle resource definition and execution |
| next-headless-crud/next | server-only | route contracts, catch-all dispatch, and mutation navigation |
There is intentionally no /ui entrypoint and no package stylesheet.
Runnable example
The example uses PGlite, so it needs no external database or environment variables.
npm install
npm run example:devOpen /users for metadata-driven pages and /posts for a custom list and writing form. Each feature keeps its resource, actions, and UI together under examples/nextjs/users or examples/nextjs/posts.
Development
npm install
npm run check
npm run example:buildSee AGENTS.md for repository architecture and invariants.
Current scope
V1 includes PostgreSQL and MySQL, a single primary key, list/detail/create/update/delete, exact-count offset pagination, multi-column sorting, text/number/date/boolean/select filtering, global search, read-only fields from direct Drizzle one() relations, Standard Schema validation, Server Actions, catch-all routing, tenant scope, authorization hooks, and headless models.
Not included: UI components, CSS, composite keys, cursor pagination, collection relation fields, relation editors, inline editing, real-time updates, drag ordering, or a general-purpose SQL builder.
