Introduction to AdonisJS
AdonisJS is a full-stack Node.js framework focused on developer ergonomics, stability, and confidence. Built on TypeScript, it provides a robust structure for building web applications with features like ORM, authentication, routing, and more out-of-the-box. AdonisJS follows Laravel-inspired conventions, making it accessible for developers familiar with PHP frameworks while leveraging the speed and flexibility of Node.js.
Core Concepts
MVC Architecture
- Models: Data and business logic
- Views: Presentation layer (Edge templates)
- Controllers: Request handling logic
Directory Structure
my-app/
├── app/ # Application code
│ ├── Controllers/
│ ├── Models/
│ ├── Middleware/
│ └── Services/
├── config/ # Configuration files
├── contracts/ # TypeScript interfaces
├── database/ # Migrations, seeds, factories
├── providers/ # Service providers
├── public/ # Static assets
├── resources/ # Views, CSS, JS
├── start/ # Application bootstrap files
└── tests/ # Test files
Service Providers
- Organize and register application components
- Load only what’s needed to optimize performance
- Define boot and register lifecycle methods
Installation & Setup
Creating a New Project
bash
# Install Adonis CLI
npm i -g @adonisjs/cli
# Create new project
npm init adonis-ts-app@latest my-project
# Project types
# 1. web (API + views)
# 2. api (REST API only)
# 3. slim (minimal setup)
Running the Application
bash
# Development with hot-reload
node ace serve --watch
# Production build
node ace build --production
npm run start
Routing
Basic Routes
typescript
// start/routes.ts
import Route from '@ioc:Adonis/Core/Route'
// GET request
Route.get('/users', 'UsersController.index')
// POST request
Route.post('/users', 'UsersController.store')
// PUT request
Route.put('/users/:id', 'UsersController.update')
// DELETE request
Route.delete('/users/:id', 'UsersController.destroy')
// Multiple HTTP methods
Route.route('/users', ['GET', 'POST'], 'UsersController.handler')
Route Groups
typescript
// Group routes with prefix
Route.group(() => {
Route.get('/profile', 'UsersController.profile')
Route.put('/profile', 'UsersController.updateProfile')
}).prefix('/user')
// Apply middleware to a group
Route.group(() => {
Route.get('/dashboard', 'DashboardController.index')
}).middleware(['auth'])
// Named routes
Route.get('/posts/:id', 'PostsController.show').as('posts.show')
Route Parameters
typescript
// Required parameter
Route.get('/posts/:id', 'PostsController.show')
// Optional parameter
Route.get('/posts/:id?', 'PostsController.show')
// Where conditions
Route.get('/posts/:id', 'PostsController.show')
.where('id', /^[0-9]+$/)
Resource Routes
typescript
// Full resource
Route.resource('posts', 'PostsController')
// GET /posts - index
// GET /posts/create - create
// POST /posts - store
// GET /posts/:id - show
// GET /posts/:id/edit - edit
// PUT /posts/:id - update
// DELETE /posts/:id - destroy
// Limit to specific actions
Route.resource('posts', 'PostsController')
.only(['index', 'show'])
// API resource (no create/edit)
Route.resource('posts', 'PostsController').apiOnly()
Controllers
Creating Controllers
bash
# Create a controller
node ace make:controller User
# Create a resource controller
node ace make:controller User --resource
Basic Controller
typescript
// app/Controllers/Http/UsersController.ts
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
export default class UsersController {
public async index({ response }: HttpContextContract) {
return response.json({ message: 'List of users' })
}
public async store({ request, response }: HttpContextContract) {
const data = request.only(['name', 'email'])
// Save user...
return response.status(201).json({ message: 'User created' })
}
}
HTTP Context
typescript
// Available properties in HttpContextContract
public async handle({
request, // HTTP request
response, // HTTP response
params, // Route parameters
view, // View rendering
session, // Session management
auth, // Authentication
logger, // Application logger
route, // Current route
bouncer, // Authorization
}: HttpContextContract) {
// Controller logic
}
Database & Models
Configuration
typescript
// config/database.ts
export const sqliteConfig = {
client: 'sqlite',
connection: {
filename: Application.tmpPath('db.sqlite3'),
},
useNullAsDefault: true,
}
export const postgresConfig = {
client: 'pg',
connection: {
host: Env.get('PG_HOST'),
port: Env.get('PG_PORT'),
user: Env.get('PG_USER'),
password: Env.get('PG_PASSWORD'),
database: Env.get('PG_DB_NAME'),
},
}
Migrations
bash
# Create migration
node ace make:migration users
# Run migrations
node ace migration:run
# Rollback last migration
node ace migration:rollback
# Reset all migrations
node ace migration:reset
# Refresh migrations (reset + run)
node ace migration:refresh
Migration Example
typescript
// database/migrations/users.ts
import BaseSchema from '@ioc:Adonis/Lucid/Schema'
export default class Users extends BaseSchema {
protected tableName = 'users'
public async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').primary()
table.string('email', 255).notNullable().unique()
table.string('password', 180).notNullable()
table.string('remember_me_token').nullable()
table.timestamps(true)
})
}
public async down() {
this.schema.dropTable(this.tableName)
}
}
Models
bash
# Create model
node ace make:model User
# Create model with migration
node ace make:model User -m
Model Example
typescript
// app/Models/User.ts
import { DateTime } from 'luxon'
import Hash from '@ioc:Adonis/Core/Hash'
import {
column,
beforeSave,
BaseModel,
hasMany,
HasMany,
} from '@ioc:Adonis/Lucid/Orm'
import Post from './Post'
export default class User extends BaseModel {
@column({ isPrimary: true })
public id: number
@column()
public email: string
@column({ serializeAs: null })
public password: string
@column()
public rememberMeToken: string | null
@column.dateTime({ autoCreate: true })
public createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
public updatedAt: DateTime
@hasMany(() => Post)
public posts: HasMany<typeof Post>
@beforeSave()
public static async hashPassword(user: User) {
if (user.$dirty.password) {
user.password = await Hash.make(user.password)
}
}
}
Query Builder
typescript
// Database query builder
import Database from '@ioc:Adonis/Lucid/Database'
// Select all
const users = await Database.from('users').select('*')
// Where conditions
const user = await Database
.from('users')
.where('email', 'user@example.com')
.first()
// Joins
const posts = await Database
.from('posts')
.join('users', 'users.id', 'posts.user_id')
.select('posts.*', 'users.name')
// Transactions
await Database.transaction(async (trx) => {
const user = await trx.insertQuery()
.table('users')
.insert({ name: 'John' })
await trx.insertQuery()
.table('profiles')
.insert({ user_id: user[0], bio: 'Bio' })
})
Using Models
typescript
// Find by ID
const user = await User.find(1)
// Find or fail
const user = await User.findOrFail(1)
// First record matching query
const user = await User.query()
.where('email', 'user@example.com')
.first()
// Get all
const users = await User.all()
// Filtered query
const users = await User.query()
.where('role', 'admin')
.orderBy('created_at', 'desc')
// Create
const user = await User.create({
email: 'user@example.com',
password: 'password',
})
// Update
user.email = 'new@example.com'
await user.save()
// Delete
await user.delete()
Relationships
typescript
// Model definitions
// User.ts
@hasMany(() => Post)
public posts: HasMany<typeof Post>
// Post.ts
@belongsTo(() => User)
public user: BelongsTo<typeof User>
// Using relationships
// Preload related data
const user = await User.query()
.where('id', 1)
.preload('posts')
.firstOrFail()
// Access related data
const posts = user.posts
// Create related record
const post = await user.related('posts').create({
title: 'New Post',
content: 'Content...',
})
Validation
Creating a Validator
bash
node ace make:validator CreateUser
Validator Example
typescript
// app/Validators/CreateUserValidator.ts
import { schema, rules } from '@ioc:Adonis/Core/Validator'
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
export default class CreateUserValidator {
constructor(protected ctx: HttpContextContract) {}
public schema = schema.create({
email: schema.string({ trim: true }, [
rules.email(),
rules.unique({ table: 'users', column: 'email' }),
]),
password: schema.string({}, [
rules.confirmed(),
rules.minLength(8),
]),
age: schema.number([
rules.range(18, 100),
]),
profile: schema.object().members({
avatar: schema.string.optional(),
bio: schema.string.optional(),
}),
})
public messages = {
'email.required': 'Email is required',
'email.email': 'Email must be valid',
'email.unique': 'Email already in use',
'password.confirmed': 'Passwords do not match',
}
}
Using Validators in Controllers
typescript
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import CreateUserValidator from 'App/Validators/CreateUserValidator'
export default class UsersController {
public async store({ request, response }: HttpContextContract) {
const payload = await request.validate(CreateUserValidator)
// Create user with validated data
const user = await User.create(payload)
return response.created(user)
}
}
Authentication
Configuration
bash
# Install auth package
npm i @adonisjs/auth
# Configure auth
node ace configure @adonisjs/auth
Auth Middleware
typescript
// start/kernel.ts
Server.middleware.registerNamed({
auth: () => import('App/Middleware/Auth'),
})
// Apply to routes
Route.get('/dashboard', 'DashboardController.index')
.middleware('auth')
Authentication in Controllers
typescript
export default class AuthController {
// Login
public async login({ request, auth, response }: HttpContextContract) {
const { email, password } = request.only(['email', 'password'])
try {
// For session auth
await auth.use('web').attempt(email, password)
return response.redirect('/dashboard')
// For API auth
const token = await auth.use('api').attempt(email, password)
return token
} catch {
return response.badRequest('Invalid credentials')
}
}
// Logout
public async logout({ auth, response }: HttpContextContract) {
await auth.logout()
return response.redirect('/')
}
// Check auth status
public async user({ auth }: HttpContextContract) {
return auth.user
}
}
Middleware
Creating Middleware
bash
node ace make:middleware CheckAdmin
Middleware Example
typescript
// app/Middleware/CheckAdmin.ts
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
export default class CheckAdmin {
public async handle(
{ auth, response }: HttpContextContract,
next: () => Promise<void>
) {
if (auth.user?.role !== 'admin') {
return response.forbidden({ message: 'Access denied' })
}
await next()
}
}
Registering Middleware
typescript
// start/kernel.ts
// Global middleware
Server.middleware.register([
() => import('@ioc:Adonis/Core/BodyParser'),
])
// Named middleware
Server.middleware.registerNamed({
auth: () => import('App/Middleware/Auth'),
admin: () => import('App/Middleware/CheckAdmin'),
})
Views & Templates
Edge Templates
typescript
// Rendering a view
public async index({ view }: HttpContextContract) {
return view.render('posts/index', {
posts: await Post.all(),
})
}
Template Example
html
<!-- resources/views/posts/index.edge -->
@layout('layouts/main')
@section('content')
<h1>Blog Posts</h1>
@each(post in posts)
<div class="post">
<h2>{{ post.title }}</h2>
<p>{{ excerpt(post.content, 100) }}</p>
<a href="{{ route('posts.show', { id: post.id }) }}">Read more</a>
</div>
@endeach
@if(posts.length === 0)
<p>No posts found</p>
@endif
@endsection
Edge Directives
html
<!-- Conditional rendering -->
@if(user.isAdmin)
<span class="badge">Admin</span>
@elseif(user.isModerator)
<span class="badge">Moderator</span>
@else
<span class="badge">User</span>
@endif
<!-- Loops -->
@each(item in items)
<li>{{ item.name }}</li>
@endeach
<!-- Including partials -->
@include('partials/header')
<!-- Components -->
@component('components/alert', { type: 'error' })
<p>Something went wrong!</p>
@endcomponent
<!-- Layouts -->
@layout('layouts/main')
@section('content')
<!-- Content here -->
@endsection
File Storage
Configuration
typescript
// config/drive.ts
export const driveConfig = {
disk: Env.get('DRIVE_DISK'),
disks: {
local: {
driver: 'local',
root: Application.tmpPath('uploads'),
},
s3: {
driver: 's3',
key: Env.get('S3_KEY'),
secret: Env.get('S3_SECRET'),
bucket: Env.get('S3_BUCKET'),
region: Env.get('S3_REGION'),
},
},
}
File Uploads
typescript
public async store({ request, response }: HttpContextContract) {
// Single file upload
const avatar = request.file('avatar')
if (avatar) {
await avatar.moveToDisk('/')
// or with custom name
await avatar.moveToDisk('/', {
name: `${new Date().getTime()}.${avatar.extname}`,
})
}
// Multiple files
const images = request.files('images')
for (const image of images) {
await image.moveToDisk('/')
}
return response.created({ message: 'Files uploaded' })
}
File Operations
typescript
import Drive from '@ioc:Adonis/Core/Drive'
// Check if file exists
const exists = await Drive.exists('avatar.jpg')
// Get file URL (for S3 and other remote disks)
const url = await Drive.getUrl('avatar.jpg')
// Get signed URL
const signedUrl = await Drive.getSignedUrl('avatar.jpg', {
expiresIn: '30mins',
})
// Delete file
await Drive.delete('avatar.jpg')
Common Challenges & Solutions
CORS Configuration
typescript
// config/cors.ts
export const corsConfig = {
enabled: true,
origin: true,
methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],
headers: true,
exposeHeaders: [
'cache-control',
'content-language',
'content-type',
'expires',
'last-modified',
'pragma',
],
credentials: true,
maxAge: 90,
}
Error Handling
typescript
// app/Exceptions/Handler.ts
import Logger from '@ioc:Adonis/Core/Logger'
import HttpExceptionHandler from '@ioc:Adonis/Core/HttpExceptionHandler'
export default class ExceptionHandler extends HttpExceptionHandler {
protected statusPages = {
'403': 'errors/forbidden',
'404': 'errors/not-found',
'500..599': 'errors/server-error',
}
constructor() {
super(Logger)
}
public async handle(error, ctx) {
// Custom error handling logic
if (error.code === 'E_VALIDATION_FAILURE') {
return ctx.response.status(422).send(error.messages)
}
// Fall back to default handler
return super.handle(error, ctx)
}
}
Rate Limiting
typescript
// Middleware for rate limiting
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import { SimpleLimiter } from '@ioc:Adonis/Addons/RateLimiter'
export default class RateLimit {
public async handle(
{ request, response }: HttpContextContract,
next: () => Promise<void>
) {
// 10 requests per minute
const limiter = new SimpleLimiter('global', {
duration: 60 * 1000,
requests: 10,
})
try {
await limiter.consume(request.ip())
await next()
} catch (error) {
return response
.status(429)
.send({ message: 'Too many requests' })
}
}
}
Best Practices
Environment Variables
typescript
// .env
PORT=3333
HOST=0.0.0.0
NODE_ENV=development
APP_KEY=your-secret-key
DB_CONNECTION=sqlite
DB_NAME=adonis
// Using Env in code
import Env from '@ioc:Adonis/Core/Env'
const dbName = Env.get('DB_NAME')
const port = Env.get('PORT', 3333) // With default
Dependency Injection
typescript
// Register a binding
import { ApplicationContract } from '@ioc:Adonis/Core/Application'
export default class AppProvider {
constructor(protected app: ApplicationContract) {}
public register() {
this.app.singleton('App/Services/NotificationService', () => {
return new NotificationService()
})
}
}
// Using the binding
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
export default class UsersController {
public async notify({ container }: HttpContextContract) {
const notification = container.use('App/Services/NotificationService')
await notification.send()
}
}
Command Line Tasks
bash
# Create a custom command
node ace make:command SendDailyEmails
typescript
// app/Commands/SendDailyEmails.ts
import { BaseCommand } from '@adonisjs/core/build/standalone'
export default class SendDailyEmails extends BaseCommand {
public static commandName = 'send:emails'
public static description = 'Send daily digest emails to users'
public static settings = {
loadApp: true,
}
public async run() {
this.logger.info('Sending emails')
// Email sending logic here
this.logger.success('Emails sent successfully')
}
}
// Running the command
// node ace send:emails
Logging
typescript
import Logger from '@ioc:Adonis/Core/Logger'
// Log levels
Logger.info('Application started')
Logger.warn('Deprecated feature used')
Logger.error('Something went wrong', { error })
Logger.debug('Debug information')
// In controllers
public async index({ logger }: HttpContextContract) {
logger.info('Listing users')
}
Testing
Configuration
bash
# Install test dependencies
npm i -D @japa/runner @japa/preset-adonis
# Create test
node ace make:test user
Unit Test Example
typescript
// tests/unit/user.spec.ts
import { test } from '@japa/runner'
import User from 'App/Models/User'
test.group('User', () => {
test('create user', async ({ assert }) => {
const user = await User.create({
email: 'test@example.com',
password: 'password',
})
assert.equal(user.email, 'test@example.com')
})
})
Functional Test Example
typescript
// tests/functional/auth.spec.ts
import { test } from '@japa/runner'
import Database from '@ioc:Adonis/Lucid/Database'
test.group('Authentication', (group) => {
group.each.setup(async () => {
await Database.beginGlobalTransaction()
return () => Database.rollbackGlobalTransaction()
})
test('login with valid credentials', async ({ client, assert }) => {
// Create a user
await client
.post('/register')
.form({
email: 'test@example.com',
password: 'password',
password_confirmation: 'password',
})
// Attempt login
const response = await client
.post('/login')
.form({
email: 'test@example.com',
password: 'password',
})
response.assertStatus(200)
response.assertRedirectsTo('/dashboard')
})
})
Running Tests
bash
# Run all tests
node ace test
# Run specific test
node ace test tests/functional/auth.spec.ts
Resources for Further Learning
Official Resources
Packages & Extensions
Tutorials & Courses
Comparison with Other Frameworks
Feature | AdonisJS | Express | NestJS |
---|---|---|---|
Architecture | Full-stack MVC | Minimalist, unopinionated | Modular, Angular-inspired |
Out-of-box features | High (ORM, Auth, etc.) | Low (requires plugins) | Medium (DI, modules) |
TypeScript support | Native | Add-on | Native |
Learning curve | Medium | Low | High |
Database handling | Lucid ORM built-in | No built-in ORM | TypeORM/Mongoose integration |
Testing | Built-in test framework | No built-in testing | Jest integration |
Community size | Medium | Very large | Large |
Best for | Full-stack applications | Lightweight APIs | Enterprise applications |