Overview
OpenAdminJS is a resource-first stack for internal tools, CRMs, CMS dashboards and SaaS back offices. You describe your domain with Prisma and resource metadata; the platform wires CRUD, permissions, audit trails and UI chrome around those definitions—similar to how Nest.js modules declare providers and routes once, then compose across the runtime.
defineResource contract powers the generic admin CRUD surface, while the Nest app also exposes auth, dashboards, storefront checkout, BullMQ ops, plugin management and the admin AI service—see Full route map.
When to choose OpenAdminJS
- You want a resource-driven admin workflow on a Node.js + TypeScript stack.
- You need RBAC, audit logs and file handling without rebuilding them for every resource.
- You plan to expose some resources publicly (SEO, marketing pages) from the same models.
Installation
Prerequisites: Node.js 20+, pnpm (recommended) or npm/yarn, and the OpenAdminJS CLI. The flow mirrors a typical Nest.js workspace bootstrap: scaffold, migrate, seed, run. Dependency installation and core env prompts (DB/Redis/JWT) happen automatically during project creation, while admin origin and API port are prefilled with local defaults.
npx openadminjs create my-app
cd my-app
pnpm dev
Default local URLs after pnpm dev:
Project structure
Generated monorepos follow a predictable layout so you can jump between API, admin and web the same way you navigate modules in Nest.js.
apps/
api/ Nest.js API — auth, CRUD, files, audit, plugins, AI, jobs, storefront
admin/ Next.js admin UI generated from resources + ops pages
web/ Next.js public site, SEO routes, marketing pages
packages/
core/ defineResource, shared types
resource/ validation & field helpers
permissions/ can(), requirePermission()
auth/ session contracts
plugin-sdk/ extension surfaces
storage/ file driver interfaces
queue/ BullMQ helpers
notifications/, mail/, ...
prisma/
schema.prisma
seed.ts
Configuration
During openadminjs create, the wizard asks for DB credentials and key env values, then writes ready-to-use values into apps/api/.env
(including SUPERADMIN_EMAIL / SUPERADMIN_PASSWORD for non-interactive seed).
ADMIN_ORIGIN and API_PORT are written with defaults (http://localhost:3000 and 4000) so you only change them when your environment differs.
New apps do not get a root .env.example or root .env—treat apps/api/.env as the API env source of truth.
Values are read by the Nest API and Next apps through @nestjs/config and
NEXT_PUBLIC_* variables—treat secrets like you would in a Nest ConfigModule setup.
DATABASE_URL="postgresql://user:pass@localhost:5432/openadminjs"
JWT_SECRET="change-me-use-long-random-string"
NEXT_PUBLIC_API_URL="http://localhost:4000"
NEXT_PUBLIC_SITE_URL="http://localhost:3001"
- JWT_SECRET must be unique per environment; rotate if leaked.
- NEXT_PUBLIC_* is embedded in browser bundles—never place private keys there.
Workspace scripts
Generated apps use pnpm filters so you run database work against the API package and dev servers in parallel—same idea as nx run-many or Nest
workspaces.
pnpm dev # API + admin + web (see root package.json)
pnpm db:migrate # run Prisma migrations manually when schema changes
pnpm db:seed # run seed manually (reseed / reset flows)
pnpm test # all workspace tests
pnpm --filter @openadminjs/api build
OpenAdminJS commands are run from your project workspace (for example via pnpm exec openadminjs ...).
Database shortcuts still map to the same underlying pnpm and Prisma commands.
CLI reference
Use the packaged CLI from your project workspace via pnpm exec openadminjs ... (or through npm scripts).
Project creation
Scaffolds a monorepo with apps/api, apps/admin, apps/web, shared packages, Prisma schema, and automatically installs dependencies with the selected package manager.
npx openadminjs create my-app
cd my-app
generate resource / make resource
Emits a starter *.resource.ts next to your other resources. Model name is PascalCase (Prisma model); the file uses defineResource with kebab-case name and permission keys.
pnpm exec openadminjs generate resource Invoice --force
pnpm exec openadminjs make resource Invoice # alias
Always register the new file in registry.ts, extend Prisma if needed, then migrate.
generate plugin / make plugin
Scaffolds a starter Plugin SDK module under apps/api/src/plugins/custom/.
Wire the plugin into apps/api/plugins.manifest.json and grant the capability flags your code needs (
resource.hooks, api.routes, seo.extend, …).
pnpm exec openadminjs generate plugin com.example.my-extension --force
pnpm exec openadminjs make plugin com.example.my-extension
dev, build, start
Convenience wrappers around pnpm that run API + admin (not web, unless your root scripts differ).
Matches the binaries shipped alongside openadminjs create.
pnpm exec openadminjs dev # parallel dev for api + admin
pnpm exec openadminjs build # recursive build
pnpm exec openadminjs start # production-style start script
security check
Static checklist over env snippets and minimal admin-route presence (apps/admin/app/login/page.tsx).
pnpm exec openadminjs security
pnpm exec openadminjs security check
doctor
Runs quick health checks for a generated workspace: package.json, pnpm-workspace.yaml, prisma/schema.prisma, and either apps/api/.env or a root .env.example.
pnpm exec openadminjs doctor
db migrate | seed | studio
These commands print the exact pnpm / Prisma invocations to run from your project root so you do not guess package names.
pnpm exec openadminjs db migrate
pnpm exec openadminjs db seed
pnpm exec openadminjs db studio
Prisma & migrations
Edit prisma/schema.prisma (or fragment files merged into it), then apply migrations from the workspace root. Regenerate the client after every schema pull or merge.
# after editing models
pnpm db:migrate
pnpm prisma generate
# optional: open GUI
pnpm --filter @openadminjs/api exec prisma studio
- Keep Prisma model names aligned with
modelon each resource (model: "invoice"→ Prisma modelInvoicemaps to delegateinvoice). - Example repos under
examples/showmodels.fragment.prismamerged into a full schema—use the same pattern for modular schemas.
Creating resources (end-to-end)
A resource connects one Prisma model to generic list/create/edit/delete routes, admin UI tables and forms, validation, and RBAC.
Field keys in fields must match your Prisma column names (for example categoryId for a foreign key).
-
Model the table in Prisma — add or extend a model in
prisma/schema.prisma, then runpnpm db:migrateandpnpm prisma generate. -
Scaffold a resource module (optional) — generates
apps/api/src/resources/<name>.resource.tswith sensible defaults:pnpm exec openadminjs generate resource Invoice pnpm exec openadminjs make resource Invoice # alias -
Implement
defineResource— setname(URL segment, kebab-case),model(Prisma delegate, camelCase),permissions(at minimumread), and afieldsmap. See the full reference in Resource metadata and Field properties. -
Register the module — import the default export in
apps/api/src/resources/registry.tsand add it to theresourcesarray (keep imports sorted). -
Grant permissions — seed roles so users receive the matching strings (
invoices.read, …). Until<name>.readis on the JWT, the resource is hidden from the catalog. -
Verify — restart
pnpm dev, open the admin, and confirm list + forms. InspectGET /admin/resourceswith a token to see merged metadata (locale, stripped sensitive fields).
apps/api/src/resources/posts.resource.ts for localized labels ({ en, ru }), relations (resource + displayField),
media pointing at resource: "files", seo, and a working actions.publish handler.
Resources
A resource maps a Prisma model to admin/API behavior: labels, fields, permissions, optional actions and visibility flags.
Resource names use kebab-case (order-items); Prisma model names stay camelCase in the model field (orderItem).
The Nest side resolves (prisma as any)[resource.model], so model must match the generated client delegate name exactly.
import { defineResource } from "@openadminjs/core";
export default defineResource({
name: "posts",
label: "Posts",
model: "post",
titleField: "title",
icon: "FileText",
permissions: {
read: "posts.read",
create: "posts.create",
update: "posts.update",
delete: "posts.delete"
},
fields: {
id: { type: "id", label: "ID", create: false, edit: false, list: false },
title: { type: "text", label: "Title", required: true, searchable: true, sortable: true },
slug: { type: "slug", label: "Slug", from: "title", searchable: true },
status: {
type: "select",
label: "Status",
options: ["draft", "published", "archived"],
filterable: true,
sortable: true
},
content: { type: "richtext", label: "Body", list: false },
createdAt: { type: "datetime", label: "Created", create: false, edit: false, sortable: true }
}
});
Registry & Prisma
Register every resource in apps/api/src/resources/registry.ts (the same pattern as collecting Nest providers in a module).
After changing resources or Prisma models, run migrations so delegates stay in sync.
import type { ResourceConfig } from "@openadminjs/core";
import posts from "./posts.resource";
export const resources = [posts] satisfies ResourceConfig[];
export function getResource(name: string): ResourceConfig | undefined {
return resources.find((r) => r.name === name);
}
defineResource metadata reference
The object passed to defineResource implements ResourceConfig (from @openadminjs/core).
Invalid name / model values throw at module load so misconfigurations fail fast.
| Property | Required | Purpose |
|---|---|---|
name | Yes | URL segment and API key — kebab-case, ^[a-z][a-z0-9-]*$. |
label | Yes | Human title; string or map of BCP-47 locale → string (see Internationalization). |
model | Yes | Prisma delegate name (post, orderItem) — must exist on PrismaClient. |
fields | Yes | Map of Prisma column name → ResourceField (at least one column). |
permissions | Yes | String map; must include read. Other keys (create, update, delete, custom verbs) gate HTTP verbs and actions. |
titleField | No | Which column labels rows in lists and relation pickers when no display value is passed. |
icon | No | Name of a lucide-react icon for the admin chrome (e.g. FileText). |
actions | No | Named row operations with server handlers (publish, revoke, …). |
i18n | No | defaultLocale + supported locales[]. Labels are localized via label: { en, … } on the resource and each field. |
seo | No | Public-site metadata helpers: paths, slug/title/description fields for shared models. |
listScope | No | Restrict list queries for some callers — see List scope. |
Resource actions
Beyond CRUD, attach named actions with labels, optional confirmation, variant styling, and a permission string. Handlers receive Prisma, the current user, and optional input—use them for transitions that should not be a raw PATCH (publish, archive, refund, rotate key).
export default defineResource({
name: "posts",
label: "Posts",
model: "post",
titleField: "title",
permissions: {
read: "posts.read",
create: "posts.create",
update: "posts.update",
delete: "posts.delete",
publish: "posts.publish"
},
fields: { /* ... */ },
actions: {
publish: {
label: "Publish",
variant: "primary",
confirm: true,
permission: "posts.publish",
async handler({ id, prisma, user }) {
// await prisma.post.update({ where: { id }, data: { status: "published" } });
return { ok: true, by: user.email };
}
}
}
});
Expose actions through the admin API (see Custom actions) and render them in the admin UI when the user has the required permission.
Fields
Each entry in fields uses the Prisma column name as the object key (authorId, coverImageId).
Every field declares a type (determines widgets and API validation rules) plus optional UX and query flags described below.
Visibility in admin & API
Boolean flags drive where a column appears. If you omit a flag, OpenAdminJS treats it as “allowed” —
except where types such as id, computed, or password receive special handling (see sensitive fields).
| Flag | Controls |
|---|---|
list | List/table columns. Set list: false for long text or JSON. |
detail | Read-only detail view. Omit = shown; use false to hide. |
create | POST body acceptance + create form controls. Server skips fields with create: false. |
edit | PATCH acceptance + edit form controls. |
searchable | Included in OR text search for list endpoints (use sparingly on large columns). |
sortable | Allows sort=field:asc|desc for that column. |
filterable | Allows structured filter[field][op]= query params on list endpoints. |
defineResource runs withSafeFields — column names matching password/token patterns, fields marked sensitive: true, and types password / hidden
are removed from list/detail exposure. The API also strips hidden and sensitive values from JSON responses. id is never writable; computed is read-only in forms.
Every ResourceField property
| Property | Typical use |
|---|---|
type | Required — one of the field types (below). |
label | String or locale map ({ en, ru }) for captions in admin + API meta. |
required | Marks create-time requirement (validated server-side). |
list, detail, create, edit | Visibility toggles (table above). |
searchable, sortable, filterable | List/query capabilities. |
options | Allowed discrete values for select, multiselect, badge. |
resource | For relation, image, file: target resource name ("categories", "files"). |
displayField | For relation: column on the related row to show in pickers (default name). |
from | For slug: column to auto-fill from when the form creates a slug. |
permissions | Per-field RBAC overrides (read, create, update permission strings). |
sensitive | Forces sanitization/redaction pipelines. |
defaultValue | Seeds UI defaults on create flows. |
min, max | Numeric (number, money) validation. |
minLength, maxLength | String field validation (text, email, slug, …). |
Field type → behavior
type | Stored as | Admin / API notes |
|---|---|---|
id | Primary key | Never writable; omit from writes. |
text, textarea | string | Validated length via minLength/maxLength. |
email, url | string | Format validation on create/edit. |
slug | string | Lowercase hyphenated pattern; optional from source field. |
password | string (hashed server-side) | Write-on-create/edit only; never exposed in list/detail. |
number, money | number | Supports min/max. |
boolean | boolean | Required booleans must be sent explicitly on create. |
select, badge | string | Values constrained to options when provided. |
multiselect | string[] | Accepts JSON array or comma-separated string in forms. |
date, datetime | Date / ISO string | Coerced by server validation. |
relation | scalar FK | Pickers load GET /admin/resources/<resource>; value is the related row id. |
image, file | FK or URL (project convention) | Use resource: "files" when storing uploaded asset ids (see posts.resource.ts). |
richtext, markdown | string | Heavy content — usually list: false. |
json | JSON | Arbitrary structure; avoid searchable. |
color | string | Color picker widget. |
code | string | Monospace editor for snippets. |
hidden | any | Server-managed; omitted from API responses. |
computed | derived | Not part of create/edit payloads; display-only when populated. |
id, text, textarea, number, boolean
id: { type: "id", label: "ID", create: false, edit: false, list: true },
title: { type: "text", label: "Title", required: true, searchable: true, sortable: true },
notes: { type: "textarea", label: "Notes", list: false },
priceCents: { type: "number", label: "Price (¢)", sortable: true },
featured: { type: "boolean", label: "Featured", filterable: true }
email, url, slug
slug can derive from another field via from for auto-slugging in forms.
contactEmail: { type: "email", label: "Contact", searchable: true },
website: { type: "url", label: "Website" },
slug: { type: "slug", label: "Slug", from: "title", searchable: true }
password, money, color, badge
password: { type: "password", label: "Password", create: true, edit: true, list: false },
amount: { type: "money", label: "Amount", sortable: true },
theme: { type: "color", label: "Accent" },
status: { type: "badge", label: "Status", options: ["open", "closed"] }
select & multiselect
status: {
type: "select",
label: "Status",
options: ["draft", "published", "archived"],
filterable: true,
sortable: true
},
tags: {
type: "multiselect",
label: "Tags",
options: ["news", "product", "changelog"],
filterable: true
}
date & datetime
publishDay: { type: "date", label: "Publish date", sortable: true },
createdAt: { type: "datetime", label: "Created", create: false, edit: false, sortable: true }
relation, image, file
categoryId: {
type: "relation",
label: "Category",
resource: "categories",
displayField: "name",
filterable: true
},
coverImageId: {
type: "image",
label: "Cover",
resource: "files",
list: false
},
manualPdfId: {
type: "file",
label: "PDF",
resource: "files",
list: false
}
richtext & json
body: { type: "richtext", label: "Body", list: false },
metadata: { type: "json", label: "Metadata", list: false }
markdown & code
readme: { type: "markdown", label: "README", list: false },
snippet: { type: "code", label: "Snippet", list: false }
hidden & computed
Use hidden for internal keys; computed for derived display columns that may be filled server-side.
legacyId: { type: "hidden", label: "Legacy", list: false, create: false },
fullName: { type: "computed", label: "Full name", list: true, create: false, edit: false }
Field-level permissions & defaults
Optional permissions.read | create | update on a field gate that column independently of the resource-level permission map. defaultValue seeds create forms.
internalScore: {
type: "number",
label: "Score",
permissions: { read: "posts.score.read", update: "posts.score.write" }
},
locale: { type: "select", label: "Locale", options: ["en", "de"], defaultValue: "en" }
Permissions
Permissions are plain strings, conventionally resource.action for CRUD (posts.read) plus any custom verbs you add on actions (posts.publish).
Store them in your database, attach to roles, and issue JWTs (or session payloads) that include the flattened list of strings for the signed-in user.
Resource permission map
Every resource must define at least read; create, update, and delete gate the matching HTTP verbs. Extra keys align with actions entries.
Runtime helpers
import { can, requirePermission } from "@openadminjs/permissions";
const userPerms = ["posts.read", "posts.update"];
can(userPerms, "posts.read"); // true
requirePermission(userPerms, "posts.delete"); // throws ForbiddenError
visibleFields()
Use visibleFields(resource, mode, userPerms) from @openadminjs/core (re-exported from @openadminjs/resource) when shaping payloads: it removes sensitive columns and honors per-field permissions for list/create/edit/detail modes.
import { visibleFields } from "@openadminjs/core";
const fields = visibleFields(postsResource, "list", user.permissions);
Seeding example
// prisma/seed.ts (illustrative)
await prisma.role.create({
data: {
name: "Editor",
permissions: {
connect: [
{ key: "posts.read" },
{ key: "posts.update" },
{ key: "posts.publish" }
]
}
}
});
List scope (per-user row filtering)
Some resources should only list rows “owned” by the current user until an operator has a broader permission.
Set listScope with type: "userOwns" and the foreign-key column that references the user (userId, ownerId, …).
The API merges { [field]: signedInUserId } into Prisma where when the scope applies.
- Bypass — users with
"*"on the JWT skip list scoping. bypassPermissions— optional extra permission strings (for examplenotifications.read.all) that disable the scope for specific roles.- Detail / write — list scope filters collection queries; combine with normal RBAC checks on single-record routes.
export default defineResource({
name: "notifications",
label: "Notifications",
model: "notification",
titleField: "title",
permissions: { read: "notifications.read", update: "notifications.update" },
listScope: {
type: "userOwns",
field: "userId",
bypassPermissions: ["notifications.read.all", "*"]
},
fields: {
// ...
}
});
Live pattern: see apps/api/src/resources/operations.resource.ts (notificationsResource).
HTTP API — full route map
Base URL is commonly http://localhost:4000. OpenAPI is served at /api/docs.
Most admin routes require Authorization: Bearer <access> and the admin JWT realm (@RequireAuthRealm("admin")),
unless noted. A global throttle applies baseline rate limits (defaults: 100 requests / 60s per key—see ThrottlerModule in app.module.ts).
OpenAPI
GET /api/docs # Swagger UI
GET /health # Nest health ping
| Prefix / area | Endpoints (summary) | Auth & notes |
|---|---|---|
/auth |
POST login (throttled), POST refresh, POST logout, GET me,
GET me/realms (admin realm only),
POST password/forgot, POST password/reset, POST email/verify
|
Mixed bearer + anonymous; realm from body defaults via DEFAULT_AUTH_REALM. |
/admin/resources |
GET /overview (dashboard aggregates),
GET / resource catalog (+ optional ?locale=),
CRUD under /:resource,
POST …/orders/:id/refund (payment bridge),
POST …/:resource/:id/actions/:action
|
Admin realm JWT; refunds require working PaymentModule. |
/admin/plugins |
GET /, POST /, PATCH /:id, DELETE /:id |
Admin realm; manifests drive runtime loader. |
/admin/ai |
GET|PUT …/config, POST …/chat, POST …/chat/stream (SSE),
conversations CRUD, GET …/artifacts/:id/download
|
Admin realm; chat paths require permission ai.chat in service guards. |
/jobs |
GET /stats (jobs.read), POST /dispatch (jobs.dispatch) |
Admin realm + explicit permission decorators. |
/store |
GET /products, POST /checkout, GET /orders/:id/status, POST /webhook/:provider,
plus GET /session & GET /orders (public realm JWT)
|
Hybrid: public storefront + Stripe-style webhooks via signature headers. |
Plugins with api.routes or interceptors integrate through bootstrap (
see PluginApiInterceptor registered in app.module.ts). Outbound mail is internal (MailModule / MailService)—there is no public mail REST surface.
Authentication
The API uses JWT access + refresh tokens. Login and refresh accept a realm string (defaults align with DEFAULT_AUTH_REALM); back-office users use "admin"
while storefront sessions use "public" for routes like GET /store/session.
POST /auth/login
Content-Type: application/json
{ "email": "<superadmin-email>", "password": "<superadmin-password>", "realm": "admin" }
POST /auth/refresh
Content-Type: application/json
{ "refreshToken": "<jwt>", "realm": "admin" }
GET /admin/resources/posts
Authorization: Bearer <access_token>
Accept: application/json
What ships on /auth
POST /auth/login— throttle-limited credential exchange (realm aware).POST /auth/refresh— rotate access tokens.POST /auth/logout— revoke session server-side metadata (Bearer required).GET /auth/me— hydrate profile + permissions snapshot.GET /auth/me/realms— admin-only rollup of memberships across realms.POST /auth/password/forgot&/auth/password/reset— token-based reset funnel (email delivery viaMailService).POST /auth/email/verify— verify email confirmation tokens.
- Rotate
JWT_SECRET/JWT_REFRESH_SECRETwith a maintenance window; invalidate stale refresh tokens. - Never log access tokens; scrub them from client error reporters.
Admin REST surface
Authenticated admin routes follow a predictable resource pattern. Collection routes return paginated rows plus column metadata derived from defineResource; singleton routes hydrate a single Prisma record through the same permission gates.
The catalog endpoint (GET /admin/resources) accepts an optional ?locale= parameter so labels resolve through resolveResourceForLocale before the Next admin hydrates tables.
GET /admin/resources # catalog (+ ?locale=)
Dashboard overview
Metrics for the default admin dashboard (totals, mocked marketing funnels, audit trail preview, job health) are aggregated in AdminAnalyticsService.
GET /admin/resources/overview?period=30d
Authorization: Bearer <token>
# period accepts 7d | 30d | 90d
GET /admin/resources/:resource # list rows
GET /admin/resources/:resource/:id
POST /admin/resources/:resource
PATCH /admin/resources/:resource/:id
DELETE /admin/resources/:resource/:id
Replace :resource with the kebab-case resource name (for example order-items). IDs use your Prisma primary key format (commonly cuid/uuid).
POST /admin/resources/orders/:id/refund proxies to PaymentService when the commerce module loads. It is intentionally separate from defineResource actions—treat it as ops glue for payment providers you enable in your deployment.
CRUD & request bodies
Create (POST)
Send JSON keys matching writable fields from the resource definition. Server validation rejects unknown keys and required field violations before Prisma create.
POST /admin/resources/posts
Authorization: Bearer <token>
Content-Type: application/json
{
"title": "Launch checklist",
"status": "draft",
"slug": "launch-checklist"
}
Update (PATCH)
Partial updates merge into the existing row; fields with edit: false are stripped even if a client sends them.
PATCH /admin/resources/posts/clxyz123
Authorization: Bearer <token>
Content-Type: application/json
{ "status": "published" }
Delete
DELETE /admin/resources/posts/clxyz123
Authorization: Bearer <token>
Errors
Expect 401 for missing/invalid auth, 403 when permissions fail, 404 when the resource name or row id is unknown, and 422 for validation errors with a structured body your forms can map to fields.
Queries & filters
List endpoints accept pagination, free-text search (against fields marked searchable: true), sorting for sortable columns, and equality filters for filterable fields.
GET /admin/resources/posts?page=1&limit=20&sort=createdAt:desc&search=launch&filter[status]=published
- page / limit — cursor-friendly pagination; keep limits ≤ 100 in production unless your gateway allows higher.
- sort — format
field:asc|desc; reject sorts on non-sortable fields. - search — OR-across searchable string fields at the Prisma delegate level.
- filter[field] — legacy equality shorthand; prefer
filter[field][eq]=,[gte],[in](comma-separated) for operators enforced inAdminResourceService.buildWhere.
Custom actions
POST to /admin/resources/:resource/:id/actions/:actionName where actionName matches a key under resource.actions. The handler runs inside the same Nest transaction boundaries as CRUD so audit hooks stay consistent.
POST /admin/resources/posts/clxyz123/actions/publish
Authorization: Bearer <token>
Content-Type: application/json
{ "note": "Approved by editor" }
Optional JSON bodies surface as input on ResourceActionContext—validate them with Zod/class-validator before touching Prisma.
Plugins, background jobs & AI endpoints
Plugins (/admin/plugins)
Runtime plugin metadata is manipulated through REST while the authoritative manifest remains apps/api/plugins.manifest.json.
After changing entries you may need to restart the API depending on loader behavior (AdminPluginsService).
GET /admin/plugins
POST /admin/plugins
PATCH /admin/plugins/:id
DELETE /admin/plugins/:id
Queues (/jobs)
Operational hooks for observing BullMQ / worker health (@openadminjs/queue integration). Requires explicit permission grants on the JWT.
GET /jobs/stats # Permission: jobs.read
POST /jobs/dispatch # Permission: jobs.dispatch
Assistant (/admin/ai)
Conversation persistence, streamed completions, downloadable artifacts.
Calls into chat/stream require the ai.chat permission on the authenticated admin user (AdminAiService.ensureChatPermission).
Configuration is persisted via mutable settings payloads—tokens should never be echoed to browser logs.
GET /admin/ai/config
PUT /admin/ai/config
POST /admin/ai/chat
POST /admin/ai/chat/stream # SSE: text/event-stream
GET /admin/ai/conversations
GET /admin/ai/conversations/:id
PATCH /admin/ai/conversations/:id
DELETE /admin/ai/conversations/:id
GET /admin/ai/artifacts/:id/download
Streaming payloads mirror the envelopes described under AI streaming events.
Storefront HTTP API (/store)
Example commerce routes coexist with generic admin resources.
Checkout can run anonymously (guest checkout) while authenticated shoppers send the same Bearer token minted from the public realm (@RequireAuthRealm("public")).
Webhooks authenticate via provider signatures forwarded as stripe-signature or x-webhook-signature; raw bodies must remain unparsed upstream for Stripe compatibility.
# Public storefront
GET /store/products
POST /store/checkout
GET /store/orders/:id/status
POST /store/webhook/:provider
# Authenticated storefront (JWT, public realm)
GET /store/session
GET /store/orders?page=1&limit=20
- Prefer idempotent webhook handlers keyed by provider event ids.
- Keep payment secrets strictly server-side (
.envon the Nest process).
Admin UI
The Next.js admin app consumes the same resource metadata as the API: table columns mirror list: true fields, filters bind to filterable flags, and create/edit dialogs call the same REST routes your integration tests hit.
Customize branding under apps/admin (layouts, design tokens, logo) without forking the resource contract.
- Dashboards aggregate metrics per resource; add cards by extending the admin shell layout.
- Forms respect
create/editfield flags,required,defaultValue, and sensitive field stripping fromwithSafeFields. - Relations render searchable pickers using
resource+displayFieldmetadata. - Actions render as primary/destructive buttons when
actionsare defined and the JWT includes the action permission.
Point the admin at your API with NEXT_PUBLIC_API_URL; session cookies or bearer tokens are injected by the generated auth provider.
Shipped routes in apps/admin/app
Besides dynamic CRUD screens, these App Router segments ship in the scaffold:
/ — marketing / redirect landing
/dashboard — KPIs wired to GET /admin/resources/overview
/login — session acquisition
/profile — user profile shell
/settings — editable platform settings surfaced through resources
/files — file asset explorer (requires files.* permissions)
/audit-logs — immutable audit feed
/notifications — inbox pattern
/api-tokens — issued token management + revoke actions
/plugins — manifest editor + sandbox controls
/jobs — queue stats / dispatch tooling
/resources/* — generated list/detail/create/edit driven by metadata
Admin navigation model
The shell layout is the operational backbone of the admin app. Keep global concerns (theme switcher, locale switcher, profile, AI widget, quick actions)
in apps/admin/components/app-shell.tsx, and keep page-level logic in route files. This separation avoids coupling resource pages to global UI concerns.
- Sidebar entries should mirror real resource groups (content, users, billing, settings) instead of raw table names.
- Top-level actions should be idempotent where possible (refresh, sync, regenerate cache).
- Route params should stay resource-centric (
/resources/:resource/:id) so links can be generated by metadata.
Admin forms & field rendering
Form rendering is metadata-driven. Prefer enriching resource field metadata over custom per-page form logic: this keeps create/edit/detail screens consistent across resources.
fields: {
title: { type: "text", required: true, list: true, create: true, edit: true },
status: { type: "select", options: ["draft", "published"], list: true, filterable: true, sortable: true },
authorId: { type: "relation", resource: "users", displayField: "email", create: true, edit: true }
}
- Validation source of truth stays server-side (Prisma + API validation); UI should map returned field errors to inputs.
- Sensitive fields (
sensitive: true) must never be shown in list/detail responses. - Relation fields should provide searchable pickers with predictable labels and fallback values.
Admin operations & diagnostics
For operational reliability, every mutation path should surface explicit feedback in UI: success toast, actionable error, and structured diagnostics for failed workflows.
- Delete flows should use confirmation modals or guarded chat confirmations for destructive actions.
- Long-running operations should stream step status (queued, running, completed, failed).
- Workflow failures should display
failedStep,partialResults,rolledBack, androllbackFailures. - Retry UX should provide one-click rerun for last failed workflow payload where safe.
AI assistant
OpenAdminJS includes a built-in admin AI assistant with provider configuration (GET/PUT /admin/ai/config),
streaming replies, action execution, conversation storage, and downloadable artifacts.
Chat entry points require permission ai.chat (enforced in AdminAiService).
Provider configuration
Providers are edited through the REST config endpoint and mirrored in the Next admin settings UI when present. Secrets are stored via the API and must never be echoed into client bundles or static exports.
- Built-in provider patterns: OpenAI/compatible, Anthropic, Gemini.
- Active provider switch allows runtime fallback without code changes.
- System prompt should stay in English and enforce JSON action schema output.
AI streaming events
Chat streaming uses SSE with normalized event envelopes shared by API and admin app. Each stream emits token deltas and a final completion payload with optional action metadata.
{ "type": "delta", "delta": "Partial token..." }
{ "type": "done", "reply": "Final text", "actionResult": { "ok": true } }
{ "type": "error", "message": "Provider timeout", "code": "AI_STREAM_ERROR" }
- delta appends text progressively for typing effect.
- done finalizes message and can include pending/confirmed action payloads.
- error must surface visibly in UI and stop loading state immediately.
AI workflows & rollback
Workflow actions chain multiple steps (for example: generate image → create artifact → update resource).
Use transactionMode to control failure behavior:
- best_effort — keep completed steps even if a later step fails.
- rollback_on_error — attempt compensation for completed reversible steps.
On failure, return rich diagnostics so operators can understand exactly what happened and safely retry:
failedStep, completedSteps, partialResults, rolledBack, rollbackFailures.
Web layer (apps/web)
The secondary Next.js app hosts marketing and content routes: landing pages, resource-driven listings and detail pages backed by read-only fetches or ISR.
It is separate from the Nest storefront API under /store (checkout, webhooks, session) documented in Storefront HTTP API—compose them when you need marketing pages plus headless commerce callbacks.
apps/web/app/
page.tsx
posts/page.tsx
posts/[slug]/page.tsx
sitemap.xml/route.ts
robots.txt/route.ts
Use the generated openadmin-client helpers to fetch published rows by slug while keeping payloads shaped by the same field metadata used in admin.
SEO helpers
Use helper utilities to keep canonical URLs, OpenGraph tags and sitemap entries aligned with resource definitions so marketing pages inherit titles and descriptions from your CMS models.
import { metadataForRecord, sitemapXml } from "../lib/seo";
export function generateMetadata({ params }) {
const record = await loadPost(params.slug);
return metadataForRecord(postResource, record);
}
Emit sitemap.xml by iterating public resources and mapping slug or path fields; block indexing on staging via robots.txt using NEXT_PUBLIC_SITE_URL.
Internationalization (i18n)
Labels on the resource and on each field use the shared LocalizedLabel type: either a plain string or a map of BCP-47 locale codes → string
({ en: "Title", ru: "Заголовок" }). The Nest API merges the requested locale via resolveResourceForLocale()
(@openadminjs/core) before returning catalog metadata used by Next.js apps.
i18n.translations blob. Put each locale's wording directly on label
(resource, fields, action labels). The optional i18n block only pins defaultLocale and declares the allowed locales[] tuple used when resolving fallback order.
Localized labels in defineResource
Copy the production pattern from apps/api/src/resources/posts.resource.ts:
export default defineResource({
name: "posts",
label: { en: "Posts", ru: "Записи" },
model: "post",
titleField: "title",
i18n: {
defaultLocale: "en",
locales: ["en", "ru"]
},
permissions: {
read: "posts.read",
create: "posts.create",
update: "posts.update",
delete: "posts.delete"
},
fields: {
title: {
type: "text",
label: { en: "Title", ru: "Заголовок" },
required: true
},
status: {
type: "select",
label: { en: "Status", ru: "Статус" },
options: ["draft", "published"]
}
},
actions: {
publish: {
label: { en: "Publish", ru: "Опубликовать" },
permission: "posts.publish",
async handler(ctx) {
/* ... */
}
}
}
});
The Next.js admin app sends the viewer's locale with each resource fetch (typically via a ?locale=ru query param persisted from lib/locale.ts),
so the API can return resolved strings instead of forcing the UI to duplicate translation tables.
Adding a new locale end-to-end
-
Expand
apps/admin/lib/locale.ts— add the locale toSUPPORTED_UI_LOCALESandLOCALE_LABELSso the shell knows how to persist and display it. -
Extend each resource's locale maps — bump
i18n.localesplus every relevantlabel: { en, ru, … }payload (resource root, fields, actions). -
Prefer one reload after switching — call
setUiLocale("de")from a Settings control, reload, and rely on downstream resource requests carrying the chosen locale automatically.
Plugin system
OpenAdminJS Plugin Platform is a broad extension system (WordPress-style) with explicit capability gating.
Plugins can register resource hooks, API hooks/routes, media pipeline transforms, SEO contributors, job handlers, and admin UI extensions.
Plugins are loaded from plugins.manifest.json and enforced by trust mode + capabilities.
import type { OpenAdminPlugin } from "@openadminjs/plugin-sdk";
export const seoAndMediaPlugin: OpenAdminPlugin = {
id: "com.example.seo-media",
version: "1.0.0",
async register({ registerSurface }) {
registerSurface({
seo: {
metadata({ resourceName, record }) {
if (resourceName !== "posts") return {};
return { title: record.title ?? "Untitled" };
}
},
media: {
transform({ filename, mimeType, contentBase64 }) {
if (!mimeType.startsWith("image/")) return { filename, mimeType, contentBase64 };
return { filename: filename.replace(/\\.(png|jpe?g)$/i, ".webp"), mimeType: "image/webp", contentBase64 };
}
}
});
}
};
Register plugin entries in apps/api/plugins.manifest.json with explicit capabilities:
{
"version": 1,
"plugins": [
{
"id": "com.example.seo-media",
"enabled": true,
"package": "@example/openadmin-seo-media",
"trustMode": "sandboxed",
"capabilities": ["seo.extend", "media.pipeline"]
}
]
}
Capability matrix: resource.hooks, api.hooks, api.routes,
media.pipeline, seo.extend, jobs.run, admin.ui.extend.
Use sandboxed trust mode and request only minimal capabilities by default.
Resource hooks
Resource lifecycle hooks are now one surface within Plugin Platform. Use capability
resource.hooks and register through plugin SDK context.
registerResourceHooks("users", {
async beforeCreate({ data }) {
if (typeof data.email === "string") data.email = data.email.trim().toLowerCase();
}
});
Available lifecycle events: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete, afterDelete.
Testing guide
Every workspace package ships Vitest as the test runner. Run all suites from the root with pnpm test;
run a single package with pnpm --filter @openadminjs/api test.
The E2E smoke suite runs separately with pnpm e2e.
API unit tests
Mock Prisma and JWT services at the spec level — no database required for unit tests.
// apps/api/src/auth/auth.service.test.ts
import { describe, it, expect, vi } from "vitest";
import { AuthService } from "./auth.service";
const mockPrisma = { user: { findUnique: vi.fn() } };
const mockJwt = { signAsync: vi.fn().mockResolvedValue("tok") };
const svc = new AuthService(mockPrisma as any, mockJwt as any);
it("rejects unknown email", async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
await expect(svc.login({ email: "x@x.com", password: "p", realm: "admin" }))
.rejects.toMatchObject({ status: 401 });
});
Admin component tests
Use @testing-library/react inside a @vitest-environment jsdom block for Next.js client components.
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { AuthGuard } from "../components/auth-guard";
import { vi } from "vitest";
vi.mock("../lib/api", () => ({ hasUsableAccessToken: () => false }));
vi.mock("next/navigation", () => ({ usePathname: () => "/dashboard", useRouter: () => ({ replace: vi.fn() }) }));
it("redirects to login when no token", () => {
render(<AuthGuard><div>protected</div></AuthGuard>);
expect(screen.queryByText("protected")).toBeNull();
});
E2E smoke tests
Run pnpm e2e to execute the auth-flow E2E tests (Vitest + jsdom mock of fetch + localStorage) followed by the HTTP smoke script.
The smoke script hits /health and /auth/login on the running API and reports pass/fail.
pnpm dev & # start API + admin
pnpm e2e # auth flow tests + HTTP smoke checks
Theming & dark mode
The admin UI uses Tailwind CSS with darkMode: ["class"] and next-themes for SSR-safe theme persistence.
A sun/moon toggle in the header switches between light, dark, and system modes; the chosen value is stored in localStorage under theme.
Design tokens
Brand colors and surface values are defined as CSS custom properties in apps/admin/app/globals.css and overridden inside the .dark selector.
Extend them to align with your product brand without touching Tailwind config.
:root {
--brand-blue: #2454ff;
--brand-teal: #0ea5a4;
--admin-surface: #ffffff;
--admin-bg: #f8fafc;
}
.dark {
--brand-blue-soft: #1e2a4a;
--admin-surface: #0f172a;
--admin-bg: #0a0f1e;
}
Logo in dark mode
The <Logo> component reads resolvedTheme from next-themes and switches between the color master logo and the monochrome
(white) variant automatically — no extra CSS needed.
// apps/admin/components/logo.tsx
const isDark = resolvedTheme === "dark";
return <img src={isDark ? BRAND.logoMonochromePng : BRAND.logoNew} alt="OpenAdminJS" />;
Extending the theme
- Change
--brand-blueand--brand-tealinglobals.cssto match your product's primary colors. - Update
tailwind.config.tscolors.brandto match the new values so Tailwind utility classes stay in sync. - Replace logo files in
apps/admin/public/brand/; keep both a color master and a monochrome/dark variant. - Adjust gradient stops in
app-shell.tsx(promo banner, avatar gradient) fromfrom-[#2454ff] to-[#0ea5a4]to your brand pair.
Security
- Enforce permission checks on every mutating route (never trust the UI alone).
- Keep JWT secrets long and rotate them with a maintenance window.
- Hash passwords and API tokens at rest; never return raw token material except once on creation.
- Enable rate limiting on auth endpoints in production gateways.
- Stream audit logs to your SIEM for sensitive environments.
Troubleshooting
pnpm prisma generate from the workspace root after schema edits.
- 401 on admin API: verify tokens and clock skew; confirm
NEXT_PUBLIC_API_URLmatches the API origin. - Missing resource in UI: ensure the resource is exported in
registry.tsand the user role includes the*.readpermission. - Migration failures: inspect
prisma/migrations, restore from backup, and never edit applied migration files.
Deployment
Deploy API, admin and web as separate services (Kubernetes, Fly.io, Railway, etc.) or as a single VM with process supervision. Run database migrations as a release phase hook before swapping traffic—same discipline as Nest.js apps using Prisma in production.
pnpm --filter @openadminjs/api build
pnpm --filter @openadminjs/admin build
pnpm --filter @openadminjs/web build
node apps/api/dist/main.js
- API — expose port 4000 (or behind TLS termination); set
DATABASE_URL,JWT_SECRET, and CORS allowlists for admin/web origins. - Admin / Web — Next.js standalone output: bake
NEXT_PUBLIC_API_URLandNEXT_PUBLIC_SITE_URLat build time per environment. - Migrations — run
pnpm prisma migrate deploy(API package) in CI/CD before health checks go green.
Complete functionality reference
Use this section as a playbook for expected behavior. Canonical route tables live in HTTP API — full route map; this chapter highlights contracts, payload shapes, and guardrails.
| Subsystem | What it does | Where it lives |
|---|---|---|
| Resource engine | Prisma-backed CRUD + metadata from defineResource | apps/api/src/admin/admin-resource.service.ts, resources/*.resource.ts |
| Analytics shell | Dashboard metrics + chart fixtures + job health | admin-analytics.service.ts → GET /admin/resources/overview |
| Auth & realms | JWT issuance, refresh, logout, password/email flows | auth/auth.controller.ts, auth/auth.service.ts |
| Storefront | Products, checkout, webhooks, shopper session | store/*.controller.ts, store/payment/* |
| Queue ops | Stats + manual dispatch testing | queue/queue.controller.ts |
| AI assistant | Chat, stream, conversations, artifacts | admin-ai.*, permission ai.chat |
| Plugins | Manifest CRUD + sandbox loading | admin-plugins.*, plugins/bootstrap.ts |
| Mail transport | Password resets & transactional email | mail/mail.service.ts (internal only—no HTTP router) |
| Cross-cutting | CORS, helmet, validation pipes, PluginApiInterceptor, throttler | app.module.ts, main.ts |
| Admin UI | Next.js App Router pages + resource shell | apps/admin/app/*, components/app-shell.tsx |
| Public web | SEO-facing marketing/resource pages | apps/web/*, lib/openadmin-client.ts |
Authentication and session lifecycle
Login issues access and refresh tokens for the requested realm. Access tokens authorize requests; refresh tokens rotate sessions.
POST /auth/login
Content-Type: application/json
{
"email": "<superadmin-email>",
"password": "<superadmin-password>",
"realm": "admin"
}
{
"accessToken": "<jwt>",
"refreshToken": "<jwt>",
"user": {
"id": "clx123",
"email": "<superadmin-email>",
"permissions": ["*"]
}
}
- 401 for invalid/missing/expired token.
- 403 for realm/permission mismatch.
- Client should attempt one refresh, then redirect to login if refresh fails.
RBAC and permission enforcement
Permissions are enforced server-side for all CRUD and custom actions. UI visibility should mirror permissions, but security is API-driven.
permissions: {
read: "posts.read",
create: "posts.create",
update: "posts.update",
delete: "posts.delete"
},
actions: {
publish: { label: "Publish", permission: "posts.publish" }
}
Resource CRUD API contract
Every resource automatically receives list/detail/create/update/delete routes and metadata endpoints.
GET /admin/resources/posts
GET /admin/resources/posts/:id
POST /admin/resources/posts
PATCH /admin/resources/posts/:id
DELETE /admin/resources/posts/:id
Writes should be auditable and return deterministic error payloads for form-level error mapping.
Field metadata and UI behavior
The admin renders forms/tables from field metadata. This keeps behavior consistent without hand-written per-resource components.
fields: {
title: { type: "text", required: true, list: true, searchable: true },
status: { type: "select", options: ["draft", "published"], list: true, filterable: true },
authorId: { type: "relation", resource: "users", displayField: "email" },
secretKey: { type: "text", sensitive: true, list: false, detail: false }
}
- relation fields use searchable pickers and foreign key values.
- sensitive fields are stripped from sanitized outputs.
- filterable/sortable/searchable control query features and UI controls.
Queries, search, filters, sorting
GET /admin/resources/orders?page=1&limit=20&sort=createdAt:desc&search=enterprise
GET /admin/resources/orders?filter[status][eq]=paid
GET /admin/resources/orders?filter[total][gte]=10000
GET /admin/resources/orders?filter[currency][in]=USD,EUR
Unknown operators should return explicit validation errors (for example FILTER_OP_UNKNOWN) to prevent ambiguous behavior.
Custom actions and domain operations
Custom actions implement non-CRUD business transitions (publish, approve, cancel, regenerate, retry).
POST /admin/resources/posts/clx123/actions/publish
Authorization: Bearer <token>
Content-Type: application/json
{ "note": "Editorial approval #42" }
- Validate input payloads server-side.
- Log action execution in audit trail.
- Return structured error details for operator diagnostics.
Admin UX patterns (operations-ready)
- Inline editing for fast field updates on detail pages.
- Delete confirmations for destructive operations.
- Action status feedback with clear success/error state.
- Mobile mode should preserve core actions and avoid hidden destructive controls.
AI assistant configuration
AI provider setup is managed in admin settings and resolved by API service on each chat request.
Access is gated by ai.chat permission.
{
"activeProviderId": "openai",
"providers": [
{ "id": "openai", "baseUrl": "https://api.openai.com/v1", "model": "gpt-4.1-mini" },
{ "id": "anthropic", "model": "claude-3-5-sonnet-latest" },
{ "id": "gemini", "model": "gemini-1.5-pro" }
],
"systemPrompt": "Respond with plain text reply and optional structured action JSON."
}
AI streaming protocol (SSE)
{ "type": "delta", "delta": "Analyzing..." }
{ "type": "done", "reply": "Found 12 records.", "actionResult": { "count": 12 } }
{ "type": "error", "message": "Provider timeout", "code": "AI_STREAM_ERROR" }
- delta: append progressive response text.
- done: finalize reply and attach action results if present.
- error: immediately stop loading and show diagnostics in UI.
AI actions, artifacts, and autofill
Assistant can generate files/images and save them as artifacts. Artifacts can be downloaded or injected into subsequent workflow steps.
{
"action": {
"type": "image",
"prompt": "Generate minimal product hero image",
"filename": "hero.png"
}
}
Common refs: {{latest_image}}, {{latest_artifact}}, ai-artifact:<id>.
AI workflows and rollback diagnostics
{
"action": {
"type": "workflow",
"transactionMode": "rollback_on_error",
"steps": [
{ "type": "image", "prompt": "Create post cover" },
{ "type": "update", "resource": "posts", "id": "clx123", "data": { "coverImageId": "{{latest_image}}" } }
]
}
}
- best_effort: keep completed steps if later step fails.
- rollback_on_error: attempt compensating actions.
- Expose
failedStep,partialResults,rolledBack,rollbackFailures.
Commerce and payment flow
GET /store/products
POST /store/checkout
POST /store/webhook/:provider
GET /store/orders/:id/status
GET /store/orders # auth: public realm
GET /store/session # auth: public realm
POST /admin/resources/orders/:id/refund # admin Ops (PaymentService)
- Validate webhook signature before applying state changes.
- Use idempotency for provider events.
- Track payment transitions in transactions and audit logs.
Files, notifications, queue jobs
- File assets — first-class
filesresource over thefileAssetmodel; uploads flow through the admin resource forms (no separate upload controller in core). - Notifications —
notificationsresource plus list scoping for inbox-style experiences. - Job logs — persisted execution history with status columns; operational JSON from
/jobs/stats/ admin jobs page.
Production security checklist
- Strong and rotated JWT secrets per environment.
- Strict CORS allowlist for admin/web origins.
- Least privilege role design and action permissions.
- No provider tokens in client bundles.
- Destructive AI actions require explicit confirmation.
- Migrations run before traffic switch in deploy pipeline.
Partners & support
OpenAdminJS stays free thanks to contributors and partners. If your company wants to become a partner and be listed on the website, email openadminjs@proton.me.
- Partner applications — branding, sponsorship and collaboration requests.
- Financial support — for direct support options, contact openadminjs@proton.me.
- Bug reports & feature requests — open an issue on GitHub; include a reproduction case and the output of
pnpm exec openadminjs doctor. - Pull requests — all contributions welcome; check the
CONTRIBUTING.mdat the repo root before opening a PR.
Roadmap & next steps
The following capabilities are planned for upcoming releases. This list is intentionally public so contributors can pick up items early. Priorities shift based on community feedback — weigh in on GitHub Discussions.
Core platform
- 🟢 Resource CRUD — list, create, edit, delete with field-level permissions
- 🟢 RBAC — roles, permissions, realm-scoped tokens
- 🟢 Audit logs — immutable per-resource action records
- 🟢 JWT auth — access + refresh token flow with rotation
- 🟢 Resource relations — picker UI for
relationfields against other resources - 🟢 File uploads — local upload endpoint + storage abstraction via
@openadminjs/storage - 🟢 Bulk actions — select multiple rows and apply a named action in one request
- 🟢 Inline editing — double-click a cell in the list view to edit without opening the detail form
Admin UI
- 🟢 Dark / light theme with system preference detection
- 🟢 Responsive sidebar with mobile drawer
- 🟢 Dashboard widgets — pluggable metric cards with real-time data
- 🟢 Resource search — global ⌘K palette across all registered resources
- 🟢 Rate-limit feedback — friendly “try again in Xs” messaging on HTTP 429 responses
- 🟢 Global API error banner — unified
ApiErrorfeedback across admin pages - 🟢 Column ordering & density preferences — saved per user in localStorage
- 🟢 Drag-and-drop for sortable resources (e.g.
orderinteger field) - 🟢 Custom dashboard builder with drag-drop layout
i18n & localization
- 🟢 Multilingual captions via
LocalizedLabelmaps on resources, fields and actions (i18n.localespins fallbacks) - 🟢 Admin locale stored in localStorage, appended as
?locale= - 🟢 Locale picker in user settings (hidden from top bar by default; show via settings page)
- 🟢 Pluralization + ICU —
formatPlural(),formatIcuMessage()(MessageFormat) in@openadminjs/resource - 🟢 ICU message format support for complex translation strings
API & integrations
- 🟢 OpenAPI / Swagger docs at
/api/docs - 🟢 Webhook plugin — fire events after create/update/delete
- 🟢 SSO bridge — OIDC Authorization Code + PKCE (
/auth/sso/oidc/*), links users viaUser.oidcKey - 🟢 OIDC hardening — provider presets, stricter callback validation, richer error surfaces, callback test matrix
- 🟢 Rate limiting — dual token buckets: IP (
default) + per-user JWT (perUser,RATE_LIMIT_USER_PER_MIN) - 🟢 GraphQL — admin read queries at
/graphqlwith pagination/search/sort and filter parity (adminResourceList,adminResourceRecord, bearer auth, realmadmin) - 🟢 GraphQL parity tests — resolver filter mapping checks to stay aligned with REST query semantics
Developer experience
- 🟢 CLI scaffold with
npx openadminjs create - 🟢
openadminjs generate field— polished flags (--required,--list,--sortable,--filterable,--searchable,--label) + normalized field types + Prisma fragment output - 🟢 Storybook —
pnpm --filter @openadminjs/admin storybook(exampleLogostory)
To propose a roadmap item, open a GitHub Discussion tagged roadmap.
To pick up an in-progress or planned item, comment on the linked issue and a maintainer will assign it.