How to use
- Copy the installation command and review the source package before installing it in your own agent environment.
- Check the source document for required tools, credentials and supported hosts.
- Describe your input and desired deliverable; review the output against the source material.
npx skills add https://github.com/TerminalSkills/skills --skill adonisjsBefore you start
- Installation does not include a model subscription, API allowance or permission to access your files.
- Repository stars and installation counts describe activity, not an independent quality or security test.
Rendered from the source repository. Headings, examples, code, tables, links, and referenced images are preserved.
AdonisJS
Overview
AdonisJS is a full-stack Node.js framework — the "Laravel of Node.js." Unlike Express/Fastify where you assemble everything from packages, AdonisJS ships with an ORM (Lucid), authentication, validation (VineJS), mailer, queues, and testing out of the box. Opinionated, TypeScript-first, and production-ready.
This skill targets AdonisJS 7 (@adonisjs/core 7.6, Lucid 22, Auth 10, VineJS 4, Ally 6), which requires Node.js 24. Version 7 changed the starter kits, generates Lucid schema classes from migrations, and adds a controllers barrel file and transformers — v6 snippets found online often no longer match.
When to Use
- Building a complete web application (not just an API) with Node.js
- Want an opinionated framework with conventions (like Rails/Laravel)
- Need built-in auth (sessions, API tokens, social OAuth)
- Database-driven applications with migrations and models
- Teams that prefer convention over configuration
Instructions
Setup
node -v # must be v24 or later
npm create adonisjs@latest bookshelf -- --kit=hypermedia
cd bookshelf
node ace serve --hmr # http://localhost:3333Starter kits (--kit): hypermedia (Edge templates + Alpine.js), react and vue (Inertia), api (JSON API with access-token auth), api-monorepo. The v6 alias --kit=web no longer exists. Every kit comes with Lucid on SQLite, a users table, and working signup/login. node ace serve --watch restarts the whole process instead of using HMR.
Routes and Controllers
// start/routes.ts
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers' // barrel file, regenerated when `node ace serve`/`test` starts or by `node ace codegen`
router.resource('posts', controllers.Posts).only(['index', 'show'])
router
.group(() => {
router.resource('posts', controllers.Posts).only(['store', 'update', 'destroy'])
})
.use(middleware.auth()) // unauthenticated visitors are redirected to /loginController routes get names automatically (posts.index, posts.show, …); list them with node ace list:routes. The v6 style const PostsController = () => import('#controllers/posts_controller') still works.
// app/controllers/posts_controller.ts — node ace make:controller posts --resource
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
import { createPostValidator, updatePostValidator } from '#validators/post'
export default class PostsController {
async index({ request, view }: HttpContext) {
const page = request.input('page', 1)
const posts = await Post.query().preload('author').orderBy('createdAt', 'desc').paginate(page, 20)
return view.render('pages/posts/index', { posts })
}
async show({ params, view }: HttpContext) {
const post = await Post.findOrFail(params.id) // 404 when the row is missing
await post.load('author')
return view.render('pages/posts/show', { post })
}
async store({ request, auth, response }: HttpContext) {
const payload = await request.validateUsing(createPostValidator)
const post = await auth.getUserOrFail().related('posts').create(payload)
return response.redirect().toRoute('posts.show', { id: post.id })
}
async update({ params, request, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
await post.merge(await request.validateUsing(updatePostValidator)).save()
return response.redirect().toRoute('posts.show', { id: post.id })
}
async destroy({ params, response }: HttpContext) {
await (await Post.findOrFail(params.id)).delete()
return response.redirect().toRoute('posts.index')
}
}Lucid ORM (Models and Migrations)
Lucid 22 is migrations-first: columns are defined in the migration, and node ace migration:run regenerates database/schema.ts with one typed schema class per table. Models extend that class and add only relationships, hooks and methods. Never edit database/schema.ts by hand.
node ace make:model post -m # app/models/post.ts + database/migrations/1790874006726_create_posts_table.ts
node ace migration:run # creates the table, regenerates database/schema.ts
node ace migration:rollback # undo the last batch// database/migrations/1790874006726_create_posts_table.ts — inside up()
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.integer('user_id').unsigned().notNullable().references('id').inTable('users').onDelete('CASCADE')
table.string('title').notNullable()
table.text('body').notNullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
})// app/models/post.ts — PostSchema already declares id, userId, title, body, createdAt, updatedAt
import { PostSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import User from '#models/user'
export default class Post extends PostSchema {
@belongsTo(() => User)
declare author: BelongsTo<typeof User>
}In app/models/user.ts add the inverse: @hasMany(() => Post) declare posts: HasMany<typeof Post> (import hasMany from @adonisjs/lucid/orm). A model may still extend BaseModel and declare @column() fields itself.
VineJS Validation
// app/validators/post.ts — node ace make:validator post
import vine from '@vinejs/vine'
export const createPostValidator = vine.create({
title: vine.string().trim().minLength(3).maxLength(120),
body: vine.string().trim().minLength(20),
})
export const updatePostValidator = vine.create({
title: vine.string().trim().minLength(3).maxLength(120).optional(),
body: vine.string().trim().minLength(20).optional(),
})vine.create({...}) is shorthand for vine.compile(vine.object({...})). Lucid adds database rules, for example vine.string().email().unique({ table: 'users', column: 'email' }); confirm a password with passwordConfirmation: vine.string().sameAs('password'). When validation fails, request.validateUsing() throws: form posts are redirected back with errors flashed to the session, JSON clients get 422 with an errors array.
Authentication
// config/auth.ts (api kit) — a token guard and a session guard side by side
import { defineConfig } from '@adonisjs/auth'
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens'
const authConfig = defineConfig({
default: 'api',
guards: {
api: tokensGuard({
provider: tokensUserProvider({ tokens: 'accessTokens', model: () => import('#models/user') }),
}),
web: sessionGuard({
useRememberMeTokens: false,
provider: sessionUserProvider({ model: () => import('#models/user') }),
}),
},
})
export default authConfig// app/models/user.ts — withAuthFinder hashes the password on save and adds verifyCredentials
// (compose: @adonisjs/core/helpers, hash: @adonisjs/core/services/hash,
// withAuthFinder: @adonisjs/auth/mixins/lucid, DbAccessTokensProvider: @adonisjs/auth/access_tokens)
export default class User extends compose(UserSchema, withAuthFinder(hash)) {
static accessTokens = DbAccessTokensProvider.forModel(User, { expiresIn: '30 days' })
}
// Session login (hypermedia / Inertia kits)
const user = await User.verifyCredentials(email, password)
await auth.use('web').login(user)
// Access token (api kit) — the plain value is available only at creation
const token = await User.accessTokens.create(user)
return { token: token.value!.release() } // "oat_…", sent as Authorization: BearerProtect routes with .use(middleware.auth()) or middleware.auth({ guards: ['api'] }). Tokens are opaque and stored hashed in auth_access_tokens. Social login: node ace add @adonisjs/ally --providers=github (Ally 6.3 is the AdonisJS 7 line and npm's latest; an AdonisJS 6 app stays on @adonisjs/ally@5). It writes config/ally.ts with a default callback URL of ${APP_URL}/oauth/github/callback and placeholder GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET values for .env. Redirect with ally.use('github').redirect(); in the callback await ally.use('github').user() throws a self-handled exception (flash message and redirect for HTML, JSON error otherwise) on denied access or a state mismatch, so a plain user() call is safe. Check emailVerificationState before trusting user.email to find or link an account. Upgrade to Ally 6.3.1 (5.1.2 on AdonisJS 6) or later: earlier versions accepted a callback without a state parameter when the state cookie was also missing, which let an attacker sign a victim in to the attacker's account or link the attacker's provider account to the victim's. The fixed versions count a missing state cookie as a mismatch.
Upgrading from v6
Node.js 24; replace ts-node with @poppinss/ts-exec in ace.js; add hooks: { init: [indexEntities()] } to adonisrc.ts; move the app key to config/encryption.ts; router.makeUrl() → urlFor(); Request/Response classes → HttpRequest/HttpResponse; flash key errors.* → inputErrorsBag.*. Full list: https://docs.adonisjs.com/v6-to-v7
Examples
Example 1: Full CRUD web application
User prompt: "Build a blog with AdonisJS — posts CRUD, user auth, and server-rendered pages."
npm create adonisjs@latest bookshelf -- --kit=hypermedia
cd bookshelf
node ace make:model post -m
node ace make:controller posts --resource
node ace make:validator post
# fill in the migration, model, validator, controller and routes shown above
node ace codegen && node ace migration:run # codegen first: routes.ts needs controllers.Posts in the barrel
node ace serve --hmr{{-- resources/views/pages/posts/index.edge --}}
<h1>Posts</h1>
<ul>
@each(post in posts)
<li>
<a href="{{ urlFor('posts.show', { id: post.id }) }}">{{ post.title }}</a>
by {{ post.author.fullName ?? post.author.email }}
</li>
@end
</ul>
<p>Total: {{ posts.total }}</p>After signing up at /signup and posting the form to /posts, GET /posts renders <a href="/posts/1">Why we moved to AdonisJS 7</a> by Maya Okafor and Total: 1. Forms need the CSRF field — the kit's @form component adds it; in a hand-written form use {{ csrfField() }}.
Example 2: REST API with token auth
User prompt: "Create a REST API with AdonisJS, API token authentication, and pagination."
npm create adonisjs@latest inventory -- --kit=api
cd inventory
node ace make:model product -m && node ace make:transformer product
node ace make:controller products --api && node ace make:validator product
node ace migration:run # after adding sku, name, price_cents and stock columns to the new migration// start/routes.ts — inside the existing .prefix('/api/v1') group
router.resource('products', controllers.Products).apiOnly().use('*', middleware.auth())
// app/transformers/product_transformer.ts
export default class ProductTransformer extends BaseTransformer<Product> {
toObject() { return this.pick(this.resource, ['id', 'sku', 'name', 'priceCents', 'stock']) }
}
// app/controllers/products_controller.ts
async index({ request, serialize }: HttpContext) {
const products = await Product.query().orderBy('name').paginate(request.input('page', 1), 20)
return serialize(ProductTransformer.paginate(products.all(), products.getMeta()))
}
async store({ request, response, serialize }: HttpContext) {
const product = await Product.create(await request.validateUsing(createProductValidator))
response.status(201)
return serialize(ProductTransformer.transform(product))
}API=http://localhost:3333/api/v1
curl -s -X POST $API/auth/signup -H 'Content-Type: application/json' \
-d "{\"fullName\":\"Lena Fischer\",\"email\":\"lena@inventory.dev\",\"password\":\"$INVENTORY_PASSWORD\",\"passwordConfirmation\":\"$INVENTORY_PASSWORD\"}"
# {"data":{"token":"oat_…","user":{"id":1,"fullName":"Lena Fischer",…}}} — export the token as INVENTORY_TOKEN; POST $API/auth/login returns the same shape
curl -s -X POST $API/products -H "Authorization: Bearer $INVENTORY_TOKEN" -H 'Content-Type: application/json' -d '{"sku":"MUG-BLK-350","name":"Black mug 350 ml","priceCents":1290,"stock":48}' # 201
curl -s "$API/products?page=1" -H "Authorization: Bearer $INVENTORY_TOKEN"
# {"data":[{"id":1,"sku":"MUG-BLK-350","name":"Black mug 350 ml","priceCents":1290,"stock":48}],
# "metadata":{"total":1,"perPage":20,"currentPage":1,"lastPage":1,…}}Without a token the API answers 401 {"errors":[{"message":"Unauthorized access"}]}. In Japa tests (node ace make:test products --suite=functional, node ace test functional) authenticate with client.get('/api/v1/products').loginAs(user).
Guidelines
- Use `node ace` for everything — generators, migrations, REPL, build;
node ace add @adonisjs/mail(or any official package) installs and configures it - Controllers stay thin — move business logic to services
- VineJS for all input — never trust
request.body()directly - `serializeAs: null` — the generated
passwordcolumn already has it; use transformers to decide which fields an API returns - Lucid relations are lazy — use
.preload()ormodel.load(); transformers never query, so preload what they read - Migrations are sequential — don't modify old migrations; create new ones. Production needs
node ace migration:run --force.migration:rollback --forcealso runs there (it drops tables) unlessmigrations.disableRollbacksInProduction: trueis set on the connection inconfig/database.ts - `make:model --controller` quirk — in Lucid 22.4 it generates a transformer instead of a controller; run
make:controllerseparately - CSRF — the hypermedia kit enables CSRF protection (
config/shield.ts), so POST/PUT/PATCH/DELETE requests without a token are rejected; theapikit ships with it disabled - Secrets —
APP_KEYand database credentials live in.env, validated instart/env.ts;.envis not copied into the build - Testing with Japa — built-in test runner, no Jest needed
- Deploy:
node ace build, thencd build && npm ci --omit=dev && node bin/server.jswithNODE_ENV=production

