How to Build a CRUD API with AdonisJS
Build a CRUD API with AdonisJS v7: create posts routes, Lucid models, VineJS validation, and tested JSON responses with curl.
AdonisJS is a TypeScript-first, Laravel-inspired Node.js framework that ships routing, an ORM (Lucid), validation (VineJS), authentication, and a CLI (ace) in one box — which makes it a strong fit for CRUD APIs where you want conventions instead of wiring. This walkthrough builds a complete posts CRUD endpoint with AdonisJS v7, covering every action from scaffold to a tested curl request, including VineJS validation and structured error responses.
AdonisJS v7 differs from v6 in three places this tutorial reflects: setup (Node.js 24+, npm create adonisjs@latest); the API starter (a Turborepo monorepo with the backend under apps/backend/, with Lucid ORM and authentication preconfigured); and code patterns (routing through #generated/controllers, Lucid models extending generated schema classes from apps/backend/database/schema.ts). The examples below follow that v7 structure throughout.
Key Takeaways
- The current AdonisJS v7 API starter is scaffolded with
npm create adonisjs@latest my-api -- --kit=api, not the old v6npm init adonisjs@latest ... -- -K=apiform. - AdonisJS v7 requires Node.js 24+ and npm 11+, so update your local runtime before scaffolding a project.
- New v7 starter kits include Lucid ORM, SQLite, authentication, VineJS, and testing setup out of the box, so you do not need to add Lucid manually in a fresh API starter.
- Current v7 routing examples use the generated controller barrel import,
import { controllers } from '#generated/controllers', then bind routes with[controllers.Posts, 'index']style handlers. - VineJS validation failures in API requests return HTTP 422 with a structured
errorsarray containing field names, failed rules, and messages — the shape frontend form libraries expect for field-level error display. - Lucid’s
Post.findOrFail(id)throws a not-found exception that AdonisJS renders as a 404 JSON response automatically, with no manual null check.
Prerequisites
AdonisJS v7 requires Node.js 24 or later and npm 11 or later. Confirm your local versions before scaffolding:
node --version # must be >= 24.x
npm --version # must be >= 11.x
The v7 starter kits include Lucid configured with SQLite by default, so you do not need to start with PostgreSQL just to complete this tutorial. SQLite writes to a local file and is perfect for a disposable CRUD walkthrough. If you later move to PostgreSQL, MySQL, or another SQL database, Lucid still supports those drivers; the controller and validation code below stays the same.
Scaffold the project with the API starter kit
The AdonisJS v7 API starter is scaffolded with npm create adonisjs@latest <name> -- --kit=api. Run it now:
npm create adonisjs@latest my-api -- --kit=api
The --kit=api flag selects the API starter kit. It gives you a backend-focused project with API-friendly defaults, including Lucid ORM, VineJS validation, authentication, CORS, testing, and a default SQLite database.
Move into the project and start the development server:
cd my-api
npm run dev
The backend runs on http://localhost:3333 by default. The API starter also includes authentication endpoints under /api/v1/auth, but this tutorial keeps the posts endpoints public so you can focus on CRUD first.
Use the default Lucid setup
Discover how at OpenReplay.com.
Lucid is the AdonisJS SQL ORM. In older tutorials you will often see a manual setup step like this:
node ace add @adonisjs/lucid
Do not run that in a fresh v7 API starter. Lucid is already installed and configured, and the starter uses SQLite by default. You can move straight to creating a table.
Generate a migration
A migration is a versioned schema change. Generate a migration for the posts table:
node ace make:migration posts
The command writes a timestamped file under database/migrations/. Open it and define the columns for the table:
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'posts'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('title').notNullable()
table.text('body').notNullable()
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
The import path is still @adonisjs/lucid/schema. The important v7 change comes after the migration runs: Lucid generates schema classes from your database so your app model can inherit typed column definitions.
Run the migration and create the model
Apply the schema to the database:
node ace migration:run
Lucid creates the posts table and generates database/schema.ts. That generated file contains a PostsSchema class with typed column definitions for the table. Do not edit database/schema.ts directly; it is regenerated after migrations.
Now create the model:
node ace make:model Post
Open app/models/post.ts and make the model extend the generated schema class exported from database/schema.ts:
import { PostSchema } from '#database/schema'
export default class Post extends PostSchema {}
That is the v7 Lucid model pattern. The column decorators live in the generated schema class, while your app model is where you add relationships, hooks, query scopes, and custom methods later.
Generate a controller
node ace make:controller posts --resource scaffolds a resourceful controller for the posts resource. Run it now:
node ace make:controller posts --resource
The --resource flag scaffolds the conventional REST methods: index, create, store, show, edit, update, and destroy. For a JSON API, you only need five of them:
| Method | Purpose |
|---|---|
index | List all posts |
store | Create a new post from the request body |
show | Return a single post by id |
update | Modify an existing post by id |
destroy | Delete a post by id |
You can delete the generated create and edit methods, because those exist for server-rendered HTML forms.
Wire the routes
Current v7 docs show controllers being imported through the generated barrel file, #generated/controllers. Open start/routes.ts and add the posts routes:
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router
.group(() => {
router.get('/posts', [controllers.Posts, 'index'])
router.post('/posts', [controllers.Posts, 'store'])
router.get('/posts/:id', [controllers.Posts, 'show'])
router.route('/posts/:id', ['PUT', 'PATCH'], [controllers.Posts, 'update'])
router.delete('/posts/:id', [controllers.Posts, 'destroy'])
})
.prefix('/api/v1')
This registers the API endpoints under /api/v1/posts, matching the versioned API style used by the v7 API starter.
| Method | Path | Controller method | Description |
|---|---|---|---|
| GET | /api/v1/posts | index | List all posts |
| POST | /api/v1/posts | store | Create a post |
| GET | /api/v1/posts/:id | show | Fetch one post |
| PUT | /api/v1/posts/:id | update | Update a post |
| PATCH | /api/v1/posts/:id | update | Partially update a post |
| DELETE | /api/v1/posts/:id | destroy | Delete a post |
AdonisJS still documents router.resource() for RESTful routes, but a plain resource route registers seven routes, including GET /posts/create and GET /posts/:id/edit. The explicit route list above keeps the API surface JSON-only and avoids accidentally exposing form-display routes.
Verify the live routes with:
node ace list:routes
If the routes do not appear, restart the dev server so the generated controller barrel file is refreshed.
Add VineJS validation
VineJS is the validation library used by AdonisJS. Generate a validator file:
node ace make:validator post
Open app/validators/post.ts and define validators for creating and updating posts:
import vine from '@vinejs/vine'
export const createPostValidator = vine.create({
title: vine.string().trim().minLength(3),
body: vine.string().trim().minLength(1),
})
export const updatePostValidator = vine.create({
title: vine.string().trim().minLength(3).optional(),
body: vine.string().trim().minLength(1).optional(),
})
The current AdonisJS validation guide uses vine.create() to define validators. In controllers, request.validateUsing() runs the validator against the request body and returns the typed, validated payload.
When request.validateUsing() fails for an API request, AdonisJS returns HTTP 422 with a structured JSON errors array. Each error includes the field name, the failed rule, and a message. That is the shape frontend code can map directly to form field state.
Session replays of frontends consuming CRUD APIs frequently surface a specific failure mode: a form submits, the server returns a 422, but no field-level error message ever appears in the UI. The root cause is often that the server returned a free-form string instead of structured field errors. VineJS gives you the structured shape by default, so preserve it. Do not catch the validation exception and rethrow a flattened string unless you also preserve the field-level contract.
Implement the controller methods
Each controller action is a short handler. The two failure paths that matter — a missing record and invalid input — are handled by Lucid and VineJS without manual branching.
Open app/controllers/posts_controller.ts and replace its body:
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
import { createPostValidator, updatePostValidator } from '#validators/post'
export default class PostsController {
async index({ response }: HttpContext) {
const posts = await Post.all()
return response.json(posts)
}
async store({ request, response }: HttpContext) {
const payload = await request.validateUsing(createPostValidator)
const post = await Post.create(payload)
return response.created(post)
}
async show({ params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
return response.json(post)
}
async update({ params, request, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
const payload = await request.validateUsing(updatePostValidator)
await post.merge(payload).save()
return response.json(post)
}
async destroy({ params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
await post.delete()
return response.noContent()
}
}
A few details matter:
storereturnsresponse.created(post), which sets HTTP 201.destroyreturnsresponse.noContent(), which sets HTTP 204.await post.delete()is awaited, so the response does not return before the delete finishes.Post.findOrFail(params.id)throws when no row matches. AdonisJS turns that exception into a 404 response automatically.
Test with curl
An AdonisJS v7 CRUD API can be exercised end-to-end with curl — every resource action (index, store, show, update, destroy) maps to a single HTTP request under /api/v1/posts. With npm run dev running, create a post:
curl -X POST http://localhost:3333/api/v1/posts \
-H "Content-Type: application/json" \
-d '{"title": "First post", "body": "Hello from Adonis"}'
A successful store response returns HTTP 201 with the created record:
{
"title": "First post",
"body": "Hello from Adonis",
"createdAt": "2026-06-01T12:00:00.000+00:00",
"updatedAt": "2026-06-01T12:00:00.000+00:00",
"id": 1
}
Now trigger validation errors by sending a title that is too short and an empty body:
curl -X POST http://localhost:3333/api/v1/posts \
-H "Content-Type: application/json" \
-d '{"title": "ok", "body": ""}'
The response is HTTP 422 with a structured VineJS error array:
{
"errors": [
{
"field": "title",
"rule": "minLength",
"message": "The title field must have at least 3 characters"
},
{
"field": "body",
"rule": "minLength",
"message": "The body field must have at least 1 characters"
}
]
}
This is the contract a frontend maps to form state: iterate over errors, key by field, and render message beside the matching input.
Fetch a record that does not exist to see the 404 path:
curl -i http://localhost:3333/api/v1/posts/9999
findOrFail produces a 404 response instead of returning null with a misleading 200.
The remaining CRUD requests follow the same pattern:
curl http://localhost:3333/api/v1/posts # index
curl http://localhost:3333/api/v1/posts/1 # show
curl -X PUT http://localhost:3333/api/v1/posts/1 \
-H "Content-Type: application/json" \
-d '{"title": "Updated title"}' # update
curl -X PATCH http://localhost:3333/api/v1/posts/1 \
-H "Content-Type: application/json" \
-d '{"body": "Updated body"}' # partial update
curl -X DELETE http://localhost:3333/api/v1/posts/1 # destroy → 204
Where to go next
An AdonisJS v7 CRUD API needs three follow-up pieces before it becomes production-ready: authentication, pagination, and tests.
For authentication, the API starter already includes auth routes and an access-token-oriented setup. Protect the posts routes with the auth middleware when they should only be available to logged-in users. If you are adding auth to an existing non-starter project, the documented command is:
node ace add @adonisjs/auth --guard=access_tokens
For large collections, replace Post.all() in index with Lucid pagination so the endpoint returns one page at a time instead of the whole table.
For tests, use Japa, the test runner configured by the starter. The v7 API starter uses:
npm run test
Start by testing the three contracts that matter most to the frontend: a successful create returns 201, validation failures return 422 with field-level errors, and missing records return 404.
FAQs
Why does this v7 tutorial use explicit routes instead of router.resource().apiOnly()?
The current AdonisJS v7 routing docs prominently document router.resource(), but a plain resource route registers seven routes, including GET /posts/create and GET /posts/:id/edit for HTML forms. For a JSON-only API, explicit GET, POST, PUT, PATCH, and DELETE routes make the public API surface obvious and avoid registering form-display routes accidentally.
Why does node ace make:controller --resource scaffold seven methods when this API only uses five?
The --resource flag scaffolds the full RESTful controller set: index, create, store, show, edit, update, and destroy. The create and edit methods exist to render HTML forms in server-rendered apps. A JSON API only needs index, store, show, update, and destroy, so the unused create and edit methods can be deleted safely.
What HTTP methods should the update endpoint support?
This tutorial maps both PUT and PATCH at /api/v1/posts/:id to the same update controller method. PUT typically signals a full replacement of the resource and PATCH a partial update, but many CRUD APIs handle both through the same validation and merge logic. Run node ace list:routes to confirm the live mapping in your app.
Will an AdonisJS v6 tutorial work on v7?
Not reliably. Much of the controller logic still looks familiar, but v7 changes important tutorial details: the runtime requirement is Node.js 24+, scaffolding uses npm create adonisjs@latest, starter kits include Lucid and auth by default, current routing examples use #generated/controllers, and Lucid models extend generated schema classes from #database/schema. Treat v6 tutorials as conceptual references only.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k