# AdonisJS v7 — Full Documentation
> Complete documentation content for LLM consumption.
> Source: https://github.com/adonisjs/v7-docs
> Generated: 2026-05-10
---
# Introduction
AdonisJS is a **backend-first, type-safe framework** for building web applications with Node.js and TypeScript. It provides the core building blocks for writing and maintaining complete backends, eliminating the need for third-party services to handle **common features such as authentication, file uploads, caching, and rate limiting**.
Each AdonisJS application is written in TypeScript, runs in ESM mode, and offers end-to-end type safety across the entire stack.
## Who is AdonisJS for?
AdonisJS is designed for developers building production web applications who need more structure than Express but less ceremony than enterprise frameworks. If you're building REST APIs, server-rendered applications, or full-stack web apps, and you value conventions over configuration, AdonisJS will feel natural.
If you're coming from Laravel, Rails, or Django, you'll recognize the MVC patterns and conventions. If you've worked with Express or Fastify, you'll appreciate having structure and batteries included without sacrificing simplicity.
## Why AdonisJS
AdonisJS provides the structure, consistency, and tooling expected from a full-featured framework, while remaining lightweight and modern in design.
It is suitable for teams that value:
- **Ownership of backend logic**: Build critical features in-house rather than depending on external services for authentication, rate-limiting, or background jobs.
- **Cohesive developer experience**: Every AdonisJS application follows the same conventions and directory structure, making it easy to onboard new developers and share knowledge across teams.
- **Unified ecosystem**: Core features are maintained together under consistent quality standards, eliminating dependency fragmentation.
- **Extensibility and freedom**: Core features are built from low-level packages that you can use directly to create custom flows, integrations, or abstractions.
AdonisJS is designed to provide everything you need for real-world backend applications, while remaining approachable and easy to configure.
## What can you build with AdonisJS?
AdonisJS is designed for real-world backend applications:
- **REST APIs**: Build type-safe APIs for mobile apps or SPAs. Companies use AdonisJS to power APIs serving millions of requests.
- **Full-stack web applications**: Use Edge templates for server-rendered pages, or pair with Vue/React for hybrid applications. The MVC structure keeps your backend organized as your app grows.
- **SaaS platforms**: Build multi-tenant applications without relying on third-party services for core functionality like authentication, authorization, or background jobs.
Whether you're building a startup MVP or a production system serving thousands of users, AdonisJS provides the foundation without getting in your way.
## Practical, not overengineered
Many frameworks introduce enterprise abstractions that complicate projects without adding clarity. AdonisJS focuses on a different approach.
It offers a **practical development model** that focuses on clarity, type safety, and maintainability rather than patterns for their own sake. The framework encourages good structure through conventions but never enforces heavy architectural layers.
The framework includes batteries such as routing, middleware, validation, and ORM out of the box, allowing teams to focus on application logic instead of building common patterns repeatedly.
AdonisJS APIs are functional and modern. You can use class-based components where a structured approach is helpful, such as in controllers, models, and services.
## How AdonisJS compares
**vs. Express/Fastify**: AdonisJS provides structure and conventions that Express lacks, while remaining just as performant. Instead of assembling packages yourself, you get an integrated toolkit out of the box.
**vs. NestJS**: AdonisJS focuses on practical patterns over enterprise abstractions. No decorators everywhere, no dependency injection containers to configure, just straightforward TypeScript code that follows clear conventions.
**vs. Laravel/Rails**: If you love Laravel or Rails but work in Node.js, AdonisJS brings that same cohesive experience: migrations, seeders, factories, model relationships, and consistent conventions.
Choose AdonisJS when you want the productivity of a full-featured framework without the complexity of enterprise patterns or the fragmentation of minimal frameworks.
## MVC with a configurable view layer
AdonisJS uses the **Model-View-Controller (MVC)** pattern to keep data, logic, and presentation separate. The view layer is optional and can be configured to fit your needs.
You can use:
- **Edge**, the official server-side templating engine, for traditional full-stack applications.
- **Vue**, **React**, or another frontend framework to build a single-page or hybrid application.
- Or skip the view layer entirely when building an API-first or backend-only service.
Most backend code, such as routing, controllers, models, and middleware, stays the same no matter how you render views. This flexibility allows you to start with server-side rendering and transition to a modern SPA setup later, without modifying your core backend logic.
## Ecosystem and stability
AdonisJS has been in active development since 2015, with version 7 representing years of real-world usage and refinement. The framework is maintained by its creator full-time, with support from the core team members and an active community.
The ecosystem includes [official packages](https://adonisjs.com/packages) for common backend needs, all maintained by the core team with the same quality standards. Community packages extend functionality for specific needs like payment processing, cloud storage, and third-party integrations.
All documentation, tooling, and packages follow semantic versioning, ensuring stable upgrades and long-term maintainability.
## Next steps
AdonisJS documentation is organized to guide both new and experienced developers:
- If this is your **first time** using AdonisJS, then continue reading all the docs in the **Start** section and eventually build an app by following the [Tutorial](./tutorial/hypermedia/overview.md).
- If you already know the basics, explore the [Guides](../guides/basics/routing.md) to learn specific topics like validation, database management, or testing.
---
# Pick your path
This guide introduces AdonisJS's approach to frontend development and the three primary stacks you can choose from. You will learn:
- Why AdonisJS is backend-first but frontend-flexible.
- Understand the difference between Hypermedia, Inertia, and API-only approaches.
- See how the View layer works in each stack.
- Learn about the starter kits that provide opinionated setups for each approach.
## Overview
AdonisJS is deeply opinionated about the backend, providing built-in authentication, authorization, validation, database tooling, and more, **but deliberately flexible about the frontend**. This backend-first philosophy means you get a robust foundation for building your server-side logic while choosing how you build your user interface.
You can create traditional server-rendered applications, modern single-page applications, or anything in between, all using the same backend framework. A marketing website has different requirements than an admin dashboard, which differs from a mobile app's API backend. Rather than forcing you into a single approach, AdonisJS lets you choose the frontend stack that fits your needs.
## The three approaches
AdonisJS supports three primary approaches to building your frontend. Each approach represents a different way of thinking about the View layer in your application's architecture.
### Hypermedia
Hypermedia applications generate complete HTML pages on the server and send them to the browser. You build your interface using a template engine (AdonisJS provides [Edge](https://edgejs.dev)) and add interactivity using lightweight JavaScript libraries like Alpine.js or HTMX/Unpoly when needed.
:::note{title="What is Hypermedia"}
The term "Hypermedia" refers to HTML as a medium for building interactive applications, where the server drives the application state and the client (browser) displays it. If you're new to this concept, the HTMX project has an excellent essay explaining [Hypermedia-driven applications](https://htmx.org/essays/hypermedia-driven-applications/) in depth.
:::
In a Hypermedia application:
- The server is responsible for rendering your views.
- Your controllers return HTML instead of JSON.
- Navigation between pages happens through traditional page loads or progressively enhanced requests.
This approach embraces the web's native capabilities and keeps most of your application's logic on the server where you have full control.
Choose this approach when you want to build applications with the server in control, or you want to minimize the amount of JavaScript your users download. Hypermedia applications can be highly interactive using libraries like Alpine.js and HTMX while keeping your frontend codebase lean and your deployment simple.
### Inertia (React or Vue)
Inertia.js provides a middle ground between server-rendered templates and SPAs. You use React or Vue components as your views while keeping server-side routing and controllers. AdonisJS officially supports building applications with React or Vue through Inertia, giving you the component-based development experience of modern frontend frameworks without the complexity of maintaining a separate single-page application.
With Inertia:
- Your backend routes map directly to frontend components, eliminating the complexity of dual routing systems.
- Your controllers return data to Inertia components instead of rendering templates or returning JSON.
- Navigation feels like a single-page application with smooth transitions, but your routing logic stays on the server where it's easier to protect and maintain.
Inertia also simplifies form submissions and data fetching while keeping your application a monolithic deployment. You get a modern, reactive user experience without building and maintaining a separate API layer.
Choose this approach when you want to use React or Vue but prefer server-side routing, you want to avoid the complexity of separate frontend and backend deployments, or you want a tightly integrated full-stack development experience. Visit [inertiajs.com](https://inertiajs.com) to learn more about how Inertia bridges the gap between server-side and client-side frameworks.
### API-only
You can build a JSON API backend with AdonisJS while your frontend lives in a completely separate codebase. This approach creates a clear separation where AdonisJS handles all backend logic and exposes data through API endpoints, while your frontend application (built with any framework) consumes these endpoints.
In an API-only setup:
- Your controllers return JSON responses.
- Your frontend and backend are separate deployments with their own build processes, repositories (or monorepo), and deployment pipelines.
- The two communicate exclusively through HTTP requests to your API endpoints.
:::note
In monorepos, you can use a [type-safe API client](../guides/frontend/api_client.md) for true end-to-end typing across backend and frontend. [Transformers](../guides/frontend/transformers.md) also produce reusable, independent response types, so your UI can rely directly on the serialized API contract.
:::
This approach covers a wide variety of applications: APIs for mobile apps (iOS, Android), web applications built with any frontend framework, desktop applications, or even multiple frontends (web and mobile) consuming the same API. The separation provides flexibility in how you deploy and scale each layer independently.
Choose this approach when you're building an API that serves multiple client applications, your team prefers working with separate frontend and backend repositories, you need independent deployment and scaling of frontend and backend, or you're building a public API that external developers will consume.
## The same controller, three different returns
In the following example, you can see us using the same route, same controller, same data fetching logic. Only the return statement changes.
::::tabs
:::tab{title="Hypermedia"}
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('posts', [controllers.Posts, 'index'])
```
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async index({ view }: HttpContext) {
const posts = await Post.all()
return view.render('posts/index', { posts }) // [!code highlight]
}
}
```
```edge title="views/pages/posts/index.edge"
@each(post in posts)
{{ post.title }}
@end
```
:::
:::tab{title="Inertia"}
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('posts', [controllers.Posts, 'index'])
```
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import { HttpContext } from '@adonisjs/core/http'
// [!code highlight]
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ inertia }: HttpContext) {
const posts = await Post.all()
// [!code highlight:3]
return inertia.render('posts/index', {
posts: PostTransformer.transform(posts)
})
}
}
```
```tsx title="pages/posts/index.tsx"
import { InertiaProps } from '~/types'
import { Data } from '~/generated/data'
type PageProps = InertiaProps<{ posts: Data.Post[] }>
export default function PostsIndex({ posts }: PageProps) {
return <>
{
posts.map((post) => (
{ post.title }
))
}
>
}
```
:::
:::tab{title="API-only"}
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('posts', [controllers.Posts, 'index'])
```
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import { HttpContext } from '@adonisjs/core/http'
// [!code highlight]
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ serialize }: HttpContext) {
const posts = await Post.all()
// [!code highlight]
return serialize(PostTransformer.transform(posts))
}
}
```
```json title="JSON response"
{
"data": [
{
"id": 1,
"title": "AdonisJS In 30",
"description": "In this series, we'll ...",
}
]
}
```
:::
::::
## What NOT to expect
If you're coming from meta-frameworks, there are some patterns you won't find in AdonisJS. These differences are intentional and provide clarity about how your application works.
:::note
AdonisJS is designed to be the backend that powers your frontend applications, not a replacement for meta-frameworks. You can use AdonisJS alongside frameworks like TanStack Start, Nuxt, Next.js, or others, with clear boundaries and well-defined API contracts between the two.
:::
**No file-based routing**: AdonisJS uses explicit route definitions in your route files. Routes are declared using the router API, giving you full control and visibility over your application's URL structure. This makes it easy to see all your routes in one place and apply middleware or constraints as needed.
**No compiler magic with "use" directives**: You won't find magical statements like "use server" or "use client" that blur the boundaries between where code executes. AdonisJS maintains a clear separation between your backend code (which runs on the server) and your frontend code (which runs in the browser).
**No mixing of server and client code**: Your backend logic lives in controllers, models, and services. Your frontend code lives in templates, components, or a separate frontend application. There's no ambiguity about where code runs, which makes debugging straightforward and helps teams work with well-defined boundaries.
This separation provides clarity about where code executes, makes debugging predictable, and allows teams to work with clear contracts between frontend and backend. When your frontend needs data from your backend, you work with explicit API calls or template data passing, not magical boundaries that blur at compile time.
## Starter kits
AdonisJS provides starter kits for each approach that come with opinionated configurations and best practices already in place. You don't have to configure everything from scratch.
These starter kits include properly **configured build tools**, **authentication scaffolding**, and all the **necessary integrations set up correctly**. When you create a new AdonisJS application, you can choose which starter kit to use based on the approach you've decided on.
This gives you a **"flexible but not on your own"** experience. You get to choose your stack, but once you've chosen, you get a fully configured setup that works out of the box.
- [Hypermedia starter kit](https://github.com/adonisjs/starter-kits/tree/main/hypermedia)
- [React starter kit](https://github.com/adonisjs/starter-kits/tree/main/inertia-react)
- [Vue starter kit](https://github.com/adonisjs/starter-kits/tree/main/inertia-vue)
- [API-only](https://github.com/adonisjs/starter-kits/tree/main/api)
- [API monorepo](https://github.com/adonisjs/starter-kits/tree/main/api-monorepo)
## Next steps
Now that you understand all different approaches AdonisJS supports, you're ready to create your first application. The [installation guide](./installation.md) will walk you through using the starter kits to set up a new project with your chosen stack.
---
# Installation
This guide explains how to set up a new AdonisJS application from scratch. It covers the prerequisites, project creation, and starting the development server. You do not need prior experience with AdonisJS, but basic knowledge of Node.js is useful.
## Prerequisites
Before you begin, make sure you have the following tools installed.
- **Node.js ≥ 24.x**
- **npm ≥ 11.x**
You can verify your installed versions using the following commands.
```sh
node -v
npm -v
```
Check that your Node.js and npm versions meet these requirements before continuing. You can download the latest versions from the [Node.js website](https://nodejs.org/) or use a version manager like Volta/nvm.
## Creating a new application
AdonisJS provides the `create-adonisjs` initializer package to scaffold new applications. This package creates a new project directory with all necessary files, dependencies, and configuration based on your selections during the setup process.
Replace `[project-name]` with your desired project name. The initializer will create a new directory with that name and set up your AdonisJS application inside it.
```sh
npm create adonisjs@latest [project-name]
```
This command starts an interactive setup and asks you to select a starter kit, or you may pre-define a starter kit using the `--kit` CLI option.
```sh title="Create a new Hypermedia application"
npm create adonisjs@latest [project-name] -- --kit=hypermedia
```
```sh title="Create a new React application"
npm create adonisjs@latest [project-name] -- --kit=react
```
```sh title="Create a new Vue application"
npm create adonisjs@latest [project-name] -- --kit=vue
```
```sh title="Create a new API application"
npm create adonisjs@latest [project-name] -- --kit=api
```
### Official starter kits
AdonisJS offers four official starter kits. Each kit sets up a different type of application, depending on how you want to build your user interface and manage interactivity.
- [**Hypermedia Starter Kit**](https://github.com/adonisjs/starter-kits/tree/main/hypermedia). Uses Edge as the server-side templating engine and integrates Alpine.js to add lightweight, reactive behavior to your frontend. Ideal for applications that primarily render HTML on the server and only need minimal frontend logic.
- [**React Starter Kit**](https://github.com/adonisjs/starter-kits/tree/main/inertia-react). Uses Inertia.js alongside React to build a fullstack React application powered by the AdonisJS backend. It can operate as a server-rendered app or a Single Page Application (SPA), depending on your configuration.
- [**Vue Starter Kit**](https://github.com/adonisjs/starter-kits/tree/main/inertia-vue). Similar to the React setup, but with Vue as the frontend framework. It utilizes Inertia.js and provides the same full-stack capabilities, including backend-driven routing, shared state, and SPA support.
- [**API Starter Kit**](https://github.com/adonisjs/starter-kits/tree/main/api). A monorepo setup with two apps: an AdonisJS backend and an empty frontend project where you can configure any frontend framework of your choice (TanStack Start, Nuxt, Next.js, or others). End-to-end type-safety and shared transformer types are already configured between the backend and frontend.
All starter kits come pre-configured with sensible defaults, streamlined development workflows, and ready-to-use authentication features. For a detailed comparison and usage guidance, see the [Pick your path](./pick_your_path.md) guide.
### Community starter kits
In addition to the official kits, the AdonisJS community also maintains starter kits for specific use cases.
- [**Slim Starter Kit**](https://github.com/batosai/adonisjs-slim-starter-kit). A minimal AdonisJS v7 setup with the framework core and Japa test runner, designed for teams that want to start from a lightweight foundation.
- [**MCP Starter Kit**](https://github.com/batosai/adonisjs-mcp-starter-kit). A minimal AdonisJS v7 setup with built-in MCP support, useful when you want to expose MCP tools, resources, and prompts in your application.
## Project defaults
Every newly created AdonisJS application includes:
- Opinionated folder structure.
- [Lucid ORM](https://lucid.adonisjs.com) configured with **SQLite** as the default database.
- Built-in **authentication** flows for login and signup.
- **ESLint** and **Prettier** setup with pre-defined configuration.
These features help you get started quickly. You can customize, extend, or remove them as your project grows.
## Starting the development server
After creating your app, move into your project directory and start the development server.
::::tabs
:::tab{title="Hypermedia / Inertia kits"}
```bash
node ace serve --hmr
```
Once the server is running, open your browser and visit [http://localhost:3333](http://localhost:3333). You should see the AdonisJS welcome page confirming your installation was successful.
:::
:::tab{title="API kit"}
The API starter kit is a monorepo managed by [Turborepo](https://turbo.build/repo). Start all apps from the project root:
```bash
npm run dev
```
This starts both the backend (AdonisJS) and frontend dev servers. The backend runs at [http://localhost:3333](http://localhost:3333) and returns a JSON response:
```json
{ "hello": "world" }
```
:::
::::
## What you just installed
Your starter kit includes:
- **Pre-configured development environment**. TypeScript, ESLint, Prettier, and Vite are set up with sensible defaults.
- **Database setup**. Lucid ORM is configured with SQLite, ready for you to start building models and running migrations.
- **Organized project structure**. Routes are defined in `start/routes.ts`, models live in `app/models/`, controllers are in `app/controllers/`, and middleware resides in `app/middleware/`. This convention keeps your codebase organized as it grows.
- **Working authentication**. All starter kits include a fully functional authentication system with signup and login flows.
::::tabs
:::tab{title="Hypermedia / Inertia kits"}
Try creating an account at [http://localhost:3333/signup](http://localhost:3333/signup) and logging in at [http://localhost:3333/login](http://localhost:3333/login). The `users` table already exists in your SQLite database (`tmp/db.sqlite`).
:::
:::tab{title="API kit"}
The authentication endpoints are available at `POST /api/v1/auth/signup` and `POST /api/v1/auth/login`. You can test them with any HTTP client (curl, Postman, or your frontend app). The `users` table already exists in your SQLite database (`apps/backend/tmp/db.sqlite`).
:::
::::
### Dev-server modes
- **Hot Module Replacement (--hmr)**. This is the recommended approach for most development scenarios. HMR updates your application in the browser without requiring a full page reload, preserving your application's state while reflecting code changes instantly. This provides the fastest development feedback loop, especially when working on frontend components or styles.
- **File watching (--watch)**. This mode automatically restarts the entire server process when you make changes to your code. While this approach takes slightly longer than HMR since it requires a full restart, it ensures a clean application state with every change and can be useful when working on server-side logic or when HMR updates aren't sufficient.
## Exploring other commands
The `ace` command-line tool includes many commands for development and production workflows.
To see all available commands, run the following.
```bash
node ace
```
## Next steps
- **New to AdonisJS?** Follow the [Tutorial](./tutorial/hypermedia/overview.md) to build a complete application and learn how everything works together.
- **Want to understand the codebase first?** Read [Folder Structure](./folder_structure.md) to see how the project is organized.
---
# Folder structure
When you create a new AdonisJS application, it comes with a thoughtful default folder structure designed to keep projects tidy, predictable, and easy to refactor.
This structure reflects conventions that work well for most projects, but AdonisJS does not lock you into them. You are free to reorganize files and directories to suit your team's workflow.
Depending on the starter kit you select, some files or directories may differ. For example, the **Inertia** starter kit contains a top-level `inertia` folder, whereas the **Hypermedia** starter kit does not include this folder.
## Overview
Here's what a freshly created AdonisJS project looks like.
::::tabs
:::tab{title="Hypermedia / Inertia kits"}
```sh
├── app
├── bin
├── config
├── database
├── resources
├── start
├── tests
├── ace.js
├── adonisrc.ts
├── eslint.config.js
├── package-lock.json
├── package.json
├── tsconfig.json
└── vite.config.ts
```
:::
:::tab{title="API kit"}
The API starter kit is a monorepo with two workspaces managed by [Turborepo](https://turbo.build/repo):
```sh
├── apps
│ ├── backend # AdonisJS application
│ │ ├── app
│ │ ├── bin
│ │ ├── config
│ │ ├── database
│ │ ├── start
│ │ ├── tests
│ │ ├── ace.js
│ │ ├── adonisrc.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── frontend # Your frontend application
│ ├── package.json
│ └── ...
├── package.json # Root package.json with workspaces
├── package-lock.json
└── turbo.json # Turborepo pipeline configuration
```
The `apps/backend` directory contains a standard AdonisJS application — all the sections below (`app/`, `config/`, `start/`, etc.) apply to that directory. The `apps/frontend` directory is where you set up your frontend framework (TanStack Start, Next.js, Nuxt, or others).
The root `package.json` defines the workspaces:
```json
{
"workspaces": ["apps/*"]
}
```
And `turbo.json` configures the `dev`, `build`, and other scripts to run across both apps. When you run `npm run dev` from the project root, Turborepo starts both the backend and frontend dev servers in parallel.
:::
::::
Each directory and file has a specific purpose. The sections below explain the role of each item and what you are likely to find there.
## `app/`
The `app` directory organizes code for the domain logic of your application. For example, the controllers, models, mails, middleware, etc., all live within the `app` directory.
Feel free to create additional folders in the `app` directory to better organize your codebase.
```sh
├── app
│ ├── controllers
│ ├── exceptions
│ ├── mails
│ ├── middleware
│ ├── models
│ ├── transformers
│ └── validators
```
## `bin/`
The `bin` directory contains the entry point files used to start your AdonisJS application in different environments.
- The `console.ts` file uses the Ace commandline framework to execute CLI commands.
- The `server.ts` file starts the application in the web environment to listen for HTTP requests.
- The `test.ts` file is used to boot the application for the testing environment.
You usually won't need to modify these files unless you want to customize how the app boots in a
specific context.
```sh
├── bin
│ ├── console.ts
│ ├── server.ts
│ └── test.ts
```
## `config/`
All application and third-party configuration files live inside the `config` directory. You can also store config local to your application inside this directory.
```sh
├── config
│ ├── app.ts
│ ├── auth.ts
│ ├── bodyparser.ts
│ ├── database.ts
│ ├── hash.ts
│ ├── limiter.ts
│ ├── logger.ts
│ ├── mail.ts
│ ├── session.ts
│ ├── shield.ts
│ ├── static.ts
│ └── vite.ts
```
## `database/`
The `database` directory holds artifacts related to the database layer. By default, AdonisJS ships with Lucid ORM configured for SQLite; switching databases does not require reorganizing this folder.
- `migrations/` - versioned schema changes
- `seeders/` - scripts to insert initial or test data
- `factories/` - blueprints for generating model instances in tests or seeders
- `schema.ts` - auto-generated database schema used by models
- `schema_rules.ts` - auto-generated schema rules used by validators
See also: [Lucid documentation](https://lucid.adonisjs.com)
```sh
database/
├── migrations/
├── seeders/
├── factories/
├── schema.ts
└── schema_rules.ts
```
## `providers/`
The `providers` directory is used to store the [service providers](../guides/concepts/service_providers.md) used by your application. You can create new providers using the `node ace make:provider` command.
```sh
├── providers
│ └── app_provider.ts
```
## `public/`
The `public` directory contains raw static assets that are served directly to the browser without any compilation step. Files in this directory are publicly accessible over HTTP using the `http://localhost:3333/public/` URL.
:::note
The API starter kit does not include a `public` directory, since the backend serves only JSON responses and does not serve static assets.
:::
Typical examples of files stored in this folder include:
- Favicon `(favicon.ico)`
- Robots file `(robots.txt)`
- Static images `(logo.png, social-banner.jpg)`
- Downloadable assets `(manual.pdf)`
## `resources/`
The `resources` directory stores Edge templates and uncompiled frontend assets such as CSS and JavaScript files.
:::note
The API starter kit does not include a `resources` directory, since the backend serves only JSON responses and does not render HTML templates.
:::
For applications using Inertia (alongside Vue or React), the frontend code is kept within the `inertia` directory, and the `resources` directory contains only the root Edge template. Think of this root template as the `index.html` file that contains the HTML shell for your frontend application.
::::tabs
:::tab{title="Hypermedia app"}
```sh
├── resources
│ ├── css
│ ├── js
│ └── views
│ ├── home.edge
```
:::
:::tab{title="Inertia app"}
```sh
├── resources
│ └── views
│ └── inertia_layout.edge
```
:::
::::
## `inertia/`
The `inertia` directory exists only in projects using the Inertia starter kit. It represents a sub-application containing the frontend source code, including React or Vue components, pages, and supporting utilities.
- `pages/` - stores your Inertia pages written in React or Vue.
- `app.(tsx|vue)` - the main entry point for the client-side application.
- `ssr.(tsx|vue)` - the entry point for server-side rendering (SSR).
- `tsconfig.json` - The TypeScript config file for the frontend codebase. The defaults are optimized for browser environments, JSX/TSX syntax, and Vite-based builds
You are free to create additional subfolders, such as `components/`, `layouts/`, or `utils/`, to organize your frontend code.
```sh
├── inertia
│ ├── css
│ ├── layouts
│ ├── pages
│ │ └── home.tsx
│ ├── app.tsx
│ ├── ssr.tsx
│ ├── tsconfig.json
│ └── types.ts
```
### Clear separation between frontend and backend
AdonisJS maintains a clear boundary between the backend and the frontend. You should **never import backend code** (such as models, services, or transformers) into your frontend application.
In practice, your frontend communicates with the backend through HTTP requests and **receives plain JSON data**. AdonisJS encourages you to model this reality explicitly. Data is fetched and transformed via API responses, rather than being hidden behind shared abstractions.
### Shared types
The frontend can still rely on shared TypeScript types automatically generated by AdonisJS. These are stored inside the `.adonisjs/client` directory and include type definitions for routes, props, and other shared contracts.
This approach allows frontend code to remain strongly typed without compromising the separation between the client and the server.
:::tip
You should commit the `.adonisjs` directory to version control. The framework relies on these generated files for import resolution (e.g. `#generated/controllers`) and TypeScript type checking. Without them, production builds and CI pipelines will fail.
:::
We recommend reading the [types generation docs](../guides/frontend/transformers.md#step-4-understanding-the-generated-types) to understand how AdonisJS creates shared types.
## `start/`
The `start` directory contains the files you want to import during the boot lifecycle of the application. For example, the files to register routes and define event listeners should live within this directory.
```sh
├── start
│ ├── env.ts
│ ├── kernel.ts
│ └── routes.ts
```
AdonisJS does not auto-import files from the `start` directory. It is merely used as a convention to group similar files. We recommend reading about [preload files](../guides/concepts/application_lifecycle.md#running-code-before-the-application-starts) and the [application boot lifecycle](../guides/concepts/application_lifecycle.md) to have a better understanding of which files to keep under the start directory.
## `tests/`
The `tests` directory contains automated tests. AdonisJS uses the [Japa](https://japa.dev) test runner.
Tests are organized within specific sub-folders. When running `unit` tests, AdonisJS boots the application but does not start the HTTP server. Whereas, during `functional` tests, we start the HTTP server and the Vite Dev-server.
See also: [Testing docs](../guides/testing/introduction.md)
```sh
├── tests
│ ├── unit
│ ├── functional
│ └── bootstrap.ts
```
## `tmp/`
The temporary files generated by your application are stored within the `tmp` directory. For example, these could be user-uploaded files (generated during development) or logs written to the disk.
The `tmp` directory must be ignored by the `.gitignore` rules, and you should not copy it to the production server either.
## `ace.js`
The `ace.js` file is the entry point for executing Ace commands. This file configures a JIT TypeScript compiler and then imports the `bin/console.ts` file.
## `adonisrc.ts`
`adonisrc.ts` is the project manifest. It tells AdonisJS how to discover and load parts of your application and includes configuration for:
- Directory aliases (for `app`, `start`, etc.)
- Preload files
- Registered providers and commands
- Assembler hooks
See also: [AdonisRC file reference](../reference/adonisrc_file.md)
## `eslint.config.js`
This file configures ESLint for the project. The default rules are tuned for TypeScript and AdonisJS conventions. You can modify or extend this configuration to match your team's style.
## `package.json` and `package-lock.json`
- `package.json` - project metadata, scripts, and dependency declarations.
- `package-lock.json` - locks exact dependency versions for consistent installs.
## `tsconfig.json`
`tsconfig.json` controls TypeScript compiler options, module resolution, and path aliases. The provided configuration is suitable for both development and production builds. However, you can adjust the compiler settings to match your specific workflow.
:::note
AdonisJS relies on `experimentalDecorators` for Dependency Injection, Ace commands, and the Lucid ORM.
:::
The following configuration options are required for AdonisJS internals to work correctly.
::::tabs
:::tab{title="Non-Inertia apps"}
```json
{
"compilerOptions": {
"module": "NodeNext",
"isolatedModules": true,
"declaration": false,
"outDir": "./build",
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true
},
"include": ["**/*", ".adonisjs/server/**/*"]
}
```
:::
:::tab{title="Inertia apps"}
```json
{
"compilerOptions": {
"module": "NodeNext",
"isolatedModules": true,
"declaration": false,
"outDir": "./build",
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true
},
// [!code ++:5]
"references": [
{
"path": "./tsconfig.inertia.json"
}
],
"include": ["**/*", ".adonisjs/server/**/*"]
}
```
:::
::::
## `vite.config.ts`
When the project uses Vite (Edge or Inertia starter kits), `vite.config.ts` defines how frontend assets are compiled and served. Customize entry points, aliases, and plugin settings here if you need specialized bundling behavior.
---
# Development environment setup
This guide covers the recommended development environment for AdonisJS applications. You will learn how to:
- Configure TypeScript with required compiler options
- Set up ESLint and Prettier for code quality
- Install recommended code editor extensions
- Choose a database for local development
## Overview
AdonisJS applications come with a fully configured development environment out of the box. **TypeScript, ESLint, and Prettier** are pre-configured with sensible defaults, allowing you to start building immediately without manual setup.
This guide explains what's already configured in your project, recommends optional editor extensions that enhance the development experience, and provides guidance on choosing a database for local development. Understanding these configurations helps you leverage the full capabilities of the framework and maintain consistency across your team.
## Code editors and extensions
AdonisJS works with any modern code editor that supports **TypeScript**. The framework does not rely on custom domain-specific languages (DSLs), so most editors provide full language support out of the box. The only framework-specific syntax is the **Edge templating engine**, which benefits from dedicated syntax highlighting extensions.
:::card{name="AdonisJS Extension" logo="resources/assets/adonisjs-icon.svg"}
---
links:
- href: https://marketplace.visualstudio.com/items?itemName=jripouteau.adonis-vscode-extension
text: VSCode
---
Provides IntelliSense for AdonisJS APIs, file generators, and command palette integration for running Ace commands.
:::
:::card{name="Japa Extension" logo="resources/assets/japa-icon.svg"}
---
links:
- href: https://marketplace.visualstudio.com/items?itemName=jripouteau.japa-vscode
text: VSCode
---
Test runner integration for running individual tests or test suites directly from the editor.
:::
:::card{name="Edge Templates Extension" logo="resources/assets/edge-icon.svg"}
---
links:
- href: https://marketplace.visualstudio.com/items?itemName=AdonisJS.vscode-edge
text: VSCode
- href: https://zed.dev/extensions/edge
text: Zed
- href: https://packagecontrol.io/packages/Edge%20templates%20extension
text: Sublime Text
---
Full syntax highlighting and basic autocomplete for Edge template files.
:::
## TypeScript setup
TypeScript is a first-class citizen in AdonisJS. Every application is created and runs using TypeScript by default, with all configuration handled automatically. Understanding how TypeScript works in development versus production, and the required compiler options, helps you make informed decisions about deployment and tooling.
### Required TypeScript configuration
AdonisJS requires specific TypeScript compiler options to function correctly. The framework relies heavily on **experimental decorators** for dependency injection, model definitions, and Ace commands.
The following `tsconfig.json` configuration represents the bare minimum required for AdonisJS applications.
::::tabs
:::tab{title="Non-Inertia apps"}
```json
{
"compilerOptions": {
"module": "NodeNext",
"isolatedModules": true,
"declaration": false,
"outDir": "./build",
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true
},
"include": ["**/*", ".adonisjs/server/**/*"]
}
```
:::
:::tab{title="Inertia apps"}
```json
{
"compilerOptions": {
"module": "NodeNext",
"isolatedModules": true,
"declaration": false,
"outDir": "./build",
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true
},
// [!code ++:5]
"references": [
{
"path": "./inertia/tsconfig.json"
}
],
"include": ["**/*", ".adonisjs/server/**/*"]
}
```
:::
::::
### Development mode (JIT compilation)
In development, AdonisJS uses a **Just-in-Time (JIT) compiler** provided by the `@poppinss/ts-exec` package. This approach executes TypeScript files directly without a separate compilation step, enabling instant feedback when you save changes.
This differs from Node.js' native TypeScript support because AdonisJS requires:
- **Experimental decorators** support (used for dependency injection and model decorators)
- **JSX compilation** (if you replace Edge with a JSX-based template engine like Inertia)
Since Node.js' built-in TypeScript loader does not support these features, [`@poppinss/ts-exec`](https://github.com/poppinss/ts-exec) provides the necessary compatibility layer.
### Production mode (ahead-of-time compilation)
For production deployments, AdonisJS compiles your TypeScript code into JavaScript using the official TypeScript compiler (`tsc`). This process generates a `build/` directory containing transpiled `.js` files optimized for the Node.js runtime.
```bash
node ace build
```
The compiled output includes:
- Transpiled JavaScript files with decorators transformed
- Copied static assets and templates
- The `package.json` and your package manager lock file. You must `cd` into the build directory, install dependencies and start the production server.
See also: [Deploying to production](./deployment.md)
## ESLint and Prettier configuration
AdonisJS projects include pre-configured **ESLint** and **Prettier** setups that enforce TypeScript best practices and maintain consistent code formatting across your team.
:::tip
Most code editors support running ESLint and Prettier automatically on file save. Configuring this in your editor eliminates manual formatting steps and catches linting issues immediately.
:::
### ESLint
The default ESLint configuration extends the AdonisJS base config, which includes rules for TypeScript, async/await patterns, and framework conventions. You can override or extend these rules in `eslint.config.js` as needed.
```js title="eslint.config.js"
import { configApp } from '@adonisjs/eslint-config'
export default configApp()
```
Run ESLint manually:
```bash
npm run lint
```
### Prettier
Prettier configuration is defined in `package.json`, ensuring all files are formatted consistently. The AdonisJS preset includes sensible defaults for indentation, quotes, and line length.
```json title="package.json"
{
"prettier": "@adonisjs/prettier-config"
}
```
Run Prettier manually:
```bash
npm run format
```
See also: [ESLint configuration reference](https://github.com/adonisjs/tooling-config/tree/main/packages/eslint-config), [Prettier configuration reference](https://github.com/adonisjs/tooling-config/tree/main/packages/prettier-config)
## Database setup
AdonisJS applications are pre-configured with **SQLite**, a lightweight file-based database. SQLite requires no installation and stores data in a local `tmp/database.sqlite` file, allowing you to start building immediately without setting up external database servers.
However, most applications use PostgreSQL or MySQL in production. We recommend [switching to your production database](../guides/database/lucid.md#configuration) engine early in development to avoid schema differences and driver-specific behavior that can cause deployment issues.
### Local database tools
You can use the following tools to run PostgreSQL or MySQL locally:
- [Dbngin](https://dbngin.com/) (macOS and Windows) for managing PostgreSQL and MySQL through a GUI
- [Docker](https://www.docker.com/) for running databases in isolated containers
- [Postgres.app](https://postgresapp.com/) for native PostgreSQL on macOS
---
# Configuration & Environment
This guide covers configuration in AdonisJS applications. You will learn about:
- Config files in the `config` directory
- Environment variables and the `.env` file
- Validating environment variables with type safety
- Variable interpolation within `.env` files
- Environment-specific `.env` files for development, testing, and production
- Accessing configuration in Edge templates
- The `adonisrc.ts` file for framework configuration
## Overview
Configuration in AdonisJS is organized into three distinct systems, each serving a specific purpose.
- **Config files** contain your application settings. These files live in the `config` directory and define things like database connections, mail settings, and session configuration.
- **Environment variables** stored in the `.env` file hold runtime secrets and values that change between environments. API keys, database passwords, and environment-specific URLs belong here. AdonisJS supports multiple `.env` files for different environments and provides type-safe validation to catch missing variables at startup.
- **The adonisrc.ts file** configures the framework itself. It tells AdonisJS how your workspace is organized, which providers to load, and which commands are available.
## Configuration files
Configuration files live in the `config` directory at the root of your project. Each file exports a configuration object for a specific part of your application (database connections, mail settings, authentication, session handling, and so on).
A typical AdonisJS project includes several config files out of the box. The `config/database.ts` file configures database connections, `config/mail.ts` handles email delivery, and `config/auth.ts` defines authentication settings.
Here's what a database configuration file looks like.
```ts title="config/database.ts"
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/lucid'
const dbConfig = defineConfig({
connection: 'sqlite',
prettyPrintDebugQueries: true,
connections: {
sqlite: {
client: 'better-sqlite3',
connection: {
filename: app.tmpPath('db.sqlite3'),
},
useNullAsDefault: true,
migrations: {
naturalSort: true,
paths: ['database/migrations'],
},
debug: app.inDev,
},
},
})
export default dbConfig
```
Mail configuration follows a similar pattern.
```ts title="config/mail.ts"
import env from '#start/env'
import { defineConfig, transports } from '@adonisjs/mail'
const mailConfig = defineConfig({
default: env.get('MAIL_MAILER'),
from: {
address: env.get('MAIL_FROM_ADDRESS'),
name: env.get('MAIL_FROM_NAME'),
},
mailers: {
resend: transports.resend({
key: env.get('RESEND_API_KEY'),
baseUrl: 'https://api.resend.com',
}),
},
})
export default mailConfig
declare module '@adonisjs/mail/types' {
export interface MailersList extends InferMailers {}
}
```
Notice how this config file references environment variables through `env.get()`. This is the correct way to use environment-specific values in your configuration. The config file defines the structure and defaults, while the `.env` file provides the actual values.
### When config files are loaded
Configuration files are loaded during the application boot cycle, before your routes and controllers are ready. This means you should keep config files simple and avoid importing application-level code like models, services, or controllers.
Config files should only import framework utilities, define configuration objects, and reference environment variables. **Importing application code creates circular dependencies and will cause your app to fail during startup**.
### Accessing config in Edge templates
Edge templates have access to your application's configuration through the `config` global. This allows you to reference configuration values directly in your views without passing them explicitly from controllers.
```edge title="resources/views/layouts/main.edge"
{{ config('app.appName') }}
```
The `config()` helper accepts a dot-notation path to any configuration value. The path corresponds to the config file name and the property within it. For example, `config('database.connection')` reads the `connection` property from `config/database.ts`.
You can also provide a default value as the second argument.
```edge
{{ config('app.timezone', 'UTC') }}
```
## Environment variables
Environment variables store secrets and configuration that varies between environments. During development, you define these variables in the `.env` file. In production, you must define them through your hosting provider's UI or configuration interface.
A typical `.env` file looks like this.
```bash title=".env"
HOST=0.0.0.0
PORT=3333
APP_KEY=your-secret-app-key-here
MAIL_MAILER=resend
MAIL_FROM_ADDRESS=hello@example.com
MAIL_FROM_NAME=My App
RESEND_API_KEY=re_your_api_key_here
```
The `.env` file is already listed in `.gitignore` in AdonisJS starter kits, so you won't accidentally commit secrets to your repository.
### The APP_KEY
The `APP_KEY` is a special environment variable that AdonisJS uses for encrypting cookies, signing sessions, and other cryptographic operations. Every AdonisJS application requires an APP_KEY to function securely.
Run the `generate:key` command to create your APP_KEY.
```bash
node ace generate:key
```
This creates a cryptographically secure random key and adds it to your `.env` file automatically.
The APP_KEY must remain secret. Anyone with access to this key can decrypt your application's encrypted data and forge session tokens. When you deploy to production, use a different APP_KEY for each environment (development, staging, production). Never reuse keys across environments.
If your APP_KEY is compromised, generate a new one immediately. This will invalidate all existing user sessions and encrypted data.
### Using environment variables in config files
Config files access environment variables through the `env` service, which provides type-safe access to your `.env` file values. You import the env service and call `env.get()` with the variable name.
```ts
import env from '#start/env'
const apiKey = env.get('RESEND_API_KEY')
```
This pattern keeps your configuration organized and validated. The env service ensures required variables are present and throws clear errors if they're missing.
You should never access environment variables directly in your controllers, services, or other application code. Always access them through config files. This creates a single source of truth for configuration.
### Variable interpolation
The `.env` file supports variable interpolation, allowing you to reference other environment variables within a value. Use the `$VAR` or `${VAR}` syntax to interpolate variables.
```bash title=".env"
HOST=localhost
PORT=3333
APP_URL=http://$HOST:$PORT
```
In this example, `APP_URL` resolves to `http://localhost:3333`. This is useful when you need to compose values from other variables without repeating yourself.
You can also use curly braces for clarity or when the variable name is adjacent to other characters.
```bash title=".env"
S3_BUCKET=my-app-uploads
S3_REGION=us-east-1
S3_URL=https://${S3_BUCKET}.s3.${S3_REGION}.amazonaws.com
```
To include a literal dollar sign in a value, escape it with a backslash.
```bash title=".env"
PRICE=\$99.99
```
### Environment-specific .env files
AdonisJS supports multiple `.env` files for different environments and use cases. The framework loads these files in a specific order, with later files overriding earlier ones.
| File | Purpose | Git status |
|------|---------|------------|
| `.env` | Base environment variables for all environments | Ignored |
| `.env.local` | Local overrides, loaded in all environments except test | Ignored |
| `.env.development` | Development-specific variables | Ignored |
| `.env.staging` | Staging-specific variables | Ignored |
| `.env.production` | Production-specific variables | Ignored |
| `.env.test` | Test-specific variables, loaded when `NODE_ENV=test` | Ignored |
| `.env.example` | Template showing required variables with placeholder values | Committed |
The loading order depends on your `NODE_ENV` value. For `NODE_ENV=development`, AdonisJS loads files in this order:
1. `.env` (base variables)
2. `.env.development` (environment-specific)
3. `.env.local` (local overrides)
For `NODE_ENV=test`, the order is:
1. `.env` (base variables)
2. `.env.test` (test-specific)
Note that `.env.local` is not loaded during tests. This prevents local development settings from interfering with test runs.
The `.env.example` file serves as documentation for your team. It lists all required environment variables with placeholder values, helping new developers set up their local environment. Unlike other `.env` files, you should commit `.env.example` to version control.
```bash title=".env.example"
HOST=0.0.0.0
PORT=3333
APP_KEY=
DATABASE_URL=postgresql://user:password@localhost:5432/myapp
RESEND_API_KEY=
```
### Validating environment variables
AdonisJS validates environment variables at startup through the `start/env.ts` file. This validation ensures your application won't start with missing or invalid configuration, catching errors early rather than at runtime when they're harder to debug.
The `start/env.ts` file uses schema based validation to define which environment variables your application expects, their types, and any constraints.
```ts title="start/env.ts"
import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {
/**
* Variables for configuring the HTTP server
*/
HOST: Env.schema.string({ format: 'host' }),
PORT: Env.schema.number(),
/**
* App-specific variables
*/
APP_KEY: Env.schema.string(),
NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
/**
* Database configuration
*/
DATABASE_URL: Env.schema.string(),
/**
* Optional variables with defaults
*/
LOG_LEVEL: Env.schema.enum(['debug', 'info', 'warn', 'error'] as const).optional(),
CACHE_DRIVER: Env.schema.string.optional(),
})
```
When your application starts, AdonisJS reads the `.env` file and validates each variable against this schema. If a required variable is missing or has an invalid value, the application throws a descriptive error and refuses to start.
The schema provides several validation methods.
:::option{name="Env.schema.string"}
Validates the value is a non-empty string. You can validate the string format by speficying one of the following formats.
```ts
// Validates the value is a valid hostname or IP
Env.schema.string({ format: 'host' })
// Validates the value is a valid URL
Env.schema.string({ format: 'url' })
// Validates the value is a valid URL without tld
Env.schema.string({ format: 'url', tld: false })
// Validates the value is a valid URL without protocol
Env.schema.string({ format: 'url', protocol: false })
// Validates the value is a valid email address
Env.schema.string({ format: 'email' })
```
:::
:::option{name="Env.schema.number"}
Validates and casts the value to a number
:::
:::option{name="Env.schema.boolean"}
Validates and casts the value to a boolean
:::
:::option{name="Env.schema.enum"}
Validates the value is one of the allowed options
:::
:::option{name="Optional chaining"}
Any schema method can be made optional by chaining `.optional()`. Optional variables return `undefined` if not set.
```ts title="start/env.ts"
import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {
SENTRY_DSN: Env.schema.string.optional(),
CACHE_DRIVER: Env.schema.string.optional()
})
```
:::
After validation, the `env` service provides full TypeScript type inference. When you call `env.get('NODE_ENV')`, TypeScript knows the return type is `'development' | 'production' | 'test'` based on your schema definition.
## The adonisrc.ts file
The `adonisrc.ts` file configures the framework itself, not your application. While config files define your app's behavior and `.env` stores secrets, `adonisrc.ts` tells AdonisJS how your workspace is structured and which framework features to load.
You rarely need to modify this file directly. Most operations that require changes to `adonisrc.ts` are handled automatically by Ace commands. When you run `node ace make:provider` or `node ace make:command`, those commands register the new provider or command in your `adonisrc.ts` file for you.
Here's what a basic `adonisrc.ts` file contains.
```ts title="adonisrc.ts"
import { defineConfig } from '@adonisjs/core/app'
export default defineConfig({
commands: [
() => import('@adonisjs/core/commands'),
() => import('@adonisjs/lucid/commands'),
],
providers: [
() => import('@adonisjs/core/providers/app_provider'),
() => import('@adonisjs/core/providers/hash_provider'),
() => import('@adonisjs/core/providers/vinejs_provider'),
() => import('@adonisjs/session/session_provider'),
() => import('@adonisjs/lucid/database_provider'),
() => import('@adonisjs/auth/auth_provider'),
],
preloads: [
() => import('#start/routes'),
() => import('#start/kernel')
],
hooks: {
buildStarting: [
() => import('@adonisjs/vite/build_hook')
],
},
})
```
The `providers` array lists all service providers that AdonisJS should load when your application starts. Providers set up framework features like database access, authentication, and session handling.
The `commands` array registers Ace commands from packages. Your application's commands in the `commands` directory are automatically discovered, so you only list package commands here.
The `hooks` object defines functions that run during specific lifecycle events. The `buildStarting` hook runs when you build your application for production.
You can learn more about the available properties in the [AdonisRC file reference guide](../reference/adonisrc_file.md)
---
# Deployment
This guide covers deploying AdonisJS applications to production. You will learn how to:
- Understand the standalone build and why your source files are not needed in production
- Configure `NODE_ENV` correctly during build and runtime
- Create a production build using the `node ace build` command
- Configure static files to be copied to the build output
- Run your application in production
- Handle user-uploaded files with persistent storage
- Configure logging for production environments
- Run database migrations safely in production
- Create a Docker image using a multi-stage Dockerfile
## Overview
AdonisJS applications are written in TypeScript and must be compiled to JavaScript before running in production. The build process creates a **standalone build**, which means the compiled output contains everything needed to run your application without the original TypeScript source files.
Since AdonisJS apps run on the Node.js runtime, your deployment platform must support Node.js version 24 or later. The build process compiles your TypeScript code, bundles frontend assets (if using Vite), and copies necessary files to a `build` directory that you can deploy directly to your production server.
## Understanding the standalone build
The standalone build is the compiled output of your AdonisJS application. After creating the build, you only need to deploy the `build` directory to your production server. The original source files, development dependencies, and TypeScript configuration are not required in production.
This approach offers several benefits. The deployment size is significantly smaller since you are not shipping TypeScript source files or development tooling. The production environment only needs the JavaScript runtime, and you can treat the `build` folder as an independent, self-contained application.
## NODE_ENV during build and runtime
The `NODE_ENV` environment variable behaves differently during the build process versus runtime, and understanding this distinction is important for successful deployments.
During the build process, you need development dependencies installed because the build tooling (TypeScript compiler, Vite, and other build tools) are typically listed as `devDependencies` in your `package.json`. If you are creating the build in a CI environment or a sandbox where dependencies are not already installed, set `NODE_ENV=development` before running `npm install` to ensure all dependencies are available.
```sh
# In CI/CD or fresh environments
NODE_ENV=development npm install
npm run build
```
During runtime in production, `NODE_ENV` should be set to `production`. This enables production optimizations and disables development-only features like detailed error pages.
## Creating the production build
Run the build command from your project root.
```sh
npm run build
```
This executes `node ace build` under the hood. The build process performs the following steps in order:
1. Removes the existing `./build` folder if one exists
2. Rewrites the `ace.js` file to remove the TypeScript loader import
3. Compiles frontend assets using Vite (if configured)
4. Compiles TypeScript source code to JavaScript using `tsc`
5. Copies non-TypeScript files registered in the `metaFiles` array to `./build`
6. Copies `package.json` and your package manager lock file to `./build`
If there are TypeScript errors in your code, the build will fail. You must fix these errors before creating a production build. If you need to bypass TypeScript errors temporarily, use the `--ignore-ts-errors` flag.
```sh
npm run build -- --ignore-ts-errors
```
The `--package-manager` flag controls which lock file is copied to the build output. If not specified, the build command detects your package manager based on how you invoked the command (for example, `npm run build` versus `pnpm build`).
```sh
npm run build -- --package-manager=pnpm
```
## Build output contents
After a successful build, the `build` directory contains your compiled application. Here is what you will find inside:
- Compiled JavaScript files mirroring your source directory structure
- The `package.json` and lock file for installing production dependencies
- Static files and other assets configured in `metaFiles`
- Frontend assets in `build/public` (for Vite-powered applications)
Environment files (`.env`, `.env.example`) are intentionally excluded from the build output. Environment variables are not portable between environments, and you must configure them separately for each deployment target through your hosting platform's environment variable management.
## Static files
Static files that need to be included in the production build are configured using the `metaFiles` array in your `adonisrc.ts` file. These are non-TypeScript files that your application needs at runtime, such as Edge templates or public assets.
```ts title="adonisrc.ts"
{
metaFiles: [
{
pattern: 'resources/views/**/*.edge',
reloadServer: false,
},
{
pattern: 'public/**',
reloadServer: false,
},
],
}
```
The `pattern` property accepts glob patterns to match files. The `reloadServer` property controls whether file changes trigger a server restart during development and has no effect on the production build.
For Hypermedia and Inertia applications, Vite compiles frontend assets and places them in the `public` directory. These are then copied to `build/public` during the build process.
### Adjust `keepAliveTimeout` for reverse proxy and node balancers
Real-world apps are usually not accessed directly, but behind reverse proxy and node balancers, e.g. Nginx.
By default, Node.js closes idle connections after 5 seconds, but Nginx may try to keep them open for 60+ seconds. When Nginx tries to reuse an old connection it thinks is open, but Node.js has already silently closed it, Nginx throws `502 Bad Gateway` errors.
To avoid this issue, you need to change AdonisJS server's `keepAliveTimeout` to larger than Nginx's `proxy_read_timeout` (50s by default).
```ts title="config/app.ts"
{
keepAliveTimeout: 55000,
}
```
### Serving static files in production
While AdonisJS includes a [static file server](../guides/basics/static_file_server.md), you should offload static file serving to a dedicated tool in production. Every static file request handled by your Node.js process is a request that cannot be spent on dynamic work. A reverse proxy or CDN is purpose-built for this job and will deliver files faster with less resource usage.
You have two main options depending on your infrastructure.
**Reverse proxy (Nginx, Caddy, Traefik, Apache)**
Configure your reverse proxy to serve the `build/public` directory directly for static file requests and forward everything else to your AdonisJS server. This way, static files never reach your Node.js process.
With Nginx, you can add a `location` block that tries to serve files from the `public` directory first and falls back to the AdonisJS server for dynamic routes.
```nginx title="nginx.conf"
server {
listen 80;
server_name example.com;
root /path/to/your/app/build/public;
location / {
try_files $uri @adonis;
}
location @adonis {
proxy_pass http://localhost:3333;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
**CDN**
For the best performance, upload your compiled assets to a CDN. This requires updating the `assetsUrl` option in your Vite configuration so that generated asset URLs point to your CDN instead of your application server.
```ts title="config/vite.ts"
{
assetsUrl: 'https://cdn.example.com/assets',
}
```
After each build, deploy the contents of `build/public` to your CDN. Your CI/CD pipeline can automate this step.
:::tip
You can combine both approaches. Use a reverse proxy to serve files like `favicon.ico` and `robots.txt` from the `public` directory, and a CDN for Vite-compiled assets (JavaScript, CSS, images) that benefit from global distribution.
:::
## Running the production build
To run your application in production, treat the `build` directory as the root of your application. Change into the build directory, install production dependencies, and start the server.
```sh
cd build
npm ci --omit=dev
node bin/server.js
```
Using `npm ci --omit=dev` instead of `npm install` ensures a clean, reproducible installation using only the lock file and skips development dependencies. You must also provide all required environment variables before starting the server. Your hosting platform should have a mechanism for configuring environment variables. You can reference your `env.ts` file or `.env.example` to see which variables your application requires.
## User-uploaded files
User-uploaded files require persistent storage that survives deployments. When you deploy a new version of your application, the previous build directory is typically replaced, which means any files stored locally within that directory are lost.
For production deployments, use a cloud storage provider such as Amazon S3, Cloudflare R2, or Google Cloud Storage. These services provide durable, persistent storage that is independent of your application deployments.
If you must store files locally on your server, configure a directory outside of your application's build folder and ensure this directory persists across deployments. The specific approach depends on your hosting platform and deployment strategy.
## Logging
AdonisJS uses Pino for logging, which outputs structured logs in NDJSON format to stdout. This format is well-suited for production environments because most logging services and log aggregators can parse NDJSON directly.
Configure your hosting platform to capture stdout from your application process. Most platforms provide built-in integrations with logging services like Datadog, Logtail, or Papertrail. Alternatively, you can pipe your application's output to a log shipping agent.
Pino's structured JSON output includes timestamps, log levels, and any additional context you attach to log messages, making it straightforward to search and filter logs in your logging service.
## Database migrations
Run migrations in production using the `migration:run` command with the `--force` flag.
```sh
node ace migration:run --force
```
The `--force` flag is required because running migrations is disabled by default in production environments. This is a safety measure to prevent accidental schema changes.
AdonisJS migrations are idempotent and use exclusive locking. If multiple processes attempt to run migrations simultaneously (for example, during a rolling deployment), only one process will execute the migrations while others wait. This prevents race conditions and duplicate migration attempts.
If your deployment platform supports lifecycle hooks or has a dedicated mechanism for running database migrations, use that instead of running migrations as part of your application startup. This separation ensures migrations complete successfully before any application instances start serving traffic.
Rolling back migrations in production is disabled by default and is not recommended. Instead of rolling back, create a new migration that reverses the changes you need to undo. This approach maintains a clear history of all schema changes and avoids the risks associated with rollbacks in production.
## Dockerfile
The following Dockerfile creates an optimized production image using multi-stage builds. The first stage installs all dependencies and creates the build, while the final stage contains only the production runtime.
```dockerfile title="Dockerfile"
FROM node:lts-bookworm-slim AS base
# ----------------------------
# Stage 1: Install all dependencies
# ----------------------------
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# ----------------------------
# Stage 2: Build the application
# ----------------------------
FROM deps AS build
WORKDIR /app
COPY . .
RUN node ace build
# ----------------------------
# Stage 3: Production runtime
# ----------------------------
FROM base AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/build ./
RUN npm ci --omit=dev
EXPOSE 3333
CMD ["node", "bin/server.js"]
```
This Dockerfile follows a multi-stage pattern where each stage has a specific purpose. The `deps` stage installs all dependencies needed for the build. The `build` stage compiles the TypeScript application. The `production` stage creates a minimal image with only production dependencies.
To build and run the Docker image:
```sh
docker build -t my-adonis-app .
docker run -p 3333:3333 --env-file .env my-adonis-app
```
Pass environment variables using the `--env-file` flag or individual `-e` flags. The container exposes port 3333 by default, which you can map to any host port.
### Running migrations with Docker
For containerized deployments, you have two options for running migrations. You can run them as a separate command before starting your application containers:
```sh
docker run --rm --env-file .env my-adonis-app node ace migration:run --force
```
Alternatively, you can create an entrypoint script that runs migrations before starting the server. Create this file in your project root:
```js title="docker-entrypoint.js"
import { execSync } from 'node:child_process'
/**
* Run migrations before starting the server
* when the MIGRATE environment variable is set.
*/
if (process.env.MIGRATE === 'true') {
console.log('Running migrations...')
execSync('node ace migration:run --force', { stdio: 'inherit' })
}
/**
* Start the server
*/
console.log('Starting server...')
await import('./bin/server.js')
```
Update your Dockerfile to use this entrypoint:
```dockerfile title="Dockerfile"
FROM node:lts-bookworm-slim AS base
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM deps AS build
WORKDIR /app
COPY . .
RUN node ace build
FROM base AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/build ./
RUN npm ci --omit=dev
# [!code ++:2]
# Copy the entrypoint script
COPY docker-entrypoint.js ./
EXPOSE 3333
# [!code --]
CMD ["node", "bin/server.js"]
# [!code ++]
CMD ["node", "docker-entrypoint.js"]
```
With this setup, setting `MIGRATE=true` as an environment variable will run migrations before starting the server. For horizontal scaling where multiple containers start simultaneously, the migration locking ensures only one container runs migrations while others wait.
---
# Frequently Asked Questions
## Is AdonisJS actively maintained?
Yes, AdonisJS is actively maintained with regular updates, bug fixes, and feature additions. The framework has been consistently developed since 2015 and receives continuous attention from its core team.
You can verify maintenance activity by checking [OSS Insights](https://next.ossinsight.io/analyze/adonisjs), which shows comprehensive metrics across the entire AdonisJS GitHub organization including recent commits, responsive issue discussions, and release patterns.
## How does AdonisJS compare to Express, NestJS, and Fastify?
AdonisJS is a full-stack, batteries-included framework that provides complete development solutions out of the box. This contrasts with Express and Fastify's minimalist approach and differs from NestJS's heavily opinionated enterprise architecture.
**Compared to Express**: AdonisJS offers built-in features for authentication, validation, ORM, and sessions, whereas Express requires you to choose and integrate these pieces yourself. AdonisJS provides more structure and convention, while Express offers maximum flexibility.
**Compared to NestJS**: Both frameworks support TypeScript natively and provide structured architecture. NestJS emphasizes Angular-style decorators and enterprise patterns, while AdonisJS follows Laravel-inspired conventions with a simpler learning curve. AdonisJS is also faster to embrace modern Node.js primitives like ESM, making it more aligned with current JavaScript ecosystem standards.
**Compared to Fastify**: Fastify focuses on maximum performance with minimal overhead, while AdonisJS prioritizes developer productivity with comprehensive built-in features. Both deliver excellent performance, but AdonisJS includes more functionality by default.
Choose AdonisJS when you want a complete framework with built-in solutions and Laravel-style development experience in Node.js. Choose alternatives when you need maximum flexibility (Express), enterprise patterns (NestJS), or minimal abstraction (Fastify).
## Is AdonisJS production-ready?
Yes, AdonisJS is production-ready and has powered thousands of applications since 2015, ranging from small startups to large-scale enterprise systems. The framework handles high-traffic scenarios efficiently and follows security best practices by default.
Companies using AdonisJS in production include [Marie Claire](https://www.marieclaire.com/), [Ledger](https://www.ledger.com/), [Kayako](https://kayako.com/), [Renault Group](https://www.renaultgroup.com/en/), [Zakodium](https://www.zakodium.com/), [FIVB](https://www.fivb.com/), [Petpooja](https://www.petpooja.com/), [Paytm](https://paytm.com/), [Verifiables](https://verifiables.com), [Pappers](https://www.pappers.fr/), [Edmond de Rothschild](https://www.edmond-de-rothschild.com/en/home), [France Travail](https://www.francetravail.fr/accueil/), and many more.
While production-ready, consider that the AdonisJS community is smaller than Express or Next.js communities, which means fewer Stack Overflow answers and potentially more challenging hiring. However, developers familiar with TypeScript and modern frameworks become productive quickly, and the official documentation is comprehensive.
The framework deploys successfully to all major hosting platforms including traditional VPS providers, Docker containers, and modern platforms like Railway, Render, and Fly.io.
## Does AdonisJS support TypeScript natively?
Yes, AdonisJS is built with TypeScript from the ground up and provides first-class TypeScript support. Unlike frameworks where TypeScript is an optional add-on, AdonisJS is designed specifically for TypeScript and leverages its full power.
When you create a new AdonisJS project, TypeScript is already configured with optimal settings. The build system, type checking, and development workflow work seamlessly without additional setup. Every framework API is fully typed, providing complete IntelliSense and compile-time error checking.
The framework uses advanced TypeScript features to infer types automatically, meaning you get type safety without writing excessive type annotations. For example, validation schemas automatically infer the validated data type, and models automatically provide types for all properties and methods.
TypeScript compiles away during the build process, so there's no runtime overhead. Your production code runs as optimized JavaScript with the same performance as hand-written JavaScript.
## Who maintains AdonisJS?
AdonisJS is primarily maintained by Harminder Virk , who created the framework in 2015 and continues to lead its development. The framework also has a [small core team](https://adonisjs.com/team) of contributors who help with specific areas like documentation, package maintenance, and community support.
Harminder works on AdonisJS full-time as his primary professional focus, not as a side project. This ensures consistent attention, timely issue responses, and regular feature development. The framework receives financial support through the [Insiders](https://adonisjs.com/insiders) and [Partners](https://adonisjs.com/partner) programs, enabling sustainable full-time maintenance.
While some developers worry about frameworks maintained primarily by one person, this model has proven sustainable for nearly a decade. A single maintainer ensures coherent vision, consistent code quality, and fast decision-making. Many successful open-source projects (Linux, Ruby on Rails, Laravel, Vue.js) have followed similar models successfully.
The codebase is well-documented and structured to enable community contributions. The framework is open source under the MIT license, ensuring the code remains accessible regardless of future circumstances.
## Where can I get help with AdonisJS?
The primary support channel is the official [Discord server](https://discord.gg/vDcEjq6), where community members and core team typically respond within hours. The server has dedicated channels for different topics including general help, database questions, and deployment issues.
For longer-form questions or architectural advice, use [GitHub Discussions](https://github.com/adonisjs/core/discussions). For bug reports and feature requests, use [GitHub Issues](https://github.com/adonisjs/core/issues). The official [documentation](https://docs.adonisjs.com) is comprehensive and answers most common questions.
To get better answers faster, provide clear context, share relevant code snippets, include complete error messages, specify your environment (versions), and explain what you've already tried.
## Can I use AdonisJS for building APIs?
Yes, AdonisJS is excellent for building APIs and many developers choose it primarily for API development. The framework provides extensive built-in features specifically designed for APIs, including RESTful resource routing, built-in validation with VineJS, multiple authentication schemes, transformers for serializing data, CORS handling, and rate limiting support.
---
:variantSelector{}
# Building DevShow - A Community showcase website
In this tutorial, you will build DevShow. **DevShow is a small community showcase website where users can share what they've built.** Every user can create an account, publish a "showcase entry" (a project, tool, experiment, or anything they're proud of), and browse entries created by others.
## Overview
We're taking a hands-on approach in this tutorial by building a real application from start to finish. Instead of learning about features in isolation, you will see how everything in AdonisJS works together: **routing, controllers, models, validation, authentication, and templating all coming together to create a functioning web application**.
By the end of this tutorial, you'll have built:
- **Post listing and detail pages** - Display all posts and individual post details with comments
- **Post creation and editing** - Forms to create and update posts with validation
- **Comment system** - Allow users to comment on posts
- **Authorization** - Ensure users can only edit/delete their own posts and comments
- **Navigation and styling** - Polished UI with proper navigation between pages
The authentication system (signup, login, logout) is already included in your starter kit and fully functional.
## Understanding the starter kit
We're starting with the AdonisJS Hypermedia starter kit, which already has authentication built in. Let's see what we have to work with by opening the routes file.
```ts title="start/routes.ts"
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
import router from '@adonisjs/core/services/router'
router.on('/').render('pages/home').as('home')
/**
* Signup and login routes - only accessible to guests
*/
router
.group(() => {
router.get('signup', [controllers.NewAccount, 'create'])
router.post('signup', [controllers.NewAccount, 'store'])
router.get('login', [controllers.Session, 'create'])
router.post('login', [controllers.Session, 'store'])
})
.use(middleware.guest())
/**
* Logout route - only accessible to authenticated users
*/
router
.group(() => {
router.post('logout', [controllers.Session, 'destroy'])
})
.use(middleware.auth())
```
The starter kit gives us user signup, login, and logout routes. Notice how `middleware.guest()` ensures only logged-out users can access signup/login, while `middleware.auth()` protects the logout route.
:::note
We'll use the `auth` middleware throughout the tutorial to protect routes that require authentication.
:::
### How controllers work
Let's look at the signup controller to see how requests flow through the application.
```ts title="app/controllers/new_account_controller.ts"
import User from '#models/user'
import { signupValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class NewAccountController {
async create({ view }: HttpContext) {
return view.render('pages/auth/signup')
}
async store({ request, response, auth }: HttpContext) {
/**
* Validate the submitted data
*/
const payload = await request.validateUsing(signupValidator)
/**
* Create the new user in the database
*/
const user = await User.create(payload)
/**
* Log them in automatically
*/
await auth.use('web').login(user)
/**
* Redirect to home page
*/
response.redirect().toRoute('home')
}
}
```
Each controller method receives an HTTP context object as its first parameter. The context contains everything about the current request: the request data, response object, auth state, view renderer, and more. We destructure just the properties we need (`view` for rendering templates, `request` for form data, `response` for redirects, and `auth` for authentication).
The `create` method simply shows the signup form. The `store` method does the heavy lifting. It validates data, creates the user, logs them in, and redirects home. **This pattern of bringing together validators, models, and auth is what you'll see throughout the tutorial**.
You might notice the controller references a `User` model and a `signupValidator`. The starter kit already includes these. We'll explore how models work in the [Database and Models](./database_and_models.md) chapter and validators in the [Forms and Validation](./forms_and_validation.md) chapter.
### How views work
When a controller calls `view.render('pages/auth/signup')`, AdonisJS looks for a template file and renders it as HTML. Let's see what that signup view looks like.
```edge title="resources/views/pages/auth/signup.edge"
@layout()
@end
```
Views live in the `resources/views` directory. AdonisJS uses Edge as its templating engine. Edge templates look similar to HTML but with special tags that start with `@`.
The `@layout()` tag wraps the page content with a common layout (header, footer, CSS). The `@form()` and `@field.root()` tags are components that come with the starter kit. They render standard HTML form elements with built-in features like CSRF protection and validation error display.
When you visit `/signup`, the route calls the controller's `create` method, which renders this view, and Edge converts it to HTML that your browser displays.
## Try creating an account
Before we move forward, start your development server with `node ace serve --hmr` and try creating an account. Get comfortable with how the starter kit works. We'll be building on this foundation.
---
:variantSelector{}
# Building DevShow - A Community showcase website
In this tutorial, you will build DevShow. **DevShow is a small community showcase website where users can share what they've built.** Every user can create an account, publish a "showcase entry" (a project, tool, experiment, or anything they're proud of), and browse entries created by others.
## Overview
We're taking a hands-on approach in this tutorial by building a real application from start to finish. Instead of learning about features in isolation, you will see how everything in AdonisJS and React works together: **routing, controllers, models, validation, authentication, transformers, and React components all coming together to create a functioning web application**.
By the end of this tutorial, you'll have built:
- **Post listing and detail pages** - Display all posts and individual post details with comments
- **Post creation and editing** - Forms to create and update posts with validation
- **Comment system** - Allow users to comment on posts
- **Authorization** - Ensure users can only edit/delete their own posts and comments
- **Navigation and styling** - Polished UI with proper navigation between pages
The authentication system (signup, login, logout) is already included in your starter kit and fully functional.
## Understanding the starter kit
We're starting with the AdonisJS Inertia + React starter kit, which already has authentication built in. Let's see what we have to work with by opening the routes file.
```ts title="start/routes.ts"
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
import router from '@adonisjs/core/services/router'
router.on('/').renderInertia('home')
/**
* Signup and login routes - only accessible to guests
*/
router
.group(() => {
router.get('signup', [controllers.NewAccount, 'create'])
router.post('signup', [controllers.NewAccount, 'store'])
router.get('login', [controllers.Session, 'create'])
router.post('login', [controllers.Session, 'store'])
})
.use(middleware.guest())
/**
* Logout route - only accessible to authenticated users
*/
router
.group(() => {
router.post('logout', [controllers.Session, 'destroy'])
})
.use(middleware.auth())
```
The starter kit gives us user signup, login, and logout routes. Notice how `middleware.guest()` ensures only logged-out users can access signup/login, while `middleware.auth()` protects the logout route.
:::note
We'll use the `auth` middleware throughout the tutorial to protect routes that require authentication.
:::
### How controllers work with Inertia
Let's look at the signup controller to see how requests flow through the application with Inertia.
```ts title="app/controllers/new_account_controller.ts"
import User from '#models/user'
import { signupValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class NewAccountController {
async create({ inertia }: HttpContext) {
return inertia.render('auth/signup')
}
async store({ request, response, auth }: HttpContext) {
/**
* Validate the submitted data
*/
const payload = await request.validateUsing(signupValidator)
/**
* Create the new user in the database
*/
const user = await User.create(payload)
/**
* Log them in automatically
*/
await auth.use('web').login(user)
/**
* Redirect to home page
*/
response.redirect().toRoute('home')
}
}
```
Each controller method receives an HTTP context object as its first parameter. The context contains everything about the current request: the request data, response object, auth state, Inertia renderer, and more. We destructure just the properties we need (`inertia` for rendering React components, `request` for form data, `response` for redirects, and `auth` for authentication).
The `create` method renders the signup form using `inertia.render()`. Instead of returning HTML like traditional server-rendered apps, Inertia sends a JSON response containing the component name and any props. Your React frontend receives this and renders the corresponding component.
The `store` method does the heavy lifting. It validates data, creates the user, logs them in, and redirects home. **This pattern of bringing together validators, models, and auth is what you'll see throughout the tutorial**.
You might notice the controller references a `User` model and a `signupValidator`. The starter kit already includes these. We'll explore how models work in the [Database and Models](./database_and_models.md) chapter and validators in the [Forms and Validation](./forms_and_validation.md) chapter.
### About Inertia and React
If you've worked with meta-frameworks like Next.js or Remix, this starter kit might look unusual. **Most of our code lives in AdonisJS (the backend), and React is only used to render views.** There's no frontend routing, no isomorphic code running on both server and client, and no complex state management libraries.
This is intentional. Inertia's philosophy is simple: **keep your backend and frontend separate, but make them work together seamlessly.**
- Your **backend** (AdonisJS) handles routing, authentication, database queries, validation, and business logic
- Your **frontend** (React) handles rendering and user interactions
- **Inertia** acts as the glue, sending JSON responses from your controllers to your React components
If you're hearing about Inertia for the first time, you might want to visit [inertiajs.com](https://inertiajs.com) to learn more about its philosophy. Or just power through this tutorial and see for yourself how simple it is compared to the complexity cocktail offered by meta-frameworks.
**Here's how a request flows in an AdonisJS + Inertia app:**
```
Browser Request
↓
AdonisJS Router
↓
Controller (validates, queries database, etc.)
↓
inertia.render('component-name', props)
↓
React Component (rendered via Vite)
↓
Browser Response
```
### How the signup form works
When a controller calls `inertia.render('auth/signup')`, Inertia looks for a React component at `inertia/pages/auth/signup.tsx` and renders it. Let's look at that component.
```tsx title="inertia/pages/auth/signup.tsx"
import { Form } from '@adonisjs/inertia/react'
export default function Signup() {
return (
Signup
Enter your details below to create your account
)
}
```
Page components live in the `inertia/pages` directory. The `Form` component from `@adonisjs/inertia/react` handles form submissions. It accepts a `route` prop (the named route to submit to) and provides an `errors` object through a render prop pattern. When you submit the form, Inertia sends the request to your backend and automatically handles the response, including displaying validation errors.
## Try creating an account
Before we move forward, start your development server with `node ace serve --hmr` and try creating an account. Get comfortable with how the starter kit works. We'll be building on this foundation.
---
:variantSelector{}
# Command line and REPL
You might be wondering why we're covering CLI and REPL instead of jumping straight into building features. Here's why: throughout this tutorial, you'll constantly use Ace commands to generate controllers, models, and other files. Getting familiar with the CLI now prevents us from interrupting the flow later.
More importantly, the [REPL](../../../guides/ace/repl.md) will become our playground for experimenting with models and databases. When we explore database queries, filters, and relationships in later sections, we'll use the REPL to try things out. It's a throwaway environment that lets us focus on learning concepts without the ceremony of building complete features.
## Exploring available commands
Let's start by seeing what commands AdonisJS gives us. Run this in your terminal.
```bash
node ace list
```
You should see something like this:
:::media

:::
Notice how the commands are grouped together?
- The `make:*` commands help you generate files.
- The `migration:*` commands help you run and revert database migrations.
- The `db:*` commands handle database seeding, and so on.
Want to know more about a specific command? Just add `--help` to the end. This shows you everything that command can do, including any options you can pass to it.
```bash
node ace make:controller --help
```
## Using the REPL
The REPL will be our experimentation playground throughout the tutorial. Let's explore how to use it by creating and querying users for our DevShow web-app.
### Start the REPL and load models
First, start the REPL:
```bash
node ace repl
```
Once the REPL starts, load all your models using the `loadModels()` helper. The REPL provides several built-in helper functions like this to make experimentation easier. This helper will make all your models available under the `models` namespace.
```ts
await loadModels()
// Access user model
models.user
```
### Create users
Let's use the `User` model (stored within the `app/models/user.ts` file) to create a couple of users that we can use to log into our app later. The `create` method accepts the model properties as an object, persists them to the database and returns a model instance.
```ts
await models.user.create({ fullName: 'Harminder Virk', email: 'virk@adonisjs.com', password: 'secret' })
```
Let's create another user.
```typescript
await models.user.create({ fullName: 'Jane Doe', email: 'jane@example.com', password: 'secret' })
```
### Fetch all users
Now that you have created a couple of users, let's fetch them using the `all` method. This method will execute a `SELECT *` query and returns an array containing both users. Each user is a User model instance, not a plain JavaScript object.
```typescript
await models.user.all()
```
### Find and delete a user
You can find a user with a given ID using the `find` method. The return value is an instance of the User model or `null` (if no user was found).
```typescript
const user = await models.user.find(1)
user.id
// 1
user.email
// 'virk@adonisjs.com'
```
You can delete this user by simply calling the `delete` method on the User instance.
```ts
await user.delete()
user.$isDeleted // true
```
If you list all users again, you should see only Jane remains:
```ts
await models.user.all()
```
### Exit the REPL
When you're done exploring, type `.exit` or press `Ctrl+D` to leave the REPL and return to your terminal.
---
:variantSelector{}
# Command line and REPL
You might be wondering why we're covering CLI and REPL instead of jumping straight into building features. Here's why: throughout this tutorial, you'll constantly use Ace commands to generate controllers, models, and other files. Getting familiar with the CLI now prevents us from interrupting the flow later.
More importantly, the [REPL](../../../guides/ace/repl.md) will become our playground for experimenting with models and databases. When we explore database queries, filters, and relationships in later sections, we'll use the REPL to try things out. It's a throwaway environment that lets us focus on learning concepts without the ceremony of building complete features.
## Exploring available commands
Let's start by seeing what commands AdonisJS gives us. Run this in your terminal.
```bash
node ace list
```
You should see something like this:
:::media

:::
Notice how the commands are grouped together?
- The `make:*` commands help you generate files.
- The `migration:*` commands help you run and revert database migrations.
- The `db:*` commands handle database seeding, and so on.
Want to know more about a specific command? Just add `--help` to the end. This shows you everything that command can do, including any options you can pass to it.
```bash
node ace make:controller --help
```
## Using the REPL
The REPL will be our experimentation playground throughout the tutorial. Let's explore how to use it by creating and querying users for our DevShow web-app.
### Start the REPL and load models
First, start the REPL:
```bash
node ace repl
```
Once the REPL starts, load all your models using the `loadModels()` helper. The REPL provides several built-in helper functions like this to make experimentation easier. This helper will make all your models available under the `models` namespace.
```ts
await loadModels()
// Access user model
models.user
```
### Create users
Let's use the `User` model (stored within the `app/models/user.ts` file) to create a couple of users that we can use to log into our app later. The `create` method accepts the model properties as an object, persists them to the database and returns a model instance.
```ts
await models.user.create({ fullName: 'Harminder Virk', email: 'virk@adonisjs.com', password: 'secret' })
```
Let's create another user.
```typescript
await models.user.create({ fullName: 'Jane Doe', email: 'jane@example.com', password: 'secret' })
```
### Fetch all users
Now that you have created a couple of users, let's fetch them using the `all` method. This method will execute a `SELECT *` query and returns an array containing both users. Each user is a User model instance, not a plain JavaScript object.
```typescript
await models.user.all()
```
### Find and delete a user
You can find a user with a given ID using the `find` method. The return value is an instance of the User model or `null` (if no user was found).
```typescript
const user = await models.user.find(1)
user.id
// 1
user.email
// 'virk@adonisjs.com'
```
You can delete this user by simply calling the `delete` method on the User instance.
```ts
await user.delete()
user.$isDeleted // true
```
If you list all users again, you should see only Jane remains:
```ts
await models.user.all()
```
### Exit the REPL
When you're done exploring, type `.exit` or press `Ctrl+D` to leave the REPL and return to your terminal.
---
:variantSelector{}
# Database and Models
In this chapter, you will create models and migrations for the Post and Comment resources, establish relationships between them, generate dummy data using factories and seeders, and query your data using the REPL.
## Overview
This chapter introduces Lucid, AdonisJS's SQL ORM. Instead of writing raw SQL queries, you'll work with JavaScript classes called **models** that represent your database tables. Throughout this chapter and the rest of the tutorial, you'll interact with your database exclusively through models.
An important distinction: models define how you interact with data, but they don't modify the database structure. That's the job of **migrations**, which create and alter tables. You'll use both as you build DevShow's database structure.
:::note
**A note on learning:** This chapter introduces several database concepts at once. Don't worry if you don't fully understand everything - the goal is to learn by doing and get something working. Deeper understanding will come with practice.
:::
## Creating the Post model
Our app needs posts, so let's create a Post model and its corresponding database migration. In AdonisJS, you create one model per database table. Lucid uses naming conventions to automatically connect models to their tables - a `Post` model maps to a `posts` table, a `User` model maps to a `users` table, and so on.
::::steps
:::step{title="Generate the model and migration"}
Run this command to create both files at once.
```bash
node ace make:model Post -m
```
The `-m` flag tells Ace to create a migration file alongside the model. You'll see this output.
```bash
DONE: create app/models/post.ts
DONE: create database/migrations/1763866156451_create_posts_table.ts
```
:::
:::step{title="Understanding the generated model"}
Let's look at what was generated in the model file.
```ts title="app/models/post.ts"
import { PostSchema } from '#database/schema'
export default class Post extends PostSchema {
}
```
The model extends `PostSchema` — a class that is auto-generated from your database migrations. You don't need to define columns in your model file. When you run migrations, AdonisJS scans your database tables and generates the `database/schema.ts` file with all column definitions, types, and decorators. Your model file is where you add relationships and business logic.
:::
:::step{title="Define the table structure in the migration"}
Let's update the migration file to define the database table structure. This is where you add columns — the model will pick them up automatically after running the migration.
```ts title="database/migrations/1763866156451_create_posts_table.ts"
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')
// [!code ++:3]
table.string('title').notNullable()
table.string('url').notNullable()
table.text('summary').notNullable()
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
```
A few important things about migrations:
- The `up` method runs when you execute the migration and creates the table.
- The `down` method runs when you roll back the migration and drops the table.
- Notice that column names in the database use `snake_case` (like `created_at`), while your model properties use `camelCase` (like `createdAt`). Lucid handles this conversion automatically.
:::
::::
## Creating the Comment model
Let's create the Comment model following the same process we used for posts.
::::steps
:::step{title="Generate the model and migration"}
Run this command.
```bash
node ace make:model Comment -m
```
You'll see output showing the created files.
```bash
DONE: create app/models/comment.ts
DONE: create database/migrations/1763866347711_create_comments_table.ts
```
:::
:::step{title="Define the table structure in the migration"}
Update the migration to create the comments table with a content column.
```ts title="database/migrations/1763866347711_create_comments_table.ts"
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'comments'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
// [!code ++:1]
table.text('content').notNullable()
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
```
:::
::::
## Running migrations
Now let's create these tables in your database by running the migrations.
```bash
node ace migration:run
```
You'll see output showing which migrations were executed.
```bash
❯ migrated database/migrations/1763866156451_create_posts_table
❯ migrated database/migrations/1763866347711_create_comments_table
```
Your database now has `posts` and `comments` tables! You'll also notice that `database/schema.ts` has been updated with `PostSchema` and `CommentSchema` classes containing all the column definitions. This file is auto-generated every time you run migrations — you never need to edit it manually.
Migrations are tracked in a special `adonis_schema` table in your database. Once a migration runs successfully, it won't run again even if you execute `node ace migration:run` multiple times.
## Adding relationships
Right now our posts and comments exist independently, but in our DevShow web-app, comments belong to posts and posts belong to users. We need to establish these connections in our database and models.
To create these relationships, we need foreign key columns in our tables. A foreign key is a column that references the primary key of another table. For example, a `post_id` column in the comments table will reference the `id` column in the posts table, linking each comment to its post.
Since our tables already exist, we'll create a new migration to add these foreign key columns.
::::steps
:::step{title="Create a migration for foreign keys"}
The following command will create a new migration file that will modify our existing tables.
```bash
node ace make:migration add_foreign_keys_to_posts_and_comments
```
:::
:::step{title="Add foreign key columns"}
Update the migration file to add the foreign key columns.
```ts title="database/migrations/1732089800000_add_foreign_keys_to_posts_and_comments.ts"
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
async up() {
/**
* Add user_id to posts table
*/
this.schema.alterTable('posts', (table) => {
table.integer('user_id').unsigned().notNullable()
table.foreign('user_id').references('users.id').onDelete('CASCADE')
})
/**
* Add user_id and post_id to comments table
*/
this.schema.alterTable('comments', (table) => {
table.integer('user_id').unsigned().notNullable()
table.foreign('user_id').references('users.id').onDelete('CASCADE')
table.integer('post_id').unsigned().notNullable()
table.foreign('post_id').references('posts.id').onDelete('CASCADE')
})
}
async down() {
this.schema.alterTable('posts', (table) => {
table.dropForeign(['user_id'])
table.dropColumn('user_id')
})
this.schema.alterTable('comments', (table) => {
table.dropForeign(['user_id'])
table.dropForeign(['post_id'])
table.dropColumn('user_id')
table.dropColumn('post_id')
})
}
}
```
A few things to understand about this migration:
- We're using `alterTable` instead of `createTable` because we're modifying existing tables.
- The foreign key constraints help maintain data integrity by ensuring that a `user_id` or `post_id` always references a valid record in the respective table.
- The `onDelete('CASCADE')` means if a user or post is deleted, their comments are automatically deleted too.
:::
:::step{title="Run migration"}
```bash
node ace migration:run
```
```bash
❯ migrated database/migrations/1732089800000_add_foreign_keys_to_posts_and_comments
```
:::
:::step{title="Define relationships in the Post model"}
Now that the database has the foreign key columns, let's update our models to define these relationships.
```ts title="app/models/post.ts"
import { PostSchema } from '#database/schema'
// [!code ++:4]
import { hasMany, belongsTo } from '@adonisjs/lucid/orm'
import type { HasMany, BelongsTo } from '@adonisjs/lucid/types/relations'
import Comment from '#models/comment'
import User from '#models/user'
export default class Post extends PostSchema {
// [!code ++:11]
/**
* A post has many comments
*/
@hasMany(() => Comment)
declare comments: HasMany
/**
* A post belongs to a user
*/
@belongsTo(() => User)
declare user: BelongsTo
}
```
Remember, column definitions like `title`, `url`, `summary`, and `userId` are already handled by `PostSchema` (auto-generated from your migrations). The model file is where you add relationships and business logic.
The `@hasMany` decorator defines a one-to-many relationship - one post has many comments. The `@belongsTo` decorator defines the inverse - a post belongs to one user.
:::
:::step{title="Define relationships in the Comment model"}
```ts title="app/models/comment.ts"
import { CommentSchema } from '#database/schema'
// [!code ++:4]
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Post from '#models/post'
import User from '#models/user'
export default class Comment extends CommentSchema {
// [!code ++:11]
/**
* A comment belongs to a post
*/
@belongsTo(() => Post)
declare post: BelongsTo
/**
* A comment belongs to a user
*/
@belongsTo(() => User)
declare user: BelongsTo
}
```
:::
::::
Perfect! Our models now understand their relationships. When you load a post, you can easily access its comments through `post.comments`, and when you load a comment, you can access its post through `comment.post` or its user through `comment.user`.
:::note
We haven't added the inverse `hasMany` relationships on the User model (e.g., `user.posts` or `user.comments`) because we don't need them in this tutorial. You could add them later if your application needs to query posts or comments from the user side.
:::
## Creating factories
Now that our models and database tables are ready, we need to populate them with dummy data for development and testing. Factories act as blueprints for creating model instances filled with realistic fake data. You define the blueprint once, then generate as many instances as you need with a single line of code.
AdonisJS factories use a library called [Faker](https://fakerjs.dev/) to generate realistic data like names, URLs, paragraphs of text, and more. This makes your dummy data look authentic rather than obvious test placeholders.
::::steps
:::step{title="Create the Post factory"}
```bash
node ace make:factory Post
```
```bash
DONE: create database/factories/post_factory.ts
```
This creates a factory file where we'll define how to generate dummy Post data.
:::
:::step{title="Define the Post factory data"}
Open the factory file and configure what data to generate for each Post.
```ts title="database/factories/post_factory.ts"
import factory from '@adonisjs/lucid/factories'
import Post from '#models/post'
export const PostFactory = factory
.define(Post, async ({ faker }) => {
return {
title: faker.helpers.arrayElement([
'My First iOS Weather App',
'Personal Portfolio Website with Dark Mode',
'Real-time Chat Application',
'Expense Tracker Progressive Web App',
'Markdown Blog Engine',
'Recipe Finder with AI Recommendations',
'2D Platformer Game in JavaScript',
'Task Management Dashboard',
'URL Shortener with Analytics',
'Fitness Tracking Mobile App',
]),
url: faker.internet.url(),
summary: faker.lorem.paragraphs(3),
}
})
.build()
```
- The `faker.helpers.arrayElement()` picks a random title from the array.
- The `faker.internet.url()` generates a realistic URL.
- The `faker.lorem.paragraphs(3)` creates three paragraphs of placeholder text.
:::
:::step{title="Create the Comment factory"}
Now let's create a factory for comments using the same process.
```bash
node ace make:factory Comment
```
```bash
DONE: create database/factories/comment_factory.ts
```
:::
:::step{title="Define the Comment factory data"}
Open the Comment factory and add the data generation logic.
```ts title="database/factories/comment_factory.ts"
import factory from '@adonisjs/lucid/factories'
import Comment from '#models/comment'
export const CommentFactory = factory
.define(Comment, async ({ faker }) => {
return {
content: faker.lorem.paragraph(),
}
})
.build()
```
The Comment factory is simpler. It only needs to generate the `content` field using `faker.lorem.paragraph()`, which creates a single paragraph of text. We'll handle the `userId` and `postId` relationships in the seeder.
:::
::::
## Creating seeders
Factories define HOW to create fake data, but they don't actually create it automatically. That's where seeders come in - they're scripts that use factories to populate your database with actual data. Every time you reset your database or a teammate clones the project, running `node ace db:seed` populates the database with consistent, realistic data.
::::steps
:::step{title="Create the seeder"}
Let's create a seeder that will generate posts and comments.
```bash
node ace make:seeder PostSeeder
```
```bash
DONE: create database/seeders/post_seeder.ts
```
:::
:::step{title="Implement the seeding logic"}
Now open the seeder file and add the logic to create posts with comments.
```ts title="database/seeders/post_seeder.ts"
import User from '#models/user'
import { BaseSeeder } from '@adonisjs/lucid/seeders'
import { PostFactory } from '#database/factories/post_factory'
import { CommentFactory } from '#database/factories/comment_factory'
export default class extends BaseSeeder {
async run() {
const user = await User.findByOrFail('email', 'jane@example.com')
const posts = await PostFactory.merge({ userId: user.id }).createMany(10)
for (const post of posts) {
await CommentFactory.merge({ postId: post.id, userId: user.id }).createMany(
Math.floor(Math.random() * 3) + 3
)
}
}
}
```
Let's break down what this seeder does:
First, we fetch the user with email `jane@example.com`. This is the user we created in the previous chapter when exploring the CLI and REPL. If you followed along, this user should exist in your database. The `findByOrFail` method will throw an error if the user doesn't exist.
Next, we use `PostFactory.merge({ userId: user.id }).createMany(10)` to create 10 posts. The `merge()` method is important here - it merges additional data with the factory's generated values. We need this to set the `userId` foreign key on each post. Without it, the foreign key constraint would fail because `userId` would be undefined.
Then, for each post, we create between 3 to 5 comments using a similar approach. The formula `Math.floor(Math.random() * 3) + 3` generates a random number between 3 and 5.
:::
:::step{title="Run the seeder"}
Now execute the seeder to populate your database.
```bash
node ace db:seed
```
```bash
❯ running PostSeeder
```
Your database now has 10 posts, each with several comments!
:::
::::
## Understanding the tools we've used
Before we move on to querying data, let's take a moment to understand what we've built. AdonisJS provides dedicated tools for working with databases, and each one has a specific purpose.
**Migrations** define your database structure. They create tables, add columns, and establish constraints. Think of them as instructions that transform your database schema. When you run `node ace migration:run`, these instructions execute and modify your database structure. They also auto-generate the `database/schema.ts` file with column definitions for your models.
**Models** are your JavaScript interface to database tables. They extend auto-generated schema classes (like `PostSchema`) that contain column definitions, so you only need to add relationships and business logic. Models provide a clean, type-safe API for querying and manipulating data without writing raw SQL.
**Factories** generate realistic dummy data for your models. Instead of manually creating test data over and over, you define a blueprint once, and the factory creates as many realistic instances as you need. This is invaluable during development and testing.
**Seeders** are scripts that populate your database with data. They typically use factories to generate data, but can also create specific records or import data from other sources. Running `node ace db:seed` executes all your seeders and gives you a consistent database state.
These tools work together: migrations shape the database, models interact with it, factories generate data, and seeders populate it. Each tool is focused on its specific job, making your database workflow organized and maintainable.
## Querying data with the REPL
Now that we have data in our database, let's explore it using AdonisJS's REPL (Read-Eval-Print Loop). The REPL is an interactive shell where you can run JavaScript code and interact with your models in real-time.
### Start the REPL and load models
First, start the REPL.
```bash
node ace repl
```
Once the REPL starts, load all your models.
```ts
await loadModels()
```
This makes all your models available under the `models` object.
### Fetch all posts
Let's fetch all posts from the database.
```ts
await models.post.all()
```
You'll see an array of all 10 posts with their data.
```ts
[
Post {
id: 1,
title: 'My First iOS Weather App',
url: 'https://example.com/fp',
summary: 'Lorem ipsum dolor sit amet...',
userId: 1,
createdAt: DateTime { ... },
updatedAt: DateTime { ... }
},
// ... 9 more posts
]
```
Each post is a Post model instance, not a plain JavaScript object.
### Search posts by title
Let's search for posts containing "Task Management" in the title using the `query()` method.
```ts
await models.post.query().where('title', 'like', '%Task Management%')
```
The `query()` method returns a chainable query builder built on top of Knex, giving you powerful SQL query capabilities while staying in JavaScript. You'll see an array of matching posts, which might be just one or zero depending on what the factory generated.
### Fetch a post and load its comments
Now let's demonstrate how to work with relationships. First, fetch a specific post by its ID.
```ts
const post = await models.post.find(1)
```
The post is loaded, but its comments aren't loaded yet (relationships are lazy-loaded by default). Use the `load` method to load the comments relationship.
```ts
await post.load('comments')
```
Now you can access the comments.
```ts
post.comments
```
You'll see all the comments that belong to this post.
### Load relationships efficiently with preload
You can also load relationships when initially fetching the post.
```ts
const postWithComments = await models.post.query().preload('comments').first()
```
This fetches the first post and its comments in a single operation. The `preload` method is more efficient than loading relationships separately because it avoids the N+1 query problem. Instead of making one query for the post and then one query per comment, it makes just two queries total.
### Exit the REPL
When you're done exploring, type `.exit` to leave the REPL and return to your terminal.
## What you learned
You now know how to:
- Create models and migrations using the Ace CLI
- Create database tables and modify them with migrations
- Understand how column definitions are auto-generated in `database/schema.ts` from your migrations
- Define relationships between models using `hasMany` and `belongsTo`
- Generate dummy data with factories and seeders
- Query data using the REPL and model methods
- Use the query builder for complex queries
- Load relationships with `load()` and `preload()`
---
:variantSelector{}
# Database and Models
In this chapter, you will create models and migrations for the Post and Comment resources, establish relationships between them, generate dummy data using factories and seeders, and query your data using the REPL.
## Overview
This chapter introduces Lucid, AdonisJS's SQL ORM. Instead of writing raw SQL queries, you'll work with JavaScript classes called **models** that represent your database tables. Throughout this chapter and the rest of the tutorial, you'll interact with your database exclusively through models.
An important distinction: models define how you interact with data, but they don't modify the database structure. That's the job of **migrations**, which create and alter tables. You'll use both as you build DevShow's database structure.
:::note
**A note on learning:** This chapter introduces several database concepts at once. Don't worry if you don't fully understand everything - the goal is to learn by doing and get something working. Deeper understanding will come with practice.
:::
## Creating the Post model
Our app needs posts, so let's create a Post model and its corresponding database migration. In AdonisJS, you create one model per database table. Lucid uses naming conventions to automatically connect models to their tables - a `Post` model maps to a `posts` table, a `User` model maps to a `users` table, and so on.
::::steps
:::step{title="Generate the model and migration"}
Run this command to create both files at once.
```bash
node ace make:model Post -m
```
The `-m` flag tells Ace to create a migration file alongside the model. You'll see this output.
```bash
DONE: create app/models/post.ts
DONE: create database/migrations/1763866156451_create_posts_table.ts
```
:::
:::step{title="Understanding the generated model"}
Let's look at what was generated in the model file.
```ts title="app/models/post.ts"
import { PostSchema } from '#database/schema'
export default class Post extends PostSchema {
}
```
The model extends `PostSchema` — a class that is auto-generated from your database migrations. You don't need to define columns in your model file. When you run migrations, AdonisJS scans your database tables and generates the `database/schema.ts` file with all column definitions, types, and decorators. Your model file is where you add relationships and business logic.
:::
:::step{title="Define the table structure in the migration"}
Let's update the migration file to define the database table structure. This is where you add columns — the model will pick them up automatically after running the migration.
```ts title="database/migrations/1763866156451_create_posts_table.ts"
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')
// [!code ++:3]
table.string('title').notNullable()
table.string('url').notNullable()
table.text('summary').notNullable()
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
```
A few important things about migrations:
- The `up` method runs when you execute the migration and creates the table.
- The `down` method runs when you roll back the migration and drops the table.
- Notice that column names in the database use `snake_case` (like `created_at`), while your model properties use `camelCase` (like `createdAt`). Lucid handles this conversion automatically.
:::
::::
## Creating the Comment model
Let's create the Comment model following the same process we used for posts.
::::steps
:::step{title="Generate the model and migration"}
Run this command.
```bash
node ace make:model Comment -m
```
You'll see output showing the created files.
```bash
DONE: create app/models/comment.ts
DONE: create database/migrations/1763866347711_create_comments_table.ts
```
:::
:::step{title="Define the table structure in the migration"}
Update the migration to create the comments table with a content column.
```ts title="database/migrations/1763866347711_create_comments_table.ts"
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'comments'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
// [!code ++:1]
table.text('content').notNullable()
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
```
:::
::::
## Running migrations
Now let's create these tables in your database by running the migrations.
```bash
node ace migration:run
```
You'll see output showing which migrations were executed.
```bash
❯ migrated database/migrations/1763866156451_create_posts_table
❯ migrated database/migrations/1763866347711_create_comments_table
```
Your database now has `posts` and `comments` tables! You'll also notice that `database/schema.ts` has been updated with `PostSchema` and `CommentSchema` classes containing all the column definitions. This file is auto-generated every time you run migrations — you never need to edit it manually.
Migrations are tracked in a special `adonis_schema` table in your database. Once a migration runs successfully, it won't run again even if you execute `node ace migration:run` multiple times.
## Adding relationships
Right now our posts and comments exist independently, but in our DevShow web-app, comments belong to posts and posts belong to users. We need to establish these connections in our database and models.
To create these relationships, we need foreign key columns in our tables. A foreign key is a column that references the primary key of another table. For example, a `post_id` column in the comments table will reference the `id` column in the posts table, linking each comment to its post.
Since our tables already exist, we'll create a new migration to add these foreign key columns.
::::steps
:::step{title="Create a migration for foreign keys"}
The following command will create a new migration file that will modify our existing tables.
```bash
node ace make:migration add_foreign_keys_to_posts_and_comments
```
:::
:::step{title="Add foreign key columns"}
Update the migration file to add the foreign key columns.
```ts title="database/migrations/1732089800000_add_foreign_keys_to_posts_and_comments.ts"
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
async up() {
/**
* Add user_id to posts table
*/
this.schema.alterTable('posts', (table) => {
table.integer('user_id').unsigned().notNullable()
table.foreign('user_id').references('users.id').onDelete('CASCADE')
})
/**
* Add user_id and post_id to comments table
*/
this.schema.alterTable('comments', (table) => {
table.integer('user_id').unsigned().notNullable()
table.foreign('user_id').references('users.id').onDelete('CASCADE')
table.integer('post_id').unsigned().notNullable()
table.foreign('post_id').references('posts.id').onDelete('CASCADE')
})
}
async down() {
this.schema.alterTable('posts', (table) => {
table.dropForeign(['user_id'])
table.dropColumn('user_id')
})
this.schema.alterTable('comments', (table) => {
table.dropForeign(['user_id'])
table.dropForeign(['post_id'])
table.dropColumn('user_id')
table.dropColumn('post_id')
})
}
}
```
A few things to understand about this migration:
- We're using `alterTable` instead of `createTable` because we're modifying existing tables.
- The foreign key constraints help maintain data integrity by ensuring that a `user_id` or `post_id` always references a valid record in the respective table.
- The `onDelete('CASCADE')` means if a user or post is deleted, their comments are automatically deleted too.
:::
:::step{title="Run migration"}
```bash
node ace migration:run
```
```bash
❯ migrated database/migrations/1732089800000_add_foreign_keys_to_posts_and_comments
```
:::
:::step{title="Define relationships in the Post model"}
Now that the database has the foreign key columns, let's update our models to define these relationships.
```ts title="app/models/post.ts"
import { PostSchema } from '#database/schema'
// [!code ++:4]
import { hasMany, belongsTo } from '@adonisjs/lucid/orm'
import type { HasMany, BelongsTo } from '@adonisjs/lucid/types/relations'
import Comment from '#models/comment'
import User from '#models/user'
export default class Post extends PostSchema {
// [!code ++:11]
/**
* A post has many comments
*/
@hasMany(() => Comment)
declare comments: HasMany
/**
* A post belongs to a user
*/
@belongsTo(() => User)
declare user: BelongsTo
}
```
Remember, column definitions like `title`, `url`, `summary`, and `userId` are already handled by `PostSchema` (auto-generated from your migrations). The model file is where you add relationships and business logic.
The `@hasMany` decorator defines a one-to-many relationship - one post has many comments. The `@belongsTo` decorator defines the inverse - a post belongs to one user.
:::
:::step{title="Define relationships in the Comment model"}
```ts title="app/models/comment.ts"
import { CommentSchema } from '#database/schema'
// [!code ++:4]
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Post from '#models/post'
import User from '#models/user'
export default class Comment extends CommentSchema {
// [!code ++:11]
/**
* A comment belongs to a post
*/
@belongsTo(() => Post)
declare post: BelongsTo
/**
* A comment belongs to a user
*/
@belongsTo(() => User)
declare user: BelongsTo
}
```
:::
::::
Perfect! Our models now understand their relationships. When you load a post, you can easily access its comments through `post.comments`, and when you load a comment, you can access its post through `comment.post` or its user through `comment.user`.
:::note
We haven't added the inverse `hasMany` relationships on the User model (e.g., `user.posts` or `user.comments`) because we don't need them in this tutorial. You could add them later if your application needs to query posts or comments from the user side.
:::
## Creating factories
Now that our models and database tables are ready, we need to populate them with dummy data for development and testing. Factories act as blueprints for creating model instances filled with realistic fake data. You define the blueprint once, then generate as many instances as you need with a single line of code.
AdonisJS factories use a library called [Faker](https://fakerjs.dev/) to generate realistic data like names, URLs, paragraphs of text, and more. This makes your dummy data look authentic rather than obvious test placeholders.
::::steps
:::step{title="Create the Post factory"}
```bash
node ace make:factory Post
```
```bash
DONE: create database/factories/post_factory.ts
```
This creates a factory file where we'll define how to generate dummy Post data.
:::
:::step{title="Define the Post factory data"}
Open the factory file and configure what data to generate for each Post.
```ts title="database/factories/post_factory.ts"
import factory from '@adonisjs/lucid/factories'
import Post from '#models/post'
export const PostFactory = factory
.define(Post, async ({ faker }) => {
return {
title: faker.helpers.arrayElement([
'My First iOS Weather App',
'Personal Portfolio Website with Dark Mode',
'Real-time Chat Application',
'Expense Tracker Progressive Web App',
'Markdown Blog Engine',
'Recipe Finder with AI Recommendations',
'2D Platformer Game in JavaScript',
'Task Management Dashboard',
'URL Shortener with Analytics',
'Fitness Tracking Mobile App',
]),
url: faker.internet.url(),
summary: faker.lorem.paragraphs(3),
}
})
.build()
```
- The `faker.helpers.arrayElement()` picks a random title from the array.
- The `faker.internet.url()` generates a realistic URL.
- The `faker.lorem.paragraphs(3)` creates three paragraphs of placeholder text.
:::
:::step{title="Create the Comment factory"}
Now let's create a factory for comments using the same process.
```bash
node ace make:factory Comment
```
```bash
DONE: create database/factories/comment_factory.ts
```
:::
:::step{title="Define the Comment factory data"}
Open the Comment factory and add the data generation logic.
```ts title="database/factories/comment_factory.ts"
import factory from '@adonisjs/lucid/factories'
import Comment from '#models/comment'
export const CommentFactory = factory
.define(Comment, async ({ faker }) => {
return {
content: faker.lorem.paragraph(),
}
})
.build()
```
The Comment factory is simpler. It only needs to generate the `content` field using `faker.lorem.paragraph()`, which creates a single paragraph of text. We'll handle the `userId` and `postId` relationships in the seeder.
:::
::::
## Creating seeders
Factories define HOW to create fake data, but they don't actually create it automatically. That's where seeders come in - they're scripts that use factories to populate your database with actual data. Every time you reset your database or a teammate clones the project, running `node ace db:seed` populates the database with consistent, realistic data.
::::steps
:::step{title="Create the seeder"}
Let's create a seeder that will generate posts and comments.
```bash
node ace make:seeder PostSeeder
```
```bash
DONE: create database/seeders/post_seeder.ts
```
:::
:::step{title="Implement the seeding logic"}
Now open the seeder file and add the logic to create posts with comments.
```ts title="database/seeders/post_seeder.ts"
import User from '#models/user'
import { BaseSeeder } from '@adonisjs/lucid/seeders'
import { PostFactory } from '#database/factories/post_factory'
import { CommentFactory } from '#database/factories/comment_factory'
export default class extends BaseSeeder {
async run() {
const user = await User.findByOrFail('email', 'jane@example.com')
const posts = await PostFactory.merge({ userId: user.id }).createMany(10)
for (const post of posts) {
await CommentFactory.merge({ postId: post.id, userId: user.id }).createMany(
Math.floor(Math.random() * 3) + 3
)
}
}
}
```
Let's break down what this seeder does:
First, we fetch the user with email `jane@example.com`. This is the user we created in the previous chapter when exploring the CLI and REPL. If you followed along, this user should exist in your database. The `findByOrFail` method will throw an error if the user doesn't exist.
Next, we use `PostFactory.merge({ userId: user.id }).createMany(10)` to create 10 posts. The `merge()` method is important here - it merges additional data with the factory's generated values. We need this to set the `userId` foreign key on each post. Without it, the foreign key constraint would fail because `userId` would be undefined.
Then, for each post, we create between 3 to 5 comments using a similar approach. The formula `Math.floor(Math.random() * 3) + 3` generates a random number between 3 and 5.
:::
:::step{title="Run the seeder"}
Now execute the seeder to populate your database.
```bash
node ace db:seed
```
```bash
❯ running PostSeeder
```
Your database now has 10 posts, each with several comments!
:::
::::
## Understanding the tools we've used
Before we move on to querying data, let's take a moment to understand what we've built. AdonisJS provides dedicated tools for working with databases, and each one has a specific purpose.
**Migrations** define your database structure. They create tables, add columns, and establish constraints. Think of them as instructions that transform your database schema. When you run `node ace migration:run`, these instructions execute and modify your database structure. As a bonus, AdonisJS automatically generates the `database/schema.ts` file with column definitions for your models.
**Models** are your JavaScript interface to database tables. They extend auto-generated schema classes that provide column definitions, and you add relationships and business logic on top. Models give you a clean, type-safe API for querying and manipulating data without writing raw SQL.
**Factories** generate realistic dummy data for your models. Instead of manually creating test data over and over, you define a blueprint once, and the factory creates as many realistic instances as you need. This is invaluable during development and testing.
**Seeders** are scripts that populate your database with data. They typically use factories to generate data, but can also create specific records or import data from other sources. Running `node ace db:seed` executes all your seeders and gives you a consistent database state.
These tools work together: migrations shape the database, models interact with it, factories generate data, and seeders populate it. Each tool is focused on its specific job, making your database workflow organized and maintainable.
## Querying data with the REPL
Now that we have data in our database, let's explore it using AdonisJS's REPL (Read-Eval-Print Loop). The REPL is an interactive shell where you can run JavaScript code and interact with your models in real-time.
### Start the REPL and load models
First, start the REPL.
```bash
node ace repl
```
Once the REPL starts, load all your models.
```ts
await loadModels()
```
This makes all your models available under the `models` object.
### Fetch all posts
Let's fetch all posts from the database.
```ts
await models.post.all()
```
You'll see an array of all 10 posts with their data.
```ts
[
Post {
id: 1,
title: 'My First iOS Weather App',
url: 'https://example.com/fp',
summary: 'Lorem ipsum dolor sit amet...',
userId: 1,
createdAt: DateTime { ... },
updatedAt: DateTime { ... }
},
// ... 9 more posts
]
```
Each post is a Post model instance, not a plain JavaScript object.
### Search posts by title
Let's search for posts containing "Task Management" in the title using the `query()` method.
```ts
await models.post.query().where('title', 'like', '%Task Management%')
```
The `query()` method returns a chainable query builder built on top of Knex, giving you powerful SQL query capabilities while staying in JavaScript. You'll see an array of matching posts, which might be just one or zero depending on what the factory generated.
### Fetch a post and load its comments
Now let's demonstrate how to work with relationships. First, fetch a specific post by its ID.
```ts
const post = await models.post.find(1)
```
The post is loaded, but its comments aren't loaded yet (relationships are lazy-loaded by default). Use the `load` method to load the comments relationship.
```ts
await post.load('comments')
```
Now you can access the comments.
```ts
post.comments
```
You'll see all the comments that belong to this post.
### Load relationships efficiently with preload
You can also load relationships when initially fetching the post.
```ts
const postWithComments = await models.post.query().preload('comments').first()
```
This fetches the first post and its comments in a single operation. The `preload` method is more efficient than loading relationships separately because it avoids the N+1 query problem. Instead of making one query for the post and then one query per comment, it makes just two queries total.
### Exit the REPL
When you're done exploring, type `.exit` to leave the REPL and return to your terminal.
## What you learned
You now know how to:
- Create models and migrations using the Ace CLI
- Define column properties on models using decorators
- Create database tables and modify them with migrations
- Define relationships between models using `hasMany` and `belongsTo`
- Generate dummy data with factories and seeders
- Query data using the REPL and model methods
- Use the query builder for complex queries
- Load relationships with `load()` and `preload()`
---
:variantSelector{}
# Routes, controllers and views
In the previous chapter, we created the Post and Comment models with their database tables and relationships. Now we'll bring those models to life by building pages where users can actually see posts.
:::note
This tutorial covers basic routing, controllers, and views. For advanced topics like route groups, middleware, named routes, route parameters validation, and Edge template components, see the [Routing guide](../../../guides/basics/routing.md), [Controllers guide](../../../guides/basics/controllers.md), and [Edge documentation](https://edgejs.dev).
:::
## Overview
Right now, your posts and comments exist only in the database. Let's build two pages: one that lists all posts, and another that shows a single post with its comments.
This is where you'll see the complete MVC (Model-View-Controller) pattern in action — **models handle data**, **controllers coordinate logic**, and **views display everything to users**.
Before we begin, make sure your development server is running.
```bash
node ace serve --hmr
```
## Displaying the posts list
Let's build the complete feature for displaying a list of posts. We'll create a controller, add a method to fetch posts, register a route, and create the view template.
::::steps
:::step{title="Creating the controller"}
Start by creating a controller to handle posts-related requests. Run this command.
```bash
node ace make:controller posts
```
This creates a new file at `app/controllers/posts_controller.ts`. Open it up and you'll see a basic controller class. Let's add a method to list all posts.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import { type HttpContext } from '@adonisjs/core/http'
export default class PostsController {
// [!code ++:8]
async index({ view }: HttpContext) {
const posts = await Post
.query()
.preload('user')
.orderBy('createdAt', 'desc')
return view.render('posts/index', { posts })
}
}
```
A few things to note here:
- We're preloading the `user` relationship so we can display the author's name without extra queries
- We're ordering posts by creation date with newest first
- And passing the posts to a view template called `posts/index`.
:::
:::step{title="Defining the route"}
Open your routes file and register a route.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.on('/').render('pages/home').as('home')
// [!code ++]
router.get('/posts', [controllers.Posts, 'index'])
```
The route definition connects the `/posts` URL to your controller's `index` method. When someone visits `/posts`, AdonisJS will call `PostsController.index()` and return whatever that method returns.
:::note
The `#generated/controllers` import is automatically generated by AdonisJS and provides type-safe references to your controllers. The development server watches for new controllers and regenerates this file automatically — this is why the dev server must be running when you create new controllers. For more details on how this works, see the [Controllers guide](../../../guides/basics/controllers.md#the-barrel-file).
:::
:::
:::step{title="Creating the view template"}
Time to create the view template.
```bash
node ace make:view posts/index
```
This creates a new file at `resources/views/posts/index.edge`. Open it and add the following code inside it.
```edge title="resources/views/posts/index.edge"
@layout()
@end
```
This template uses the existing `layout` component that came with your starter kit. The layout handles the basic HTML structure, and you provide the main content by wrapping it in `@layout` tag.
Inside, we loop through each post with `@each` and display its title, the author's name, and a link to view post comments.
:::
::::
Visit [`/posts`](http://localhost:3333/posts) and you should see a list of all your posts!
## Displaying a single post
Now let's add the ability to view an individual post with its details. We'll implement the controller method, register the route with a dynamic parameter, and create the view template.
::::steps
:::step{title="Implementing the controller method"}
Add the `show` method to your controller.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async index({ view }: HttpContext) {
const posts = await Post
.query()
.preload('user')
.orderBy('createdAt', 'desc')
return view.render('posts/index', { posts })
}
// [!code ++:9]
async show({ params, view }: HttpContext) {
const post = await Post
.query()
.where('id', params.id)
.preload('user')
.firstOrFail()
return view.render('posts/show', { post })
}
}
```
We're using `firstOrFail()` here, which will automatically throw a 404 error if no post exists with that ID. No need to manually check if the post exists—AdonisJS handles that for you.
:::
:::step{title="Registering the route"}
Now let's register the route for this controller method.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('/posts', [controllers.Posts, 'index'])
// [!code ++]
router.get('/posts/:id', [controllers.Posts, 'show'])
```
- The `:id` part is a route parameter.
- When someone visits `/posts/5`, AdonisJS captures that `5` and makes it available in your controller as `params.id`.
- You can name the parameter anything you want, `:id`, `:postId`, `:slug` — just be consistent when accessing it.
:::
:::step{title="Creating the view template"}
Create the view template for displaying a single post.
```bash
node ace make:view posts/show
```
This creates `resources/views/posts/show.edge`. Open it and add the following code.
```edge title="resources/views/posts/show.edge"
@layout()
@end
```
Try clicking on a post from your [list page](http://localhost:3333/posts). You should now see the full post with its title, author, and content.
:::
::::
## Using named routes
Right now, we're hardcoding URLs like `/posts/{{ post.id }}` in our templates. This works, but what happens if we decide to change our URL pattern from `/posts/:id` to `/showcase/:id`? We'd have to find and replace every hardcoded URL throughout our application.
This is where **named routes** come in. Named routes let you assign a unique name to each route, then reference that name when building URLs. If the URL pattern changes, you only update it in one place (the route definition), and all your links automatically work with the new pattern.
When you use a controller in your route definition, AdonisJS automatically generates a route name based on the controller and method names. For example:
- The `[controllers.Posts, 'index']` gets the name `posts.index`.
- And `[controllers.Posts, 'show']` gets the name `posts.show`.
You can also manually name routes using the `.as()` method, which is useful for routes that don't use controllers or when you want a custom name.
**Named routes can be referenced across your entire application:**
- In templates using the `urlFor()` helper
- In the `@form` component's `route` parameter
- In the `@link` component's `route` parameter
- In controllers using `response.redirect().toRoute()`
- And more
For the complete guide on URL building and named routes, see the [URL builder documentation](../../../guides/basics/url_builder.md).
Let's update our posts listing page to use named routes.
```edge title="resources/views/posts/index.edge"
@layout()
@end
```
## Adding comments to the post view
Finally, let's display the comments for each post. First, we need to preload the comments and their authors in the controller.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async index({ view }: HttpContext) {
const posts = await Post.query().preload('user').orderBy('createdAt', 'desc')
return view.render('posts/index', { posts })
}
async show({ params, view }: HttpContext) {
const post = await Post.query()
.where('id', params.id)
.preload('user')
// [!code ++:3]
.preload('comments', (query) => {
query.preload('user').orderBy('createdAt', 'asc')
})
.firstOrFail()
return view.render('posts/show', { post })
}
}
```
We're preloading comments along with each comment's user (the author), and ordering them by creation date with oldest first. Now update the view to display them.
```edge title="resources/views/posts/show.edge"
@layout()
By {{ comment.user.fullName }} on {{ comment.createdAt.toFormat('MMM dd, yyyy') }}
@else
No comments yet.
@end
@end
```
Refresh your post detail page and you'll now see all the comments listed below the post content!
## What you've built
You've just completed the full MVC flow in AdonisJS:
- **Routes** that map URLs to controller actions
- **Controllers** that fetch data from your models and pass it to views
- **Views** that display data using Edge templates
- **Relationships** that let you eager load related data efficiently
- **Named routes** that make your templates maintainable
---
:variantSelector{}
# Routes, controllers and views
In the previous chapter, we created the Post and Comment models with their database tables and relationships. Now we'll bring those models to life by building pages where users can actually see posts.
:::note
This tutorial covers basic routing, controllers, and views. For advanced topics like route groups, middleware, route parameters validation, and custom Inertia components, see the [Routing guide](../../../guides/basics/routing.md), [Controllers guide](../../../guides/basics/controllers.md), and [Inertia documentation](https://inertiajs.com).
:::
## Overview
Right now, your posts and comments exist only in the database. Let's build two pages: one that lists all posts, and another that shows a single post with its comments.
This is where you'll see the complete flow in action — **models handle data**, **transformers serialize it for the frontend**, **controllers coordinate logic**, and **React components display everything to users**.
Before we begin, make sure your development server is running.
```bash
node ace serve --hmr
```
## Displaying the posts list
Let's build the complete feature for displaying a list of posts. We'll create a transformer to serialize post data, add a controller method to fetch posts, register a route, and create the React component.
::::steps
:::step{title="Creating the transformer"}
Transformers convert your Lucid models into plain JSON objects that can be safely sent to your React frontend. They explicitly control what data gets serialized and generate TypeScript types for your components.
Create a transformer for posts:
```bash
node ace make:transformer post
```
This creates `app/transformers/post_transformer.ts`. Open it and define what data to serialize:
```ts title="app/transformers/post_transformer.ts"
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Post from '#models/post'
import UserTransformer from '#transformers/user_transformer'
export default class PostTransformer extends BaseTransformer {
toObject() {
return {
...this.pick(this.resource, ['id', 'title', 'url', 'summary', 'createdAt']),
author: UserTransformer.transform(this.resource.user),
}
}
}
```
We're using `this.pick()` to select specific fields from the Post model, and transforming the related `user` with `UserTransformer`. The starter kit already includes `UserTransformer`, which serializes user data (id, fullName, email).
:::
:::step{title="Creating the controller"}
Now create a controller to handle post-related requests.
```bash
node ace make:controller posts
```
This creates `app/controllers/posts_controller.ts`. Add a method to list all posts:
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ inertia }: HttpContext) {
const posts = await Post.query()
.preload('user')
.orderBy('createdAt', 'desc')
return inertia.render('posts/index', {
posts: PostTransformer.transform(posts),
})
}
}
```
A few things to note here:
- We're preloading the `user` relationship so we can display the author's name without extra queries
- We're ordering posts by creation date with newest first
- We're using `PostTransformer.transform()` to serialize the posts
:::
:::step{title="Defining the route"}
Open your routes file and register a route.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.on('/').renderInertia('home').as('home')
// [!code ++]
router.get('/posts', [controllers.Posts, 'index'])
```
The route connects the `/posts` URL to your controller's `index` method. When someone visits `/posts`, AdonisJS calls `PostsController.index()` and Inertia renders the React component with the posts data.
:::note
The `#generated/controllers` import is automatically generated by AdonisJS and provides type-safe references to your controllers. The development server watches for new controllers and regenerates this file automatically — this is why the dev server must be running when you create new controllers. For more details on how this works, see the [Controllers guide](../../../guides/basics/controllers.md#the-barrel-file).
:::
:::
:::step{title="Creating the React component"}
Time to create the React component that will display the posts.
```bash
node ace make:page posts/index
```
This creates `inertia/pages/posts/index.tsx`. Open it and add the following code:
```tsx title="inertia/pages/posts/index.tsx"
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'
type PageProps = InertiaProps<{
posts: Data.Post[]
}>
export default function PostsIndex(props: PageProps) {
const { posts } = props
return (
)
}
```
Let's break down what's happening:
- **TypeScript props**: We're using `InertiaProps` combined with the generated `Data.Post` type to get full type safety. The `Data.Post` type is automatically generated from our `PostTransformer`.
- **Mapping posts**: We loop through the posts array and display each post's title, author, and URL.
- **Type safety**: Your editor will autocomplete `post.title`, `post.author.fullName`, etc., and TypeScript will catch any typos.
:::
::::
Visit [`/posts`](http://localhost:3333/posts) and you should see a list of all your posts!
## Displaying a single post
Now let's add the ability to view an individual post with its details. We'll implement the controller method, register the route with a dynamic parameter, and create the React component.
::::steps
:::step{title="Implementing the controller method"}
Add the `show` method to your controller:
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ inertia }: HttpContext) {
const posts = await Post.query()
.preload('user')
.orderBy('createdAt', 'desc')
return inertia.render('posts/index', {
posts: PostTransformer.transform(posts),
})
}
// [!code ++:10]
async show({ inertia, params }: HttpContext) {
const post = await Post.query()
.where('id', params.id)
.preload('user')
.firstOrFail()
return inertia.render('posts/show', {
post: PostTransformer.transform(post),
})
}
}
```
We're using `firstOrFail()` which will automatically throw a 404 error if no post exists with that ID. The pattern is the same as `index`: fetch data, transform it, and pass it as props.
:::
:::step{title="Registering the route"}
Register the route for this controller method:
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('/posts', [controllers.Posts, 'index'])
// [!code ++]
router.get('/posts/:id', [controllers.Posts, 'show'])
```
The `:id` part is a route parameter. When someone visits `/posts/5`, AdonisJS captures that `5` and makes it available in your controller as `params.id`.
:::
:::step{title="Creating the React component"}
Create the component for displaying a single post:
```bash
node ace make:page posts/show
```
This creates `inertia/pages/posts/show.tsx`. Open it and add the following code:
```tsx title="inertia/pages/posts/show.tsx"
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'
type PageProps = InertiaProps<{
post: Data.Post
}>
export default function PostsShow(props: PageProps) {
const { post } = props
return (
)
}
```
Again, we're using `InertiaProps` with the generated `Data.Post` type to get automatic type safety for the `post` prop.
Try clicking on a post from your [list page](http://localhost:3333/posts). You should now see the full post with its title, author, and content.
:::
::::
## Using client-side navigation
Right now, clicking between pages causes full page reloads. Inertia provides a `Link` component that enables client-side navigation — clicking a link fetches only the new page data via AJAX and swaps the component, making your app feel instant.
Let's update the posts list to use the `Link` component:
```tsx title="inertia/pages/posts/index.tsx"
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'
// [!code ++]
import { Link } from '@adonisjs/inertia/react'
type PageProps = InertiaProps<{
posts: Data.Post[]
}>
export default function PostsIndex(props: PageProps) {
const { posts } = props
return (
)
}
```
The `Link` component accepts two important props:
- **`route`**: The name of the route to navigate to. In this case, `posts.show` refers to our `PostsController.show` method.
- **`routeParams`**: An object containing parameters for the route. Here, we're passing the post's ID to fill in the `:id` parameter.
### About named routes
You might be wondering where `posts.show` comes from. When you define a route with a controller, AdonisJS automatically generates a route name based on the controller and method names. For example, `[controllers.Posts, 'show']` automatically gets the name `posts.show`.
This is much better than hardcoding URLs like `href={/posts/${post.id}}` because:
- **Maintainability**: If you change the URL pattern from `/posts/:id` to `/showcase/:id` in your routes file, all your links automatically work with the new pattern. No find-and-replace needed.
- **Type safety**: The `route` prop expects valid route names, so TypeScript will catch typos.
- **Centralized routing**: All URL patterns are defined in one place (your routes file), making your application easier to understand and modify.
Now when you click "View comments", Inertia handles the navigation without a full page reload. The browser's back button still works, and the URL updates properly — but it feels much faster.
## Adding comments to the post view
Finally, let's display the comments for each post. First, we need to create a transformer for comments, update the Post transformer to include them, preload them in the controller, and finally display them in the React component.
::::steps
:::step{title="Create the Comment transformer"}
Create a transformer for comments:
```bash
node ace make:transformer comment
```
Open it and define what data to serialize:
```ts title="app/transformers/comment_transformer.ts"
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Comment from '#models/comment'
import UserTransformer from '#transformers/user_transformer'
export default class CommentTransformer extends BaseTransformer {
toObject() {
return {
...this.pick(this.resource, ['id', 'content', 'createdAt']),
author: UserTransformer.transform(this.resource.user),
}
}
}
```
:::
:::step{title="Update the Post transformer"}
Now update `PostTransformer` to include comments:
```ts title="app/transformers/post_transformer.ts"
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Post from '#models/post'
import UserTransformer from '#transformers/user_transformer'
// [!code ++]
import CommentTransformer from '#transformers/comment_transformer'
export default class PostTransformer extends BaseTransformer {
toObject() {
return {
...this.pick(this.resource, ['id', 'title', 'url', 'summary', 'createdAt']),
author: UserTransformer.transform(this.resource.user),
// [!code ++]
comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments))?.depth(2),
}
}
}
```
We're using `this.whenLoaded()` to guard against undefined relationships. This means the `comments` field will only be included if we preloaded the relationship in the controller.
:::
:::step{title="Preload comments in the controller"}
Update the `show` method to preload comments and their authors:
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ inertia }: HttpContext) {
const posts = await Post.query()
.preload('user')
.orderBy('createdAt', 'desc')
return inertia.render('posts/index', {
posts: PostTransformer.transform(posts),
})
}
async show({ inertia, params }: HttpContext) {
const post = await Post.query()
.where('id', params.id)
.preload('user')
// [!code ++:3]
.preload('comments', (query) => {
query.preload('user').orderBy('createdAt', 'asc')
})
.firstOrFail()
return inertia.render('posts/show', {
post: PostTransformer.transform(post),
})
}
}
```
We're preloading comments along with each comment's user (the author), and ordering them by creation date with oldest first.
:::
:::step{title="Display comments in the React component"}
Update the component to display the comments:
```tsx title="inertia/pages/posts/show.tsx"
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'
type PageProps = InertiaProps<{
post: Data.Post
}>
export default function PostsShow(props: PageProps) {
const { post } = props
return (
By {comment.author.fullName} on{' '}
{comment.createdAt && new Date(comment.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
))
) : (
No comments yet.
)}
)
}
```
Notice how TypeScript knows that `post.comments` exists and what shape each comment has. This is all thanks to the generated `Data.Post` type from our transformers.
:::
::::
Refresh your post detail page and you'll now see all the comments listed below the post content!
## What you've built
You've just completed the full data flow in an AdonisJS + Inertia app:
- **Routes** that map URLs to controller actions
- **Controllers** that fetch data from your models
- **Transformers** that serialize models into type-safe JSON for your frontend
- **React components** that receive and display data with full TypeScript support
- **Relationships** that let you eager load related data efficiently
- **Client-side navigation** with Inertia's `Link` component for a SPA-like experience
---
:variantSelector{}
# Forms and Validation
In this chapter, you'll first add the ability for authenticated users to create new posts. Then, you'll apply the same pattern to let users leave comments on existing posts. Along the way, you'll be introduced to AdonisJS's validation layer and learn how to organize your code using separate controllers for different resources.
:::note
This tutorial covers basic form handling and validation. For advanced topics like custom validation rules, conditional validation, error message customization, and file uploads, see the [Validation guide](../../../guides/basics/validation.md) and [VineJS documentation](https://vinejs.dev).
:::
## Overview
So far in the DevShow tutorial, you've built an application that displays posts from your database. But what about creating new posts? That's where forms come in.
Handling forms involves three main steps:
1. Displaying a form to collect user input.
2. Validating that input on the server to ensure it meets your requirements.
3. Finally saving the validated data to your database.
AdonisJS provides Edge form components that render standard HTML form elements with automatic CSRF protection, and [VineJS](https://vinejs.dev/docs/introduction) for defining validation rules.
## Adding post creation
Let's start by adding the ability for users to create new posts. We'll need a controller method to display the form, routes to wire everything up, and a template for the form itself.
::::steps
:::step{title="Add controller methods"}
First, let's add a `create` method to your `PostsController` that will render the form for creating a new post. We'll also stub out a `store` method that we'll implement later to handle the form submission.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
export default class PostsController {
// ... existing methods (index, show)
// [!code ++:13]
/**
* Display the form for creating a new post
*/
async create({ view }: HttpContext) {
return view.render('posts/create')
}
/**
* Handle the form submission for creating a new post
*/
async store({}: HttpContext) {
// We'll implement this later
}
}
```
:::
:::step{title="Register the routes"}
Now let's wire up the routes. We need two: one to display the form and another to handle submissions. Both should only be accessible to logged-in users.
:::warning
The `/posts/create` route must be defined before the `/posts/:id` route.
:::
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
router.get('/posts', [controllers.Posts, 'index'])
// [!code ++:2]
router.get('/posts/create', [controllers.Posts, 'create']).use(middleware.auth())
router.post('/posts', [controllers.Posts, 'store']).use(middleware.auth())
router.get('/posts/:id', [controllers.Posts, 'show'])
```
The `auth()` middleware ensures only logged-in users can access these routes. Unauthenticated visitors will be redirected to the login page.
:::
:::step{title="Create the form template"}
Create the template for the form using the Ace CLI.
```bash
node ace make:view posts/create
```
This creates `resources/views/posts/create.edge`. Open it and add the following form.
```edge title="resources/views/posts/create.edge"
@layout()
Share your creation
Share the URL and a short summary of your creation
@form({ route: 'posts.store', method: 'POST' })
@field.root({ name: 'title' })
@!field.label({ text: 'Post title' })
@!input.control({ placeholder: 'Title of your creation' })
@!field.error()
@end
@field.root({ name: 'summary' })
@!field.label({ text: 'Short summary' })
@!textarea.control({ rows: 4, placeholder: 'Briefly describe what you are sharing' })
@!field.error()
@end
@!button({ text: 'Publish', type: 'submit' })
@end
@end
```
These Edge form components are part of the starter kit. They render standard HTML elements with helpful features like automatic CSRF protection (via `@form`) and validation error display (via `@!field.error()`).
:::
:::step{title="Create a validator"}
Before handling form submissions, we need to define validation rules. AdonisJS uses [VineJS for validation](https://vinejs.dev), a schema-based validation library that lets you define rules for your data.
Create a validator using the Ace CLI.
```bash
node ace make:validator post
```
This creates `app/validators/post.ts`. Add a `createPostValidator` to validate post creation.
```ts title="app/validators/post.ts"
import vine from '@vinejs/vine'
/**
* Validates the post's creation form
*/
export const createPostValidator = vine.create({
title: vine.string().minLength(3).maxLength(255),
url: vine.string().url(),
summary: vine.string().minLength(80).maxLength(500),
})
```
The `vine.create()` method creates a pre-compiled validator from a schema. Inside, we define each field with its type and rules.
- The `title` field must be string between 3-255 characters.
- The `url` field must be a string and formatted as a URL.
- The `summary` field must be between 80-500 characters.
:::
:::step{title="Implement the store method"}
Now let's implement the `store` method to validate the data, create the post, and redirect the user.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
// [!code ++:1]
import { createPostValidator } from '#validators/post'
export default class PostsController {
// ... existing methods
// [!code ++:9]
async store({ request, auth, response }: HttpContext) {
const payload = await request.validateUsing(createPostValidator)
await Post.create({
...payload,
userId: auth.user!.id,
})
return response.redirect().toRoute('posts.index')
}
}
```
- When the form is submitted, `request.validateUsing()` validates the data.
- If validation fails, the user is automatically redirected back with errors that appear next to the relevant fields.
- If validation succeeds, we create the post and associate it with the logged-in user using `auth.user.id` (available via the HTTP context), then redirect to the posts index.
Now visit [`/posts/create`](http://localhost:3333/posts/create), fill out the form, and submit it. Your new post should appear on the posts page! Try submitting invalid data (like a short summary or invalid URL) to see the validation errors in action.
:::
::::
## Adding comments to posts
Now that you can create posts, let's add the ability for users to leave comments. We'll create a separate controller for comments. Having one controller per resource is the recommended approach in AdonisJS.
::::steps
:::step{title="Create the comment validator"}
Let's start by defining validation rules for comments.
```bash
node ace make:validator comment
```
Since comments only have a content field, the validation is simple.
```ts title="app/validators/comment.ts"
import vine from '@vinejs/vine'
/**
* Validates the comment's creation form
*/
export const createCommentValidator = vine.create({
content: vine.string().trim().minLength(1),
})
```
:::
:::step{title="Create the CommentsController"}
Generate a new controller using the Ace CLI.
```bash
node ace make:controller comments
```
This creates `app/controllers/comments_controller.ts`. Add a `store` method to handle comment submissions.
```ts title="app/controllers/comments_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import Comment from '#models/comment'
import { createCommentValidator } from '#validators/comment'
export default class CommentsController {
/**
* Handle the form submission for creating a new comment
*/
async store({ request, auth, params, response }: HttpContext) {
// Validate the comment content
const payload = await request.validateUsing(createCommentValidator)
// Create the comment and associate it with the post and user
await Comment.create({
...payload,
postId: params.id,
userId: auth.user!.id,
})
// Redirect back to the post page
return response.redirect().back()
}
}
```
We're using `params.id` to get the post ID from the route parameter and use it to associate the comment with the post via `postId`. The `response.redirect().back()` sends the user back to the post page.
:::
:::step{title="Register the comment route"}
Add a route for creating comments, also protected by the auth middleware.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
router.on('/').render('pages/home').as('home')
router.get('/posts', [controllers.Posts, 'index'])
router.get('/posts/create', [controllers.Posts, 'create']).use(middleware.auth())
router.post('/posts', [controllers.Posts, 'store']).use(middleware.auth())
router.get('/posts/:id', [controllers.Posts, 'show'])
// [!code ++:1]
router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())
```
:::
:::step{title="Add the comment form"}
Open your `resources/views/posts/show.edge` template and add the comment form.
```edge title="resources/views/posts/show.edge"
@layout()
{{-- ... existing post display code ... --}}
Comments
// [!code ++:14]
@form({ route: 'comments.store', routeParams: post, method: 'POST' })
@end
```
The `routeParams: post` passes the post object to the route helper, which extracts `post.id` to generate the correct URL like `/posts/1/comments`.
Now visit any post page while logged in and try leaving a comment. After submitting, you'll be redirected back to see your comment in the list.
:::
::::
## What you learned
You've now added full form handling and validation to your DevShow application. Here's what you accomplished:
- Created forms using Edge form components (`@form`, `@field.root`, `@input.control`, etc.)
- Defined validation rules using VineJS validators
- Validated form submissions in your controllers using `request.validateUsing()`
- Protected routes with the `auth()` middleware to ensure only logged-in users can create content
- Associated posts and comments with users using `auth.user!.id`
- Organized your code by creating separate controllers for different resources (PostsController and CommentsController)
- Handled form errors automatically with the `@!field.error()` component
---
:variantSelector{}
# Forms and Validation
In this chapter, you'll first add the ability for authenticated users to create new posts. Then, you'll apply the same pattern to let users leave comments on existing posts. Along the way, you'll be introduced to AdonisJS's validation layer and learn how to organize your code using separate controllers for different resources.
:::note
This tutorial covers basic form handling and validation. For advanced topics like custom validation rules, conditional validation, error message customization, and file uploads, see the [Validation guide](../../../guides/basics/validation.md) and [VineJS documentation](https://vinejs.dev).
:::
## Overview
So far in the DevShow tutorial, you've built an application that displays posts from your database. But what about creating new posts? That's where forms come in.
Handling forms involves three main steps:
1. Displaying a form to collect user input.
2. Validating that input on the server to ensure it meets your requirements.
3. Finally saving the validated data to your database.
AdonisJS provides [VineJS](https://vinejs.dev/docs/introduction) for defining validation rules, and Inertia's `Form` component handles form submissions with automatic error handling.
## Adding post creation
Let's start by adding the ability for users to create new posts. We'll need a controller method to display the form, routes to wire everything up, and a React component for the form itself.
::::steps
:::step{title="Add controller methods"}
First, let's add a `create` method to your `PostsController` that will render the form for creating a new post. We'll also stub out a `store` method that we'll implement later to handle the form submission.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
// ... existing methods (index, show)
// [!code ++:13]
/**
* Display the form for creating a new post
*/
async create({ inertia }: HttpContext) {
return inertia.render('posts/create', {})
}
/**
* Handle the form submission for creating a new post
*/
async store({}: HttpContext) {
// We'll implement this later
}
}
```
:::
:::step{title="Register the routes"}
Now let's wire up the routes. We need two: one to display the form and another to handle submissions. Both should only be accessible to logged-in users.
:::warning
The `/posts/create` route must be defined before the `/posts/:id` route.
:::
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
router.get('/posts', [controllers.Posts, 'index'])
// [!code ++:2]
router.get('/posts/create', [controllers.Posts, 'create']).use(middleware.auth())
router.post('/posts', [controllers.Posts, 'store']).use(middleware.auth())
router.get('/posts/:id', [controllers.Posts, 'show'])
```
The `auth()` middleware ensures only logged-in users can access these routes. Unauthenticated visitors will be redirected to the login page.
:::
:::step{title="Create the form component"}
Create the React component for the form using the Ace CLI.
```bash
node ace make:page posts/create
```
This creates `inertia/pages/posts/create.tsx`. Open it and add the following form:
```tsx title="inertia/pages/posts/create.tsx"
import { Form } from '@adonisjs/inertia/react'
export default function PostsCreate() {
return (
Share your creation
Share the URL and a short summary of your creation
)
}
```
The `Form` component from `@adonisjs/inertia/react` handles form submissions. It accepts a `route` prop (the named route to submit to) and provides an `errors` object through a render prop pattern. When you submit the form, Inertia sends the request to your backend and automatically handles the response, including displaying validation errors.
:::
:::step{title="Create a validator"}
Before handling form submissions, we need to define validation rules. AdonisJS uses [VineJS for validation](https://vinejs.dev), a schema-based validation library that lets you define rules for your data.
Create a validator using the Ace CLI.
```bash
node ace make:validator post
```
This creates `app/validators/post.ts`. Add a `createPostValidator` to validate post creation.
```ts title="app/validators/post.ts"
import vine from '@vinejs/vine'
/**
* Validates the post's creation form
*/
export const createPostValidator = vine.create({
title: vine.string().minLength(3).maxLength(255),
url: vine.string().url(),
summary: vine.string().minLength(80).maxLength(500),
})
```
The `vine.create()` method creates a pre-compiled validator from a schema. Inside, we define each field with its type and rules.
- The `title` field must be string between 3-255 characters.
- The `url` field must be a string and formatted as a URL.
- The `summary` field must be between 80-500 characters.
:::
:::step{title="Implement the store method"}
Now let's implement the `store` method to validate the data, create the post, and redirect the user.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
import PostTransformer from '#transformers/post_transformer'
// [!code ++:1]
import { createPostValidator } from '#validators/post'
export default class PostsController {
// ... existing methods
// [!code ++:9]
async store({ request, auth, response }: HttpContext) {
const payload = await request.validateUsing(createPostValidator)
await Post.create({
...payload,
userId: auth.user!.id,
})
return response.redirect().toRoute('posts.index')
}
}
```
When the form is submitted, `request.validateUsing()` validates the data. If validation fails, the user is automatically redirected back with errors that appear next to the relevant fields. If validation succeeds, we create the post and associate it with the logged-in user using `auth.user!.id` (available via the HTTP context), then redirect to the posts index.
Now visit [`/posts/create`](http://localhost:3333/posts/create), fill out the form, and submit it. Your new post should appear on the posts page! Try submitting invalid data (like a short summary or invalid URL) to see the validation errors in action.
:::
::::
## Adding comments to posts
Now that you can create posts, let's add the ability for users to leave comments. We'll create a separate controller for comments. Having one controller per resource is the recommended approach in AdonisJS.
::::steps
:::step{title="Create the comment validator"}
Let's start by defining validation rules for comments.
```bash
node ace make:validator comment
```
Since comments only have a content field, the validation is simple.
```ts title="app/validators/comment.ts"
import vine from '@vinejs/vine'
/**
* Validates the comment's creation form
*/
export const createCommentValidator = vine.create({
content: vine.string().trim().minLength(1),
})
```
:::
:::step{title="Create the CommentsController"}
Generate a new controller using the Ace CLI.
```bash
node ace make:controller comments
```
This creates `app/controllers/comments_controller.ts`. Add a `store` method to handle comment submissions.
```ts title="app/controllers/comments_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import Comment from '#models/comment'
import { createCommentValidator } from '#validators/comment'
export default class CommentsController {
/**
* Handle the form submission for creating a new comment
*/
async store({ request, auth, params, response }: HttpContext) {
// Validate the comment content
const payload = await request.validateUsing(createCommentValidator)
// Create the comment and associate it with the post and user
await Comment.create({
...payload,
postId: params.id,
userId: auth.user!.id,
})
// Redirect back to the post page
return response.redirect().back()
}
}
```
We're using `params.id` to get the post ID from the route parameter and use it to associate the comment with the post via `postId`. The `response.redirect().back()` sends the user back to the post page.
:::
:::step{title="Register the comment route"}
Add a route for creating comments, also protected by the auth middleware.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
router.on('/').renderInertia('home')
router.get('/posts', [controllers.Posts, 'index'])
router.get('/posts/create', [controllers.Posts, 'create']).use(middleware.auth())
router.post('/posts', [controllers.Posts, 'store']).use(middleware.auth())
router.get('/posts/:id', [controllers.Posts, 'show'])
// [!code ++:1]
router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())
```
:::
:::step{title="Add the comment form"}
Open your `inertia/pages/posts/show.tsx` component and add the comment form.
```tsx title="inertia/pages/posts/show.tsx"
import { InertiaProps } from '~/types'
import { Data } from '~/generated/data'
// [!code ++]
import { Form } from '@adonisjs/inertia/react'
type PageProps = InertiaProps<{
post: Data.Post
}>
export default function PostsShow(props: PageProps) {
const { post } = props
return (
By {comment.author.fullName} on{' '}
{comment.createdAt && new Date(comment.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
))
) : (
No comments yet.
)}
)
}
```
The `Form` component accepts `routeParams` to pass the post ID to the route. The route `comments.store` corresponds to our `CommentsController.store` method, and we're passing `{ id: post.id }` to fill in the `:id` parameter in the route `/posts/:id/comments`.
Now visit any post page while logged in and try leaving a comment. After submitting, you'll be redirected back to see your comment in the list.
:::
::::
## What you learned
You've now added full form handling and validation to your DevShow application. Here's what you accomplished:
- Created forms using the `Form` component from `@adonisjs/inertia/react`
- Defined validation rules using VineJS validators
- Validated form submissions in your controllers using `request.validateUsing()`
- Protected routes with the `auth()` middleware to ensure only logged-in users can create content
- Associated posts and comments with users using `auth.user!.id`
- Organized your code by creating separate controllers for different resources (PostsController and CommentsController)
- Handled form errors automatically through the `Form` component's render prop pattern
---
:variantSelector{}
# Styling and Cleanup
In the previous chapter, we added forms to create posts and comments. We're not done building DevShow yet — there are more features to add — but we've built enough that it's worth pausing to improve the design and user experience.
Right now, users can't easily navigate between pages, and the design looks bare. Let's fix both by adding proper navigation links and styling everything with CSS.
## Styling the application
Let's start by adding CSS to make DevShow look polished. The Hypermedia starter kit already includes a CSS file with some base styles. We'll add DevShow-specific styles to enhance the posts, comments, and overall layout.
Open your CSS file and add the following styles at the end.
```css title="resources/css/app.css"
/* Dev-show styles */
.container {
max-width: 980px;
margin: auto;
padding: 40px 0;
}
.container h1 {
font-size: 32px;
letter-spacing: -0.5px;
margin-bottom: 5px;
}
.post-item {
padding: 18px 0;
min-width: 680px;
border-bottom: 1px solid var(--gray-4);
}
.post-meta {
display: flex;
align-items: center;
margin-top: 8px;
color: var(--gray-6);
font-size: 14px;
font-weight: 500;
gap: 15px;
}
.post-meta a {
text-decoration: underline;
}
.post-meta a:hover {
color: var(--gray-12);
}
.post-item h2 {
white-space: nowrap;
display: flex;
align-items: center;
gap: 10px;
}
.post-subtext {
font-size: 16px;
line-height: 1;
}
.post-actions {
display: flex;
gap: 10px;
margin-bottom: 40px;
padding: 5px 0;
align-items: center;
border-bottom: 1px solid var(--gray-4);
}
.post-actions button {
padding: 0;
background: none;
cursor: pointer;
}
.post {
min-width: 680px;
max-width: 800px;
margin: auto;
}
.post-summary {
padding: 15px 0;
border-bottom: 1px solid var(--gray-4);
}
.post-comment-form {
padding-bottom: 15px;
margin: 10px 0 40px 0;
border-bottom: 1px solid var(--gray-4);
}
.post-comment-form textarea {
width: 100%;
}
.comment-item {
padding: 18px 0;
border-bottom: 1px solid var(--gray-4);
}
.comment-actions {
display: flex;
}
.comment-actions button {
padding: 0;
background: none;
cursor: pointer;
}
.comment-meta {
color: var(--gray-6);
font-size: 14px;
font-weight: 500;
margin-top: 5px;
}
.posts-list-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
```
These styles use CSS variables like `var(--gray-4)` and `var(--gray-6)` that are already defined in the starter kit's base styles. They provide consistent spacing, typography, and color throughout DevShow.
Refresh your browser and visit [`/posts`](http://localhost:3333/posts). You should immediately see improved styling with better spacing, cleaner borders, and more readable typography.
## Updating the homepage
Right now, the homepage doesn't tell users what DevShow is or how to get started. Let's replace it with a proper landing page that explains the site and links to the posts listing.
Replace the entire content of your homepage with this:
```edge title="resources/views/pages/home.edge"
@layout()
DevShow - Share what you have built
A small community showcase website to share your creations. Be it a project, tool, experiment, or anything they're proud of.
@!link({
text: 'Browse posts created by others',
route: 'posts.index',
class: 'button'
})
@end
```
[In Chapter 4](./routes_controllers_and_views.md#using-named-routes), we learned about named routes and used the `urlFor()` helper to generate URLs in our templates. This time, we use the `@!link()` component which accepts the route name as the `route` parameter. The `class: 'button'` applies styling from the starter kit's CSS.
Visit the homepage at [`/`](http://localhost:3333) and you'll see the new landing page with a clear call-to-action button that takes users to the posts listing.
## Adding a post creation link
Users who want to share their projects need an easy way to reach the creation form. Let's add a prominent button at the top of the posts listing.
Update your posts index template to add the button in the header.
```edge title="resources/views/posts/index.edge"
@layout()
@each(post in posts)
{{-- ... Existing code ... --}}
@end
@end
```
The `posts-list-title` class uses flexbox (from the CSS we added earlier) to position the heading and button on opposite sides of the header.
Visit [`/posts`](http://localhost:3333/posts) and you'll see the new "Create new post" button in the top-right corner, making it easy for users to share their projects.
## Adding navigation to the post creation page
When users are on the post creation form, they might want to go back to browsing posts. Let's add a back link at the top of the page.
Update your posts create template.
```edge title="resources/views/posts/create.edge"
@layout()
// [!code ++:4]
@!link({
route: 'posts.index',
text: '‹ Go back to posts listing'
})
Share your creation
Share the URL and a short summary of your creation
@form({ route: 'posts.store', method: 'POST' })
{{-- ... rest of the form ... --}}
@end
@end
```
Visit [`/posts/create`](http://localhost:3333/posts/create) and you'll see the back link above the heading, making navigation intuitive.
## Adding navigation to the post detail page
Finally, let's add a back link on individual post pages so users can easily return to the full listing.
Update your posts show template.
```edge title="resources/views/posts/show.edge"
@layout()
// [!code ++:4]
@!link({
route: 'posts.index',
text: '‹ Go back to posts listing'
})
{{ post.title }}
{{-- ... post details ... --}}
@end
```
Now visit any post detail page (click on a post from [`/posts`](http://localhost:3333/posts)) and you'll see the back link, completing the navigation flow throughout DevShow.
## What you built
You've transformed DevShow's user experience with styling and navigation. Here's what you accomplished:
- Added CSS to style posts, comments, and overall layout with consistent spacing and typography
- Updated the homepage with a hero section that explains DevShow and links to posts
- Added a "Create new post" button on the posts listing for easy access
- Added back navigation links on the post creation and detail pages
- Improved the overall navigation flow, making it easy for users to move through the app
---
:variantSelector{}
# Styling and Cleanup
In the previous chapter, we added forms to create posts and comments. We're not done building DevShow yet — there are more features to add — but we've built enough that it's worth pausing to improve the design and user experience.
Right now, users can't easily navigate between pages, and the design looks bare. Let's fix both by adding proper navigation links and styling everything with CSS.
## Styling the application
Let's start by adding CSS to make DevShow look polished. The Inertia starter kit already includes a CSS file with some base styles. We'll add DevShow-specific styles to enhance the posts, comments, and overall layout.
Open your CSS file and add the following styles at the end.
```css title="inertia/css/app.css"
/* Dev-show styles */
.container {
max-width: 980px;
margin: auto;
padding: 40px 0;
}
.container h1 {
font-size: 32px;
letter-spacing: -0.5px;
margin-bottom: 5px;
}
.post-item {
padding: 18px 0;
min-width: 680px;
border-bottom: 1px solid var(--gray-4);
}
.post-meta {
display: flex;
align-items: center;
margin-top: 8px;
color: var(--gray-6);
font-size: 14px;
font-weight: 500;
gap: 15px;
}
.post-meta a {
text-decoration: underline;
}
.post-meta a:hover {
color: var(--gray-12);
}
.post-item h2 {
white-space: nowrap;
display: flex;
align-items: center;
gap: 10px;
}
.post-subtext {
font-size: 16px;
line-height: 1;
}
.post-actions {
display: flex;
gap: 10px;
margin-bottom: 40px;
padding: 5px 0;
align-items: center;
border-bottom: 1px solid var(--gray-4);
}
.post-actions button {
padding: 0;
background: none;
cursor: pointer;
}
.post {
min-width: 680px;
max-width: 800px;
margin: auto;
}
.post-summary {
padding: 15px 0;
border-bottom: 1px solid var(--gray-4);
}
.post-comment-form {
padding-bottom: 15px;
margin: 10px 0 40px 0;
border-bottom: 1px solid var(--gray-4);
}
.post-comment-form textarea {
width: 100%;
}
.comment-item {
padding: 18px 0;
border-bottom: 1px solid var(--gray-4);
}
.comment-actions {
display: flex;
}
.comment-actions button {
padding: 0;
background: none;
cursor: pointer;
}
.comment-meta {
color: var(--gray-6);
font-size: 14px;
font-weight: 500;
margin-top: 5px;
}
.posts-list-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
```
These styles use CSS variables like `var(--gray-4)` and `var(--gray-6)` that are already defined in the starter kit's base styles. They provide consistent spacing, typography, and color throughout DevShow.
Refresh your browser and visit [`/posts`](http://localhost:3333/posts). You should immediately see improved styling with better spacing, cleaner borders, and more readable typography.
## Updating the homepage
Right now, the homepage doesn't tell users what DevShow is or how to get started. Let's replace it with a proper landing page that explains the site and links to the posts listing.
Open the home page component and replace its content:
```tsx title="inertia/pages/home.tsx"
import { Link } from '@adonisjs/inertia/react'
export default function Home() {
return (
DevShow - Share what you have built
A small community showcase website to share your creations. Be it a project, tool,
experiment, or anything they're proud of.
Browse posts created by others
)
}
```
We're using the `Link` component from `@adonisjs/inertia/react` with the `route` prop to reference the named route. The `className="button"` applies styling from the starter kit's CSS.
Visit the homepage at [`/`](http://localhost:3333) and you'll see the new landing page with a clear call-to-action button that takes users to the posts listing.
## Adding a post creation link
Users who want to share their projects need an easy way to reach the creation form. Let's add a prominent button at the top of the posts listing.
Update your posts index component to add the button in the header.
```tsx title="inertia/pages/posts/index.tsx"
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'
import { Link } from '@adonisjs/inertia/react'
type PageProps = InertiaProps<{
posts: Data.Post[]
}>
export default function PostsIndex(props: PageProps) {
const { posts } = props
return (
)
}
```
The `posts-list-title` class uses flexbox (from the CSS we added earlier) to position the heading and button on opposite sides of the header.
Visit [`/posts`](http://localhost:3333/posts) and you'll see the new "Create new post" button in the top-right corner, making it easy for users to share their projects.
## Adding navigation to the post creation page
When users are on the post creation form, they might want to go back to browsing posts. Let's add a back link at the top of the page.
Update your posts create component:
```tsx title="inertia/pages/posts/create.tsx"
import { Form } from '@adonisjs/inertia/react'
// [!code ++]
import { Link } from '@adonisjs/inertia/react'
export default function PostsCreate() {
return (
// [!code ++:3]
‹ Go back to posts listing
Share your creation
Share the URL and a short summary of your creation
)
}
```
Visit [`/posts/create`](http://localhost:3333/posts/create) and you'll see the back link above the heading, making navigation intuitive.
## Adding navigation to the post detail page
Finally, let's add a back link on individual post pages so users can easily return to the full listing.
Update your posts show component:
```tsx title="inertia/pages/posts/show.tsx"
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'
import { Form } from '@adonisjs/inertia/react'
// [!code ++]
import { Link } from '@adonisjs/inertia/react'
type PageProps = InertiaProps<{
post: Data.Post
}>
export default function PostsShow(props: PageProps) {
const { post } = props
return (
// [!code ++:3]
‹ Go back to posts listing
{post.title}
{/* ... post details ... */}
)
}
```
Now visit any post detail page (click on a post from [`/posts`](http://localhost:3333/posts)) and you'll see the back link, completing the navigation flow throughout DevShow.
## What you built
You've transformed DevShow's user experience with styling and navigation. Here's what you accomplished:
- Added CSS to style posts, comments, and overall layout with consistent spacing and typography
- Updated the homepage with a hero section that explains DevShow and links to posts
- Added a "Create new post" button on the posts listing for easy access
- Added back navigation links on the post creation and detail pages
- Improved the overall navigation flow, making it easy for users to move through the app
---
:variantSelector{}
# Authorization
In the previous chapter, we improved DevShow's navigation and styling. Now let's add the ability for users to edit and delete their own posts and comments. Right now, any logged-in user could modify anyone's content if we added those features. We need to add authorization checks to prevent this.
## Overview
To handle permissions properly, we'll use [AdonisJS's Bouncer package](../../../guides/auth/authorization.md). Bouncer lets you organize authorization logic into **policies** (classes where each method represents a permission check). For example, a `PostPolicy` can have an `edit` method that checks if a user can edit a specific post.
Instead of scattering permission checks throughout your controllers, you define the rules once in a policy and use them everywhere. In this chapter, we'll install Bouncer, create policies for posts and comments, and implement edit and delete features with proper authorization.
## Installing Bouncer
Let's install and configure the Bouncer package using the following command.
```bash
node ace add @adonisjs/bouncer
```
Running this command will first install the package and then performs the following actions.
- Creates an `app/abilities/main.ts` file where you can define authorization abilities (we won't need this file for now, so don't worry about it)
- Registers a middleware that initializes Bouncer for every HTTP request
- Makes the `bouncer` object available on the `HttpContext`, so you can use it in your controllers
You're all set! Now let's create our first policy.
## Creating the PostPolicy
Policies are classes where each method represents a permission check. Let's create a policy for posts.
```bash
node ace make:policy post
```
Open the generated file and add permission checks for editing and deleting posts.
```ts title="app/policies/post_policy.ts"
import type User from '#models/user'
import type Post from '#models/post'
import { BasePolicy } from '@adonisjs/bouncer'
export default class PostPolicy extends BasePolicy {
/**
* Only the post owner can edit their post
*/
edit(user: User, post: Post) {
return user.id === post.userId
}
/**
* Only the post owner can delete their post
*/
delete(user: User, post: Post) {
return user.id === post.userId
}
}
```
Each policy method receives the currently logged-in user as the first parameter, followed by the resource being checked (in this case, the `post`). The method returns `true` if the user is allowed to perform the action, or `false` if they're not. Here, we're simply checking if the user's ID matches the post's `userId`.
You might notice that `edit` and `delete` have identical logic right now. Even though they're the same, keeping them separate gives you flexibility. Later, you might decide that posts can't be edited after 24 hours, or that admins can delete any post but can't edit them. Having separate methods makes these kinds of changes easier.
## Creating the CommentPolicy
Now create a policy for comments.
```bash
node ace make:policy comment
```
Add the delete permission check.
```ts title="app/policies/comment_policy.ts"
import type User from '#models/user'
import type Comment from '#models/comment'
import { BasePolicy } from '@adonisjs/bouncer'
export default class CommentPolicy extends BasePolicy {
/**
* Only the comment owner can delete their comment
*/
delete(user: User, comment: Comment) {
return user.id === comment.userId
}
}
```
Perfect! Now let's put these policies to work.
## Adding edit functionality
::::steps
:::step{title="Create the update validator"}
We'll add a validator for updating posts. Since we already have a `validators/post.ts` file for creating posts, we'll add the update validator there too. A single validator file can export multiple validators (this keeps related validation logic organized together).
Open your existing post validator file and add the update validator.
```ts title="app/validators/post.ts"
import vine from '@vinejs/vine'
export const createPostValidator = vine.create({
title: vine.string().minLength(3).maxLength(255),
url: vine.string().url(),
summary: vine.string().minLength(80).maxLength(500),
})
// [!code ++:6]
/**
* Same validation rules as creating a post
*/
export const updatePostValidator = vine.create(
createPostValidator.schema.clone()
)
```
We're cloning the `createPostValidator` schema to reuse the same validation rules. This approach keeps our validation logic DRY (Don't Repeat Yourself). If you need to change a rule later, you only update it in one place. In many applications, you might want different rules for creating vs. updating, but for DevShow, the requirements are the same.
:::
:::step{title="Add controller methods"}
We'll add two controller methods: `edit` to show the edit form, and `update` to handle the form submission. Both methods will use Bouncer to check if the current user is allowed to modify the post before performing any action.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
// [!code ++:2]
import { createPostValidator, updatePostValidator } from '#validators/post'
import PostPolicy from '#policies/post_policy'
export default class PostsController {
// ... existing methods (index, create, store, show)
// [!code ++:28]
/**
* Show the edit form
*/
async edit({ bouncer, params, view }: HttpContext) {
const post = await Post.findOrFail(params.id)
// Check if the current user can edit this post
await bouncer.with(PostPolicy).authorize('edit', post)
return view.render('posts/edit', { post })
}
/**
* Update the post
*/
async update({ bouncer, params, request, response, session }: HttpContext) {
const post = await Post.findOrFail(params.id)
// Check authorization again. Someone could send a PUT request directly
await bouncer.with(PostPolicy).authorize('edit', post)
// Validate and update the post
const data = await request.validateUsing(updatePostValidator)
await post.merge(data).save()
session.flash('success', 'Post updated successfully')
return response.redirect().toRoute('posts.show', { id: post.id })
}
}
```
The key part here is `bouncer.with(PostPolicy).authorize('edit', post)`. This line:
- Calls the `edit` method in our `PostPolicy`
- Passes the the post to the policy method
- If the policy returns `false`, Bouncer automatically throws a 403 Forbidden error
- If the policy returns `true`, the code continues executing
Notice we check authorization in both methods. Even though `edit` checks permissions, someone could bypass the form and send a PUT request directly to the `update` route. Always verify permissions before performing sensitive actions.
:::
:::step{title="Understanding flash messages"}
You'll notice `session.flash('success', 'Post updated successfully')` in the `update` method. This is our first use of **flash messages** in DevShow, so let's understand what they do.
[Flash messages](../../../guides/basics/session.md#flash-messages) are temporary messages stored in the session. They're available on the next request only, then automatically removed. This makes them perfect for showing success or error messages after form submissions.
You don't need to add any code to display these messages — the starter kit's layout already includes a component that renders flash messages automatically. When a flash message is set, it will appear as a notification at the top of the page on the next request.
We'll use flash messages throughout the rest of this chapter whenever we want to confirm that an action (like updating or deleting) was successful.
:::
:::step{title="Register the routes"}
Now let's register the routes for editing posts. Open your routes file.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
router.get('/posts', [controllers.Posts, 'index'])
router.get('/posts/create', [controllers.Posts, 'create']).use(middleware.auth())
router.post('/posts', [controllers.Posts, 'store']).use(middleware.auth())
router.get('/posts/:id', [controllers.Posts, 'show'])
// [!code ++:2]
router.get('/posts/:id/edit', [controllers.Posts, 'edit']).use(middleware.auth())
router.put('/posts/:id', [controllers.Posts, 'update']).use(middleware.auth())
router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())
```
We need two routes (one to show the edit form and another to handle the form submission). Both require authentication.
:::
:::step{title="Create the edit form template"}
Create the edit form template.
```bash
node ace make:view posts/edit
```
Now add the form markup.
```edge title="resources/views/posts/edit.edge"
@layout()
@!link({
route: 'posts.show',
routeParams: post,
text: '‹ Back to post'
})
Edit Post
@form({ route: 'posts.update', routeParams: post, method: 'PUT' })
@end
```
This form is similar to the create form, with a few key differences:
- **HTTP method**: Uses `method: 'PUT'` to indicate this is an update request, not a POST for creating new data
- **Pre-filled values**: Each field shows the current post data (`value: post.title`, `value: post.url`, `text: post.summary`) so users can see what they're editing
- **Route**: Submits to the `posts.update` route with the post ID included in the URL
:::
:::step{title="Add edit button to post detail page"}
Now add an Edit button to the post detail page. Open your `posts/show.edge` template.
```edge title="resources/views/posts/show.edge"
@layout()
...
...
// [!code ++:10]
{{-- Show edit button only to the post owner --}}
@can('PostPolicy.edit', post)
@!link({
route: 'posts.edit',
routeParams: post,
text: 'Edit post',
})
@end
@end
```
The `@can` tag checks the policy method in your template, similar to how `bouncer.authorize()` works in controllers:
- **First parameter** (`'PostPolicy.edit'`) - Specifies which policy and method to use
- **Second parameter** (`post`) - The resource being checked, passed to the policy method
- **When check fails** - Everything between `@can` and `@end` is hidden from the HTML output
Non-owners won't even see the Edit button in the page source. If someone tries to visit the edit URL directly, they still get a 403 error from the controller's authorization check.
:::
::::
Visit a post you created and you'll see the Edit button. Click it and try updating your post!
## Adding delete functionality
::::steps
:::step{title="Add controller method"}
Deleting a post is simpler than editing because there's no form to show (just a button that submits a DELETE request). Let's add the controller method to handle deletions.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
import { createPostValidator, updatePostValidator } from '#validators/post'
import PostPolicy from '#policies/post_policy'
export default class PostsController {
// ... existing methods
// [!code ++:14]
/**
* Delete a post
*/
async destroy({ bouncer, params, response, session }: HttpContext) {
const post = await Post.findOrFail(params.id)
// Check if the user can delete this post
await bouncer.with(PostPolicy).authorize('delete', post)
await post.delete()
session.flash('success', 'Post deleted successfully')
return response.redirect().toRoute('posts.index')
}
}
```
The pattern is familiar by now: find the post, authorize the action using the policy, perform the deletion, flash a success message, and redirect. After deleting a post, we redirect to the posts index page since the post detail page no longer exists.
:::
:::step{title="Register the route"}
Now register the delete route.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
router.put('/posts/:id', [controllers.Posts, 'update']).use(middleware.auth())
// [!code ++:1]
router.delete('/posts/:id', [controllers.Posts, 'destroy']).use(middleware.auth())
router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())
```
:::
:::step{title="Add delete button to post detail page"}
Add a delete button next to the edit button in your post detail template.
```edge title="resources/views/posts/show.edge"
@layout()
...
...
{{-- Show edit button only to the post owner --}}
@can('PostPolicy.edit', post)
@!link({
route: 'posts.edit',
routeParams: post,
text: 'Edit post',
})
@end
// [!code ++:7]
{{-- Show delete button only to the post owner --}}
@can('PostPolicy.delete', post)
.
@form({ route: 'posts.destroy', routeParams: post, method: 'DELETE' })
@!button({ text: 'Delete', class: 'destructive' })
@end
@end
@end
```
A few important things about this delete button:
- **DELETE method** - We're using `method: 'DELETE'` in the form, but HTML forms only support GET and POST methods natively
- **Form method spoofing** - Under the hood, the `@form` component submits a POST request with a `?_method=DELETE` query string. AdonisJS recognizes this pattern and treats the request as a DELETE. This technique is called [form method spoofing](../../../guides/basics/routing.md#form-method-spoofing)
- **Authorization check** - The `@can('PostPolicy.delete', post)` tag ensures only the post owner sees the button
Try it out! Visit a post you created and you'll see both Edit and Delete buttons. Visit a post created by someone else and no buttons appear.
:::
::::
## Adding comment deletion
::::steps
:::step{title="Add controller method"}
Let's add the controller method for deleting comments.
```ts title="app/controllers/comments_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import Comment from '#models/comment'
import { createCommentValidator } from '#validators/comment'
// [!code ++:1]
import CommentPolicy from '#policies/comment_policy'
export default class CommentsController {
// ... existing store method
// [!code ++:17]
/**
* Delete a comment
*/
async destroy({ bouncer, params, response, session }: HttpContext) {
const comment = await Comment.findOrFail(params.id)
// Load the post so we can redirect back to it
await comment.load('post')
// Check if the user can delete this comment
await bouncer.with(CommentPolicy).authorize('delete', comment)
await comment.delete()
session.flash('success', 'Comment deleted successfully')
return response.redirect().toRoute('posts.show', { id: comment.post.id })
}
}
```
Here's what this method does:
- **Finds the comment** using the ID from the route parameter
- **Loads the post relationship** so we have access to `comment.post.id` for redirecting after deletion
- **Checks authorization** with `CommentPolicy`. If the user doesn't own the comment, they get a 403 error
- **Deletes the comment** from the database
- **Redirects back** to the post detail page where the comment was displayed
:::
:::step{title="Register the route"}
Now register the delete route for comments.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
router.get('/posts/:id/edit', [controllers.Posts, 'edit']).use(middleware.auth())
router.put('/posts/:id', [controllers.Posts, 'update']).use(middleware.auth())
router.delete('/posts/:id', [controllers.Posts, 'destroy']).use(middleware.auth())
router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())
// [!code ++:3]
router
.delete('/comments/:id', [controllers.Comments, 'destroy'])
.use(middleware.auth())
```
:::
:::step{title="Add delete button to comments"}
Finally, add delete button to the comments list in your post detail template.
```edge title="resources/views/posts/show.edge"
@layout()
@end
```
How the comment delete button works:
- **Policy check** - The `@can` tag checks `CommentPolicy` to determine if the current user can delete each comment
- **Visibility** - Only the comment's author will see the delete button
- **Action** - The button submits a DELETE request to the `comments.destroy` route
Visit a post with comments you created and you'll see delete buttons next to your comments. Try viewing comments from other users and no delete buttons will appear.
:::
::::
## What you built
You've successfully added authorization to DevShow using Bouncer's policy system. Here's what you accomplished:
- Created `PostPolicy` and `CommentPolicy` to centralize all permission logic in one place
- Used `bouncer.with(Policy).authorize()` in controllers to enforce permissions before allowing actions
- Implemented the complete edit post feature with form, validation, and authorization
- Added delete functionality for both posts and comments with proper permission checks
- Used the `@can` tag in templates to conditionally show action buttons only to authorized users
The key benefit of this approach is that your authorization logic is reusable and maintainable. When you need to change a permission rule, you update it in one place (the policy), and it automatically applies everywhere you use that policy (in controllers, templates, and anywhere else in your application).
---
:variantSelector{}
# Authorization
In the previous chapter, we improved DevShow's navigation and styling. Now let's add the ability for users to edit and delete their own posts and comments. Right now, any logged-in user could modify anyone's content if we added those features. We need to add authorization checks to prevent this.
## Overview
To handle permissions properly, we'll use [AdonisJS's Bouncer package](../../../guides/auth/authorization.md). Bouncer lets you organize authorization logic into **policies** (classes where each method represents a permission check). For example, a `PostPolicy` can have an `edit` method that checks if a user can edit a specific post.
Instead of scattering permission checks throughout your controllers, you define the rules once in a policy and use them everywhere. In this chapter, we'll install Bouncer, create policies for posts and comments, and implement edit and delete features with proper authorization.
## Installing Bouncer
Let's install and configure the Bouncer package using the following command.
```bash
node ace add @adonisjs/bouncer
```
Running this command will first install the package and then performs the following actions.
- Creates an `app/abilities/main.ts` file where you can define authorization abilities (we won't need this file for now, so don't worry about it)
- Registers a middleware that initializes Bouncer for every HTTP request
- Makes the `bouncer` object available on the `HttpContext`, so you can use it in your controllers
You're all set! Now let's create our first policy.
## Creating the PostPolicy
Policies are classes where each method represents a permission check. Let's create a policy for posts.
```bash
node ace make:policy post
```
Open the generated file and add permission checks for editing and deleting posts.
```ts title="app/policies/post_policy.ts"
import type User from '#models/user'
import type Post from '#models/post'
import { BasePolicy } from '@adonisjs/bouncer'
export default class PostPolicy extends BasePolicy {
/**
* Only the post owner can edit their post
*/
edit(user: User, post: Post) {
return user.id === post.userId
}
/**
* Only the post owner can delete their post
*/
delete(user: User, post: Post) {
return user.id === post.userId
}
}
```
Each policy method receives the currently logged-in user as the first parameter, followed by the resource being checked (in this case, the `post`). The method returns `true` if the user is allowed to perform the action, or `false` if they're not. Here, we're simply checking if the user's ID matches the post's `userId`.
You might notice that `edit` and `delete` have identical logic right now. Even though they're the same, keeping them separate gives you flexibility. Later, you might decide that posts can't be edited after 24 hours, or that admins can delete any post but can't edit them. Having separate methods makes these kinds of changes easier.
## Creating the CommentPolicy
Now create a policy for comments.
```bash
node ace make:policy comment
```
Add the delete permission check.
```ts title="app/policies/comment_policy.ts"
import type User from '#models/user'
import type Comment from '#models/comment'
import { BasePolicy } from '@adonisjs/bouncer'
export default class CommentPolicy extends BasePolicy {
/**
* Only the comment owner can delete their comment
*/
delete(user: User, comment: Comment) {
return user.id === comment.userId
}
}
```
Perfect! Now let's put these policies to work.
## Adding edit functionality
::::steps
:::step{title="Create the update validator"}
We'll add a validator for updating posts. Since we already have a `validators/post.ts` file for creating posts, we'll add the update validator there too. A single validator file can export multiple validators (this keeps related validation logic organized together).
Open your existing post validator file and add the update validator.
```ts title="app/validators/post.ts"
import vine from '@vinejs/vine'
export const createPostValidator = vine.create({
title: vine.string().minLength(3).maxLength(255),
url: vine.string().url(),
summary: vine.string().minLength(80).maxLength(500),
})
// [!code ++:6]
/**
* Same validation rules as creating a post
*/
export const updatePostValidator = vine.create(
createPostValidator.schema.clone()
)
```
We're cloning the `createPostValidator` schema to reuse the same validation rules. This approach keeps our validation logic DRY (Don't Repeat Yourself). If you need to change a rule later, you only update it in one place. In many applications, you might want different rules for creating vs. updating, but for DevShow, the requirements are the same.
:::
:::step{title="Add controller methods"}
We'll add two controller methods: `edit` to show the edit form, and `update` to handle the form submission. Both methods will use Bouncer to check if the current user is allowed to modify the post before performing any action.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
import PostTransformer from '#transformers/post_transformer'
// [!code ++:2]
import { createPostValidator, updatePostValidator } from '#validators/post'
import PostPolicy from '#policies/post_policy'
export default class PostsController {
// ... existing methods (index, create, store, show)
// [!code ++:28]
/**
* Show the edit form
*/
async edit({ bouncer, params, inertia }: HttpContext) {
const post = await Post.findOrFail(params.id)
// Check if the current user can edit this post
await bouncer.with(PostPolicy).authorize('edit', post)
return inertia.render('posts/edit', {
post: PostTransformer.transform(post),
})
}
/**
* Update the post
*/
async update({ bouncer, params, request, response, session }: HttpContext) {
const post = await Post.findOrFail(params.id)
// Check authorization again. Someone could send a PUT request directly
await bouncer.with(PostPolicy).authorize('edit', post)
// Validate and update the post
const data = await request.validateUsing(updatePostValidator)
await post.merge(data).save()
session.flash('success', 'Post updated successfully')
return response.redirect().toRoute('posts.show', { id: post.id })
}
}
```
The key part here is `bouncer.with(PostPolicy).authorize('edit', post)`. This line:
- Calls the `edit` method in our `PostPolicy`
- Passes the post to the policy method
- If the policy returns `false`, Bouncer automatically throws a 403 Forbidden error
- If the policy returns `true`, the code continues executing
We check authorization in both methods. Even though `edit` checks permissions, someone could bypass the form and send a PUT request directly to the `update` route. Always verify permissions before performing sensitive actions.
You'll also notice `session.flash('success', 'Post updated successfully')` in the `update` method. [Flash messages](../../../guides/basics/session.md#flash-messages) are temporary messages stored in the session that are available on the next request and then automatically removed. This is perfect for showing success or error messages after form submissions.
You don't need to add any code to display these messages — the starter kit's layout already includes a component that renders flash messages automatically. When a flash message is set, it will appear as a notification at the top of the page on the next request.
:::
:::step{title="Update the Post transformer to include authorization"}
Now that we understand how to use policies in controllers, let's also use them in transformers to send authorization flags to the frontend.
Here's an important consideration: **Bouncer policies run in the backend environment, so they cannot be imported or used directly in your React code.** Your React components have no access to the backend's authorization logic.
The solution is to **pre-compute user permissions within transformers and send them as flags** to the frontend. Transformers run on the backend where they have access to policies, and can include permission checks in the serialized data.
We'll use a **transformer variant** for this. Variants allow you to define multiple output shapes for the same resource. For example, you might want minimal data for list views but detailed data (including permissions) for detail views. Learn more about variants in the [Transformers documentation](../../../guides/frontend/transformers.md#using-variants).
Let's add a `forDetailedView` variant to the `PostTransformer`:
```ts title="app/transformers/post_transformer.ts"
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Post from '#models/post'
import UserTransformer from '#transformers/user_transformer'
import CommentTransformer from '#transformers/comment_transformer'
// [!code ++:3]
import { inject } from '@adonisjs/core'
import { HttpContext } from '@adonisjs/core/http'
import PostPolicy from '#policies/post_policy'
export default class PostTransformer extends BaseTransformer {
toObject() {
return {
...this.pick(this.resource, ['id', 'title', 'url', 'summary', 'createdAt']),
author: UserTransformer.transform(this.resource.user),
comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments)),
}
}
// [!code ++:15]
/**
* Include authorization data for the post detail view
*/
@inject()
async forDetailedView({ bouncer }: HttpContext) {
return {
...this.toObject(),
can: {
edit: await bouncer.with(PostPolicy).allows('edit', this.resource),
delete: await bouncer.with(PostPolicy).allows('delete', this.resource),
},
}
}
}
```
Notice we're using the same `bouncer.with(PostPolicy)` pattern we used in the controller, but instead of `.authorize()` (which throws errors), we use `.allows()` (which returns boolean). The `@inject()` decorator allows us to access the HTTP context in our transformer.
When your React component receives this data, it has simple boolean flags (`post.can.edit`, `post.can.delete`) it can use for conditional rendering—without needing to know anything about the authorization logic itself.
Now update the `show` method to use this variant:
```ts title="app/controllers/posts_controller.ts"
async show({ inertia, params }: HttpContext) {
const post = await Post.query()
.where('id', params.id)
.preload('user')
.preload('comments', (query) => {
query.preload('user').orderBy('createdAt', 'asc')
})
.firstOrFail()
// [!code --:3]
return inertia.render('posts/show', {
post: PostTransformer.transform(post),
})
// [!code ++:3]
return inertia.render('posts/show', {
post: PostTransformer.transform(post).useVariant('forDetailedView'),
})
}
```
:::
:::step{title="Register the routes"}
Now let's register the routes for editing posts. Open your routes file.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
router.get('/posts', [controllers.Posts, 'index'])
router.get('/posts/create', [controllers.Posts, 'create']).use(middleware.auth())
router.post('/posts', [controllers.Posts, 'store']).use(middleware.auth())
router.get('/posts/:id', [controllers.Posts, 'show'])
// [!code ++:2]
router.get('/posts/:id/edit', [controllers.Posts, 'edit']).use(middleware.auth())
router.put('/posts/:id', [controllers.Posts, 'update']).use(middleware.auth())
router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())
```
We need two routes (one to show the edit form and another to handle the form submission). Both require authentication.
:::
:::step{title="Create the edit form component"}
Create the edit form component.
```bash
node ace make:page posts/edit
```
Now add the form markup.
```tsx title="inertia/pages/posts/edit.tsx"
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'
import { Form, Link } from '@adonisjs/inertia/react'
type PageProps = InertiaProps<{
post: Data.Post
}>
export default function PostsEdit(props: PageProps) {
const { post } = props
return (
‹ Back to post
Edit Post
)
}
```
This form is similar to the create form, with a few key differences:
- **Pre-filled values**: Each field shows the current post data (`defaultValue={post.title}`, etc.) so users can see what they're editing
- **Route**: Submits to the `posts.update` route with the post ID included via `routeParams`. The HTTP method (PUT) is automatically inferred from the route definition
:::
:::step{title="Add edit button to post detail page"}
Now add an Edit button to the post detail page. Open your `posts/show.tsx` component.
```tsx title="inertia/pages/posts/show.tsx"
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'
import { Form, Link } from '@adonisjs/inertia/react'
type PageProps = InertiaProps<{
post: Data.Post.Variants['forDetailedView']
}>
export default function PostsShow(props: PageProps) {
const { post } = props
return (
)
}
```
We're using conditional rendering with `post.can?.edit` to show the Edit button only to the post owner. The `can` object comes from our transformer's `forDetailedView` variant, which used the same `PostPolicy` we saw in the controller.
Non-owners won't even see the Edit button in the component. If someone tries to visit the edit URL directly, they still get a 403 error from the controller's authorization check.
:::
::::
Visit a post you created and you'll see the Edit button. Click it and try updating your post!
## Adding delete functionality
::::steps
:::step{title="Add controller method"}
Deleting a post is simpler than editing because there's no form to show (just a button that submits a DELETE request). Let's add the controller method to handle deletions.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
import PostTransformer from '#transformers/post_transformer'
import { createPostValidator, updatePostValidator } from '#validators/post'
import PostPolicy from '#policies/post_policy'
export default class PostsController {
// ... existing methods
// [!code ++:14]
/**
* Delete a post
*/
async destroy({ bouncer, params, response, session }: HttpContext) {
const post = await Post.findOrFail(params.id)
// Check if the user can delete this post
await bouncer.with(PostPolicy).authorize('delete', post)
await post.delete()
session.flash('success', 'Post deleted successfully')
return response.redirect().toRoute('posts.index')
}
}
```
The pattern is familiar by now: find the post, authorize the action using the policy, perform the deletion, flash a success message, and redirect. After deleting a post, we redirect to the posts index page since the post detail page no longer exists.
:::
:::step{title="Register the route"}
Now register the delete route.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
router.put('/posts/:id', [controllers.Posts, 'update']).use(middleware.auth())
// [!code ++:1]
router.delete('/posts/:id', [controllers.Posts, 'destroy']).use(middleware.auth())
router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())
```
:::
:::step{title="Add delete button to post detail page"}
Add a delete button next to the edit button in your post detail component.
```tsx title="inertia/pages/posts/show.tsx"
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'
import { Form, Link } from '@adonisjs/inertia/react'
type PageProps = InertiaProps<{
post: Data.Post.Variants['forDetailedView']
}>
export default function PostsShow(props: PageProps) {
const { post } = props
return (
)
}
```
A few important things about this delete button:
- **Authorization check** - The `post.can?.delete` check ensures only the post owner sees the button
- **Form component** - Even for a simple delete action, we use the `Form` component to properly handle the request. The HTTP method is automatically inferred from the route definition
Try it out! Visit a post you created and you'll see both Edit and Delete buttons. Visit a post created by someone else and no buttons appear.
:::
::::
## Adding comment deletion
::::steps
:::step{title="Update the Comment transformer to include authorization"}
Similar to posts, we need to add authorization data to comments. We'll use the same policy pattern we learned earlier. Create a variant in the `CommentTransformer`:
```ts title="app/transformers/comment_transformer.ts"
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Comment from '#models/comment'
import UserTransformer from '#transformers/user_transformer'
// [!code ++:3]
import { inject } from '@adonisjs/core'
import { HttpContext } from '@adonisjs/core/http'
import CommentPolicy from '#policies/comment_policy'
export default class CommentTransformer extends BaseTransformer {
toObject() {
return {
...this.pick(this.resource, ['id', 'content', 'createdAt']),
author: UserTransformer.transform(this.resource.user),
}
}
// [!code ++:13]
/**
* Include authorization data for comments
*/
@inject()
async withAuthorization({ bouncer }: HttpContext) {
return {
...this.toObject(),
can: {
delete: await bouncer.with(CommentPolicy).allows('delete', this.resource),
},
}
}
}
```
:::
:::step{title="Update Post transformer to use comment authorization variant"}
Now update the `PostTransformer` to use the comment authorization variant:
```ts title="app/transformers/post_transformer.ts"
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Post from '#models/post'
import UserTransformer from '#transformers/user_transformer'
import CommentTransformer from '#transformers/comment_transformer'
import { inject } from '@adonisjs/core'
import { HttpContext } from '@adonisjs/core/http'
import PostPolicy from '#policies/post_policy'
export default class PostTransformer extends BaseTransformer {
toObject() {
return {
...this.pick(this.resource, ['id', 'title', 'url', 'summary', 'createdAt']),
author: UserTransformer.transform(this.resource.user),
comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments)),
}
}
@inject()
async forDetailedView({ bouncer }: HttpContext) {
return {
...this.toObject(),
// [!code --:1]
comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments)),
// [!code ++:3]
comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments))
.useVariant('withAuthorization')
.depth(2),
can: {
edit: await bouncer.with(PostPolicy).allows('edit', this.resource),
delete: await bouncer.with(PostPolicy).allows('delete', this.resource),
},
}
}
}
```
:::
:::step{title="Add controller method"}
Let's add the controller method for deleting comments.
```ts title="app/controllers/comments_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import Comment from '#models/comment'
import { createCommentValidator } from '#validators/comment'
// [!code ++:1]
import CommentPolicy from '#policies/comment_policy'
export default class CommentsController {
// ... existing store method
// [!code ++:17]
/**
* Delete a comment
*/
async destroy({ bouncer, params, response, session }: HttpContext) {
const comment = await Comment.findOrFail(params.id)
// Load the post so we can redirect back to it
await comment.load('post')
// Check if the user can delete this comment
await bouncer.with(CommentPolicy).authorize('delete', comment)
await comment.delete()
session.flash('success', 'Comment deleted successfully')
return response.redirect().toRoute('posts.show', { id: comment.post.id })
}
}
```
Here's what this method does:
- **Finds the comment** using the ID from the route parameter
- **Loads the post relationship** so we have access to `comment.post.id` for redirecting after deletion
- **Checks authorization** with `CommentPolicy`. If the user doesn't own the comment, they get a 403 error
- **Deletes the comment** from the database
- **Redirects back** to the post detail page where the comment was displayed
:::
:::step{title="Register the route"}
Now register the delete route for comments.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
router.get('/posts/:id/edit', [controllers.Posts, 'edit']).use(middleware.auth())
router.put('/posts/:id', [controllers.Posts, 'update']).use(middleware.auth())
router.delete('/posts/:id', [controllers.Posts, 'destroy']).use(middleware.auth())
router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())
// [!code ++:3]
router
.delete('/comments/:id', [controllers.Comments, 'destroy'])
.use(middleware.auth())
```
:::
:::step{title="Add delete button to comments"}
Finally, add delete buttons to the comments list in your post detail component.
```tsx title="inertia/pages/posts/show.tsx"
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'
import { Form, Link } from '@adonisjs/inertia/react'
type PageProps = InertiaProps<{
post: Data.Post.Variants['forDetailedView']
}>
export default function PostsShow(props: PageProps) {
const { post } = props
return (
By {comment.author.fullName} on{' '}
{comment.createdAt && new Date(comment.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
// [!code ++:10]
{comment.can?.delete && (
)}
))
) : (
No comments yet.
)}
)
}
```
How the comment delete button works:
- **Policy check** - The `comment.can?.delete` checks if the current user can delete each comment
- **Visibility** - Only the comment's author will see the delete button
- **Action** - The button submits a DELETE request to the `comments.destroy` route
Visit a post with comments you created and you'll see delete buttons next to your comments. Try viewing comments from other users and no delete buttons will appear.
:::
::::
## What you built
You've successfully added authorization to DevShow using Bouncer's policy system. Here's what you accomplished:
- Created `PostPolicy` and `CommentPolicy` to centralize all permission logic in one place
- Used `bouncer.with(Policy).authorize()` in controllers to enforce permissions before allowing actions
- Learned about **transformer variants** - multiple output shapes for the same resource depending on context
- Used the `@inject()` decorator to access the HTTP context in transformer variants
- Pre-computed user permissions in transformers using policies and sent them as `can` flags to the frontend
- Implemented the complete edit post feature with form, validation, and authorization
- Added delete functionality for both posts and comments with proper permission checks
- Used conditional rendering in React components to show action buttons only to authorized users
The key benefit of this approach is that your authorization logic lives entirely on the backend where it can't be bypassed. Bouncer policies run in a secure environment, and transformers send pre-computed permission flags to React components for UI decisions. This keeps your frontend simple while maintaining security.
---
# Contributing
This is a general contribution guide for all of the [AdonisJS](https://github.com/adonisjs) repos. Please read this guide thoroughly before contributing to any of the repos
Code is not the only way to contribute. Following are also some ways to contribute and become part of the community.
- Fixing typos in the documentation
- Improving existing docs
- Writing cookbooks or blog posts to educate others in the community
- Triaging issues
- Sharing your opinion on existing issues
- Help the community on [discord](https://discord.gg/vDcEjq6) and in the discussion forums.
## Reporting bugs
Many issues reported on open source projects are usually questions or misconfiguration at the reporter's end. Therefore, we highly recommend you properly troubleshoot your issues before reporting them.
If you're reporting a bug, include as much information as possible with the code samples you have written. The scale of good to bad issues looks as follows.
- **PERFECT ISSUE**: You isolate the underlying bug. Create a failing test in the repo and open a Github issue around it.
- **GOOD ISSUE**: You isolate the underlying bug and provide a minimal reproduction of it as a Github repo. Antfu has written a great article on [Why Reproductions are Required](https://antfu.me/posts/why-reproductions-are-required).
- **DECENT ISSUE**: You correctly state your issue. Share the code that produces the issue in the first place. Also, include the related configuration files and the package version you use.
Last but not least is to format every code block properly by following the [Github markdown syntax guide](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax).
- **POOR ISSUE**: You dump the question you have with the hope that the other person will ask the relevant questions and help you. These kinds of issues are closed automatically without any explanation.
## Having a discussion
You often want to discuss a topic or maybe share some ideas. In that case, create a discussion in the discussions forum under the **💡Ideas** category.
## Educating others
Educating others is one of the best ways to contribute to any community and earn recognition.
You can use the **📚 Cookbooks** category on our discussion forum to share an article with others. The cookbooks section is NOT strictly moderated, except the shared knowledge should be relevant to the project.
## Creating pull requests
It is never a good experience to have your pull request declined after investing a lot of time and effort in writing the code. Therefore, we highly recommend you to [kick off a discussion](https://github.com/orgs/adonisjs/discussions) before starting any new work on your side.
Just start a discussion and explain what are you planning to contribute?
- **Are you trying to create a PR to fix a bug**: PRs for bugs are mostly accepted once the bug has been confirmed.
- **Are you planning to add a new feature**: Please thoroughly explain why this feature is required and share links to the learning material we can read to educate ourselves.
For example: If you are adding support for snapshot testing to Japa or AdonisJS. Then share the links I can use to learn more about snapshot testing in general.
> Note: You should also be available to open additional PRs for documenting the contributed feature or improvement.
## Repository setup
1. Start by cloning the repo on your local machine.
```sh
git clone
```
2. Install dependencies on your local. Please do not update any dependencies along with a feature request. If you find stale dependencies, create a separate PR to update them.
We use `npm` for managing dependencies, therefore do not use `yarn` or any other tool.
```sh
npm install
```
3. Run tests by executing the following command.
```sh
npm test
```
## Tools in use
Following is the list of tools in use.
| Tool | Usage |
|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| TypeScript | All of the repos are authored in TypeScript. The compiled JavaScript and Type-definitions are published on npm. |
| TS Node | We use [ts-node](https://typestrong.org/ts-node/) to run tests or scripts without compiling TypeScript. The main goal of ts-node is to have a faster feedback loop during development |
| SWC | [SWC](https://swc.rs/) is a Rust based TypeScript compiler. TS Node ships with first-class support for using SWC over the TypeScript official compiler. The main reason for using SWC is the speed gain. |
| Release-It | We use [release-it](https://github.com/release-it/release-it) to publish our packages on npm. It does all the heavy lifting of creating a release and publishes it on npm and Github. Its config is defined within the `package.json` file. |
| ESLint | ESLint helps us enforce a consistent coding style across all the repos with multiple contributors. All our ESLint rules are published under the [eslint-plugin-adonis](https://github.com/adonisjs-community/eslint-plugin-adonis) package. |
| Prettier | We use prettier to format the codebase for consistent visual output. If you are confused about why we are using ESLint and Prettier both, then please read [Prettier vs. Linters](https://prettier.io/docs/en/comparison.html) doc on the Prettier website. |
| EditorConfig | The `.editorconfig` file in the root of every project configures your Code editor to use a set of rules for indentation and whitespace management. Again, Prettier is used for post formatting your code, and Editorconfig is used to configure the editor in advance. |
| Conventional Changelog | All of the commits across all the repos uses [commitlint](https://github.com/conventional-changelog/commitlint/#what-is-commitlint) to enforce consistent commit messages. |
| Husky | We use [husky](https://typicode.github.io/husky/#/) to enforce commit conventions when committing the code. Husky is a git hooks system written in Node |
## Commands
| Command | Description |
|-------|--------|
| `npm run test` | Run project tests using `ts-node` |
| `npm run compile` | Compile the TypeScript project to JavaScript. The compiled output is written inside the `build` directory |
| `npm run release` | Start the release process using `np` |
| `npm run lint` | Lint the codebase using ESlint |
| `npm run format` | Format the codebase using Prettier |
| `npm run sync-labels` | Sync the labels defined inside the `.github/labels.json` file with Github. This command is for the project admin only. |
## Coding style
All of our projects are written in TypeScript and are moving to pure ESM.
- You can learn more about [my coding style here](https://github.com/thetutlage/meta/discussions/3)
- Check out the setup I follow for [ESM and TypeScript here](https://github.com/thetutlage/meta/discussions/2)
Also, make sure to run the following commands before pushing the code.
```sh
# Formats using prettier
npm run format
# Lints using Eslint
npm run lint
```
## Getting recognized as a contributor
We rely on GitHub to list all the repo contributors in the right-side panel of the repo. Following is an example of the same.
Also, we use the [auto generate release notes](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes#about-automatically-generated-release-notes) feature of Github, which adds a reference to the contributor profile within the release notes.
---
# Releases
::releases
---
# Governance
This document is based upon the [Benevolent Dictator Governance Model](http://oss-watch.ac.uk/resources/benevolentdictatorgovernancemodel) by Ross Gardler and Gabriel Hanganu, licensed under a [Creative Commons Attribution-ShareAlike 4.0 International License](https://creativecommons.org/licenses/by-sa/4.0/). This document itself is also licensed under the same license.
## Roles and responsibilities
### Authors
Harminder Virk (the creator of AdonisJS) serves as the Project Author. The project author is responsible for the project's governance, standards, and direction. To summarize:
- The project author decides which new projects should live under the AdonisJS umbrella.
- The project author is responsible for assigning leads to projects and transferring projects to a new lead when an existing lead steps down.
- It is the author's responsibility to share/document the framework's vision and keep project leads in sync with the same.
### Project Leads
AdonisJS is a combination of several packages created and managed by the core team. All of these packages are led by a project lead selected by the project Author.
In almost every case, the creator of the package serves as the project lead since they are the ones who have put the initial efforts into bringing the idea to life.
The project lead has the final say in all aspects of decision-making within the project. However, because the community always has the ability to fork, this person is fully answerable to the community. It is the project lead's responsibility to set the strategic objectives of the project and communicate these clearly to the community. They also have to understand the community as a whole and strive to satisfy as many conflicting needs as possible while ensuring that the project survives in the long term.
In many ways, the role of the project lead is about diplomacy. The key is to ensure that, as the project expands, the right people are given influence over it, and the community rallies behind the vision of the project lead. The lead's job is then to ensure that the core team members (see below) make the right decisions on behalf of the project. Generally speaking, as long as the core team members are aligned with the project's strategy, the project lead will allow them to proceed as desired.
:::note
A project lead cannot archive or decide to remove the project from the AdonisJS umbrella. They can decide to stop working on the project, and in that case, we will find a new project lead.
:::
### Core team
Members of the core team are contributors who have made multiple valuable contributions to the project and are now relied upon to both write code directly to the repository and screen the contributions of others. In many cases, they are programmers, but it is also possible that they contribute in a different role, for example, community engagement. Typically, a core team member will focus on a specific aspect of the project and will bring a level of expertise and understanding that earns them the respect of the community and the project lead. The role of core team member is not an official one, it is simply a position that influential members of the community will find themselves in as the project lead looks to them for guidance and support.
Core team members have no authority over the overall direction of the project. However, they do have the ear of the project lead. It is a core team member's job to ensure that the lead is aware of the community's needs and collective objectives, and to help develop or elicit appropriate contributions to the project. Often, core team members are given informal control over their specific areas of responsibility, and are assigned rights to directly modify certain areas of the source code. That is, although core team members do not have explicit decision-making authority, they will often find that their actions are synonymous with the decisions made by the lead.
#### Active Core Team Members
Active Core Team Members contribute to the project on a regular basis. An active core team member usually has one or more focus areas - in the most common cases, they will be responsible for the regular issue triaging, bug fixing, documentation improvements or feature development in a subproject repository.
#### Core Team Emeriti
Some core team members who have made valuable contributions in the past may no longer be able to commit to the same level of participation today due to various reasons. That is perfectly normal, and any past contributions to the project are still highly appreciated. These core team members are honored for their contributions as Core Team Emeriti, and are welcome to resume active participation at any time.
### Contributors
Contributors are community members who either have no desire to become core team members, or have not yet been given the opportunity by the project lead. They make valuable contributions, such as those outlined in the list below, but generally do not have the authority to make direct changes to the project code. Contributors engage with the project through communication tools, such as the RFC discussions, GitHub issues and pull requests, Discord chatroom, and the forum.
Anyone can become a contributor. There is no expectation of commitment to the project, no specific skill requirements and no selection process. To become a contributor, a community member simply has to perform one or more actions that are beneficial to the project.
Some contributors will already be engaging with the project as users, but will also find themselves doing one or more of the following:
- Supporting new users (current users often provide the most effective new user support)
- Reporting bugs
- Identifying requirements
- Programming
- Assisting with project infrastructure
- Fixing bugs
- Adding features
As contributors gain experience and familiarity with the project, they may find that the project lead starts relying on them more and more. When this begins to happen, they gradually adopt the role of core team member, as described above.
### Users
Users are community members who have a need for the project. They are the most important members of the community: without them, the project would have no purpose. Anyone can be a user; there are no specific requirements.
Users should be encouraged to participate in the life of the project and the community as much as possible. User contributions enable the project team to ensure that they are satisfying the needs of those users. Common user activities include (but are not limited to):
- Evangelizing about the project.
- Informing developers of project strengths and weaknesses from a new user's perspective.
- Providing moral support (a 'thank you' goes a long way).
- Providing financial support through GitHub Sponsors.
Users who continue to engage with the project and its community will often find themselves becoming more and more involved. Such users may then go on to become contributors, as described above.
## Support
All participants in the community are encouraged to provide support for new users within the project management infrastructure. This support is provided as a way of growing the community. Those seeking support should recognize that all support activity within the project is voluntary and is therefore provided as and when time allows. A user requiring guaranteed response times or results should therefore seek to purchase a support contract. However, for those willing to engage with the project on its terms, and willing to help support other users, the community support channels are ideal.
### Monetary Donations
For an open development project, money is less important than active contribution. However, some people or organizations are cash-rich and time-poor and would prefer to make their contribution in the form of cash. If you want to make a significant donation, you may be able to sponsor us to implement a new feature or fix some bugs. The project website provides clear guidance on how to go about donating.
If you run a business using the project as a revenue-generating product, it makes business sense to sponsor its development. It ensures the project that your product relies on stays healthy and actively maintained. It can also improve exposure in our community and make it easier to attract new developers.
## Branding and Ownership
AdonisJS (spelled with "JS" at the end) is a registered trademark of Harminder Virk.
Only the projects under the `@adonisjs` npm scope and the AdonisJS GitHub organization are managed and officially supported by the core team.
Also, you must not use the AdonisJS name or logos in a way that could mistakenly imply any official connection with or endorsement of AdonisJS. Any use of the AdonisJS name or logos in a manner that could cause customer confusion is not permitted.
This includes naming a product or service in a way that emphasizes the AdonisJS brand, like "AdonisJS UIKit" or "AdonisJS Studio", as well as in domain names like "adonisjs-studio.com".
Instead, you must use your own brand name in a way that clearly distinguishes it from AdonisJS.
Additionally, you may not use our trademarks for t-shirts, stickers, or other merchandise without explicit written consent.
## Projects under AdonisJS umbrella
Projects under the AdonisJS umbrella are the intellectual property of the Project Author. Once a project created by a project lead becomes part of the "AdonisJS GitHub organization," or if it is published under the `@adonisjs` npm scope, the project leads cannot delete or abandon the project.
---
# Upgrading from v6 to v7
AdonisJS v7 is a major release after two years of v6. This guide covers the changes you must make to upgrade your existing v6 application.
We have worked hard to keep the breaking changes surface area low. Yet, there are some breaking changes, and certain updates are necessary. At a foundational level:
- AdonisJS v7 requires Node.js 24
- Works with TypeScript 5.9/6.0 and ESLint 10
- And the Vite integration has been updated to work with Vite 7
## Helpful links
- [v6 documentation](https://v6-docs.adonisjs.com) - In case you need to reference the old APIs during the upgrade.
- [Report Upgrade issues](https://github.com/orgs/adonisjs/discussions/5051) - Running into something unexpected? Post it here and we'll help.
## Upgrade to Node.js 24
AdonisJS v7 requires Node.js 24 or above. Older Node.js versions are no longer supported. Make sure you update your local development environment, CI pipelines, and production servers before proceeding with the rest of this guide.
```sh
node -v
```
## Upgrade using a coding agent
Use the following prompt with your coding agent (Cursor, Claude Code, Copilot, etc.) to handle the mechanical parts of the upgrade. Review the changes it makes against the breaking changes listed above.
:upgradeprompt
## Upgrade all packages
Update every `@adonisjs/*` package in your project to its latest version. You must also upgrade `@vinejs/vine`, `edge.js` and Inertia depedencies to their latest versions.
Following is a cross-platform script you can run to automatically find AdonisJS specific dependencies within your project's `package.json` file and update them in one go.
```sh
npm i $(node -e "const pkg = require('./package.json'); const deps = {...pkg.dependencies, ...pkg.devDependencies}; console.log(Object.keys(deps).filter(k => k.startsWith('@adonisjs/') || k === '@vinejs/vine' || k === 'edge.js' || k === '@japa/plugin-adonisjs' || k === 'vite' || k === 'argon2').map(k => k + '@latest').join(' '))") --force
```
## Replace the TypeScript JIT compiler
We have replaced `ts-node` (and `ts-node-maintained`) with `@poppinss/ts-exec` as the JIT compiler. Remove the old packages and install the new one.
```sh
npm uninstall ts-node ts-node-maintained @swc/core
npm install -D @poppinss/ts-exec
```
Then update the import in your `ace.js` file.
```ts title="ace.js"
// [!code --]
import 'ts-node-maintained/register/esm'
// [!code ++]
import '@poppinss/ts-exec'
```
## Install Youch as a project dependency
Youch is no longer bundled inside `@adonisjs/ace` and `@adonisjs/http-server`. It has been rewritten from scratch, but this does not impact your application code since Youch is consumed internally by the framework. You just need to install it as a dev dependency.
```sh
npm install -D youch
```
## Configure hooks in `adonisrc.ts`
v7 introduces a new hooks system in `adonisrc.ts`. You must add the `indexEntities` hook at a minimum. Depending on your stack, you will need additional hooks for Inertia, Tuyau, Bouncer, and Vite.
If your app uses Tuyau, make sure to install the `@tuyau/core` package.
```sh
npm install @tuyau/core
```
The following example shows a complete hooks configuration. Include only the hooks relevant to your stack.
```ts title="adonisrc.ts"
import { indexEntities } from '@adonisjs/core'
import { indexPages } from '@adonisjs/inertia'
import { defineConfig } from '@adonisjs/core/app'
import { indexPolicies } from '@adonisjs/bouncer'
import { generateRegistry } from '@tuyau/core/hooks'
export default defineConfig({
hooks: {
init: [
// [!code highlight:2]
// Always needed
indexEntities(),
// [!code highlight:6]
// If using Inertia (adjust framework to match yours)
indexPages({ framework: 'react' }),
generateRegistry(),
indexEntities({
transformers: { enabled: true, withSharedProps: true },
}),
// [!code highlight:2]
// If using Bouncer
indexPolicies(),
],
buildStarting: [
// [!code highlight:2]
// If using Vite
() => import('@adonisjs/vite/build_hook'),
],
},
})
```
## Assembler hooks have been renamed
The assembler hook names have changed. If you were using the `onBuildStarting` hook (the most common one, used for Vite), update it to `buildStarting`.
```ts title="adonisrc.ts"
{
hooks: {
// [!code --]
onBuildStarting: [() => import('@adonisjs/vite/build_hook')],
// [!code ++]
buildStarting: [() => import('@adonisjs/vite/build_hook')],
}
}
```
The full list of renamed hooks is as follows.
```diff
- onSourceFileChanged
+ fileChanged
- onDevServerStarted
+ devServerStarted
- onBuildCompleted
+ buildFinished
- onBuildStarting
+ buildStarting
+ fileAdded
+ fileRemoved
+ devServerStarting
+ testsStarting
+ testsFinished
```
## Update the tests glob pattern
We replaced the `glob` package with the Node.js built-in glob helper. This requires a small syntax change in the test file patterns inside `adonisrc.ts`.
```ts title="adonisrc.ts"
tests: {
suites: [
{
// [!code --]
files: ['tests/unit/**/*.spec(.ts|.js)'],
// [!code ++]
files: ['tests/unit/**/*.spec.{ts,js}'],
},
],
forceExit: false,
},
```
## Remove the `assetsBundler` property
The `assetsBundler` property in `adonisrc.ts` is no longer in use. Remove it to resolve the TypeScript error you will see after upgrading.
## Encryption config changes
The `appKey` export from `config/app.ts` is no longer used for encryption. Instead, you must create a dedicated `config/encryption.ts` file. The `APP_KEY` environment variable is still in use.
```ts title="config/app.ts"
// [!code --]
export const appKey = env.get('APP_KEY')
```
v6 apps must use the `legacy` driver to continue decrypting existing data. Learn more about new [Encryption drivers](../../guides/security/encryption.md#choosing-an-algorithm)
```ts title="config/encryption.ts"
import env from '#start/env'
import { defineConfig, drivers } from '@adonisjs/core/encryption'
export default defineConfig({
default: 'legacy',
list: {
legacy: drivers.legacy({
keys: [env.get('APP_KEY')],
}),
},
})
```
Resolving the `encryption` binding from the container now returns an instance of `EncryptionManager` instead of the `Encryption` class. This is because the rewritten encryption package supports multiple algorithms and uses a manager to switch between them.
```ts
const encryption = await app.container.make('encryption')
// In v6
encryption instanceof Encryption
// In v7
encryption instanceof EncryptionManager
```
If you were resolving the `Encryption` class from the container and passing it as a dependency, fix this by resolving the class constructor directly.
```ts
import { Encryption } from '@adonisjs/core/encryption'
const encryption = await app.container.make(Encryption)
encryption instanceof Encryption // true
```
## `router.makeUrl` deprecated in favor of URL builder
The `router.makeUrl` and `router.makeSignedUrl` methods have been deprecated. Use the new type-safe `urlFor` helper instead.
```ts
// [!code --:2]
import router from '@adonisjs/core/services/router'
router.makeUrl('posts.show', { id: 1 })
// [!code ++:2]
import { urlFor } from '@adonisjs/core/services/url_builder'
urlFor('posts.show', { id: 1 })
```
Inside Edge templates, the `route` helper is deprecated in favor of `urlFor`.
```edge
// [!code --]
route('posts.show', { id: 1 })
// [!code ++]
urlFor('posts.show', { id: 1 })
```
## Removed helpers
The following helpers have been removed from `@adonisjs/core/helpers`. Each one has a straightforward replacement.
| Removed helper | Replacement |
|---------------|-------------|
| `getDirname` | `import.meta.dirname` |
| `getFilename` | `import.meta.filename` |
| `slash` | `stringHelpers.toUnixSlash` |
| `joinToURL` | `new URL()` |
| `cuid` / `isCuid` | Use UUIDs instead |
| `parseImports` | Use the `parse-imports` package directly |
```ts
// [!code --:3]
import { getDirname, getFilename } from '@adonisjs/core/helpers'
getDirname()
getFilename()
// [!code ++:2]
import.meta.dirname
import.meta.filename
```
```ts
// [!code --:2]
import { slash } from '@adonisjs/core/helpers'
slash('foo\\bar') // foo/bar
// [!code ++:2]
import stringHelpers from '@adonisjs/core/helpers/string'
stringHelpers.toUnixSlash('foo\\bar') // foo/bar
```
## `Request` and `Response` classes renamed
The `Request` and `Response` classes in the HTTP package have been renamed to `HttpRequest` and `HttpResponse`. This avoids conflicts with the globally available platform-native `Request` and `Response` classes.
Most projects will not be affected since the majority of codebases interact with the `HttpContext` object rather than importing these classes directly. However, you will need to update your code if you extend these classes, use module augmentation to add custom properties, or register macros on them.
```ts
// [!code --]
import { Request } from '@adonisjs/core/http'
// [!code ++]
import { HttpRequest } from '@adonisjs/core/http'
declare module '@adonisjs/core/http' {
// [!code --:3]
interface Request {
someMethod(): void
}
// [!code ++:3]
interface HttpRequest {
someMethod(): void
}
}
// [!code --]
Request.macro('someMethod', () => {})
// [!code ++]
HttpRequest.macro('someMethod', () => {})
```
## Flash messages `errors` key removed
The deprecated `errors` key has been removed from the flash messages store.
Validation errors have always been available under the `inputErrorsBag` key. The `errors` key was a duplicate that unnecessarily increased session payload size.
If your templates or frontend code read from `errors`, update them to use `inputErrorsBag` instead.
```edge
// [!code --]
{{ flashMessages.get('errors.email') }}
// [!code ++]
{{ flashMessages.get('inputErrorsBag.email') }}
```
## Multipart files and fields merged in `request.all()`
Calling `request.all()` method now returns a merged object containing both request fields and multipart files. Previously, it only returned fields.
The `request.allFiles()`, `request.file(name)`, and `request.files(name)` methods continue to work as before.
## Auto-generated route names from controllers
Routes that use controllers now automatically receive a generated name. This can cause a conflict:
- If you have two routes pointing to the same controller method where only one of them has an explicit name.
- The auto-generated name for the unnamed route will collide with the other, and a duplicate route error will be thrown at boot time.
- You will catch this immediately when starting your application.
## Status pages skipped for JSON API requests
The status pages rendered by the [global exception handler](../../guides/basics/exception_handling.md#status-pages) are no longer returned when the request's `Accept` header asks for a JSON response. API clients will now receive JSON error responses instead of rendered HTML pages. This was a bug fix, but it changes behavior if your API consumers were previously receiving HTML error pages.
## `BaseModifiers` removed from VineJS
The `BaseModifiers` class has been removed in the latest version of VineJS. In most cases, this will not affect your application. However, if you were extending or directly using `BaseModifiers` for a custom use case, you will need to adjust your implementation. See the [VineJS v4 release notes](https://github.com/vinejs/vine/releases/tag/v4.0.0) for details.
## Application shutdown hooks run in reverse order
Shutdown hooks now execute in reverse order (last registered, first executed). This is a bug fix that aligns with the expected behavior of cleanup logic, but it may affect your app if you relied on the previous (incorrect) ordering.
## Inertia integration rewrite
The Inertia integration has been significantly reworked in v7. The goals were to bring end-to-end type safety to the `render` method and page props, add first-class support for Transformers when computing props, and align with upstream changes in the Inertia ecosystem.
### Type-safe `render` method
The `inertia.render` method is now type-safe. This may surface TypeScript errors in your application where invalid or incomplete data was previously allowed.
### Config changes
The following changes have been made to the `config/inertia.ts` file.
```ts title="config/inertia.ts"
export default defineConfig({
// [!code --]
entrypoint: 'inertia/app/app.tsx',
// [!code --:3]
history: {
encrypt: true,
},
// [!code ++]
encryptHistory: true,
// [!code --:3]
sharedData: {
// ...
},
// Replaced by inertia_middleware (see below)
})
```
- The `entrypoint` config option has been removed. This configuration option was not used anywhere.
- The `history.encrypt` option has been renamed to `encryptHistory` as a top-level property.
- The `sharedData` property has been removed in favor of the Inertia middleware (covered below).
### File structure changes
The Inertia entrypoint and SSR files have been moved out of the `app` subdirectory and now live directly in the `inertia` root.
```diff
- inertia/app/app.tsx
+ inertia/app.tsx
- inertia/app/ssr.tsx
+ inertia/ssr.tsx
```
The exact file extension depends on your framework. For example, Vue apps will use `inertia/app.ts` and `inertia/ssr.ts`.
### Shared data moved to middleware
The `sharedData` property in `config/inertia.ts` has been removed. You must create an Inertia middleware to define shared data instead and register it as a server middleware in the kernel file.
```sh
node ace make:middleware inertia_middleware
```
```ts title="start/kernel.ts"
server.use([
() => import('#middleware/inertia_middleware'),
])
```
The `share` method receives the `HttpContext`, which may not be fully hydrated if a response is sent before the request reaches the route handler (for example, during a 404). Guard your property access carefully.
Following is the default middleware file. Since, you are upgrading from v6, there will not be a `UserTransformer` in your app. So feel free to remove this import and serialize the user as you are doing it in other parts of your codebase.
```ts title="app/middleware/inertia_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import UserTransformer from '#transformers/user_transformer'
import BaseInertiaMiddleware from '@adonisjs/inertia/inertia_middleware'
export default class InertiaMiddleware extends BaseInertiaMiddleware {
share(ctx: HttpContext) {
/**
* The share method is called every time an Inertia page is rendered. In
* certain cases, a page may get rendered before the session middleware
* or the auth middleware are executed. For example: During a 404 request.
*
* In that case, we must always assume that HttpContext is not fully hydrated
* with all the properties.
*/
const { session, auth } = ctx as Partial
/**
* Data shared with all Inertia pages. Make sure you are using
* transformers for rich data-types like Models.
*/
return {
errors: ctx.inertia.always(this.getValidationErrors(ctx)),
flash: ctx.inertia.always({
error: session?.flashMessages.get('error'),
success: session?.flashMessages.get('success'),
}),
user: ctx.inertia.always(auth?.user ? UserTransformer.transform(auth.user) : undefined),
}
}
async handle(ctx: HttpContext, next: NextFn) {
await this.init(ctx)
const output = await next()
this.dispose(ctx)
return output
}
}
declare module '@adonisjs/inertia/types' {
type MiddlewareSharedProps = InferSharedProps
export interface SharedProps extends MiddlewareSharedProps {}
}
```
### Add `tsconfig.inertia.json`
You must create a new `tsconfig.inertia.json` file to avoid circular reference issues between the Inertia frontend codebase and the backend codebase.
This circular reference occurs because the codegen has the Inertia app referencing backend code, and the backend code referencing Inertia pages for inferring prop types.
Create the file in the root of your project.
```json title="tsconfig.inertia.json"
{
"extends": "./inertia/tsconfig.json",
"compilerOptions": {
"rootDir": "./inertia",
"composite": true
},
"include": ["./inertia/**/*.ts", "./inertia/**/*.tsx"]
}
```
Then add a `references` entry to your main `tsconfig.json`.
```json title="tsconfig.json"
{
"extends": "@adonisjs/tsconfig/tsconfig.app.json",
"compilerOptions": {
"rootDir": "./",
"jsx": "react",
"outDir": "./build"
},
// [!code ++]
"references": [{ "path": "./tsconfig.inertia.json" }]
}
```
## Add new subpath imports to `package.json`
Add the following entries to the `imports` field in your `package.json`. These are in addition to any existing aliases your project already has.
```json title="package.json"
{
"imports": {
"#generated/*": "./.adonisjs/server/*.js",
"#transformers/*": "./app/transformers/*.js",
"#database/*": "./database/*.js"
}
}
```
---
# Routing
This guide covers routing in AdonisJS applications. You will learn how to:
- Define routes for different HTTP methods
- Handle dynamic route parameters with validation
- Organize routes into groups with shared configuration
- Generate RESTful resource routes
- Apply middleware to routes
- Register domain-specific routes
- Build type-safe URLs using the URL builder
- Extend the router with custom functionality
## Overview
Routing connects incoming HTTP requests to specific handlers in your application. When a user visits URLs like `/`, `/about`, or `/posts/1`, the router examines the HTTP method and URL pattern, then executes the appropriate handler function. This is the foundation of how your application responds to web requests.
A route consists of three main components:
- **HTTP method** – The type of request (GET, POST, PUT, DELETE, etc.)
- **URI pattern** – The URL path that should match, which can include dynamic segments
- **Handler** – The function or [controller](./controllers.md) method that processes the request and returns a response
Routes can also include [middleware](./middleware.md) for authentication, rate-limiting, or any logic that should run before the handler executes. Every HTTP request your application handles flows through the routing system, making it essential to understand how routes work and how to organize them effectively.
## Basic example
In AdonisJS, routes are defined inside the `start/routes.ts` file using the router service.
A route handler is the function that runs when a route matches. It receives the HTTP context and can return a string, an object, or call services to produce a response.
The following example shows static routes and a dynamic route using `:id`, which matches any value passed in that segment.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/', () => 'Hello world from the home page.')
router.get('/about', () => 'This is the about page.')
router.get('/posts/:id', ({ params }) => {
return `This is post with id ${params.id}`
})
router.post('/users', async ({ request }) => {
const data = request.all()
await createUser(data)
return 'User created successfully'
})
```
### Using a controller as a route handler
Instead of inline callbacks, you can delegate request handling to a controller method. Controllers help organize logic into dedicated classes and make handlers reusable across multiple routes.
See also: [Controllers guide](./controllers.md) and [HTTP Context documentation](./http_context.md)
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('/posts/:id', [controllers.Posts, 'show'])
```
## Viewing registered routes
You can view all routes registered by your application using the Ace CLI command below. This is helpful for debugging, verifying route names, or checking which middleware is attached to specific routes.
```sh
node ace list:routes
```
:::media

:::
If you're using the [official VSCode extension](https://marketplace.visualstudio.com/items?itemName=jripouteau.adonis-vscode-extension), routes are also visible directly from the VSCode activity bar, making it easy to navigate your application's endpoints.
:::media

:::
## Route params
Route params allow parts of the URL to be dynamic, capturing values from specific segments and making them available in your handler. Each param matches any value in that position and is accessible via `ctx.params`.
### Basic route params
A basic route param is defined with a colon `:` followed by a name. The captured value can be accessed in your handler through the `params` object.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/posts/:id', ({ params }) => {
return `Showing post with id: ${params.id}`
})
```
When someone visits `/posts/42`, the value `42` is captured and `params.id` equals `"42"` (as a string).
### Multiple route params
You can include more than one param in a single route. Each param must have a unique name and is separated by `/`.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/posts/:id/comments/:commentId', ({ params }) => {
console.log(params.id) // Post ID
console.log(params.commentId) // Comment ID
})
```
This matches URLs like `/posts/42/comments/7`, capturing both values.
### Optional route params
Sometimes, a parameter is not always required. You can mark it optional by appending `?` to its name. Optional params must be the last segment in the route pattern.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/posts/:id?', ({ params }) => {
if (!params.id) {
return 'Showing all posts'
}
return `Showing post with id ${params.id}`
})
```
This route matches both `/posts` and `/posts/42`.
### Wildcard route params
A wildcard param captures all remaining segments of the URL as an array. It is defined using `*` and must appear last in the pattern.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/docs/:category/*', ({ params }) => {
console.log(params.category) // 'guides'
console.log(params['*']) // ['sql', 'orm', 'query-builder']
})
```
When someone visits `/docs/guides/sql/orm/query-builder`, the wildcard captures `['sql', 'orm', 'query-builder']`.
Use wildcard params for:
- Documentation paths with nested sections
- File browsers with directory structures
- Catch-all routes that need to capture arbitrary depth
## Route param validation
By default, route params accept any value and are always passed to your handler as strings. You can restrict which values are valid and automatically cast them to the correct type using the `.where()` method.
When a param fails validation, the router skips that route and continues searching for other matching routes. This allows you to have multiple routes with the same pattern but different validation rules.
### Why validate params
Without validation, you need to manually check and convert params in every handler.
```ts title="❌ Without param validation"
router.get('/posts/:id', ({ params, response }) => {
if (!/^[0-9]+$/.test(params.id)) {
return response.badRequest('Invalid ID format')
}
const id = Number(params.id)
// Now use id...
})
```
With param validation, the router handles this automatically before your handler runs.
```ts title="✅ With param validation"
router
.get('/posts/:id', ({ params }) => {
console.log(typeof params.id) // 'number'
// params.id is already validated and cast to number
})
.where('id', {
match: /^[0-9]+$/,
cast: (value) => Number(value),
})
```
Use param validation to:
- Ensure IDs are numeric before querying databases
- Validate UUIDs match the correct format
- Verify slugs contain only URL-safe characters
- Prevent invalid data from reaching your handler
- Automatically cast strings to proper types (number, boolean, etc.)
### Custom matchers
The `.where()` method accepts an object with two properties: `match` for validation and `cast` for type conversion.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router
.get('/posts/:id', ({ params }) => {
console.log(typeof params.id) // 'number'
console.log(params.id) // 42 (not "42")
})
.where('id', {
match: /^[0-9]+$/, // Only digits allowed
cast: (value) => Number(value), // Convert string to number
})
```
If someone visits `/posts/abc`, this route won't match because "abc" fails the regex test. The router continues searching for other routes, or returns 404 if none match.
### Built-in matchers
For common patterns like numbers, UUIDs, and slugs, AdonisJS provides shorthand matchers that handle both validation and type casting.
| Matcher | Validates | Casts To | Example Use Case |
|---------|-----------|----------|------------------|
| `number()` | Digits only (`/^\d+$/`) | `number` | Database IDs, pagination offsets |
| `uuid()` | Valid UUID v4 format | `string` | Public resource identifiers, secure IDs |
| `slug()` | URL-safe strings (`/^[a-z0-9-_]+$/`) | `string` | SEO-friendly URLs, article slugs |
```ts title="Numeric IDs"
import router from '@adonisjs/core/services/router'
router
.get('/posts/:id', ({ params }) => {
console.log(typeof params.id) // 'number'
})
.where('id', router.matchers.number())
```
```ts title="UUID identifiers"
import router from '@adonisjs/core/services/router'
router
.get('/users/:userId', ({ params }) => {
console.log(params.userId) // '550e8400-e29b-41d4-a716-446655440000'
})
.where('userId', router.matchers.uuid())
```
```ts title="URL-friendly slugs"
import router from '@adonisjs/core/services/router'
router
.get('/articles/:slug', ({ params }) => {
console.log(params.slug) // 'getting-started-with-adonisjs'
})
.where('slug', router.matchers.slug())
```
### Global matchers
You can apply matchers globally so every route inherits the same validation rules automatically. This is useful when most of your routes follow a convention, like using UUIDs for all IDs.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
// Set a global default: all :id params must be UUIDs
router.where('id', router.matchers.uuid())
// These routes automatically inherit the UUID matcher
router.get('/posts/:id', () => {})
router.get('/users/:id', () => {})
// Override for a specific route that needs numeric IDs
router
.get('/categories/:id', () => {})
.where('id', router.matchers.number())
```
Global matchers are applied first, then route-specific matchers override them. This pattern helps maintain consistency while allowing exceptions where needed.
## HTTP methods
The router provides dedicated methods for each standard HTTP verb. Each method corresponds to a specific type of operation in RESTful applications.
| Method | Purpose | Common Use Case |
|--------|---------|-----------------|
| `GET` | Retrieve resources | Display a list of users, show a blog post, fetch data |
| `POST` | Create new resources | Submit a form, create a new user, upload a file |
| `PUT` | Replace entire resources | Update all fields of a user profile |
| `PATCH` | Update partial resources | Update only the email field of a user |
| `DELETE` | Remove resources | Delete a blog post, remove a user account |
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/users', () => {}) // List all users
router.post('/users', () => {}) // Create a new user
router.put('/users/:id', () => {}) // Replace user completely
router.patch('/users/:id', () => {}) // Update specific user fields
router.delete('/users/:id', () => {}) // Delete a user
```
### Matching multiple methods
To match all HTTP methods or specify custom verbs:
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
// Matches GET, POST, PUT, PATCH, DELETE, and all other methods
router.any('/reports', () => {})
// Match specific custom HTTP methods
router.route('/', ['TRACE'], () => {})
router.route('/api/data', ['GET', 'POST', 'PUT'], () => {})
```
The `.any()` method is useful for endpoints that need to respond to any HTTP method, such as webhook receivers or catch-all debugging routes.
## Route middleware
**Middleware** are functions that execute before your route handler, allowing you to run code like authentication checks, logging, rate limiting, or request transformation. Think of middleware as a series of checkpoints that requests pass through before reaching your main handler.
See also: [Middleware guide](./middleware.md)
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
router
.get('/posts', () => {
console.log('Inside route handler')
return 'Viewing all posts'
})
.use(middleware.auth())
```
You can also attach inline middleware directly.
```ts title="start/routes.ts"
router
.get('/posts', () => {
console.log('Inside route handler')
return 'Viewing all posts'
})
.use((ctx, next) => {
console.log('Inside middleware')
return next()
})
```
## Route identifiers
Each route can have a unique **name** that you can use to generate URLs or redirects without hardcoding paths. This keeps your URLs maintainable, if you change a route's path, all references automatically update when using the name.
Named routes are essential for:
- Generating URLs in templates without hardcoding paths
- Creating redirects that survive URL changes
- Building navigation menus programmatically
- Organizing routes with meaningful identifiers
You can provide unique names to routes using the `.as` method.
:::note
When using controllers, routes are automatically named after the `controller+method` name.
:::
See also: [URL builder](./url_builder.md)
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/users', () => {}).as('users.index')
router.post('/users', () => {}).as('users.store')
router.get('/users/:id', () => {}).as('users.show')
router.delete('/users/:id', () => {}).as('users.destroy')
```
## Grouping routes
Route groups let you apply shared configuration to multiple routes at once, eliminating repetition and making your route file easier to maintain. Without groups, you'd need to repeat the same prefix, middleware, or naming convention on every individual route, creating duplication that becomes error-prone as your application grows.
Use route groups when you have multiple routes that share any of the following:
- **URL prefix** – API versions (`/v1`, `/v2`), admin sections (`/admin`), or language codes (`/en`, `/es`)
- **Middleware** – Authentication, authorization, rate limiting, or CORS settings
- **Naming convention** – Namespace prefixes for organized route names
- **Domain** – Multi-tenant applications with subdomain routing
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
router
.group(() => {
router.get('/users', () => {}).as('users.index')
router.post('/users', () => {}).as('users.store')
router.get('/posts', () => {}).as('posts.index')
router.post('/posts', () => {}).as('posts.store')
})
.prefix('/api')
.use(middleware.auth())
.as('api')
```
### Prefixing routes
Prefixes are prepended to all routes inside a group. This is especially useful for API versioning, admin areas, or organizing related resources under a common path segment.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router
.group(() => {
router.get('/users', () => {}) // Becomes: GET /api/users
router.get('/payments', () => {}) // Becomes: GET /api/payments
router.get('/invoices', () => {}) // Becomes: GET /api/invoices
})
.prefix('/api')
```
### Naming routes inside a group
When routes inside a group have names, you can prefix their names as well. This creates organized route namespaces that make it clear which routes belong together.
Named route groups make URL generation clearer and help you avoid naming collisions between different sections of your application. For example, you might have both `web.users.index` and `api.users.index` routes that serve different purposes.
See also: [URL builder](./url_builder.md)
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router
.group(() => {
router.get('/users', () => {}).as('users.index') // Name: api.users.index
router.post('/users', () => {}).as('users.store') // Name: api.users.store
router.get('/posts', () => {}).as('posts.index') // Name: api.posts.index
})
.prefix('/api')
.as('api')
```
### Applying middleware to a group
You can attach middleware at the group level. Group middleware executes before any route-level middleware, creating a pipeline where shared logic runs first, followed by route-specific logic.
See also: [Middleware guide](./middleware.md)
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
router
.group(() => {
router
.get('/posts', () => {
console.log('3. Inside route handler')
})
.use((_, next) => {
console.log('2. Route-level middleware')
return next()
})
})
.use((_, next) => {
console.log('1. Group-level middleware')
return next()
})
```
## Resource routes
Resource routes automatically generate all standard RESTful routes for a controller, eliminating the need to manually define each CRUD route. This is particularly valuable when building traditional web applications with full CRUD interfaces or RESTful APIs.
This single line generates all the following routes with correct HTTP methods, URL patterns, and route names following RESTful conventions.
See also: [Resource driven controllers](./controllers.md#resource-driven-controllers)
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.resource('posts', controllers.Posts)
```
::::options
:::option{name="GET /posts"}
- **Action:** `PostsController.index`
- **Name:** `posts.index`
- **Purpose:** Display a list of all posts
:::
:::option{name="GET /posts/create"}
- **Action:** `PostsController.create`
- **Name:** `posts.create`
- **Purpose:** Show form to create a new post
:::
:::option{name="POST /posts"}
- **Action:** `PostsController.store`
- **Name:** `posts.store`
- **Purpose:** Store a newly created post
:::
:::option{name="GET /posts/:id"}
- **Action:** `PostsController.show`
- **Name:** `posts.show`
- **Purpose:** Display a specific post
:::
:::option{name="GET /posts/:id/edit"}
- **Action:** `PostsController.edit`
- **Name:** `posts.edit`
- **Purpose:** Show form to edit a post
:::
:::option{name="PUT|PATCH /posts/:id"}
- **Action:** `PostsController.update`
- **Name:** `posts.update`
- **Purpose:** Update a specific post
:::
:::option{name="DELETE /posts/:id"}
- **Action:** `PostsController.destroy`
- **Name:** `posts.destroy`
- **Purpose:** Delete a specific post
:::
::::
## Registering routes for specific domains
Sometimes, you may want certain routes to respond only to a specific domain or subdomain. This is useful for multi-tenant applications, separate admin dashboards, or serving different content based on the hostname.
Use `.domain()` to group routes by hostname:
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router
.group(() => {
router.get('/articles', () => {})
router.get('/articles/:id', () => {})
})
.domain('blog.adonisjs.com')
```
These routes only respond when the request comes to `blog.adonisjs.com`. Requests to other domains will not match these routes.
### Dynamic subdomains
You can define dynamic segments in the domain name, just like route params. This is essential for multi-tenant applications where each customer gets their own subdomain.
Use domain-specific routing for:
- Multi-tenant SaaS applications with customer subdomains
- Separate admin dashboards on `admin.yourapp.com`
- Regional sites like `uk.yourapp.com` and `us.yourapp.com`
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router
.group(() => {
router.get('/users', ({ subdomains }) => {
return `Listing users for ${subdomains.tenant}`
})
router.get('/dashboard', ({ subdomains }) => {
return `Dashboard for ${subdomains.tenant}`
})
})
.domain(':tenant.adonisjs.com')
```
When someone visits `acme.adonisjs.com/users`, `subdomains.tenant` equals `"acme"`. When they visit `bigcorp.adonisjs.com/users`, it equals `"bigcorp"`.
## Render Edge view from a route
If a route's only purpose is to render a view without any logic, use `router.on().render()` for brevity. This eliminates the need for a controller when you're simply displaying a static or simple dynamic page.
The first argument is the view template name, and the optional second argument is data to pass to the view.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.on('/').render('home')
router.on('/about').render('about', { title: 'About us' })
router.on('/contact').render('contact', { title: 'Contact us', email: 'hello@example.com' })
```
## Render Inertia view from a route
For Inertia.js apps, use the similar `router.on().renderInertia()` method to render Inertia pages directly from routes without a controller.
This renders the corresponding Vue, React, or Svelte component through Inertia with the provided props.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.on('/').renderInertia('home')
router.on('/about').renderInertia('about', { title: 'About us' })
router.on('/contact').renderInertia('contact', { title: 'Contact us' })
```
## Redirect from a route
You can redirect one path to another using `redirectToRoute()` or `redirectToPath()`. This is useful for handling deprecated URLs, shortening URLs, or creating aliases.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
// Redirect to a named route
router.on('/posts').redirectToRoute('articles.index')
// Redirect to a static URL
router.on('/posts').redirectToPath('https://medium.com/my-blog')
```
### Forwarding and overriding params
If your route has dynamic params, you can forward them to the destination route or override them with specific values.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
// Forward the :id param to the destination route
router.on('/posts/:id').redirectToRoute('articles.show')
// Override the param with a specific value
router.on('/featured').redirectToRoute('articles.show', { id: 1 })
```
When someone visits `/posts/42`, they're redirected to the `articles.show` route with `id: 42`. When they visit `/featured`, they're redirected with `id: 1`.
### Adding query strings
Redirects can also include query strings via the `qs` option:
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.on('/posts').redirectToRoute('articles.index', {}, {
qs: { limit: 20, page: 1 },
})
```
This redirects to `/articles?limit=20&page=1`.
## Accessing the current route
You can access the currently matched route from the [HTTP context](./http_context.md) via `ctx.route`. This is useful for debugging, auditing, logging, or implementing route-aware logic like breadcrumbs or active navigation.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/payments', ({ route }) => {
console.log(route.pattern) // '/payments'
console.log(route.name) // 'payments.index' (if named)
console.log(route.methods) // ['GET']
})
```
### Checking if a route matches
To check if the current request matches a specific named route, use `request.matchesRoute()`:
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router
.get('/posts/:id', ({ request }) => {
if (request.matchesRoute('posts.show')) {
console.log('Matched posts.show')
// Do something specific for this route
}
})
.as('posts.show')
```
This is particularly useful in middleware or shared logic where you need different behavior based on the current route.
## How AdonisJS matches routes
Routes are matched in the order you register them. When a request comes in, the router checks each route sequentially until it finds the first match, then stops searching and executes that route's handler.
This sequential matching means route order matters critically when patterns overlap. If you define a dynamic route before a static route with the same prefix, the dynamic route will capture requests intended for the static route.
:::note
**Route order matters**: Always define static routes before dynamic routes. When someone visits `/posts/archived`, a route pattern `/posts/:id` defined first will match with `id = "archived"` instead of letting the `/posts/archived` route handle it.
:::
### Ordering routes correctly
Here's what happens with incorrect ordering:
```ts title="❌ Wrong order - Dynamic route defined first"
router.get('/posts/:id', ({ params }) => {
return `Showing post ${params.id}`
// When visiting /posts/archived, this matches with params.id = "archived"
})
router.get('/posts/archived', () => {
return 'Showing archived posts'
// This never executes because the route above already matched
})
```
The correct approach is to define specific routes before dynamic ones:
```ts title="✅ Correct Order - Static routes first"
router.get('/posts/archived', () => {
return 'Showing archived posts'
})
router.get('/posts/trending', () => {
return 'Showing trending posts'
})
router.get('/posts/:id', ({ params }) => {
return `Showing post ${params.id}`
})
```
**Quick rule**: Order your routes from most specific to least specific. Static segments always beat dynamic ones, so static routes must come first.
```ts title="start/routes.ts"
// Group all static action routes together
router.get('/posts/archived', () => {})
router.get('/posts/trending', () => {})
router.get('/posts/search', () => {})
// Then define dynamic routes
router.get('/posts/:id', () => {})
router.get('/posts/:id/comments', () => {})
```
### Handling 404 requests
When no route matches an incoming request, AdonisJS raises an `E_ROUTE_NOT_FOUND` exception. You can catch this exception in your global exception handler to render a custom 404 page or return a structured JSON error.
See also: [Exception handling guide](./exception_handling.md)
```ts title="app/exceptions/handler.ts"
import { errors } from '@adonisjs/core'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
async handle(error: unknown, ctx: HttpContext) {
/**
* For API requests, return JSON instead
*/
if (error instanceof errors.E_ROUTE_NOT_FOUND && ctx.request.accepts(['json'])) {
return ctx.response.status(404).json({
error: 'Route not found',
message: `Cannot ${ctx.request.method()} ${ctx.request.url()}`
})
}
/**
* Handle route not found errors by rendering a custom 404 page
*/
if (error instanceof errors.E_ROUTE_NOT_FOUND) {
return ctx.view.render('errors/404')
}
return super.handle(error, ctx)
}
}
```
## Extending the Router
AdonisJS classes like `Router`, `Route`, and `RouteGroup` can be extended using macros or getters. This allows you to add custom methods that behave like native APIs, useful when building reusable packages or adding organization-specific conventions to your routing layer.
:::note
Read the [Extending AdonisJS](../concepts/extending_adonisjs.md) guide if you are new to the concept of macros and getters.
:::
### Router
Add methods or properties directly to the `Router` class:
```ts title="start/routes.ts"
import { Router } from '@adonisjs/core/http'
Router.macro('health', function (this: Router) {
this.get('/health', () => {
return { status: 'ok' }
})
})
Router.getter('version', function (this: Router) {
return '1.0.0'
})
```
### Route
Extend individual route instances:
```ts title="start/routes.ts"
import { Route } from '@adonisjs/core/http'
Route.macro('tracking', function (this: Route, eventName: string) {
return this.use((ctx, next) => {
console.log(`Tracking event: ${eventName}`)
return next()
})
})
Route.getter('isPublic', function (this: Route) {
return !this.middleware.includes('auth')
})
```
### RouteGroup
Extend route groups:
```ts title="start/routes.ts"
import { RouteGroup } from '@adonisjs/core/http'
RouteGroup.macro('apiVersion', function (this: RouteGroup, version: string) {
return this.prefix(`/api/${version}`)
})
RouteGroup.getter('routeCount', function (this: RouteGroup) {
return this.routes.length
})
```
### RouteResource
Extend resource routes:
```ts title="start/routes.ts"
import { RouteResource } from '@adonisjs/core/http'
RouteResource.macro('softDeletes', function (this: RouteResource) {
return this.except(['destroy'])
})
RouteResource.getter('hasDestroy', function (this: RouteResource) {
return !this.except.includes('destroy')
})
```
### BriskRoute
Extend render shortcuts (`router.on().render()`):
```ts title="start/routes.ts"
import { BriskRoute } from '@adonisjs/core/http'
BriskRoute.macro('withLayout', function (this: BriskRoute, layout: string) {
return this.render(this.view, { ...this.data, layout })
})
BriskRoute.getter('hasData', function (this: BriskRoute) {
return Object.keys(this.data).length > 0
})
```
## Next steps
Now that you understand routing, you can:
- Build [controllers](./controllers.md) to organize your route handlers
- Add [middleware](./middleware.md) for authentication and request processing
- Learn about [HTTP context](./http_context.md) to access request data
- Explore [validation](./validation.md) to secure route inputs
- Study [exception handling](./exception_handling.md) for error responses
---
# Controllers
This guide covers controllers in AdonisJS applications. You will learn how to:
- Create and organize controllers to handle HTTP requests
- Use the barrel file system for importing controllers
- Understand the controller lifecycle and request handling
- Inject dependencies into controllers using the IoC container
- Build RESTful resource-driven controllers following conventions
- Configure controller locations and barrel file generation
:::note
**Prerequisite**: You should be familiar with [routing](./routing.md) before learning about controllers, as controllers are connected to your application through routes.
:::
## Overview
Controllers organize route handlers into dedicated JavaScript classes, solving the problem of route file bloat. Instead of defining all your route logic inline, controllers let you group related request handlers into a single class, where each method (called an action) handles a specific route.
A typical controller represents a resource (like Users, Posts, or Comments) and defines actions for creating, reading, updating, and deleting that resource. Controllers keep your routes file clean and readable, enable dependency injection for services and other dependencies, and follow RESTful conventions for resource-based CRUD operations.
Without controllers, your routes file becomes cluttered with inline handlers.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/posts', async () => {
// Logic to fetch all posts
return { posts: [] }
})
router.get('/posts/:id', async ({ params }) => {
// Logic to fetch a single post
return { post: {} }
})
router.post('/posts', async ({ request }) => {
// Logic to create a post
return { post: {} }
})
// This file becomes unmanageable as routes grow
```
With controllers, you organize handlers into reusable classes.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
// Clean, organized route definitions
router.get('/posts', [controllers.Posts, 'index'])
router.get('/posts/:id', [controllers.Posts, 'show'])
router.post('/posts', [controllers.Posts, 'store'])
```
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async index({ serialize }: HttpContext) {
// Logic to fetch all posts
return serialize({ posts: [] })
}
async show({ params, serialize }: HttpContext) {
// Logic to fetch a single post
return serialize({ post: {} })
}
async store({ request, serialize }: HttpContext) {
// Logic to create a post
return serialize({ post: {} })
}
}
```
## Creating your first controller
::::steps
:::step{title="Generate the controller"}
Controllers are stored in the `app/controllers` directory. The easiest way to create a controller is using the `make:controller` command.
```bash
node ace make:controller posts
```
```
# Output
DONE: create app/controllers/posts_controller.ts
```
This command creates a controller scaffolded with a plain JavaScript class and a default export.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
}
```
:::
:::step{title="Add your first action"}
A controller action is simply a method that handles an HTTP request. Let's add an `index` method to list all posts.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
// [!code ++:11]
/**
* Handle GET requests to list all posts
*/
async index({ response }: HttpContext) {
const posts = [
{ id: 1, title: 'Getting started with AdonisJS' },
{ id: 2, title: 'Understanding controllers' },
]
return response.json({ posts })
}
}
```
A few important things to know about controller actions:
- The first parameter is always the **HTTPContext** object
- You can destructure specific properties like `request`, `response`, `params`, `session`, or `auth`
- Controller methods can return values directly (objects, arrays) or explicitly call `response.json()` or `response.send()`
:::
:::step{title="Connect the controller to a route"}
Now bind your controller action to a route.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
// [!code ++:3]
import { controllers } from '#generated/controllers'
router.get('/posts', [controllers.Posts, 'index'])
```
The first argument (`controllers.Posts`) references your `PostsController` class, while the second argument (`'index'`) specifies which method to call. The controller is lazy-loaded, meaning it's only imported when the route is accessed.
:::
:::step{title="Test it out"}
Start your development server if it's not already running.
```bash
node ace serve --hmr
```
Visit [`http://localhost:3333/posts`](http://localhost:3333/posts) in your browser. You should see the JSON response from your controller.
```json
{
"posts": [
{ "id": 1, "title": "Getting started with AdonisJS" },
{ "id": 2, "title": "Understanding controllers" }
]
}
```
:::
::::
## The barrel file
The `#generated/controllers` import you used in the routing step is powered by a **barrel file** - a single file that consolidates all your controller imports into one convenient location. **This barrel file is automatically generated and maintained by AdonisJS**.
The barrel file is located at `.adonisjs/server/controllers.ts` and is automatically created when you start your development server. It stays up-to-date as you add or remove controllers.
Without the barrel file, you would need to manually import each controller individually in your routes file.
```ts title="❌ Without barrel file"
import router from '@adonisjs/core/services/router'
const PostsController = () => import('#controllers/posts_controller')
const UsersController = () => import('#controllers/users_controller')
const CommentsController = () => import('#controllers/comments_controller')
// ...dozens more imports as your app grows
router.get('/posts', [PostsController, 'index'])
router.get('/users', [UsersController, 'index'])
router.get('/comments', [CommentsController, 'index'])
```
The barrel file eliminates this repetition.
```ts title="✅ With barrel file"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('/posts', [controllers.Posts, 'index'])
router.get('/users', [controllers.Users, 'index'])
router.get('/comments', [controllers.Comments, 'index'])
```
See also: [Barrel files generation guide](../concepts/barrel_files.md) for detailed configuration options.
## Understanding controller lifecycle
Controllers in AdonisJS are **instantiated per request**. Every time an HTTP request matches a route bound to a controller, AdonisJS creates a fresh instance of that controller class using the IoC container.
This means:
- Each request gets its own isolated controller instance
- No risk of state leakage between requests
- You can safely use instance properties if needed
- The controller instance is garbage collected after the request completes
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
// This property is unique to each request
#requestId = Math.random()
async index({ response }: HttpContext) {
// Each request will see a different requestId
return response.json({ requestId: this.#requestId })
}
}
```
## Dependency injection
Controllers support dependency injection, allowing you to inject services, repositories, or other classes into your controller methods or constructors. The IoC container automatically resolves and injects these dependencies for you.
See also: [Dependency Injection guide](../concepts/dependency_injection.md) for a comprehensive understanding of how dependency injection works in AdonisJS.
### Constructor injection
Constructor injection injects dependencies once when the controller is instantiated. Use this when all or most methods in your controller need the same dependencies.
```ts title="app/controllers/users_controller.ts"
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import UserService from '#services/user_service'
@inject() // [!code highlight]
export default class UsersController {
// [!code highlight]
constructor(protected userService: UserService) {}
async index(ctx: HttpContext) {
return this.userService.all()
}
async show({ params }: HttpContext) {
return this.userService.find(params.id)
}
async store({ request }: HttpContext) {
const data = request.all()
return this.userService.create(data)
}
}
```
- The `@inject()` decorator tells AdonisJS to use dependency injection for this controller.
- Dependencies are type-hinted in the constructor parameters, and the IoC container automatically resolves and injects them when the controller is instantiated.
### Method injection
Method injection injects dependencies into individual controller methods. Use this when only specific methods need certain dependencies, or different methods require different dependencies.
```ts title="app/controllers/users_controller.ts"
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import UserService from '#services/user_service'
import EmailService from '#services/email_service'
export default class UsersController {
async index(ctx: HttpContext) {
return [{ id: 1, name: 'John' }]
}
// [!code highlight:2]
@inject()
async store(ctx: HttpContext, userService: UserService) {
const data = ctx.request.all()
return userService.create(data)
}
// [!code highlight:6]
@inject()
async sendEmail(
ctx: HttpContext,
userService: UserService,
emailService: EmailService
) {
const user = await userService.find(ctx.params.id)
await emailService.send(user.email, 'Welcome!')
return { sent: true }
}
}
```
With method injection:
- The `@inject()` decorator is applied to individual methods rather than the class.
- The first parameter must always be HTTPContext, with dependencies following after.
- This allows each method to have different dependencies based on its specific needs.
## Resource-driven controllers
Resource-driven controllers follow RESTful conventions for handling CRUD (Create, Read, Update, Delete) operations on a resource. AdonisJS provides special routing methods that automatically map HTTP verbs to standard controller methods.
### The seven resourceful actions
A typical resourceful controller defines seven methods that handle all CRUD operations.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
/**
* Display a list of all posts
* GET /posts
*/
async index({ response }: HttpContext) {
const posts = [] // Fetch from database
return response.json({ posts })
}
/**
* Render a form to create a new post
* GET /posts/create
*
* Not needed for API-only applications
*/
async create({ view }: HttpContext) {
return view.render('posts/create')
}
/**
* Handle form submission to create a new post
* POST /posts
*/
async store({ request }: HttpContext) {
const data = request.all()
// Create post in database
return { post: data }
}
/**
* Display a single post by id
* GET /posts/:id
*/
async show({ params }: HttpContext) {
// Fetch post from database
return { post: { id: params.id } }
}
/**
* Render a form to edit an existing post
* GET /posts/:id/edit
*
* Not needed for API-only applications
*/
async edit({ params, view }: HttpContext) {
return view.render('posts/edit', { id: params.id })
}
/**
* Handle form submission to update a post
* PUT/PATCH /posts/:id
*/
async update({ params, request }: HttpContext) {
const data = request.all()
// Update post in database
return { post: { id: params.id, ...data } }
}
/**
* Delete a post by id
* DELETE /posts/:id
*/
async destroy({ params }: HttpContext) {
// Delete post from database
return { deleted: true }
}
}
```
### Generating resourceful controllers
Create a controller with all seven methods pre-filled using the `--resource` flag. This generates a controller with all seven method stubs already in place, saving you time and ensuring you follow RESTful conventions.
```bash
node ace make:controller posts --resource
```
### Registering resource routes
Instead of manually defining seven individual routes, use the `router.resource()` method to create all seven routes in a single line.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.resource('posts', controllers.Posts)
```
This generates the following routes.
::::options
:::option{name="GET /posts"}
- **Action:** `PostsController.index`
- **Name:** `posts.index`
- **Purpose:** Display a list of all posts
:::
:::option{name="GET /posts/create"}
- **Action:** `PostsController.create`
- **Name:** `posts.create`
- **Purpose:** Show form to create a new post
:::
:::option{name="POST /posts"}
- **Action:** `PostsController.store`
- **Name:** `posts.store`
- **Purpose:** Store a newly created post
:::
:::option{name="GET /posts/:id"}
- **Action:** `PostsController.show`
- **Name:** `posts.show`
- **Purpose:** Display a specific post
:::
:::option{name="GET /posts/:id/edit"}
- **Action:** `PostsController.edit`
- **Name:** `posts.edit`
- **Purpose:** Show form to edit a post
:::
:::option{name="PUT|PATCH /posts/:id"}
- **Action:** `PostsController.update`
- **Name:** `posts.update`
- **Purpose:** Update a specific post
:::
:::option{name="DELETE /posts/:id"}
- **Action:** `PostsController.destroy`
- **Name:** `posts.destroy`
- **Purpose:** Delete a specific post
:::
::::
### Nested resources
Nested resources represent hierarchical relationships between resources. For example, comments that belong to posts.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.resource('posts.comments', controllers.Comments)
```
This creates routes with both parent and child IDs.
```sh
GET /posts/:post_id/comments
GET /posts/:post_id/comments/create
POST /posts/:post_id/comments
GET /posts/:post_id/comments/:id
GET /posts/:post_id/comments/:id/edit
PUT/PATCH /posts/:post_id/comments/:id
DELETE /posts/:post_id/comments/:id
```
Your controller receives both parent and child parameters.
```ts title="app/controllers/comments_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class CommentsController {
async index({ params }: HttpContext) {
// params.post_id - the parent post ID
// Fetch comments for this post
return { post_id: params.post_id, comments: [] }
}
async show({ params }: HttpContext) {
// params.post_id - the parent post ID
// params.id - the comment ID
return { post_id: params.post_id, comment: { id: params.id } }
}
}
```
### Shallow nested resources
Shallow resources omit the parent ID from routes where the child resource can be uniquely identified on its own. This is useful when the child ID is globally unique and doesn't need to be scoped to the parent.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.shallowResource('posts.comments', controllers.Comments)
```
With shallow resources, the `show`, `edit`, `update`, and `destroy` actions omit the parent ID since a comment can be looked up by its own ID:
```sh
GET /posts/:post_id/comments
GET /posts/:post_id/comments/create
POST /posts/:post_id/comments
# Without parent ID
GET /comments/:id
GET /comments/:id/edit
PUT/PATCH /comments/:id
DELETE /comments/:id
```
Use shallow nesting when the child resource has a globally unique identifier and doesn't require the parent ID for lookup. This creates cleaner, shorter URLs while maintaining the hierarchical relationship where needed (creation and listing).
### Naming resource routes
Routes created by `router.resource()` are automatically named using a combination of the resource name and the controller action. The resource name is converted to snake_case and concatenated with the action name using a dot (`.`) separator.
For example, `router.resource('posts', controllers.Posts)` generates route names like:
- `posts.index`
- `posts.show`
- `posts.store`
You can customize the route names using the `.as()` method.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router
.resource('posts', controllers.Posts)
.as('articles')
```
This changes the route names while keeping the URL paths the same.
| Resource | Action name | Route name |
|----------|-------------|------------|
| posts | index | articles.index |
| posts | show | articles.show |
| posts | store | articles.store |
| posts | update | articles.update |
| posts | destroy | articles.destroy |
[Naming routes](./routing.md#route-identifiers) is important because it allows you to reference routes by name rather than hardcoding URLs throughout your application.
### Filtering resource routes
By default, `router.resource()` creates all seven RESTful routes. You can filter which routes are generated using several methods.
#### API-only resources
When building APIs, you typically don't need the `create` and `edit` routes since forms are displayed by client-side code. The `.apiOnly()` method excludes these routes and creates only five routes: `index`, `store`, `show`, `update`, and `destroy`:
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.resource('posts', controllers.Posts).apiOnly()
```
#### Selective filtering with `only` and `except`
For more granular control, use the `.only()` or `.except()` methods. These methods accept an array of action names.
The `.only()` method creates only the specified routes.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router
.resource('posts', controllers.Posts)
.only(['index', 'store', 'destroy'])
```
The `.except()` method creates all routes except the specified ones.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router
.resource('posts', controllers.Posts)
.except(['create', 'edit'])
```
### Renaming resource params
By default, resource routes use `:id` as the parameter name. You can customize this using the `.params()` method, which accepts an object where the key is the resource name and the value is the desired parameter name.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router
.resource('posts', controllers.Posts)
.params({ posts: 'post' })
```
This changes the URL parameters from `:id` to `:post`:
| Before (default) | After (with custom param) |
| ----------------------------- | ------------------------------- |
| `/posts/:id` | `/posts/:post` |
| `/posts/:id/edit` | `/posts/:post/edit` |
| `/posts/:id` (update/destroy) | `/posts/:post` (update/destroy) |
The same approach works for nested resources, generating URLs like `/posts/:post/comments/:comment`.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router
.resource('posts.comments', controllers.Comments)
.params({ posts: 'post', comments: 'comment' })
```
### Assigning middleware to resources
You can apply middleware to specific resource routes using the `.use()` method. This method accepts an array of action names and the middleware to apply. For example, to apply authentication middleware only to routes that modify data (create, store, update, destroy) while leaving read-only routes (index, show) public.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
router
.resource('posts', controllers.Posts)
.use(
['create', 'store', 'update', 'destroy'],
middleware.auth()
)
```
To apply middleware to all resource routes, use the wildcard `*` to ensure all routes in the resource require authentication.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
router
.resource('posts', controllers.Posts)
.use('*', middleware.auth())
```
## Configuration
Controllers work out of the box with no initial configuration required. However, you can customize certain aspects of how controllers are generated and organized.
### Customizing controller location
By default, controllers are stored in the `app/controllers` directory. You can change this location in your `adonisrc.ts` file.
See also: [AdonisRC reference](../../reference/adonisrc_file.md)
```ts title="adonisrc.ts"
import { defineConfig } from '@adonisjs/core/app'
export default defineConfig({
directories: {
controllers: 'app/http/controllers'
}
})
```
### Barrel file configuration
The auto-generated barrel file at `#generated/controllers` can be customized to control which controllers are included or excluded, and how the file is generated.
See also: [Barrel files generation guide](../concepts/barrel_files.md)
```ts title="adonisrc.ts"
import { defineConfig } from '@adonisjs/core/app'
export default defineConfig({
barrelFiles: {
controllers: {
enabled: true,
export: (path) => `export * as ${path.name} from '${path.modulePath}'`
}
}
})
```
---
# HTTP Context
This guide covers the HTTP context object in AdonisJS. You will learn:
- What the HTTP context is and why it exists
- How to access it in route handlers and middleware
- What properties the HTTP context exposes and when each is available
- How to inject it into services using dependency injection
- How to add custom properties to the context
- How to access it via async local storage
## Overview
The **HTTP context** is a request-scoped object that holds everything you need to handle an HTTP request. It contains properties like `request`, `response`, `auth`, `logger`, `session`, and more. AdonisJS creates a fresh HTTP context instance for every incoming request and passes it to your route handlers and middleware.
Instead of using global variables or importing request/response objects from different modules, the HTTP context provides a clean, type-safe way to access all request-specific data and services in one place. Every property on the context is specifically tied to the current request, ensuring complete isolation between concurrent requests.
## Accessing HTTP context
### In route handlers
The most common way to access the HTTP context is by receiving it as a parameter in your route handlers. You typically destructure only the properties you need rather than working with the full context object.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/posts/:id', async ({ params, request, response }) => {
const id = params.id
const include = request.qs().include
return response.json({ id, include })
})
```
When using controllers, the pattern is identical. The controller method receives the HTTP context as its first parameter:
```ts title="app/controllers/posts_controller.ts"
import { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
export default class PostsController {
async show({ params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
return response.json(post)
}
}
```
### In middleware
Middleware functions also receive the HTTP context as their first parameter. The second parameter is the `next` function that passes control to the next middleware in the chain. The logger used below is already request-scoped, so every log line is automatically tagged with the current request id.
```ts title="app/middleware/log_request_middleware.ts"
import { HttpContext } from '@adonisjs/core/http'
import { NextFn } from '@adonisjs/core/types/http'
export default class LogRequestMiddleware {
async handle({ request, logger }: HttpContext, next: NextFn) {
logger.info(`${request.method()} ${request.url()}`)
await next()
}
}
```
## Available properties
The HTTP context bundles together everything you need to handle a request: the incoming data, the response you are building, logging, authentication, and more. The properties on it come from three sources: the core framework, optional packages you install, and middleware or providers that live inside your project.
### Always available
These properties are attached to every HTTP context instance by the framework itself.
::::options
:::option{name="request" dataType="HttpRequest"}
The [`HttpRequest`](https://github.com/adonisjs/http-server/blob/-/src/request.ts) instance exposes the incoming request data: the query string, the request body, headers, cookies, uploaded files, the client IP, the hostname, and the HTTP method. Use it whenever you need to read something the client sent.
```ts
const page = ctx.request.input('page', 1)
const agent = ctx.request.header('user-agent')
```
:::
:::option{name="response" dataType="HttpResponse"}
The [`HttpResponse`](https://github.com/adonisjs/http-server/blob/-/src/response.ts) instance is used to build and send the response. It provides methods to set the status code, headers, and cookies, to send JSON, HTML, files, or rendered views, to redirect the client, to stream content, and to abort the request early.
```ts
return ctx.response.status(201).json({ id: post.id })
```
:::
:::option{name="params" dataType="Record"}
A plain object holding the route parameters parsed from the URL. For a route defined as `/posts/:id/comments/:commentId`, a request to `/posts/1/comments/42` makes `params` equal to `{ id: '1', commentId: '42' }`. Parameter values are always strings. Cast them inside your handler if you need another type.
:::
:::option{name="route" dataType="Route | undefined"}
A reference to the [route](https://github.com/adonisjs/http-server/blob/-/src/types/route.ts#L149) definition that matched the current request, including the pattern, the HTTP methods, the registered middleware stack, and the handler. This is `undefined` for requests that did not match any route, which typically only happens inside the exception handler.
:::
:::option{name="logger" dataType="Logger"}
A request-scoped [`Logger`](https://github.com/adonisjs/logger/blob/-/src/logger.ts) instance. Log lines written through `ctx.logger` are automatically tagged with a unique request id, which makes it straightforward to trace all output belonging to a single request across your application.
```ts
ctx.logger.info({ postId: id }, 'Loading post')
```
:::
::::
### Available with optional packages
These properties are contributed by optional packages. They only exist on the context when the corresponding package is installed and its middleware (where applicable) is registered.
::::options
:::option{name="session" dataType="Session"}
A [`Session`](https://github.com/adonisjs/session/blob/-/src/session.ts) instance for reading and writing session data, including flash messages, for the current request. Available when `@adonisjs/session` is installed and the session middleware is registered.
:::
:::option{name="auth" dataType="Authenticator"}
An [`Authenticator`](https://github.com/adonisjs/auth/blob/-/src/authenticator.ts) instance used to authenticate the request and access the currently logged-in user. Available when `@adonisjs/auth` is installed and the auth middleware is registered.
:::
:::option{name="view" dataType="EdgeRenderer"}
An [Edge](https://github.com/edge-js/edge/blob/-/src/edge/renderer.ts) renderer scoped to the current request, used to render server-side templates. Available when `edge.js` is installed and registered via the view provider.
:::
:::option{name="inertia" dataType="Inertia"}
An [`Inertia`](https://github.com/adonisjs/inertia/blob/-/src/inertia.ts) instance used to render React or Vue pages through Inertia.js. Available when `@adonisjs/inertia` is installed and the Inertia middleware is registered.
:::
:::option{name="bouncer" dataType="Bouncer"}
A [`Bouncer`](https://github.com/adonisjs/bouncer/blob/-/src/bouncer.ts) instance used to authorize actions against the authenticated user through Bouncer abilities and policies. Available when `@adonisjs/bouncer` is installed.
:::
:::option{name="i18n" dataType="I18n"}
An [`I18n`](https://github.com/adonisjs/i18n/blob/-/src/i18n.ts) instance scoped to the request's detected language, used to translate messages and format dates, numbers, and currencies. Available when `@adonisjs/i18n` is installed and the i18n middleware is registered.
:::
::::
### Added by project scaffolding
These properties are attached by middleware or providers that live inside your project rather than being provided by the framework or an installed package. Their availability depends on which files your project scaffolding includes, so they stay with your project even as framework and package versions change.
::::options
:::option{name="containerResolver" dataType="ContainerResolver"}
A request-scoped IoC container resolver, attached to the context by the `container_bindings_middleware` file that ships with every new AdonisJS project. Use it when you need to manually resolve classes from the container while preserving the current request scope, for example when instantiating a service that type-hints `HttpContext` in its constructor.
:::
:::option{name="serialize" dataType="ApiSerializer"}
A helper for type-safe serialization of response payloads, attached to the context by the `providers/api_provider.ts` file that ships with the Inertia and API starter kits. Projects scaffolded from other starter kits, or projects where this provider has been removed, will not have `ctx.serialize` available.
:::
::::
## Using dependency injection
When you need to access the HTTP context inside service classes or other parts of your codebase, you can use dependency injection. This pattern is cleaner than passing the context manually through multiple function calls.
### Injecting into services
To inject the HTTP context into a service, type-hint it in the constructor and mark the class with the `@inject()` decorator.
```ts title="app/services/post_service.ts"
import { inject } from '@adonisjs/core'
import { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
// [!code highlight]
@inject()
export default class PostService {
// [!code highlight]
constructor(protected ctx: HttpContext) {}
async getVisiblePosts() {
// [!code highlight]
const user = this.ctx.auth.user
if (user?.isAdmin) {
return Post.all()
}
return Post.query().where('published', true)
}
}
```
### Using services in controllers
To enable automatic dependency injection, you must inject the `PostService` into the controller method. When the container calls the `index` method, it will construct the entire tree of dependencies and provide the HTTP context to the `PostService`.
```ts title="app/controllers/posts_controller.ts"
import { inject } from '@adonisjs/core'
import { HttpContext } from '@adonisjs/core/http'
import PostService from '#services/post_service'
export default class PostsController {
// [!code highlight:2]
@inject()
async index({}: HttpContext, postService: PostService) {
return postService.getVisiblePosts()
}
}
```
## Using Async local storage
Async local storage (ALS) allows you to access the HTTP context from anywhere in your application without explicitly passing it through function parameters.
:::tip
Prefer dependency injection or passing `HttpContext` by reference as your default patterns. Reach for ALS only when those are not feasible, such as inside utility functions or third-party libraries that cannot be refactored to accept the context explicitly.
:::
To use ALS, you must first enable it inside the `config/app.ts` file under the `http` block.
```ts title="config/app.ts"
import { defineConfig } from '@adonisjs/core/http'
export const http = defineConfig({
useAsyncLocalStorage: true
})
```
Once enabled, you can access the current HTTP context using the static `getOrFail()` method.
```ts title="app/services/analytics_service.ts"
import { HttpContext } from '@adonisjs/core/http'
export default class AnalyticsService {
trackEvent(eventName: string) {
const ctx = HttpContext.getOrFail()
ctx.logger.info({
event: eventName,
userId: ctx.auth.user?.id,
ip: ctx.request.ip(),
})
}
}
```
Enabling async local storage introduces a small performance overhead, so leave it disabled unless you actually need `HttpContext.getOrFail()`.
:::warning
`HttpContext.getOrFail()` throws when called outside an HTTP request lifecycle. Only call it from code paths you know run during a request (route handlers, middleware, controllers, and the services they call). For code that can run outside a request, such as background jobs or CLI commands, inject or pass `HttpContext` explicitly instead.
:::
## Adding custom properties
You can add custom properties to the HTTP context by augmenting the `HttpContext` interface in your middleware file. This is useful when you need to share data across multiple middleware and route handlers during the same request.
```ts title="app/middleware/identify_tenant_middleware.ts"
import { HttpContext } from '@adonisjs/core/http'
import { NextFn } from '@adonisjs/core/types/http'
import Tenant from '#models/tenant'
/**
* Augment the HttpContext interface to add the tenant property.
* This makes TypeScript aware of the new property.
*/
declare module '@adonisjs/core/http' {
interface HttpContext {
tenant: Tenant
}
}
export default class IdentifyTenantMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
/**
* Extract the tenant identifier from the request.
* This could come from a subdomain, header, or other source.
*/
const subdomain = ctx.request.hostname().split('.')[0]
/**
* Look up the tenant and attach it to the context.
* Now all route handlers can access ctx.tenant.
*/
ctx.tenant = await Tenant.findByOrFail('subdomain', subdomain)
await next()
}
}
```
## Creating dummy context for testing
When writing tests, you often need a dummy HTTP context instance to test route handlers, middleware, or services. Use the `testUtils` service to create these test instances.
```ts title="tests/functional/posts.spec.ts"
import testUtils from '@adonisjs/core/services/test_utils'
const ctx = testUtils.createHttpContext()
```
:::tip
The created context instance is not attached to any route, so `ctx.route` and `ctx.params` are `undefined`. If the code under test reads them, assign them manually on the returned context before running your assertions.
:::
The `createHttpContext` method uses fake values for the underlying Node.js `req` and `res` objects by default. If you need to test with real request and response objects, you can provide them.
```ts title="tests/integration/server.spec.ts"
import { createServer } from 'node:http'
import testUtils from '@adonisjs/core/services/test_utils'
createServer((req, res) => {
const ctx = testUtils.createHttpContext({ req, res })
})
```
If you're building a package outside of an AdonisJS application, you can use the `HttpContextFactory` class directly. The `testUtils` service is only available within AdonisJS applications, but the factory works anywhere.
```ts title="tests/package/http_context.spec.ts"
import { HttpContextFactory } from '@adonisjs/core/factories/http'
const ctx = new HttpContextFactory().create()
```
---
# Middleware
This guide covers middleware in AdonisJS applications. You will learn how to:
- Work with the three middleware stacks (server, router, and named)
- Create custom middleware to handle cross-cutting concerns
- Register middleware in the appropriate stack
- Pass parameters to named middleware for route-specific logic
- Use dependency injection in middleware constructors
- Modify requests and responses during the middleware pipeline
- Handle exceptions within middleware
- Augment the HttpContext with custom properties
## Overview
Middleware are functions that execute during an HTTP request before the request reaches your route handler. Each middleware in the chain can either terminate the request by sending a response or forward it to the next middleware using the `next` method.
The middleware layer allows you to encapsulate logic that must run during a request into dedicated, reusable functions or classes. Instead of cluttering your controllers with repetitive logic for parsing request bodies, authenticating users, or logging requests, you can offload these responsibilities to dedicated middleware.
Every HTTP request your application handles flows through the middleware pipeline, making it essential to understand how middleware work and how to organize them effectively.
## Middleware stacks
AdonisJS divides middleware into three categories, known as stacks. Each stack serves a different purpose and executes at different points in the request lifecycle.
**Server middleware stack**
- Executes for **every** HTTP request, even when no route matches
- Runs **before** the router attempts to find a matching route
- Use for: logging, CORS, security headers, logging
**Router middleware stack**
- Executes **only** when a matching route is found
- Runs **after** route matching but **before** named middleware and handlers
- Use for: loading shared data, parsing request bodies
**Named middleware collection**
- Applied **explicitly** to individual routes or route groups
- Can accept parameters for per-route customization
- Use for: role-based authorization, route-specific rate limiting, feature flags
## Creating and using middleware
Let's walk through creating a complete logging middleware that tracks request duration. We'll generate the middleware file, implement the logging logic, and register it to run on all requests.
::::steps
:::step{title="Generating the middleware"}
Create a new middleware using the `make:middleware` command. This command generates a scaffolded middleware class in the `app/middleware` directory.
```bash
node ace make:middleware LogRequests
```
```bash
# CREATE: app/middleware/log_requests_middleware.ts
```
The generated middleware contains a basic class structure with a `handle` method where we'll add our logging logic:
```ts title="app/middleware/log_requests_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class LogRequestsMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
/**
* Logic to run before the request handler
*/
await next()
/**
* Logic to run after the request handler
*/
}
}
```
:::
:::step{title="Implementing the logging logic"}
Now let's implement the actual logging functionality. We'll track how long each request takes by capturing the start time before calling `next()`, then calculating the duration after the response is ready.
```ts title="app/middleware/log_requests_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import string from '@adonisjs/core/helpers/string'
export default class LogRequestsMiddleware {
async handle({ request, response, logger }: HttpContext, next: NextFn) {
/**
* Capture the start time before calling next().
* This happens in the downstream phase.
*/
const startTime = process.hrtime()
/**
* Call next() to execute remaining middleware and route handler.
* The await ensures we wait for the entire chain to complete.
*/
await next()
/**
* After next() completes, we're in the upstream phase.
* The response is ready, so we can log the completion details.
*/
const endTime = process.hrtime(startTime)
const responseStatus = response.getStatus()
const uri = request.url()
const method = request.method()
logger.info(`${method} ${uri}: ${responseStatus} (${string.prettyHrTime(endTime)})`)
}
}
```
:::
:::step{title="Registering the middleware"}
Finally, let's register our logging middleware in the server middleware stack so it runs for every request. We will register it as the first middleware, so that we can precisely time all the requests.
Server middleware are registered in the `start/kernel.ts` file using lazy imports, and they execute in the order they're registered.
```ts title="start/kernel.ts"
import router from '@adonisjs/core/services/router'
import server from '@adonisjs/core/services/server'
server.use([
() => import('#middleware/log_requests_middleware'), // [!code ++]
() => import('#middleware/container_bindings_middleware'),
() => import('#middleware/force_json_response_middleware'),
])
router.use([
() => import('@adonisjs/core/bodyparser_middleware'),
])
```
:::
::::
## Named middleware with parameters
Named middleware provide flexibility by allowing you to apply them selectively to specific routes and pass parameters to customize their behavior. Let's build an authorization middleware that checks user permissions, register it with a name, and apply it to protected routes.
::::steps
:::step{title="Creating the authorization middleware"}
We'll create a middleware that checks if the authenticated user has the required role or permissions to access a route. Named middleware can accept a third parameter for options, making them configurable per-route.
```ts title="app/middleware/authorize_request_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
type AuthorizationOptions =
| { permissions: string[] }
| { role: string }
export default class AuthorizeRequestMiddleware {
/**
* The third parameter 'options' contains the authorization requirements
* specified when applying this middleware to a route.
*/
async handle({ auth, response }: HttpContext, next: NextFn, options: AuthorizationOptions) {
/**
* Get the authenticated user or throw an exception
*/
const user = auth.getUserOrFail()
/**
* Check if the user has the required role
*/
if ('role' in options && user.role !== options.role) {
return response.unauthorized('Not authorized to access this route')
}
/**
* Check if the user has all required permissions
*/
if ('permissions' in options) {
const hasPermission = options.permissions.every(permission =>
user.permissions.includes(permission)
)
if (!hasPermission) {
return response.unauthorized('Not authorized to access this route')
}
}
/**
* User is authorized, continue to the next middleware or handler
*/
await next()
}
}
```
:::
:::step{title="Registering the named middleware"}
Now let's register our authorization middleware with the name `authorize` in the `start/kernel.ts` file. Exporting the middleware collection enables full TypeScript type safety when using it in routes.
```ts title="start/kernel.ts"
import router from '@adonisjs/core/services/router'
export const middleware = router.named({
authorize: () => import('#middleware/authorize_request_middleware'),
})
```
:::
:::step{title="Applying the middleware to routes"}
Finally, let's apply our `authorize` middleware to specific routes. Import the middleware collection from `start/kernel.ts` to get full TypeScript autocomplete and type checking for the options parameter.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
router
.get('/admin/reports', async () => {
return { message: 'Admin reports' }
})
.use(middleware.authorize({ role: 'admin' }))
router
.post('/posts', async () => {
return { message: 'Post created' }
})
.use(middleware.authorize({ permissions: ['posts.create'] }))
```
Your authorization middleware is now protecting routes with type-safe, configurable permission checks!
:::
::::
## Dependency injection in middleware
Middleware classes are instantiated using the IoC container, allowing you to inject dependencies directly into the middleware constructor. Dependencies can only be **injected through the constructor, not as method parameters**. The container will automatically resolve and inject your dependencies when creating the middleware instance.
See also: [Dependency Injection guide](../concepts/dependency_injection.md)
```ts title="app/middleware/rate_limit_middleware.ts"
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import RateLimitService from '#services/rate_limit_service'
@inject() // [!code highlight]
export default class RateLimitMiddleware {
/**
* The IoC container automatically creates an instance of
* RateLimitService and injects it here.
*/
// [!code highlight]
constructor(protected rateLimitService: RateLimitService) {
}
async handle({ request, response }: HttpContext, next: NextFn) {
const ip = request.ip()
const isAllowed = await this.rateLimitService.checkLimit(ip)
if (!isAllowed) {
return response.tooManyRequests('Rate limit exceeded')
}
await next()
}
}
```
## Understanding middleware execution flow
Middleware execute in two phases: the **downstream phase** and the **upstream phase**. Understanding this flow is crucial for knowing where to place your logic within a middleware.
The downstream phase occurs before the `await next()` call. During this phase, the request travels through each middleware in order.
```sh
Downstream (Request →)
Server middleware
│
▼
Router middleware
│
▼
Named middleware
│
▼
Route handler
```
The upstream phase occurs after the `await next()` call. During this phase, the response travels back through the middleware in reverse order.
```sh
Upstream (Response ←)
Server middleware
▲
│
Router middleware
▲
│
Named middleware
▲
│
Route handler
```

This two-phase execution allows middleware to execute logic both before and after the route handler runs. The logging middleware example demonstrates this pattern by capturing the start time in the downstream phase and calculating the duration in the upstream phase.
## Modifying the response
Middleware can modify the response during both the downstream and upstream phases. Since the response object is mutable, changes you make in middleware will affect the final response sent to the client.
### Adding headers in the upstream phase
A common pattern is adding headers to the response after the route handler completes. Placing header logic after `await next()` ensures the response has been fully constructed by the handler before you modify it.
```ts title="app/middleware/add_headers_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class AddHeadersMiddleware {
async handle({ response }: HttpContext, next: NextFn) {
/**
* Wait for the handler to construct the response
*/
await next()
/**
* Add custom headers after the response is ready
*/
response.header('X-Powered-By', 'AdonisJS')
response.header('X-Response-Time', Date.now().toString())
}
}
```
### Transforming the response body
You can also transform the response body in middleware by accessing and modifying it in the upstream phase. This middleware wraps all response bodies in a consistent format with metadata.
```ts title="app/middleware/wrap_response_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class WrapResponseMiddleware {
async handle({ response }: HttpContext, next: NextFn) {
/**
* Wait for the handler to generate the response
*/
await next()
/**
* Wrap the original response in a standard envelope
*/
const body = response.getBody()
response.send({
success: true,
data: body,
timestamp: new Date().toISOString()
})
}
}
```
## Exception handling
When a middleware throws an exception, AdonisJS's global exception handler catches and processes it just like exceptions thrown from route handlers. The upstream flow continues as normal, meaning any middleware that already executed in the downstream phase will still execute their upstream code.
See also: [Exception handling guide](./exception_handling.md)
```ts title="app/middleware/validate_api_key_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import { errors } from '@adonisjs/core'
export default class ValidateApiKeyMiddleware {
async handle({ request }: HttpContext, next: NextFn) {
const apiKey = request.header('X-API-Key')
if (!apiKey) {
/**
* Throwing an exception terminates the request.
* The global exception handler will catch and handle this.
*/
throw new errors.E_UNAUTHORIZED_ACCESS('API key is required')
}
await next()
}
}
```
## Conditional middleware execution
AdonisJS does not provide a way to conditionally register or apply middleware at runtime. However, middleware can use configuration files to decide at runtime whether they should execute for the current request. This pattern lets you control middleware behavior through environment variables or configuration files without changing your middleware registration.
```ts title="config/features.ts"
export default {
enableRateLimit: env.get('ENABLE_RATE_LIMIT', true),
}
```
```ts title="app/middleware/rate_limit_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import featuresConfig from '#config/features'
export default class RateLimitMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
/**
* Skip rate limiting if disabled in configuration.
* Immediately call next() to make this middleware a no-op.
*/
if (!featuresConfig.enableRateLimit) {
return next()
}
/**
* Apply rate limiting logic when enabled
*/
// ... rate limit checks
await next()
}
}
```
## Extending the HttpContext
Middleware can add custom properties to the `HttpContext` object to share data with downstream middleware and route handlers. However, this requires TypeScript module augmentation to ensure the properties appear at the type level.
### Adding properties to the context
```ts title="app/middleware/detect_tenant_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class DetectTenantMiddleware {
async #detectTenant(ctx: HttpContext) {
// ... tenant detection logic
return { id: 1, name: 'Acme Corp' }
}
async handle(ctx: HttpContext, next: NextFn) {
/**
* Detect tenant from subdomain, header, or database
*/
const tenant = await this.#detectTenant(ctx)
// [!code highlight:5]
/**
* Add tenant to context for downstream middleware and the
* route handler
*/
ctx.tenant = tenant
await next()
}
}
```
### Augmenting the HttpContext type
To make TypeScript aware of the new `tenant` property, you must augment the `HttpContext` interface. After augmentation, TypeScript will recognize `ctx.tenant` in all your middleware and route handlers.
:::warning
When you augment the `HttpContext` interface, the type changes are global. However, if you add properties via named middleware that only runs on specific routes, those properties will not exist at runtime on routes where the middleware doesn't execute.
Only augment the `HttpContext` in server or router middleware that run broadly across your application.
:::
```ts title="types/http.ts"
declare module '@adonisjs/core/http' {
interface HttpContext {
tenant: {
id: number
name: string
}
}
}
```
---
# Request
This guide covers working with HTTP requests in AdonisJS. You will learn about:
- Reading request body and uploaded files
- Accessing query strings and route parameters
- Working with request headers and metadata
- Reading cookies
- Understanding request ID generation
- Configuring trusted proxies and IP address extraction
## Overview
The Request class holds all the information related to an HTTP request, including the request body, uploaded files, query string, URL, method, headers, and cookies. You access it via the `request` property of HttpContext, which is available in route handlers, middleware, and exception handlers.
## Reading request body and files
The request body contains data sent by the client, typically from HTML forms or API requests. AdonisJS uses the [bodyparser](./body_parser.md) to automatically parse the request body based on the `Content-Type` header, converting JSON, form data, and multipart data into JavaScript objects you can easily work with.
### Accessing the entire request body
Use the `all` method to retrieve all data from the request body as an object. This is useful when you want to process all submitted fields together.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.post('/signup', ({ request }) => {
const body = request.all()
console.log(body)
// { fullName: 'John Doe', email: 'john@example.com', password: 'secret' }
})
```
:::note
**Type safety and validation:** The request body data is not type-safe because the bodyparser only collects and parses the raw request data, it does not validate it. Use the [validation system](./validation.md) to ensure both runtime safety and TypeScript type safety for your request data.
:::
### Accessing specific fields
Use the `input` method when you need to read a specific field from the request body. This method accepts a field name and an optional default value if the field doesn't exist.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.post('/signup', ({ request }) => {
const email = request.input('email')
const newsletter = request.input('newsletter', false)
console.log(email)
console.log(newsletter)
})
```
You can also use the `only` method to retrieve multiple specific fields, or the `except` method to retrieve all fields except certain ones.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.post('/signup', ({ request }) => {
/**
* Get only fullName and email, ignoring other fields
*/
const credentials = request.only(['fullName', 'email'])
console.log(credentials)
/**
* Get all fields except password
*/
const safeData = request.except(['password'])
console.log(safeData)
})
```
### Accessing uploaded files
Files uploaded through multipart form data are available using the `file` method. The method returns a file object with metadata and methods for validation and storage.
See also: [File uploads guide](./file_uploads.md) for detailed file handling and storage
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.post('/avatar', ({ request }) => {
const avatar = request.file('avatar')
console.log(avatar)
})
```
You can validate files at the time of accessing them by providing validation options.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.post('/avatar', ({ request }) => {
const avatar = request.file('avatar', {
size: '2mb',
extnames: ['jpg', 'png', 'jpeg']
})
console.log(avatar)
})
```
:::tip
**File validation approaches:** You can validate files either when accessing them with `request.file()` or using the validator. The validator approach is recommended as it provides consistent validation alongside other request data and better error handling.
:::
### Available methods
| Method | Description |
|--------|-------------|
| `all()` | Returns all request body data as an object |
| `body()` | Alias for `all()` method |
| `input(key, defaultValue?)` | Returns a specific field value with optional default |
| `only(keys)` | Returns only the specified fields |
| `except(keys)` | Returns all fields except the specified ones |
| `file(key, options?)` | Returns an uploaded file with optional validation |
## Reading request query string and route params
Query strings and route parameters are two different ways to pass data through URLs. The query string is the portion after the `?` in a URL (like `?page=1&limit=10`), while route parameters are dynamic segments defined in your route pattern (like `/posts/:id`).
### Accessing query string parameters
Use the `qs` method to retrieve all query string parameters as an object.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/posts', ({ request }) => {
const queryString = request.qs()
console.log(queryString)
// { page: '1', limit: '10', orderBy: 'created_at' }
})
```
You can access individual query parameters using the `input` method, which works for both body data and query string parameters.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/posts', ({ request }) => {
const page = request.input('page', 1)
const limit = request.input('limit', 20)
const orderBy = request.input('orderBy', 'id')
console.log({ page, limit, orderBy })
})
```
### Accessing route parameters
Route parameters are available through the `param` method or by accessing the `params` object directly. The params object is also available directly on HttpContext.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/posts/:id', ({ request }) => {
const id = request.param('id')
console.log(id)
})
```
### Available methods
| Method | Description |
|--------|-------------|
| `qs()` | Returns all query string parameters as an object |
| `param(key, defaultValue?)` | Returns a specific route parameter with optional default |
| `params()` | Returns all route parameters as an object |
## Reading request headers, method, URL, and IP address
Request metadata includes information about how the request was made, where it came from, and what the client expects in response. This includes HTTP headers, the request method (GET, POST, etc.), the requested URL, and the client's IP address.
### Accessing request headers
Use the `header` method to read a specific header value. Header names are case-insensitive.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/profile', ({ request }) => {
const authToken = request.header('Authorization')
const userAgent = request.header('User-Agent')
console.log(authToken)
console.log(userAgent)
})
```
You can retrieve all headers using the `headers` method.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/debug', ({ request }) => {
const allHeaders = request.headers()
console.log(allHeaders)
})
```
### Accessing the request method
The request method (GET, POST, PUT, DELETE, etc.) is available through the `method` method.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.all('/endpoint', ({ request }) => {
const method = request.method()
console.log(method) // 'GET', 'POST', etc.
})
```
### Accessing the request URL
Use the `url` method to get the request URL without the domain and protocol, or `completeUrl` to get the full URL including domain.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/posts', ({ request }) => {
const path = request.url()
console.log(path) // '/posts?page=1'
const fullUrl = request.completeUrl()
console.log(fullUrl) // 'https://example.com/posts?page=1'
})
```
### Accessing the client IP address
The `ip` method returns the client's IP address. When your application is behind a reverse proxy or load balancer, you need to configure [trusted proxies](#trusting-proxy-servers) to correctly [detect the real client IP](#custom-ip-address-extraction).
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/track', ({ request }) => {
const clientIp = request.ip()
console.log(clientIp)
})
```
The `ips` method returns an array of IP addresses when the request has passed through multiple proxies.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/track', ({ request }) => {
const ipChain = request.ips()
console.log(ipChain) // ['client-ip', 'proxy-1', 'proxy-2']
})
```
### Available methods
| Method | Description |
| ---------------------------- | ----------------------------------------- |
| `header(key, defaultValue?)` | Returns a specific header value |
| `headers()` | Returns all headers as an object |
| `method()` | Returns the HTTP method (GET, POST, etc.) |
| `url()` | Returns the request URL without domain |
| `completeUrl()` | Returns the complete URL including domain |
| `ip()` | Returns the client IP address |
| `ips()` | Returns array of IPs when behind proxies |
| `getPreviousUrl(allowedHosts, fallback?)` | Returns the validated previous URL from the `Referer` header |
## Reading request cookies
Cookies are small pieces of data stored in the client's browser and sent with every request. AdonisJS provides methods to read both plain and signed/encrypted cookies through the Request class.
### Accessing signed cookies
Use the `cookie` method to read a signed cookie value. By default all cookies are signed.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/preferences', ({ request }) => {
const theme = request.cookie('theme', 'light')
const language = request.cookie('language', 'en')
console.log({ theme, language })
})
```
### Accessing encrypted
Use `encryptedCookie` for encrypted cookies. This method automatically decrypt and verify the cookie value.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/dashboard', ({ request }) => {
const sessionId = request.encryptedCookie('session_id')
console.log(sessionId)
})
```
### Available methods
| Method | Description |
|--------|-------------|
| `cookie(key, defaultValue?)` | Returns a signed cookie value |
| `cookiesList()` | Returns all cookies as an object without decrypting or unsigning them |
| `encryptedCookie(key, defaultValue?)` | Returns a decrypted cookie value |
| `plainCookie(key, defaultValue?)` | Returns value for plain cookie |
## Reading request ID and understanding ID generation
Every HTTP request in AdonisJS is assigned a unique request ID. This ID is useful for distributed tracing, logging, and correlating related operations across your application and microservices.
### Accessing the request ID
Use the `id` method to retrieve the unique identifier assigned to the current request.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/api/posts', ({ request }) => {
const requestId = request.id()
console.log(`Processing request: ${requestId}`)
})
```
### How request IDs are generated
AdonisJS generates request IDs using one of these methods, in order of preference.
:::note
You must enable request ID generation in `config/app.ts` file for AdonisJS to generate request ids (incase `X-Request-Id` header is missing).
:::
1. **From X-Request-Id header:** If the client or a proxy sends an `X-Request-Id` header, AdonisJS uses that value. This allows you to trace requests across multiple services.
2. **Generated by AdonisJS:** If no `X-Request-Id` header exists, AdonisJS generates a unique ID using the `uuid` package by default (if enabled).
### Using request IDs for distributed tracing
Request IDs are particularly valuable in distributed systems where a single user request might trigger operations across multiple services. By logging the request ID with every operation, you can trace the complete flow of a request through your system.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import logger from '@adonisjs/core/services/logger'
router.post('/checkout', async ({ request }) => {
const requestId = request.id()
logger.info({ requestId }, 'Starting checkout process')
// Your checkout logic here
logger.info({ requestId }, 'Checkout completed')
})
```
## Content negotiation
Content negotiation allows your application to serve different response formats or languages based on what the client accepts. The Request class provides methods to read the `Accept`, `Accept-Language`, `Accept-Charset`, and `Accept-Encoding` headers and match them against the formats your application supports.
### Selecting response format
Use the `accepts` method to determine the best response format based on the client's `Accept` header. This is useful when your API can return data in multiple formats like JSON, HTML, or XML.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/posts', ({ request, response }) => {
const bestFormat = request.accepts(['html', 'json'])
if (bestFormat === 'json') {
return response.json({ posts: [] })
}
if (bestFormat === 'html') {
return response.view('posts/index')
}
// Client doesn't accept any supported format
return response.status(406).send('Not Acceptable')
})
```
The `types` method returns all accepted content types in order of client preference.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/posts', ({ request }) => {
const acceptedTypes = request.types()
console.log(acceptedTypes) // ['application/json', 'text/html', '*/*']
})
```
### Internationalization
Use the `language` method to determine the best language based on the client's `Accept-Language` header. This helps serve content in the user's preferred language.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/welcome', ({ request, response }) => {
const language = request.language(['en', 'fr', 'es']) || 'en'
const messages = {
en: 'Welcome',
fr: 'Bienvenue',
es: 'Bienvenido'
}
return response.send(messages[language])
})
```
The `languages` method returns all accepted languages in order of client preference.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/welcome', ({ request }) => {
const acceptedLanguages = request.languages()
console.log(acceptedLanguages) // ['en-US', 'fr', 'es']
})
```
### Available methods
| Method | Description |
|--------|-------------|
| `accepts(types)` | Returns the best matching content type or null |
| `types()` | Returns all accepted content types in preference order |
| `language(languages)` | Returns the best matching language or null |
| `languages()` | Returns all accepted languages in preference order |
| `charset(charsets)` | Returns the best matching charset or null |
| `charsets()` | Returns all accepted charsets in preference order |
| `encoding(encodings)` | Returns the best matching encoding or null |
| `encodings()` | Returns all accepted encodings in preference order |
## Trusting proxy servers
When your application runs behind a reverse proxy (like Nginx) or load balancer, you need to configure which proxy IP addresses to trust. This allows AdonisJS to correctly read the `X-Forwarded-*` headers that proxies add to requests.
```ts title="config/app.ts"
import env from '#start/env'
import { defineConfig } from '@adonisjs/core/http'
import proxyAddr from 'proxy-addr'
export const http = defineConfig({
/**
* Trust the loopback address and private IP ranges.
* This is safe for most deployment scenarios where your
* proxy runs on the same machine or private network.
*/
trustProxy: proxyAddr.compile(['loopback', 'uniquelocal'])
})
```
The `trustProxy` option accepts any value supported by the [proxy-addr](https://www.npmjs.com/package/proxy-addr) package. Common configurations include:
```ts
// Trust all proxies (not recommended for production)
trustProxy: () => true
// Trust specific IP addresses
trustProxy: proxyAddr.compile(['127.0.0.1', '192.168.1.1'])
// Trust IP ranges using CIDR notation
trustProxy: proxyAddr.compile('10.0.0.0/8')
```
## Custom IP address extraction
By default, AdonisJS extracts the client IP address from the request using standard methods. However, when running behind proxies or CDNs like Cloudflare, you may need to extract the IP from custom headers.
```ts title="config/app.ts"
import env from '#start/env'
import { defineConfig } from '@adonisjs/core/http'
import type { IncomingMessage } from 'node:http'
export const http = defineConfig({
/**
* Extract IP from Cloudflare's CF-Connecting-IP header
* Falls back to standard IP extraction if header is not present
*/
getIp(request: IncomingMessage) {
const cloudflareIp = request.headers['cf-connecting-ip']
if (cloudflareIp && typeof cloudflareIp === 'string') {
return cloudflareIp
}
// Return undefined to fall back to default IP extraction
return undefined
}
})
```
The `getIp` method receives the Node.js `IncomingMessage` object and must return a string IP address or `undefined` to fall back to default behavior. This is useful when working with CDNs that provide the real client IP in custom headers.
---
# Response
This guide covers the AdonisJS Response class and the methods available to construct HTTP responses. You will learn about:
- Sending response bodies in different formats
- Working with headers and cookies
- Handling redirects and file downloads
- Understanding how the response serialization works
- Extending the Response class with custom methods
## Overview
The Response class provides helpers for constructing HTTP responses in AdonisJS applications. Instead of working directly with Node.js's raw response object, the Response class offers a fluent, expressive API for common tasks like sending JSON, setting headers, handling redirects, and streaming file downloads.
The Response class is available via the `ctx.response` property. You can access it in route handlers, middleware, and exception handlers throughout your application. For many simple responses, you can return values directly from route handlers, and AdonisJS will automatically use the Response class to send them.
## Sending response body
The Response class provides multiple ways to send response bodies. You can either return values directly from route handlers or use explicit response methods.
### Returning values from route handlers
The simplest approach is to return values directly from your route handler. AdonisJS will automatically serialize the value and set appropriate content headers.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/', async () => {
/**
* Returns plain text with content-type: text/plain
*/
return 'This is the homepage.'
})
router.get('/welcome', async () => {
/**
* Returns HTML fragment with content-type: text/html
*/
return '
This is the homepage
'
})
router.get('/api/page', async () => {
/**
* Returns JSON with content-type: application/json
*/
return { page: 'home' }
})
router.get('/timestamp', async () => {
/**
* Date instances are converted to ISO strings
*/
return new Date()
})
```
### Using response.send()
You can also explicitly use the `response.send()` method, which provides the same automatic content-type detection.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/', async ({ response }) => {
/**
* send() method works identically to returning values.
* Useful when you need to set headers or status before sending.
*/
response.send('This is the homepage')
})
router.get('/data', async ({ response }) => {
/**
* Objects and arrays are automatically stringified
*/
response.send({ page: 'home' })
})
```
### Forcing JSON responses
When you need to ensure the response is sent as JSON (even if it might be detected as HTML), use the `response.json()` method.
See also: [Response body serialization](#response-body-serialization)
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async index({ response }: HttpContext) {
const posts = await Post.all()
/**
* Explicitly sets content-type to application/json
* and serializes the posts array
*/
response.json(posts)
}
}
```
## Working with headers
The Response class provides methods for setting, appending, and removing HTTP headers. Headers must be set before the response body is sent.
### Setting headers
Use the `response.header()` method to set a response header. If the header already exists, it will be overridden.
```ts title="app/controllers/api_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class ApiController {
async index({ response }: HttpContext) {
/**
* Set custom header for API versioning
*/
response.header('X-API-Version', 'v1')
/**
* Set cache control headers
*/
response.header('Cache-Control', 'public, max-age=3600')
return { status: 'ok' }
}
}
```
### Setting headers safely
The `response.safeHeader()` method sets a header only if it doesn't already exist. This is useful when you want to provide a default value without overriding existing headers.
```ts title="app/middleware/cors_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class CorsMiddleware {
async handle({ response }: HttpContext, next: NextFn) {
/**
* Set CORS header only if not already set by another middleware
*/
response.safeHeader('Access-Control-Allow-Origin', '*')
await next()
}
}
```
### Appending to headers
Some headers can have multiple values. Use `response.append()` to add additional values without removing existing ones.
```ts title="app/controllers/downloads_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class DownloadsController {
async show({ response }: HttpContext) {
/**
* Append multiple Set-Cookie headers for different cookies
*/
response.append('Set-Cookie', 'session=abc123; HttpOnly')
response.append('Set-Cookie', 'preferences=dark-mode; Path=/')
return { download: 'ready' }
}
}
```
### Removing headers
Remove a previously set header using `response.removeHeader()`.
```ts title="app/middleware/security_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class SecurityMiddleware {
async handle({ response }: HttpContext, next: NextFn) {
await next()
/**
* Remove server header to hide server implementation details
*/
response.removeHeader('X-Powered-By')
}
}
```
## Handling redirects
The `response.redirect()` method returns an instance of the `Redirect` class, which provides a fluent API for creating redirect responses with different destinations and options.
### Redirecting to a path
Use `response.redirect().toPath()` to redirect to a specific URI or external URL.
```ts title="app/controllers/auth_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class AuthController {
async logout({ response, auth }: HttpContext) {
await auth.logout()
/**
* Redirect to the home page after logout
*/
// [!code highlight]
response.redirect().toPath('/')
}
async external({ response }: HttpContext) {
/**
* Redirect to an external website
*/
// [!code highlight]
response.redirect().toPath('https://adonisjs.com')
}
}
```
### Redirecting to a named route
The `response.redirect().toRoute()` method accepts a route identifier and its parameters, making redirects maintainable when URLs change.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import { createPostValidator } from '#validators/post'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async store({ request, response }: HttpContext) {
/**
* Validate the incoming request data
*/
const payload = await request.validateUsing(createPostValidator)
/**
* Create the post
*/
const post = await Post.create(payload)
/**
* Redirect to the show page for the newly created post
*/
// [!code highlight]
response.redirect().toRoute('posts.show', [post.id])
}
}
```
### Redirecting back
Use `response.redirect().back()` to redirect to the previous page. The method reads the `Referer` header and validates that the referrer's host matches the current request's `Host` header. If the referrer is missing, invalid, or from an unknown host, the redirect falls back to `/`.
You can provide a custom fallback URL as an argument to `back()`.
```ts title="app/controllers/comments_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class CommentsController {
async destroy({ response, params }: HttpContext) {
/**
* Redirect back to the page the user came from.
* Falls back to /posts if no valid referrer is found.
*/
response.redirect().back('/posts')
}
}
```
### Allowing external referrer hosts
By default, `back()` only accepts referrers that match the current request's host. This prevents open redirect attacks where a malicious referrer could send users to an external site.
If your application spans multiple domains (for example, `app.example.com` and `admin.example.com`), you can add trusted hosts to the `redirect.allowedHosts` config so that `back()` accepts referrers from those domains as well.
```ts title="config/app.ts"
import { defineConfig } from '@adonisjs/core/http'
export const http = defineConfig({
redirect: {
allowedHosts: [
'app.example.com',
'admin.example.com',
],
},
})
```
### Customizing previous URL resolution
When using the `@adonisjs/session` package, you can store a previous URL in the session by setting the `redirect.previousUrl` property. When set, `back()` will use the session value instead of the `Referer` header.
This is useful when your application redirects users to third-party websites (like OAuth providers). The browser's `Referer` header will point to the external site when the user returns, so `back()` would fall back to `/` instead of the page the user was originally on. By storing the intended return URL in the session before the redirect, you ensure `back()` takes them to the right place.
```ts title="app/controllers/auth_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class AuthController {
async redirectToProvider({ session, response }: HttpContext) {
/**
* Store the current URL so back() returns here
* after the OAuth flow completes
*/
session.put('redirect.previousUrl', '/dashboard')
response.redirect().toPath('https://accounts.google.com/o/oauth2/auth')
}
async handleCallback({ response }: HttpContext) {
/**
* This will redirect to /dashboard (from the session)
* instead of using the Referer header
*/
response.redirect().back()
}
}
```
:::note
The session-based previous URL resolution requires the `@adonisjs/session` package. Without it, `back()` will always use the `Referer` header.
:::
### Setting redirect status code
By default, redirects use a `302` status code. Use the `status()` method to set a different redirect status.
```ts title="app/controllers/pages_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class PagesController {
async oldPage({ response }: HttpContext) {
/**
* Permanent redirect (301) for moved pages
*/
response.redirect().status(301).toPath('/new-page')
}
}
```
### Forwarding query strings
Query string forwarding is enabled by default in AdonisJS starter kits via the `redirect.forwardQueryString` option in your server config. You can verify this in your `config/app.ts` file.
```ts title="config/app.ts"
import { defineConfig } from '@adonisjs/core/http'
export const http = defineConfig({
redirect: {
forwardQueryString: true,
},
})
```
When enabled, all redirects automatically carry over the current URL's query string to the destination. For example, if the current URL is `/search?q=adonis&sort=date` and you redirect to `/results`, the user will be sent to `/results?q=adonis&sort=date`.
You can disable forwarding for a specific redirect by passing `false` to `withQs()`.
```ts title="app/controllers/auth_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class AuthController {
async logout({ response }: HttpContext) {
/**
* Disable query string forwarding for this redirect.
* The user will be sent to / without any query parameters.
*/
response.redirect().withQs(false).toPath('/')
}
}
```
If query string forwarding is not enabled by default, you can enable it for a specific redirect by calling `withQs()` without arguments.
```ts title="app/controllers/search_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class SearchController {
async filter({ response }: HttpContext) {
/**
* Forward existing query parameters to the new URL.
* If the current URL is /search?q=adonis&sort=date,
* this redirects to /results?q=adonis&sort=date
*/
response.redirect().withQs().toPath('/results')
}
}
```
### Setting custom query strings
Pass an object to `withQs()` to set custom query parameters. Chain the method multiple times to append additional parameters.
```ts title="app/controllers/products_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class ProductsController {
async search({ response }: HttpContext) {
/**
* Redirect with custom query parameters.
* Results in /products?category=electronics&sort=price
*/
response
.redirect()
.withQs({ category: 'electronics' })
.withQs({ sort: 'price' })
.toPath('/products')
}
}
```
### Redirecting to the intended URL
The `toIntended()` method redirects to a URL previously stored in the session via `session.setIntendedUrl()`. If no intended URL is stored, it redirects to the provided fallback. The stored URL is consumed (removed from session) after use.
The `withIntendedUrl()` method stores the current request URL in the session as the intended destination. It only stores for GET, navigational requests that matched a route.
These methods are available when the `@adonisjs/session` package is installed. See [Redirecting to the intended URL after login](../auth/session_guard.md#redirecting-to-the-intended-url) for a complete example.
```ts title="app/controllers/session_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async store({ auth, request, response }: HttpContext) {
await auth.use('web').login(
await User.verifyCredentials(
request.input('email'),
request.input('password')
)
)
/**
* Redirect to the intended URL or /dashboard
*/
return response.redirect().toIntended('/dashboard')
}
}
```
## Streaming and downloads
The Response class provides methods for streaming data and serving file downloads. These methods handle proper headers, caching, and cleanup automatically.
### Streaming responses
Use `response.stream()` to send a readable stream as the response. AdonisJS waits for the route handler and upstream middleware to finish before beginning to read from the stream.
```ts title="app/controllers/exports_controller.ts"
import { createReadStream } from 'node:fs'
import type { HttpContext } from '@adonisjs/core/http'
export default class ExportsController {
async generate({ response }: HttpContext) {
/**
* Create a readable stream from a file
*/
const stream = createReadStream('./exports/data.csv')
/**
* Stream the file to the client.
* AdonisJS handles backpressure and cleanup automatically.
*/
response.stream(stream)
}
}
```
### Downloading files
The `response.download()` method streams a file and sets appropriate headers for downloads. It accepts an absolute path to the file.
```ts title="app/controllers/invoices_controller.ts"
import app from '@adonisjs/core/services/app'
import type { HttpContext } from '@adonisjs/core/http'
export default class InvoicesController {
async download({ response, params }: HttpContext) {
/**
* Construct absolute path to the invoice file
*/
const filePath = app.makePath(`storage/invoices/${params.id}.pdf`)
/**
* Stream the file for download with ETag support.
* Browser caches the response as long as file contents remain unchanged.
*/
response.download(filePath)
}
}
```
### Force download with custom filename
Use `response.attachment()` to force a download and specify a custom filename. The browser will prompt the user to save the file with the given name.
```ts title="app/controllers/reports_controller.ts"
import app from '@adonisjs/core/services/app'
import type { HttpContext } from '@adonisjs/core/http'
export default class ReportsController {
async export({ response, params }: HttpContext) {
/**
* Absolute path to the report file
*/
const filePath = app.makePath(`storage/reports/${params.id}.xlsx`)
/**
* Force download with a user-friendly filename.
* The second parameter sets the filename in Content-Disposition header.
*/
response.attachment(filePath, `monthly-report-${params.month}.xlsx`)
}
}
```
## Setting response status
The Response class provides methods for setting HTTP status codes. Status codes communicate the result of the request to the client.
### Setting status code
Use `response.status()` to set the HTTP status code. This method overrides any previously set status code.
```ts title="app/controllers/api/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async store({ request, response }: HttpContext) {
const post = await Post.create(request.all())
/**
* Return 201 Created for successful resource creation
*/
response.status(201)
return post
}
}
```
### Setting status safely
The `response.safeStatus()` method sets a status code only if one hasn't been set already. This is useful in middleware where you want to provide a default without overriding explicit status codes.
```ts title="app/middleware/json_api_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class JsonApiMiddleware {
async handle({ response }: HttpContext, next: NextFn) {
await next()
/**
* Set default success status only if not already set.
* Controller-specific status codes take precedence.
*/
response.safeStatus(200)
}
}
```
### Status shorthand methods
AdonisJS provides shorthand methods that set both the status code and response body in one call.
```ts title="app/controllers/users_controller.ts"
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class UsersController {
async show({ response, params }: HttpContext) {
const user = await User.find(params.id)
if (!user) {
/**
* Sets status 404 and sends response body in one call
*/
return response.notFound({ error: 'User not found' })
}
return user
}
async destroy({ response, params, auth }: HttpContext) {
const user = await User.findOrFail(params.id)
if (user.id !== auth.user.id) {
/**
* Sets status 403 and sends error message
*/
return response.forbidden({ error: 'Cannot delete other users' })
}
await user.delete()
return response.noContent()
}
}
```
### Response shorthand methods
AdonisJS provides shorthand methods for common HTTP status codes. Each method sets the status code and optionally sends a response body.
| Method | Status Code |
|--------|-------------|
| `response.continue()` | 100 |
| `response.switchingProtocols()` | 101 |
| `response.ok(body?, generateEtag?)` | 200 |
| `response.created(body?, generateEtag?)` | 201 |
| `response.accepted(body?, generateEtag?)` | 202 |
| `response.nonAuthoritativeInformation(body?, generateEtag?)` | 203 |
| `response.noContent()` | 204 |
| `response.resetContent()` | 205 |
| `response.partialContent(body?, generateEtag?)` | 206 |
| `response.multipleChoices(body?, generateEtag?)` | 300 |
| `response.movedPermanently(body?, generateEtag?)` | 301 |
| `response.movedTemporarily(body?, generateEtag?)` | 302 |
| `response.seeOther(body?, generateEtag?)` | 303 |
| `response.notModified(body?, generateEtag?)` | 304 |
| `response.useProxy(body?, generateEtag?)` | 305 |
| `response.temporaryRedirect(body?, generateEtag?)` | 307 |
| `response.badRequest(body?, generateEtag?)` | 400 |
| `response.unauthorized(body?, generateEtag?)` | 401 |
| `response.paymentRequired(body?, generateEtag?)` | 402 |
| `response.forbidden(body?, generateEtag?)` | 403 |
| `response.notFound(body?, generateEtag?)` | 404 |
| `response.methodNotAllowed(body?, generateEtag?)` | 405 |
| `response.notAcceptable(body?, generateEtag?)` | 406 |
| `response.proxyAuthenticationRequired(body?, generateEtag?)` | 407 |
| `response.requestTimeout(body?, generateEtag?)` | 408 |
| `response.conflict(body?, generateEtag?)` | 409 |
| `response.gone(body?, generateEtag?)` | 410 |
| `response.lengthRequired(body?, generateEtag?)` | 411 |
| `response.preconditionFailed(body?, generateEtag?)` | 412 |
| `response.requestEntityTooLarge(body?, generateEtag?)` | 413 |
| `response.requestUriTooLong(body?, generateEtag?)` | 414 |
| `response.unsupportedMediaType(body?, generateEtag?)` | 415 |
| `response.requestedRangeNotSatisfiable(body?, generateEtag?)` | 416 |
| `response.expectationFailed(body?, generateEtag?)` | 417 |
| `response.unprocessableEntity(body?, generateEtag?)` | 422 |
| `response.tooManyRequests(body?, generateEtag?)` | 429 |
| `response.internalServerError(body?, generateEtag?)` | 500 |
| `response.notImplemented(body?, generateEtag?)` | 501 |
| `response.badGateway(body?, generateEtag?)` | 502 |
| `response.serviceUnavailable(body?, generateEtag?)` | 503 |
| `response.gatewayTimeout(body?, generateEtag?)` | 504 |
| `response.httpVersionNotSupported(body?, generateEtag?)` | 505 |
## Working with cookies
The Response class provides methods for setting cookies with different security levels. AdonisJS supports signed cookies, encrypted cookies, and plain cookies.
### Setting signed cookies
The `response.cookie()` method creates a signed cookie. Signed cookies cannot be tamprered but their content is visible to the client.
```ts title="app/controllers/preferences_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class PreferencesController {
async update({ response, request }: HttpContext) {
const theme = request.input('theme')
/**
* Set a signed cookie that expires in 2 hours.
* The signature prevents client-side tampering.
*/
response.cookie('theme', theme, {
maxAge: '2h'
})
return { success: true }
}
}
```
### Setting encrypted cookies
Use `response.encryptedCookie()` to encrypt cookie values. The `app.appKey` encrypts the value, making it unreadable to clients. If the app key changes, the cookie cannot be decrypted.
```ts title="app/controllers/sessions_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionsController {
async create({ response, auth }: HttpContext) {
/**
* Store sensitive session data in an encrypted cookie.
* The value is encrypted using app.appKey.
*/
response.encryptedCookie('session_data', {
userId: auth.user.id,
loginAt: new Date()
})
return { success: true }
}
}
```
### Setting plain cookies
The `response.plainCookie()` method creates a base64-encoded cookie without signing or encryption.
```ts title="app/controllers/tracking_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class TrackingController {
async view({ response }: HttpContext) {
/**
* Set a plain cookie for non-sensitive tracking data
*/
response.plainCookie('last_visit', new Date().toISOString())
return { success: true }
}
}
```
### Supported cookie value types
Cookie values can be any of the following data types: `string`, `number`, `bigInt`, `boolean`, `null`, `object`, and `array`.
```ts title="app/controllers/cart_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class CartController {
async add({ response, request }: HttpContext) {
const cartItems = [
{ id: 1, name: 'Product A', quantity: 2 },
{ id: 2, name: 'Product B', quantity: 1 }
]
/**
* Arrays and objects are automatically serialized
*/
response.encryptedCookie('cart', cartItems)
return { success: true }
}
}
```
### Cookie options
All cookie methods accept an options object to control cookie behavior. These options match the configuration in `config/app.ts` under the `http.cookie` block.
```ts title="app/controllers/auth_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class AuthController {
async login({ response, auth }: HttpContext) {
/**
* Set authentication cookie with security options
*/
response.encryptedCookie('auth_token', auth.token, {
domain: '',
path: '/',
maxAge: '2h',
httpOnly: true, // Prevent JavaScript access
secure: true, // Only send over HTTPS
sameSite: 'lax', // CSRF protection
partitioned: false, // Experimental: CHIPS
priority: 'medium' // Experimental: Cookie priority
})
return { success: true }
}
}
```
::::options
:::option{name="domain" dataType="string"}
Cookie domain. Empty string means current domain.
:::
:::option{name="path" dataType="string"}
Cookie path. Default is `/`.
:::
:::option{name="maxAge" dataType="string | number"}
Cookie expiry time. Can be a duration string like `'2h'` or milliseconds.
:::
:::option{name="httpOnly" dataType="boolean"}
Prevent JavaScript access to the cookie.
:::
:::option{name="secure" dataType="boolean"}
Only send cookie over HTTPS connections.
:::
:::option{name="sameSite" dataType="'lax' | 'strict' | 'none'"}
CSRF protection. Default is `'lax'`.
:::
:::option{name="partitioned" dataType="boolean"}
Experimental: Enable cookie partitioning (CHIPS).
:::
:::option{name="priority" dataType="'low' | 'medium' | 'high'"}
Experimental: Cookie priority hint for browsers.
:::
::::
## Running actions after response has been sent
The `response.onFinish()` method allows you to register callbacks that execute after the response has been sent to the client. This is useful for cleanup tasks, logging, or operations that shouldn't block the response.
### Cleaning up after file downloads
A common use case is deleting temporary files after they've been streamed to the client.
```ts title="app/controllers/files_controller.ts"
import { unlink } from 'node:fs/promises'
import app from '@adonisjs/core/services/app'
import type { HttpContext } from '@adonisjs/core/http'
export default class FilesController {
async download({ response, params }: HttpContext) {
const filePath = app.makePath(`tmp/exports/${params.id}.zip`)
/**
* Register cleanup callback before streaming the file
*/
response.onFinish(async () => {
await unlink(filePath)
})
/**
* Stream the file. After the download completes,
* the cleanup callback will run automatically.
*/
response.download(filePath)
}
}
```
### Logging response metrics
You can use `onFinish()` to log analytics or metrics without delaying the response.
```ts title="app/middleware/analytics_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class AnalyticsMiddleware {
async handle({ response, request }: HttpContext, next: NextFn) {
const startTime = Date.now()
/**
* Register analytics logging after response is sent.
* This doesn't delay the response to the client.
*/
response.onFinish(() => {
const duration = Date.now() - startTime
console.log(`${request.method()} ${request.url()} - ${duration}ms`)
})
await next()
}
}
```
## Response body serialization
The Response class automatically serializes response bodies and sets appropriate content-type headers based on the data type you send.
When you send a response, AdonisJS performs two operations: it serializes the value into a string format suitable for HTTP transmission, and it sets the appropriate `content-type` and `content-length` headers.
::::options
:::option{name="Arrays and objects"}
- **Content-Type:** `application/json`
- **Serialization Behavior:** Stringified using safe stringify (like `JSON.stringify()` but removes circular references and serializes BigInt)
:::
:::option{name="HTML fragments"}
- **Content-Type:** `text/html`
- **Serialization Behavior:** Sent as-is (strings starting with `<`)
:::
:::option{name="Numbers and booleans"}
- **Content-Type:** `text/plain`
- **Serialization Behavior:** Converted to string
:::
:::option{name="Date instances"}
- **Content-Type:** `text/plain`
- **Serialization Behavior:** Converted to ISO string using `toISOString()`
:::
:::option{name="Regular expressions"}
- **Content-Type:** `text/plain`
- **Serialization Behavior:** Converted to string using `toString()`
:::
:::option{name="Error objects"}
- **Content-Type:** `text/plain`
- **Serialization Behavior:** Converted to string using `toString()`
:::
:::option{name="JSONP responses"}
- **Content-Type:** `text/javascript`
- **Serialization Behavior:** Stringified appropriately
:::
:::option{name="Other plain strings"}
- **Content-Type:** `text/plain`
- **Serialization Behavior:** Sent as-is
:::
:::option{name="Other data types"}
- **Content-Type:** `-`
- **Serialization Behavior:** Results in an exception
:::
::::
This automatic handling means you rarely need to manually set content-type headers.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/api/data', async () => {
/**
* Returns JSON with content-type: application/json
* BigInt values are safely serialized as strings
*/
return {
id: BigInt(9007199254740991),
timestamp: new Date(),
active: true
}
})
router.get('/page', async () => {
/**
* Returns HTML with content-type: text/html
*/
return '
Welcome
'
})
router.get('/text', async () => {
/**
* Returns plain text with content-type: text/plain
*/
return 'Hello, world!'
})
```
## Extending HttpResponse class
You can add custom methods and properties to the `HttpResponse` class using macros and getters. This allows you to create reusable response helpers that match your application's needs.
:::note
Read the [Extending AdonisJS](../concepts/extending_adonisjs.md) guide if you are new to the concept of macros and getters.
:::
### Adding custom methods
Use `HttpResponse.macro()` to add methods to all response instances throughout your application.
```ts title="providers/app_provider.ts"
import { HttpResponse } from '@adonisjs/core/http'
export default class AppProvider {
async boot() {
/**
* Add a custom method to send API responses with consistent structure
*/
HttpResponse.macro('api', function (data: any, meta?: Record) {
return this.json({
success: true,
data: data,
meta: meta || {}
})
})
/**
* Add a custom method for paginated responses
*/
HttpResponse.macro('paginated', function (items: any[], pagination: any) {
return this.json({
data: items,
pagination: {
page: pagination.page,
perPage: pagination.perPage,
total: pagination.total
}
})
})
}
}
```
### Augmenting the HttpResponse class type
After adding custom methods, you must inform TypeScript about them by augmenting the `HttpResponse` interface. Otherwise, you will get type errors when trying to use the custom methods.
```ts title="types/response.ts"
import { HttpResponse } from '@adonisjs/core/http'
declare module '@adonisjs/core/http' {
export interface HttpResponse {
/**
* Send a standardized API response
*/
api(data: any, meta?: Record): void
/**
* Send a paginated response
*/
paginated(items: any[], pagination: {
page: number
perPage: number
total: number
}): void
}
}
```
### Using custom methods
Once defined, your custom methods are available on all response instances.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async index({ response }: HttpContext) {
const posts = await Post.all()
/**
* Use the custom api() method for consistent responses
*/
// [!code highlight:4]
return response.api(posts, {
version: 'v1',
timestamp: new Date()
})
}
async paginated({ response, request }: HttpContext) {
const page = request.input('page', 1)
const posts = await Post.query().paginate(page, 20)
/**
* Use the custom paginated() method
*/
// [!code highlight]
return response.paginated(posts.all(), posts.getMeta())
}
}
```
---
# Body Parser
This guide covers the body parser configuration in AdonisJS. You will learn how to:
- Configure parsers for different content types (JSON, form data, multipart)
- Set global parsing options like empty string conversion and whitespace trimming
- Adjust file upload limits and request size restrictions
- Control automatic file processing for specific routes
- Handle custom content types using the raw parser
## Overview
The body parser is responsible for parsing incoming request bodies before they reach your route handlers. It automatically detects the content type of each request and applies the appropriate parser to convert the raw request data into a usable format.
AdonisJS includes three built-in parsers: the **JSON parser** handles JSON-encoded data, the **form parser** handles URL-encoded form submissions, and the **multipart parser** handles file uploads and multipart form data. Each parser can be configured independently through the `config/bodyparser.ts` file.
You don't interact with the body parser directly in your application code. Instead, you access the parsed data through the Request class using methods like `request.all()`, `request.body()`, or `request.file()`. The body parser runs as middleware and processes request bodies automatically before your route handlers execute.
See also: [Request class documentation](./request.md) for accessing parsed request data.
## Configuration
The body parser is configured in the `config/bodyparser.ts` file. The configuration file is created automatically when you create a new AdonisJS application.
```ts title="config/bodyparser.ts"
import { defineConfig } from '@adonisjs/core/bodyparser'
const bodyParserConfig = defineConfig({
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
form: {
convertEmptyStringsToNull: true,
trimWhitespaces: true,
types: ['application/x-www-form-urlencoded'],
},
json: {
convertEmptyStringsToNull: true,
trimWhitespaces: true,
types: [
'application/json',
'application/json-patch+json',
'application/vnd.api+json',
'application/csp-report',
],
},
multipart: {
autoProcess: true,
convertEmptyStringsToNull: true,
trimWhitespaces: true,
processManually: [],
limit: '20mb',
types: ['multipart/form-data'],
},
})
export default bodyParserConfig
```
:::option{name="allowedMethods"}
The `allowedMethods` array defines which HTTP methods should have their request bodies parsed. By default, only `POST`, `PUT`, `PATCH`, and `DELETE` requests are processed. GET requests are excluded because they typically don't include request bodies.
:::
## Global parsing options
Two global options are available across all parsers: `convertEmptyStringsToNull` and `trimWhitespaces`. These options help normalize incoming data before it reaches your application logic.
:::option{name="convertEmptyStringsToNull"}
The `convertEmptyStringsToNull` option converts all empty strings in the request body to `null` values. This option solves a common problem with HTML forms.
When an HTML form input field has no value, browsers send an empty string in the request body rather than omitting the field entirely. This behavior creates challenges for database normalization, especially with nullable columns.
Consider a user registration form with an optional "country" field. Your database has a nullable `country` column, and you want to store `null` when the user doesn't select a country. However, the HTML form sends an empty string, which means you would insert an empty string into the database instead of leaving the column as `null`.
Enabling `convertEmptyStringsToNull` handles this inconsistency automatically. The body parser converts all empty strings to `null` before your validation or database logic runs.
```ts title="config/bodyparser.ts"
json: {
convertEmptyStringsToNull: true,
}
```
:::
:::option{name="trimWhitespaces"}
The `trimWhitespaces` option removes leading and trailing whitespace from all string values in the request body. This helps eliminate accidental whitespace that users might include when submitting forms.
Instead of manually trimming values in your controllers or validators, you can enable this option and let the body parser handle whitespace removal globally.
```ts title="config/bodyparser.ts"
form: {
trimWhitespaces: true,
}
```
:::
## JSON parser
The JSON parser handles requests with JSON-encoded bodies. It processes several content types by default, including `application/json`, `application/json-patch+json`, `application/vnd.api+json`, and `application/csp-report`.
:::option{name="encoding"}
The `encoding` option specifies the character encoding to use when converting the request body Buffer to a string. The default is `utf-8`, which handles most use cases. You can use any encoding supported by the [iconv-lite](https://www.npmjs.com/package/iconv-lite) package.
```ts title="config/bodyparser.ts"
json: {
encoding: 'utf-8',
}
```
:::
:::option{name="limit"}
The `limit` option sets the maximum size of request body data the parser will accept. Requests that exceed this limit will receive a `413 Payload Too Large` error response.
```ts title="config/bodyparser.ts"
json: {
limit: '1mb',
}
```
:::
:::option{name="strict"}
The `strict` option controls whether the parser accepts only objects and arrays as top-level JSON values. When enabled, the parser rejects primitive values like strings, numbers, or booleans at the root level.
```ts title="config/bodyparser.ts"
json: {
strict: true,
}
```
:::
:::option{name="types"}
The `types` array defines which content types the JSON parser should handle. You can add custom content types if your application receives JSON data with non-standard content type headers.
```ts title="config/bodyparser.ts"
json: {
types: [
'application/json',
'application/json-patch+json',
'application/vnd.api+json',
'application/csp-report',
// [!code highlight]
'application/custom+json',
]
}
```
:::
## Form parser
The form parser handles URL-encoded form data, typically from HTML forms with `application/x-www-form-urlencoded` content type.
:::option{name="encoding"}
The `encoding` option specifies the character encoding to use when converting the request body Buffer to a string. The default is `utf-8`, which handles most use cases. You can use any encoding supported by the [iconv-lite](https://www.npmjs.com/package/iconv-lite) package.
```ts title="config/bodyparser.ts"
form: {
encoding: 'utf-8',
}
```
:::
:::option{name="limit"}
The `limit` option sets the maximum size of request body data the parser will accept. Requests that exceed this limit will receive a `413 Payload Too Large` error response.
```ts title="config/bodyparser.ts"
form: {
limit: '1mb',
}
```
:::
:::option{name="queryString"}
The `queryString` option allows you to configure how the URL-encoded string is parsed into an object. These options are passed directly to the [qs](https://www.npmjs.com/package/qs) package, which handles the parsing.
```ts title="config/bodyparser.ts"
form: {
queryString: {
depth: 5,
parameterLimit: 1000,
},
}
```
See also: [qs documentation](https://www.npmjs.com/package/qs) for all available options.
:::
:::option{name="types"}
The `types` array defines which content types the form parser should handle. By default, it processes `application/x-www-form-urlencoded` requests.
```ts title="config/bodyparser.ts"
form: {
types: ['application/x-www-form-urlencoded'],
}
```
:::
## Multipart parser
The multipart parser handles file uploads and multipart form data. It processes requests with the `multipart/form-data` content type, which browsers use when submitting forms that include file inputs.
:::option{name="autoProcess"}
The `autoProcess` option controls whether uploaded files are automatically moved to your operating system's temporary directory. When enabled, the parser streams files to disk as the request is processed.
After automatic processing, you can access uploaded files in your controllers using `request.file()`, validate them, and move them to a permanent location or cloud storage service.
```ts title="config/bodyparser.ts"
multipart: {
autoProcess: true,
}
```
You can specify an array of route patterns to enable automatic processing for specific routes only. The values must be route patterns, not URLs.
```ts title="config/bodyparser.ts"
multipart: {
autoProcess: [
'/uploads',
'/posts/:id/images',
],
}
```
:::option{name="processManually"}
The `processManually` array lets you disable automatic file processing for selected routes while keeping it enabled globally. This is useful when you have a few routes that need custom file handling but want the convenience of automatic processing everywhere else.
The values must be route patterns, not URLs.
```ts title="config/bodyparser.ts"
multipart: {
autoProcess: true,
processManually: [
'/file-manager',
'/projects/:id/assets',
],
}
```
:::
:::option{name="encoding"}
The `encoding` option specifies the character encoding to use when converting text fields in the multipart request to strings. The default is `utf-8`, which handles most use cases. You can use any encoding supported by the [iconv-lite](https://www.npmjs.com/package/iconv-lite) package.
```ts title="config/bodyparser.ts"
multipart: {
encoding: 'utf-8',
}
```
:::
:::option{name="limit"}
The `limit` option sets the maximum total size of all uploaded files in a single request. Requests that exceed this limit will receive a `413 Payload Too Large` error response.
```ts title="config/bodyparser.ts"
multipart: {
limit: '20mb',
}
```
:::
:::option{name="fieldsLimit"}
The `fieldsLimit` option sets the maximum total size of all form fields (not files) in the multipart request. This prevents abuse through extremely large text field submissions. Requests that exceed this limit will receive a `413 Payload Too Large` error response.
```ts title="config/bodyparser.ts"
multipart: {
fieldsLimit: '2mb',
}
```
:::
:::option{name="tmpFileName"}
The `tmpFileName` option accepts a function that generates custom names for temporary files. By default, the parser generates random file names.
```ts title="config/bodyparser.ts"
multipart: {
tmpFileName: () => {
return `upload_${Date.now()}_${Math.random().toString(36)}`
},
}
```
:::
:::option{name="types"}
The `types` array defines which content types the multipart parser should handle. By default, it processes `multipart/form-data` requests.
```ts title="config/bodyparser.ts"
multipart: {
types: ['multipart/form-data'],
}
```
:::
## Raw parser for custom content types
The body parser includes a raw parser that can handle content types not supported by the default parsers. The raw parser provides the request body as a string, which you can then process using custom middleware.
This is useful when your application receives data in formats like XML, YAML, or other specialized content types that don't have built-in parsers.
```ts title="config/bodyparser.ts"
const bodyParserConfig = defineConfig({
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
// [!code ++:5]
raw: {
types: ['application/xml', 'text/xml'],
limit: '1mb',
encoding: 'utf-8',
},
form: {
// ... form config
},
json: {
// ... json config
},
multipart: {
// ... multipart config
},
})
export default bodyParserConfig
```
After enabling the raw parser for specific content types, create custom middleware to parse the string data into a usable format.
See also: [Middleware documentation](./middleware.md)
```ts title="app/middleware/xml_parser_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import xml2js from 'xml2js'
export default class XmlParserMiddleware {
async handle({ request }: HttpContext, next: NextFn) {
if (request.header('content-type')?.includes('xml')) {
const rawBody = request.raw()
const parser = new xml2js.Parser()
const parsed = await parser.parseStringPromise(rawBody)
request.updateBody(parsed)
}
await next()
}
}
```
## Form Method Spoofing
HTML forms only support GET and POST methods. Method spoofing allows you to specify other HTTP methods (PUT, PATCH, DELETE) via a query parameter, enabling full RESTful routing with standard HTML forms.
```ts
// title: config/app.ts
import env from '#start/env'
import { defineConfig } from '@adonisjs/core/http'
export const http = defineConfig({
/**
* Enable method spoofing for HTML forms.
* This allows forms to use PUT, PATCH, and DELETE methods
* by adding ?_method=PUT to the form action.
*/
allowMethodSpoofing: true
})
```
With method spoofing enabled, you can use the `_method` query parameter in your forms:
```html
```
---
# Validation
This guide covers validation in AdonisJS using VineJS validators at the controller level. You will learn how to:
- Create and use VineJS validators in controllers
- Handle validation errors with automatic content negotiation
- Customize error messages globally or with i18n
- Validate query strings, params, headers, and cookies
- Pass metadata to validators for context-specific validation
- Use validators outside HTTP requests in jobs and commands
## Overview
Validation in AdonisJS happens at the controller level, allowing you to validate and abort requests early if the provided data is invalid. This approach lets you model validations around forms or expected request data rather than coupling validations to your models layer.
Once data passes validation, you can trust it completely and pass it to other layers of your application (whether services, data models, or business logic) without additional checks. This creates a clear trust boundary in your application architecture.
## VineJS - The validation library
AdonisJS comes pre-bundled with [VineJS](https://vinejs.dev), a superfast validation library. While you can use a different validation library and uninstall VineJS, VineJS provides additional validation rules specifically designed for AdonisJS, such as checking for uniqueness within the database or validating multipart file uploads.
## Creating your first validator
Validators in AdonisJS are stored in the `app/validators` directory, with one file per resource containing all validators for that resource's actions. Let's create a validator for blog posts.
::::steps
:::step{title="Generate the validator file"}
Run the following command to create a new validator.
```bash
node ace make:validator post
```
This creates an empty validator file at `app/validators/post.ts` with the VineJS import.
```ts title="app/validators/post.ts"
import vine from '@vinejs/vine'
```
:::
:::step{title="Define your validation schema"}
Add a validator for creating posts. We'll validate the `title`, `body`, and `publishedAt` fields.
```ts title="app/validators/post.ts"
import vine from '@vinejs/vine'
export const createPostValidator = vine.create({
title: vine.string(),
body: vine.string(),
publishedAt: vine.date()
})
```
:::
:::step{title="Use the validator in your controller"}
Import the validator into your controller and use the `request.validateUsing()` method to validate the request body.
```ts title="app/controllers/posts_controller.ts"
import { createPostValidator } from '#validators/post'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async store({ request }: HttpContext) {
const payload = await request.validateUsing(createPostValidator)
// Now you can trust and use the payload
// Create post, save to database, etc.
}
}
```
The `request.validateUsing()` method automatically validates the request body. You don't need to explicitly pass the body data (the `request` object already has access to it). If validation fails, an exception is thrown and handled automatically. The validated payload is returned and safe to use throughout your application.
:::
::::
## Understanding error handling
When validation fails, the `request.validateUsing()` method throws an exception. You don't need to manually handle this exception. AdonisJS's [global exception handler](./exception_handling.md) automatically converts it into an appropriate response based on the request type using content negotiation.
### How content negotiation works
AdonisJS detects what kind of response the client expects and formats validation errors accordingly.
| Application Type | Behavior | Error Format |
|-----------------|----------|--------------|
| Hypermedia (server-rendered) | Redirects back to form | Flash messages in session |
| Inertia | Redirects back to form | Shared via Inertia state |
| API (JSON) | Returns 422 status | JSON with `errors` array |
**For hypermedia applications (traditional server-rendered apps)**
- The user is redirected back to the form
- Error messages are flashed to the session using AdonisJS's session flash store
- You can display these errors in your template using the `@field.error` component
**For Inertia applications**
- The user is redirected back to the form
- Error messages are shared via Inertia's shared state
- Errors are automatically available in your frontend components
**For API requests (clients expecting JSON)**
- A JSON response is returned with status code 422
- The response contains an `errors` array with all validation error messages
- Each error includes the field name, rule that failed, and error message
```json
{
"errors": [
{
"field": "title",
"rule": "required",
"message": "The title field is required"
},
{
"field": "publishedAt",
"rule": "date",
"message": "The publishedAt field must be a valid date"
}
]
}
```
This automatic handling means you write validation logic once, and it works correctly for all application types without additional code.
:::tip{title="Common confusion"}
You don't need to wrap `validateUsing()` in try/catch blocks. The global exception handler already converts validation exceptions into proper responses. Only use try/catch if you need custom error handling logic that differs from the default behavior.
:::
## Customizing error messages
By default, VineJS provides generic error messages. You can customize these messages globally in two ways. You can use a custom [VineJS error messages provider](https://vinejs.dev/docs/custom_error_messages#creating-a-messages-provider), or you can use the i18n package for localized messages.
### Using a custom messages provider
Create a `start/validator.ts` file to configure global custom messages. First, generate the preload file.
```bash
node ace make:preload validator
```
Then define your custom messages using the `SimpleMessagesProvider`.
```ts title="start/validator.ts"
import vine, { SimpleMessagesProvider } from '@vinejs/vine'
vine.messagesProvider = new SimpleMessagesProvider({
// Global messages applicable to all fields
'required': 'The {{ field }} field is required',
'string': 'The value of {{ field }} field must be a string',
'email': 'The value is not a valid email address',
// Field-specific messages override global messages
'username.required': 'Please choose a username for your account',
})
```
The `{{ field }}` placeholder is automatically replaced with the actual field name. Field-specific messages (like `username.required`) take precedence over global messages.
### Using i18n for localized messages
For applications that need multiple languages, use the `@adonisjs/i18n` package to define validation messages in translation files. This allows you to provide validation errors in different languages based on the user's locale.
First, install and configure the i18n package (see the [i18n guide](../digging_deeper/i18n.md) for full setup instructions). Then define your messages in language-specific JSON files.
```json title="resources/lang/en/validator.json"
{
"shared": {
"fields": {
"first_name": "First name",
"email": "Email address"
},
"messages": {
"required": "Enter {field}",
"username.required": "Choose a username for your account",
"email": "The email must be valid"
}
}
}
```
The `fields` object defines human-readable names for your form fields, while the `messages` object defines the error messages. This separation allows you to reuse field names across different messages.
## Validating different data sources
While the request body is the most common data source to validate, you often need to validate other parts of the HTTP request, such as query strings, route parameters, headers, or cookies.
### Validating query strings, params, headers, and cookies
Define nested objects in your schema for each data source you want to validate.
```ts title="app/validators/user.ts"
import vine from '@vinejs/vine'
export const showUserValidator = vine.create({
// Validate request body and query string values
username: vine.string(),
password: vine.string(),
filters: vine.object({
page: vine.number().optional(),
limit: vine.number().optional()
}),
// Validate route parameters
params: vine.object({
id: vine.number()
}),
// Validate cookies
cookies: vine.object({
sessionId: vine.string()
}),
// Validate headers
headers: vine.object({
'x-api-key': vine.string()
})
})
```
The validator automatically extracts data from the correct location based on these property names (`params`, `cookies`, `headers`).
When you call `request.validateUsing()`, all these sources are validated simultaneously.
```ts title="app/controllers/users_controller.ts"
import { showUserValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class UsersController {
async show({ request }: HttpContext) {
const payload = await request.validateUsing(showUserValidator)
// Access validated data
console.log(payload.params.id)
console.log(payload.filters.page)
console.log(payload.cookies.sessionId)
}
}
```
This approach allows you to validate all incoming request data in one place, creating a complete trust boundary for your controller logic.
## Passing metadata to validators
Sometimes validators need access to request-specific information that isn't part of the data being validated. A common example is validating email uniqueness while allowing the current user to keep their existing email.
### Defining metadata in the validator
Use the `withMetaData()` method to define what metadata your validator expects.
```ts title="app/validators/user.ts"
import vine from '@vinejs/vine'
export const updateUserValidator = vine
.withMetaData<{ userId: number }>()
.create({
email: vine.string().email().unique({
table: 'users',
filter: (db, value, field) => {
db.whereNot('id', field.meta.userId)
}
})
})
```
The `withMetaData()` method accepts a TypeScript type defining the shape of your metadata. Inside validation rules, you can access this metadata via `field.meta`. In this example, the filter callback excludes the current user's row when checking for uniqueness.
### Passing metadata during validation
Provide the metadata when calling `validateUsing()`.
```ts title="app/controllers/users_controller.ts"
import { updateUserValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class UsersController {
async update({ request, auth }: HttpContext) {
const payload = await request.validateUsing(updateUserValidator, {
// [!code highlight:3]
meta: {
userId: auth.user!.id
}
})
}
}
```
The validator uses the provided `userId` to exclude the user's own record from the uniqueness check. This pattern is useful whenever validation logic needs information from the current request context, such as the authenticated user, tenant ID, or other request-specific values.
## Using validators outside HTTP requests
Validators aren't limited to HTTP requests. You can use them anywhere you need to validate data, such as in background jobs, console commands, or service classes.
### Validating data directly
Call the `validate()` method directly on your compiled validator.
```ts title="app/jobs/import_posts_job.ts"
import { createPostValidator } from '#validators/post'
export default class ImportPostsJob {
async handle(data: unknown[]) {
for (const item of data) {
try {
const validPost = await createPostValidator.validate(item)
// Process valid post data
await Post.create(validPost)
} catch (error) {
// Handle validation errors for this item
console.error('Invalid post data:', error.messages)
}
}
}
}
```
The `validate()` method returns the validated payload if successful, or throws an exception if validation fails. Unlike `request.validateUsing()`, you'll typically want to handle these exceptions yourself in non-HTTP contexts, as there's no automatic error response.
This approach ensures consistent validation logic across your entire application, whether handling HTTP requests, processing background jobs, or validating data in any other context.
## Next steps
Now that you understand validation in AdonisJS, you can:
- Explore the [VineJS documentation](https://vinejs.dev) to discover all available schema types and validation rules
- Learn about [flash messages](./session.md#flash-messages) to display validation errors in your templates
- Read the [exception handling guide](./exception_handling.md) to understand how AdonisJS processes validation errors
- Check out the [i18n guide](../digging_deeper/i18n.md) for localizing validation messages in multiple languages
---
# File Uploads
This guide covers file uploads in AdonisJS, from basic single file uploads to advanced direct uploads with cloud storage providers. You will learn how to:
- Accept and validate file uploads in your application
- Store files permanently using FlyDrive
- Handle multiple file uploads and direct cloud uploads
- Secure your file upload endpoints
## Overview
File uploads allow users to send files from their browsers to your AdonisJS application. Unlike many Node.js frameworks that require additional packages for this functionality, AdonisJS has built-in support for parsing multipart requests and processing file uploads through its bodyparser.
When a file is uploaded, AdonisJS automatically saves it to the server's `tmp` directory. From there, you can validate the file in your controllers and then move it to permanent storage.
For permanent storage, AdonisJS integrates with [FlyDrive](https://flydrive.dev/docs/introduction), which provides a unified API for working with local file systems as well as cloud storage solutions like Amazon S3, Cloudflare R2, and Google Cloud Storage.
## Uploading your first file
We'll build a feature that allows users to update their profile avatar. This is a common requirement and demonstrates all the essential concepts.
::::steps
:::step{title="Create the upload form"}
First, create a form that accepts file uploads. The critical part is setting the form encoding to `multipart/form-data`. Without this, the browser won't send files correctly.
::::tabs
:::tab{title="Edge (Hypermedia)"}
```edge title="resources/views/pages/profile.edge"
@form({ route: 'profile_avatar.update', enctype: 'multipart/form-data' })
@field.root({ name: 'avatar' })
@!input.control({ type: 'file' })
@!field.label({ text: 'Upload new avatar' })
@!field.error()
@end
@!button({ type: 'Submit', text: 'Update Avatar' })
@end
```
:::
:::tab{title="React (Inertia)"}
```tsx title="inertia/pages/profile.tsx"
import { Form } from '@adonisjs/inertia/react'
export default function Profile() {
return (
)
}
```
:::
::::
:::
:::step{title="Register the route"}
Next, register a route to handle the file upload.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.put('/profile/avatar', [controllers.profileAvatar, 'update'])
```
:::
:::step{title="Create the controller"}
Now create a controller that accepts the uploaded file. The `request.file()` method gives you access to the uploaded file by its field name.
```ts title="app/controllers/profile_avatar_controller.ts"
import { HttpContext } from '@adonisjs/core/http'
export default class ProfileAvatarController {
async update({ request, response }: HttpContext) {
const avatar = request.file('avatar')
if (!avatar) {
return response.badRequest('Please upload an avatar image')
}
console.log(avatar)
return 'Avatar uploaded successfully'
}
}
```
At this point, your application can receive file uploads. The file is already saved in the tmp directory when you access it. The file object contains useful properties:
- `tmpPath` - Where the file is currently stored on your server
- `clientName` - The original filename from the user's computer
- `size` - File size in bytes
- `extname` - File extension (e.g., 'jpg', 'png')
- `type` - MIME type (e.g., 'image/jpeg')
:::
::::
## Validating uploaded files
Accepting any file without validation is dangerous. Users might upload files that are too large, have incorrect formats, or could even be malicious. AdonisJS provides two approaches for validation.
### Inline validation
You can validate files directly in the `request.file()` call by passing validation options as the second argument.
```ts title="app/controllers/profile_avatar_controller.ts"
import { HttpContext } from '@adonisjs/core/http'
export default class ProfileAvatarController {
async update({ request, response }: HttpContext) {
const avatar = request.file('avatar', {
size: '2mb',
extnames: ['jpg', 'png', 'jpeg']
})
if (!avatar) {
return response.badRequest('Please upload an avatar image')
}
if (avatar.hasErrors) {
return response.badRequest(avatar.errors)
}
return 'Avatar uploaded and validated successfully'
}
}
```
The validation happens as soon as you call `request.file()`. If the file is too large or has an invalid extension, the `avatar.hasErrors` property will be `true` and the `avatar.errors` array will contain error messages.
### VineJS validation
While inline validation works, using VineJS validators is the recommended approach because it provides better error messages, consistent validation patterns, and easier testing.
First, create a validator file.
```ts title="app/validators/user.ts"
import vine from '@vinejs/vine'
export const updateAvatarValidator = vine.create({
avatar: vine.file({
size: '2mb',
extnames: ['jpg', 'png', 'jpeg']
})
})
```
Then use the validator in your controller.
```ts title="app/controllers/profile_avatar_controller.ts"
import { HttpContext } from '@adonisjs/core/http'
import { updateAvatarValidator } from '#validators/user'
export default class ProfileAvatarController {
async update({ request }: HttpContext) {
const payload = await request.validateUsing(updateAvatarValidator)
console.log(payload.avatar)
return 'Avatar uploaded and validated successfully'
}
}
```
If validation fails, AdonisJS automatically returns a 422 response with detailed error messages. If validation succeeds, you get the validated data in the payload object. The avatar has passed size and extension checks at this point.
:::tip{title="Security feature"}
A key security feature of AdonisJS is that it uses [magic number detection](https://en.wikipedia.org/wiki/Magic_number_(programming)) to validate file types. This means even if someone renames a `.exe` file to `.jpg`, AdonisJS will detect the actual file type and reject it. This protects your application from users trying to bypass validation by simply changing file extensions.
:::
### Combining files with other fields
When your form includes both file uploads and regular fields, the validated payload contains both. Destructure the file field separately before passing the remaining data to your model — passing a multipart file object directly to `Model.create()` will cause an error:
```ts title="app/validators/task.ts"
import vine from '@vinejs/vine'
export const createTaskValidator = vine.create({
title: vine.string(),
description: vine.string().optional(),
attachment: vine.file({ size: '5mb', extnames: ['pdf', 'jpg', 'png'] }).optional(),
})
```
```ts title="app/controllers/tasks_controller.ts"
import { HttpContext } from '@adonisjs/core/http'
import { createTaskValidator } from '#validators/task'
export default class TasksController {
async store({ request }: HttpContext) {
// [!code highlight:2]
const { attachment, ...data } = await request.validateUsing(createTaskValidator)
const task = await Task.create(data)
if (attachment) {
await attachment.moveToDisk(`tasks/${task.id}.${attachment.extname}`)
task.attachmentFileName = `tasks/${task.id}.${attachment.extname}`
await task.save()
}
return task
}
}
```
The `{ attachment, ...data }` destructuring separates the file from the scalar fields so you can pass `data` directly to the model and handle the file separately.
## Storing and serving uploaded files
Files uploaded through forms are temporarily stored in the `tmp` directory. Most operating systems automatically clean up temporary files, so you cannot rely on them persisting. For permanent storage, you need to move files to a location where they will be preserved.
AdonisJS uses [FlyDrive](../digging_deeper/drive.md) for permanent file storage. FlyDrive provides a unified API that works with local file systems during development and cloud storage providers like S3 or R2 in production.
Install and configure FlyDrive by running the following command:
```bash
node ace add @adonisjs/drive
```
This command installs the `@adonisjs/drive` package and creates a `config/drive.ts` configuration file with local disk storage ready to use. For cloud storage configuration (S3, R2, GCS), see the [Drive documentation](../digging_deeper/drive.md).
### Moving files to permanent storage
Once FlyDrive is installed, you can move validated files from the `tmp` directory to permanent storage. The `@adonisjs/drive` package extends the file object with a `moveToDisk()` method that handles this automatically.
```ts title="app/controllers/profile_avatar_controller.ts"
import { HttpContext } from '@adonisjs/core/http'
import string from '@adonisjs/core/helpers/string'
import { updateAvatarValidator } from '#validators/user'
export default class ProfileAvatarController {
async update({ request, auth }: HttpContext) {
const payload = await request.validateUsing(updateAvatarValidator)
/**
* Use a unique random name for storing the file
*/
const fileName = `${string.uuid()}.${payload.avatar.extname}`
/**
* Move file using the pre-configured drive disk.
*/
// [!code highlight]
await payload.avatar.moveToDisk(fileName)
/**
* Update user row in the database to reflect the newly
* updated avatar filename
*/
const user = auth.getUserOrFail()
user.avatarFileName = fileName
await user.save()
return 'Avatar uploaded and saved successfully'
}
}
```
- We generate a unique filename using UUID to prevent collisions. Multiple users might upload files named "avatar.jpg", so unique names prevent overwriting.
- The `moveToDisk()` method handles the transfer from tmp to permanent storage. It uses the configured disk (local filesystem in development or cloud storage in production).
- Store the filename in your database after moving. You'll need it later to display the avatar or generate download links.
### Accessing your uploaded files
The `@adonisjs/drive` package includes a built-in file server that automatically serves uploaded files. The file server registers routes under `/uploads` followed by your directory structure.
For example, if you store a file as `avatars/123e4567.jpg`, it becomes accessible at:
```
http://localhost:3333/uploads/avatars/123e4567.jpg
```
Now, instead of hardcoding this path, use the appropriate method for your application type to generate the URL. This ensures the correct URL is returned whether you're using local storage or cloud providers like S3 or R2.
::::tabs
:::tab{title="Edge (Hypermedia)"}
Use the `driveUrl` Edge helper in your templates:
```edge
```
:::
:::tab{title="API / Inertia"}
In controllers that return JSON or render Inertia pages, use the `drive` service to generate URLs:
```ts
import drive from '@adonisjs/drive/services/main'
// Inside a controller or transformer
const avatarUrl = await drive.use().getUrl(user.avatarFileName)
```
You can include this URL in your API response or Inertia props:
```ts
return inertia.render('profile', {
user: {
name: user.name,
avatarUrl: await drive.use().getUrl(user.avatarFileName),
}
})
```
:::
::::
## Uploading multiple files
Many applications need to accept multiple files in a single request. For example, allowing users to upload several documents for a project, or multiple product images at once.
### Accepting multiple files in the form
To accept multiple files, add the `multiple` attribute to your file input and use an array-style field name.
```edge title="resources/views/pages/project.edge"
@form({ route: 'projects.documents.store', enctype: 'multipart/form-data' })
@field.root({ name: 'documents[]' })
@!input.control({ type: 'file', multiple: true })
@!field.label({ text: 'Upload project documents' })
@!field.error()
@end
@end
```
### Accessing multiple files
Use `request.files()` (plural) instead of `request.file()` to access multiple uploaded files. This method returns an array of file objects, even if only one file was uploaded.
```ts title="app/controllers/project_documents_controller.ts"
import { HttpContext } from '@adonisjs/core/http'
export default class ProjectDocumentsController {
async store({ request, response }: HttpContext) {
const documents = request.files('documents')
if (documents.length === 0) {
return response.badRequest('Please upload at least one document')
}
console.log(`Received ${documents.length} documents`)
return 'Documents uploaded successfully'
}
}
```
### Validating multiple files
With VineJS, use `vine.array()` to validate an array of files. Each file in the array must meet the specified size and extension requirements.
```ts title="app/validators/project.ts"
import vine from '@vinejs/vine'
export const uploadDocumentsValidator = vine.create({
documents: vine.array(
vine.file({
size: '5mb',
extnames: ['pdf', 'doc', 'docx', 'txt'],
})
),
})
```
### Processing multiple files
Loop through the validated files and move each one to permanent storage individually. Each file in the array has the same properties and methods as single files, including `moveToDisk()`.
```ts title="app/controllers/project_documents_controller.ts"
import { HttpContext } from '@adonisjs/core/http'
import string from '@adonisjs/core/helpers/string'
import { uploadDocumentsValidator } from '#validators/project'
export default class ProjectDocumentsController {
async store({ request, params }: HttpContext) {
const payload = await request.validateUsing(uploadDocumentsValidator)
const fileNames: string[] = []
for (const document of payload.documents) {
const fileName = `${string.uuid()}.${document.extname}`
await document.moveToDisk(fileName)
fileNames.push(fileName)
}
return { message: 'Documents uploaded', count: fileNames.length }
}
}
```
## Direct uploads
Direct uploads allow files to be uploaded directly from the browser to cloud storage providers like S3, R2, or Google Cloud Storage, completely bypassing your AdonisJS server.
Instead of the standard flow where files travel from `browser → your server → cloud storage`, direct uploads go straight from `browser → cloud storage`. Your server only generates a short-lived signed URL that grants temporary permission to upload to a specific location.
This pattern is recommended when handling large file uploads, typically above 100MB. Building a fault-tolerant and resumable upload server for large files is complex work. By using direct uploads, you offload that responsibility to specialized services designed for this purpose.
Additional benefits include reduced server bandwidth usage, better upload performance for users, and built-in resumable uploads from cloud providers.
### Implementing direct uploads
You'll need an account with a cloud storage provider like Amazon S3, Cloudflare R2, or Google Cloud Storage. Make sure FlyDrive is configured with your cloud provider credentials (refer to the Drive reference guide for configuration details).
Create an endpoint that generates signed upload URLs.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import drive from '@adonisjs/drive/services/main'
router.post('/signed-upload-url', async ({ request }) => {
const fileName = request.input('file_name')
const url = await drive.use('r2').getSignedUploadUrl(fileName, {
expiresIn: '30 mins',
})
return { signedUrl: url }
})
```
The client provides the filename they want to upload. You should consider validating this and generating unique filenames to prevent collisions.
The signed URL expires after 30 minutes to prevent long-term unauthorized access. Replace 'r2' with your configured disk name (could be 's3', 'gcs', etc.).
### Client-side implementation
The client-side code is more complex than standard form uploads. You'll need a JavaScript library that handles the upload process, progress tracking, and error handling. Popular libraries include [Uppy.io](https://uppy.io/), [Filepond](https://pqina.nl/filepond/), or you can use native JavaScript with the Fetch API for custom implementations.
Here's a high-level example using the Fetch API.
```javascript
async function uploadFile(file) {
// Step 1: Request a signed URL from your server
const response = await fetch('/signed-upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_name: file.name })
})
const { signedUrl } = await response.json()
// Step 2: Upload directly to cloud storage using the signed URL
await fetch(signedUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type
}
})
console.log('File uploaded successfully!')
}
```
The first step requests a signed URL from your AdonisJS application. The second step uses that URL to upload the file directly to cloud storage.
For production applications, consider using a library like Uppy.io that provides additional features like upload progress tracking, automatic retries, resumable uploads, and user-friendly interfaces.
## Restricting file upload routes
Now that you understand how to implement file uploads, it's important to secure your application against potential abuse.
By default, AdonisJS processes multipart requests (file uploads) on all routes that use `POST`, `PUT`, and `PATCH` methods. This means any endpoint in your application can potentially receive and process file uploads, even if you didn't intend for it to handle files. This unrestricted access allows attackers to target any endpoint in your application to upload files, potentially straining your server's resources, filling up disk space, or using your application to distribute malicious content.
As a first security measure, you must allow multipart auto-processing only on specific routes that actually need to handle files. Configure this in your bodyparser settings.
```ts title="config/bodyparser.ts"
import { defineConfig } from '@adonisjs/core/bodyparser'
export default defineConfig({
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
multipart: {
autoProcess: ['/profile/avatar', '/users/:id', '/projects/:id/files']
},
})
```
This configuration ensures that only the specified routes will process multipart requests. All other routes will reject file uploads, preventing attackers from uploading files to random endpoints.
If you have public endpoints that accept file uploads (endpoints that don't require authentication), apply strict rate limiting to prevent abuse. See the [rate limiting guide](../../guides/security/rate_limiting.md) for implementation details.
For comprehensive bodyparser configuration options, refer to the [BodyParser guide](./body_parser.md).
---
# Sessions
This guide covers working with HTTP sessions in AdonisJS applications. You will learn about:
- Installing and configuring the session package
- Storing and retrieving session data
- Working with flash messages
- Choosing the right storage driver
- Implementing custom session stores
## Overview
HTTP is a stateless protocol, meaning each request is independent and the server doesn't retain information between requests. Sessions solve this by providing a way to persist state across multiple HTTP requests and associate that state with a unique session identifier.
In AdonisJS, sessions are primarily used in Hypermedia and Inertia applications to maintain user authentication state and pass temporary data (flash messages) between requests. For example, after a user logs in, their authentication state is stored in the session so they remain logged in across subsequent requests. Similarly, when you redirect after a form submission, flash messages stored in the session can display success or error notifications on the next page.
:::note
If you're new to the concept of sessions, we recommend reading this [introduction to HTTP sessions](https://developer.mozilla.org/en-US/docs/Web/HTTP/Session) before continuing.
:::
## Installation
Install and configure the sessions package by running the following Ace command:
```sh
node ace add @adonisjs/session
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/session` package using the detected package manager.
2. Registers the `@adonisjs/session/session_provider` service provider inside the `adonisrc.ts` file.
3. Creates the `config/session.ts` configuration file.
4. Defines the `SESSION_DRIVER` environment variable.
5. Registers the `@adonisjs/session/session_middleware` middleware inside the `start/kernel.ts` file.
:::
## Choosing a storage driver
The session driver determines where your session data is stored. Each driver has different characteristics that make it suitable for specific use cases:
:::note
Cookie-based sessions silently truncate data exceeding 4KB. Switch to Redis for production apps with larger session data.
:::
| Driver | Description | Best For
|--------|-------------|----------|
| `cookie` | Stores data in an encrypted cookie (max ~4KB) | Simple apps, small data, no backend storage |
| `file` | Stores data in local filesystem | Development, single-server deployments |
| `redis` | Stores data in Redis database | Production, multiple servers, larger data |
| `dynamodb` | Stores data in AWS DynamoDB | AWS infrastructure, serverless apps |
| `database` | Stores data in SQL databases | Production apps using SQL, existing database infrastructure |
| `memory` | Stores data in memory (lost on restart) | Testing only
## Configuration
Session configuration is stored in `config/session.ts`, which is created during installation. Here's the default configuration:
```ts title="config/session.ts"
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, stores } from '@adonisjs/session'
export default defineConfig({
enabled: true,
cookieName: 'adonis-session',
clearWithBrowser: false,
age: '2h',
cookie: {
path: '/',
httpOnly: true,
secure: app.inProduction,
sameSite: 'lax',
},
store: env.get('SESSION_DRIVER'),
stores: {
cookie: stores.cookie(),
file: stores.file({
location: app.tmpPath('sessions')
}),
redis: stores.redis({
connection: 'main'
}),
database: stores.database({
connection: 'postgres',
tableName: 'sessions',
}),
dynamodb: stores.dynamodb({
region: env.get('AWS_REGION'),
endpoint: env.get('AWS_ENDPOINT'),
tableName: 'sessions',
}),
}
})
```
::::options
:::option{name="age" type="string | number"}
Session lifetime before expiration. Accepts a string duration like `'2 hours'` or `'7 days'`, or a number in milliseconds. After this period, the session expires and data is deleted.
```ts
age: '2 hours'
// or
age: 7200000 // 2 hours in milliseconds
```
:::
:::option{name="clearWithBrowser" dataType="boolean"}
When `true`, the session cookie is deleted when the user closes the browser, regardless of the configured `age`. When `false` (the default), the session persists for the configured `age` duration even after the browser is closed.
```ts
clearWithBrowser: false
```
:::
:::option{name="store" dataType="string"}
Determines which session driver to use. Set this using the `SESSION_DRIVER` environment variable in your `.env` file. The value must match one of the keys defined in the `stores` object.
```dotenv title=".env"
SESSION_DRIVER=cookie
```
:::
:::option{name="cookie" type="object"}
Cookie configuration object that controls how the session cookie behaves. This includes settings for cookie name, domain, path, and security options. See the [Cookie configuration](#cookie-configuration) section for detailed options.
:::
:::option{name="stores" type="object"}
An object defining all available session stores. Each key represents a driver name, and the value is the store configuration. The driver specified in the `store` property must exist in this object.
:::
::::
### Cookie configuration
Sessions use cookies to store the session ID (or the entire session data for the cookie driver). Configure cookie behavior with these options:
::::options
:::option{name="cookie.name" dataType="string"}
The name of the cookie that stores the session ID. Only change this if it conflicts with other cookies in your application.
```ts
cookie: {
name: 'adonis-session'
}
```
:::
:::option{name="cookie.domain" dataType="string"}
The domain where the cookie is valid. Leave empty to default to the current domain. Set to `'.example.com'` (with leading dot) to share cookies across subdomains like `app.example.com` and `api.example.com`.
```ts
cookie: {
domain: '' // Current domain only
// or
domain: '.example.com' // All subdomains
}
```
:::
:::option{name="cookie.path" dataType="string"}
The URL path where the cookie is valid. Setting this to `'/'` makes the cookie available across your entire application.
```ts
cookie: {
path: '/'
}
```
:::
:::option{name="cookie.httpOnly" dataType="boolean"}
When `true`, prevents JavaScript from accessing the cookie through `document.cookie`, protecting against XSS attacks where malicious scripts try to steal session IDs. Keep this `true` for security.
```ts
cookie: {
httpOnly: true
}
```
:::
:::option{name="cookie.secure" dataType="boolean"}
When `true`, ensures cookies are only sent over HTTPS connections, preventing session hijacking on unsecured networks. The starter kit uses `app.inProduction` to automatically enable this in production while keeping it disabled during local development over HTTP.
```ts
cookie: {
secure: app.inProduction
}
```
:::
:::option{name="cookie.sameSite" dataType="'lax' | 'strict' | 'none'"}
Controls when browsers send cookies with cross-site requests, protecting against CSRF attacks.
- `'lax'`: Cookies sent on top-level navigation (clicking links). Default and recommended for most applications.
- `'strict'`: Cookies never sent on cross-site requests. Most secure but may break legitimate flows.
- `'none'`: Cookies always sent. Requires `secure: true` and rarely needed.
```ts
cookie: {
sameSite: 'lax'
}
```
:::
::::
### Redis driver
The Redis driver stores session data in a Redis database, making it ideal for production applications with multiple servers or larger session data.
You may add the redis package using the following command. See the [Redis guide](../database/redis.md) for complete setup instructions.
```sh
node ace add @adonisjs/redis
```
::::options
:::option{name="stores.redis.connection" dataType="string"}
The name of the Redis connection to use for session storage. This connection must be configured in your `config/redis.ts` file.
```ts
stores: {
redis: stores.redis({
connection: 'main'
})
}
```
:::
::::
### Database driver
The Database driver stores session data in SQL databases, ideal for production applications already using a SQL database infrastructure.
::::options
:::option{name="stores.database.connection" dataType="string"}
The name of the database connection to use for session storage. This connection must be configured in your `config/database.ts` file.
```ts
stores: {
database: stores.database({
connection: 'postgres'
})
}
```
:::
:::option{name="stores.database.tableName" dataType="string"}
The name of the database table where session data will be stored. Defaults to `'sessions'`. Make sure to create the migration for the session table using the `node ace make:session-table` command.
```ts
stores: {
database: stores.database({
tableName: 'sessions'
})
}
```
:::
::::
### DynamoDB driver
The DynamoDB driver stores session data in AWS DynamoDB, ideal for serverless applications and AWS infrastructure.
**Installation**: Install the AWS SDK first:
```sh
npm install @aws-sdk/client-dynamodb
```
::::options
:::option{name="stores.dynamodb.region" dataType="string"}
The AWS region where your DynamoDB table is located.
```ts
stores: {
dynamodb: stores.dynamodb({
region: env.get('AWS_REGION')
})
}
```
:::
:::option{name="stores.dynamodb.endpoint" dataType="string"}
Optional custom endpoint URL for DynamoDB. Useful for local development with DynamoDB Local or when using custom endpoints.
```ts
stores: {
dynamodb: stores.dynamodb({
endpoint: env.get('AWS_ENDPOINT')
})
}
```
:::
:::option{name="stores.dynamodb.tableName" dataType="string"}
The name of the DynamoDB table where session data will be stored.
```ts
stores: {
dynamodb: stores.dynamodb({
tableName: 'sessions'
})
}
```
:::
::::
### File driver
The File driver stores session data in the local filesystem, suitable for development and single-server deployments.
::::options
:::option{name="stores.file.location" dataType="string"}
The directory path where session files will be stored. Defaults to `tmp/sessions` within your application root.
```ts
stores: {
file: stores.file({
location: app.tmpPath('sessions')
})
}
```
:::
::::
## Basic usage
Once installed, you can access the session from the HTTP context using the `session` property. The session store provides a simple key-value API for reading and writing data.
Let's build a simple shopping cart example that demonstrates the core session methods:
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import Product from '#models/product'
/**
* Display items in the cart.
* Uses get() with a default value of empty array.
*/
router.get('/cart', ({ session }) => {
const cartItems = session.get('cart', [])
return { items: cartItems, total: cartItems.length }
})
/**
* Add a product to the cart.
* Demonstrates put() to store updated cart data.
*/
router.post('/cart', async ({ request, session }) => {
const productId = request.input('product_id')
const product = await Product.findOrFail(productId)
const cartItems = session.get('cart', [])
cartItems.push({
id: product.id,
name: product.name,
quantity: 1
})
session.put('cart', cartItems)
return { message: 'Item added', totalItems: cartItems.length }
})
/**
* Remove a specific item from the cart.
* Shows how to update and store modified data.
*/
router.delete('/cart/:productId', ({ params, session }) => {
const cartItems = session.get('cart', [])
const updatedCart = cartItems.filter(item => item.id !== params.productId)
session.put('cart', updatedCart)
return { message: 'Item removed' }
})
/**
* Clear the entire cart.
* Uses forget() to remove a specific key.
*/
router.delete('/cart', ({ session }) => {
session.forget('cart')
return { message: 'Cart cleared' }
})
```
### Checking for values
You can check if a value exists in the session before trying to retrieve it.
```ts title="start/routes.ts"
router.get('/checkout', ({ session, response }) => {
/**
* Check if cart exists and has items before proceeding.
* The has() method returns true if the key exists.
*/
if (!session.has('cart')) {
return response.redirect('/cart')
}
const cartItems = session.get('cart')
return { items: cartItems }
})
```
### Retrieving and removing values
Sometimes you need to get a value and immediately remove it from the session. The `pull()` method combines both operations.
```ts title="start/routes.ts"
router.post('/process-payment', ({ session }) => {
/**
* Get the cart data and remove it in one operation.
* This is useful when processing one-time data.
*/
const cartItems = session.pull('cart', [])
// Process payment with cartItems
// Cart is now automatically removed from session
return { processed: cartItems.length }
})
```
### Working with numeric values
Sessions provide convenient methods for incrementing and decrementing numeric values, useful for counters or tracking numeric state.
```ts title="start/routes.ts"
router.post('/visit', ({ session }) => {
/**
* Increment visit counter by 1.
* If 'visits' doesn't exist, it's initialized to 1.
*/
session.increment('visits')
const totalVisits = session.get('visits')
return { visits: totalVisits }
})
router.post('/undo', ({ session }) => {
/**
* Decrement a counter.
* If 'actions' doesn't exist, it's initialized to -1.
*/
session.decrement('actions')
return { actionsRemaining: session.get('actions', 0) }
})
```
### Retrieving all session data
You can retrieve all session data as an object using the `all()` method.
```ts title="start/routes.ts"
router.get('/debug/session', ({ session }) => {
/**
* Returns all session data as an object.
* Useful for debugging or displaying session state.
*/
const allData = session.all()
return allData
})
```
### Clearing the entire session
To remove all data from the session store, use the `clear()` method.
```ts title="start/routes.ts"
router.post('/logout', ({ session, auth, response }) => {
// Clear authentication
await auth.logout()
/**
* Remove all session data.
* This is useful during logout to clean up all user state.
*/
session.clear()
return response.redirect('/login')
})
```
## Flash messages
Flash messages are temporary data stored in the session and available only for the next HTTP request. They're automatically deleted after being accessed once, making them perfect for displaying one-time notifications after redirects.
### Basic flash messages
Use the `flash()` method to store a message for the next request. The first parameter is the message type (a convention for categorizing messages), and the second is the message content.
```ts title="start/routes.ts"
router.post('/cart', async ({ session, response }) => {
// Add item to cart...
/**
* Flash a success message for the next request.
* Available via flashMessages.get('success') in templates.
*/
session.flash('success', 'Item added to the cart')
return response.redirect().back()
})
```
AdonisJS uses `success` and `error` as standard message type conventions. While you can use any string as a message type, these conventions help organize different kinds of notifications.
```ts title="app/controllers/orders_controller.ts"
export default class OrdersController {
async store({ session, response }: HttpContext) {
try {
// Process order...
session.flash('success', 'Order placed successfully!')
return response.redirect('/orders')
} catch (error) {
session.flash('error', 'Payment failed. Please try again.')
return response.redirect().back()
}
}
}
```
### Displaying flash messages
If you're using the Hypermedia or Inertia starter kits, flash messages are already displayed automatically in the layout files. The starter kits check for `success` and `error` messages and render them with appropriate styling.
For custom layouts or applications, display flash messages in your Edge templates using the global `flashMessages` helper.
```edge title="resources/views/layouts/main.edge"
@if(flashMessages.has('success'))
{{ flashMessages.get('success') }}
@end
@if(flashMessages.has('error'))
{{ flashMessages.get('error') }}
@end
```
### Custom flash messages
Beyond the standard message types, you can create custom message types for specific use cases.
```ts title="start/routes.ts"
router.post('/newsletter/subscribe', ({ session, response }) => {
// Subscribe user...
session.flash('newsletter', 'Check your email to confirm subscription')
return response.redirect().back()
})
```
Display custom messages in your templates.
```edge
@if(flashMessages.has('newsletter'))
{{ flashMessages.get('newsletter') }}
@end
```
### Flashing form data
When an error occurs during form processing and you need to redirect the user back to the form, you can flash the submitted form data to pre-fill the form fields.
```ts title="app/controllers/posts_controller.ts"
export default class PostsController {
async store({ request, session, response }: HttpContext) {
try {
// Attempt to create post...
throw new Error('Unable to publish post')
} catch (error) {
/**
* Flash form data to repopulate the form.
* Users won't have to re-enter their data.
*/
session.flashAll()
session.flash('error', 'Failed to create post. Please try again.')
return response.redirect().back()
}
}
}
```
You can also flash only specific fields or exclude sensitive fields.
```ts
// Flash only specific fields
session.flashOnly(['title', 'content', 'category'])
// Flash everything except sensitive fields
session.flashExcept(['password', 'credit_card'])
```
Access flashed form data in templates using the `old()` helper.
```edge title="resources/views/posts/create.edge"
```
:::note
When using AdonisJS validators, validation errors and form data are automatically flashed on validation failure. You don't need to manually flash them.
:::
### Re-flashing messages
Sometimes you need to keep flash messages for an additional request. Use `reflash()` to preserve all messages from the previous request for one more cycle.
```ts title="app/middleware/check_subscription_middleware.ts"
export default class CheckSubscriptionMiddleware {
async handle({ auth, session, response }: HttpContext, next: NextFn) {
const user = auth.getUserOrFail()
if (!user.hasActiveSubscription) {
/**
* Keep all flash messages for one more request.
*/
session.reflash()
session.flash('error', 'Please subscribe to continue')
return response.redirect('/subscribe')
}
await next()
}
}
```
You can also selectively re-flash only specific message types.
```ts
// Re-flash only error messages
session.reflashOnly(['error'])
// Re-flash all messages except success
session.reflashExcept(['success'])
```
## Intended URL
The intended URL feature allows you to store a URL the user should be redirected to after completing an intermediate step. For example, you can store the current page URL before redirecting the user to a login page, and after login, redirect them back to the page they were on.
URLs are validated before storage to prevent open redirect attacks. Invalid or unsafe URLs (like protocol-relative `//evil.com`) are silently ignored.
```ts title="app/controllers/session_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async show({ request, session, view }: HttpContext) {
/**
* If the login page is accessed with an ?intended query param,
* store the intended URL in the session for use after login
*/
const intended = request.input('intended')
if (intended) {
session.setIntendedUrl(intended)
}
return view.render('auth/login')
}
async store({ request, auth, response }: HttpContext) {
const user = await User.verifyCredentials(
request.input('email'),
request.input('password')
)
await auth.use('web').login(user)
/**
* Redirect to the intended URL if one was stored,
* otherwise fall back to dashboard
*/
return response.redirect().toIntendedRoute('dashboard')
}
}
```
You can also read, consume, or clear the intended URL directly.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/example', ({ session }) => {
/**
* Read without consuming
*/
const url = session.getIntendedUrl()
/**
* Read and remove in one step
*/
const consumed = session.pullIntendedUrl()
/**
* Remove without reading
*/
session.clearIntendedUrl()
})
```
See also: [Redirecting to the intended URL after login](../auth/session_guard.md#redirecting-to-the-intended-url)
## Session regeneration
Session regeneration creates a new session ID while preserving all existing session data. This is a critical security measure to prevent [session fixation attacks](https://owasp.org/www-community/attacks/Session_fixation), where an attacker tricks a user into using a session ID controlled by the attacker.
When a user logs in, their authentication state changes from unauthenticated to authenticated. If the same session ID is kept, an attacker who knew the session ID before login could hijack the authenticated session. Regenerating the session ID after login prevents this attack.
AdonisJS automatically handles session regeneration when using the official Auth package. However, if you're implementing custom authentication, you must manually call `session.regenerate()` after successful login.
```ts title="app/controllers/auth_controller.ts"
export default class AuthController {
async login({ request, session, response }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
// Verify credentials...
const user = await User.verifyCredentials(email, password)
/**
* Generate a new session ID while preserving data.
* This prevents session fixation attacks.
*/
await session.regenerate()
// Store user info in the new session
session.put('user_id', user.id)
return response.redirect('/dashboard')
}
}
```
## Creating custom session stores
If none of the built-in drivers meet your needs, you can create a custom session store by implementing the `SessionStoreContract` interface. This is useful for integrating databases like MongoDB or custom storage solutions.
### Implementing the store contract
Create a class that implements the four required methods.
```ts title="app/session_stores/mongodb_store.ts"
import {
SessionData,
SessionStoreFactory,
SessionStoreContract,
} from '@adonisjs/session/types'
/**
* The config you want to accept
*/
export type MongoDBConfig = {
collection: string;
database: string
}
/**
* The MongoDbStore class handles the actual storage operations.
* It implements the four required methods: read, write, destroy, and touch.
*/
export class MongoDbStore implements SessionStoreContract {
constructor(protected config: MongoDBConfig) {}
/**
* Read session data for a given session ID.
* Return null if the session doesn't exist.
*/
async read(sessionId: string): Promise {
// Query your storage and return session data
}
/**
* Write session data for a given session ID.
*/
async write(
sessionId: string,
data: SessionData,
expiresAt: Date
): Promise {
// Save session data to your storage
}
/**
* Delete session data for a given session ID.
*/
async destroy(sessionId: string): Promise {
// Remove session from your storage
}
/**
* Update the session's expiration time without changing data.
*/
async touch(sessionId: string, expiresAt: Date): Promise {
// Update expiration timestamp in your storage
}
}
/**
* The factory function accepts configuration and returns a driver
* function. The driver function creates a new instance of the store
* class when called by the session manager.
*/
export function mongoDbStore(config: MongoDbConfig): SessionStoreFactory {
return (ctx, sessionConfig) => {
return new MongoDBStore(config)
}
}
```
### Registering your custom store
Register your custom store in `config/session.ts` using the factory function.
```ts title="config/session.ts"
import { defineConfig } from '@adonisjs/session'
import { mongoDbStore } from '#session_stores/mongodb_store'
export default defineConfig({
store: env.get('SESSION_DRIVER'),
stores: {
mongodb: mongoDbStore({
collection: 'sessions',
database: 'myapp'
})
}
})
```
Set your environment variable to use the custom store.
```dotenv title=".env"
SESSION_DRIVER=mongodb
```
For complete implementation examples, see the [built-in session stores on GitHub](https://github.com/adonisjs/session/tree/8.x/src/stores).
---
# URL Builder
This guide covers URL generation in AdonisJS applications. You will learn how to:
- Generate URLs for named routes with type-safe autocompletion
- Pass route parameters using arrays or objects
- Add query strings to generated URLs
- Create signed URLs with cryptographic signatures for secure links
- Verify signed URLs to prevent tampering
- Integrate URL generation into frontend applications using Inertia
## Overview
The URL builder provides a type-safe API for generating URLs from named routes. Instead of hard-coding URLs throughout your application in templates, frontend components, API responses, or redirects, you reference routes by name. This ensures that when you change a route's path, you don't need to hunt down and update every URL reference across your codebase.
Once a route is named, you can generate URLs for it using the `urlFor` helper in templates, the `response.redirect().toRoute()` method for redirects, or by importing the `urlFor` function from the URL builder service for other contexts.
The URL builder is type-safe, meaning your IDE will provide autocompletion for route names and TypeScript will catch errors if you reference a non-existent route. This eliminates an entire class of bugs where URLs might break silently after refactoring routes.
## Defining named routes
Every route using a controller is automatically assigned a name based on the controller and method name. The naming convention follows the pattern `controller.method` (explained in detail in the [routing guide](./routing.md#route-identifiers)).
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
// Automatically named as 'posts.show'
router.get('/posts/:id', [controllers.posts, 'show'])
// Automatically named as 'posts.index'
router.get('/posts', [controllers.posts, 'index'])
```
For routes without controllers, you must explicitly assign a name using the `.as()` method.
```ts title="start/routes.ts"
router.get('/about', async () => {
return 'About page'
}).as('about')
```
You can view all named routes in your application using the following Ace command.
```bash
node ace list:routes
```
## Generating URLs in templates
Edge templates have access to the `urlFor` helper by default. This helper generates URLs for named routes and accepts route parameters as either an array or an object.
```edge title="resources/views/posts/index.edge"
View post
```
When using the Hypermedia starter kit, you can also use the `@link` component, which accepts the route and parameters as component props.
```edge title="resources/views/posts/index.edge"
@link({ route: 'posts.show', routeParams: { id: post.id } })
View post
@end
```
## Generating URLs during redirects
When redirecting users to a different page, use the `response.redirect().toRoute()` method instead of hard-coding URLs. You can only redirect to `GET` routes.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
export default class PostsController {
async store({ request, response }: HttpContext) {
const post = await Post.create(request.all())
return response
.redirect()
.toRoute('posts.show', { id: post.id })
}
}
```
## Generating URLs in other contexts
For contexts outside of templates and HTTP responses, such as background jobs, email notifications, or service classes, import the `urlFor` function from the URL builder service.
```ts title="app/services/notification_service.ts"
import { urlFor } from '@adonisjs/core/services/url_builder'
export default class NotificationService {
async sendPostNotification(post: Post) {
const postUrl = urlFor('posts.show', { id: post.id })
await mail.send({
subject: 'New post published',
html: `View post`
})
}
}
```
## Passing route parameters
Route parameters can be passed as either an array (positional matching) or an object (named matching). Choose the approach that makes your code more readable.
**Array (positional parameters):** Parameters are matched by position to the route pattern.
```ts
// Route: /posts/:id
urlFor('posts.show', [1])
// Output: /posts/1
// Route: /users/:userId/posts/:postId
urlFor('users.posts.show', [5, 10])
// Output: /users/5/posts/10
```
**Object (named parameters):** Parameters are matched by name to the route pattern.
```ts
// Route: /posts/:id
urlFor('posts.show', { id: 1 })
// Output: /posts/1
// Route: /users/:userId/posts/:postId
urlFor('users.posts.show', { userId: 5, postId: 10 })
// Output: /users/5/posts/10
```
## Adding query strings
Query strings can be added to generated URLs by passing a third options parameter with a `qs` property. The query string object can contain nested values, which are automatically serialized into the proper format.
```ts title="app/controllers/posts_controller.ts"
import { urlFor } from '@adonisjs/core/services/url_builder'
const url = urlFor('posts.index', [], {
qs: {
filters: {
title: 'typescript',
},
order: {
direction: 'asc',
column: 'id'
},
}
})
// Output: /posts?filters[title]=typescript&order[direction]=asc&order[column]=id
```
The same `qs` option works in templates and redirects.
```edge title="resources/views/partials/pagination.edge"
Next page
```
```ts title="app/controllers/posts_controller.ts"
response.redirect().toRoute('posts.index', [], {
qs: { page: 2, sort: 'title' }
})
```
## Signed URLs
Signed URLs include a cryptographic signature that prevents tampering. If someone modifies the URL, the signature becomes invalid and the request can be rejected. This is useful for scenarios where URLs are publicly accessible but need protection against manipulation, such as newsletter unsubscribe links or password reset tokens.
### Creating signed URLs
Signed URLs are created using the `signedUrlFor` helper exported from the URL builder service. The API is identical to `urlFor`, but the generated URL includes a signature.
```ts title="app/mails/newsletter_mail.ts"
import User from '#models/user'
import { appUrl } from '#config/app'
import { BaseMail } from '@adonisjs/mail'
// [!code highlight]
import { signedUrlFor } from '@adonisjs/core/services/url_builder'
export default class NewsletterMail extends BaseMail {
subject = 'Weekly Newsletter'
constructor(protected user: User) {
super()
}
prepare() {
// [!code highlight:8]
const unsubscribeUrl = signedUrlFor(
'newsletter.unsubscribe',
{ email: this.user.email },
{
expiresIn: '30 days',
prefixUrl: appUrl,
}
)
this.message.htmlView('emails/newsletter', {
user: this.user,
unsubscribeUrl
})
}
}
```
The `expiresIn` option sets when the signed URL expires. After expiration, the signature is no longer valid. The `prefixUrl` option is required when the URL will be shared externally, such as in emails or external notifications, to ensure the URL includes the full domain. For internal app navigation, relative URLs without the domain are sufficient.
The generated signed URL includes a signature query parameter appended to the URL.
```text
https://example.com/newsletter/unsubscribe?email=user@example.com&signature=eyJtZXNzYWdlIjoiL25ld3NsZXR0ZXIvdW5zdWJzY3JpYmU_ZW1haWw9dXNlckBleGFtcGxlLmNvbSIsInB1cnBvc2UiOiJzaWduZWRfdXJsIn0.1234567890abcdef
```
:::note
Signed URLs can only be created in backend code, not in frontend applications. This is because they rely on the encryption module, which uses a secret key. Exposing this key to the frontend would compromise security.
:::
### Verifying signed URLs
The route for which the signed URL was generated can verify the signature using the `request.hasValidSignature()` method during an HTTP request. This method checks both the signature and expiration.
```ts title="app/controllers/newsletter_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class NewsletterController {
async unsubscribe({ request, response }: HttpContext) {
if (!request.hasValidSignature()) {
return response.badRequest('Invalid or expired unsubscribe link')
}
const email = request.qs().email
// Process unsubscribe request
}
}
```
## Frontend integration
The URL builder service is available only within the backend application since that's where routes are defined. However, applications using Inertia or a separate frontend inside a monorepo can generate a similar URL builder for the frontend codebase.
### Why separate frontend and backend URL builders?
AdonisJS enforces a clear boundary between frontend and backend codebases to prevent issues like leaking sensitive information to the client. Routes on the backend contain details about controller mappings and internal application structure. Making all this information available on the frontend would not only leak unnecessary information but also significantly increase your bundle size.
Additionally, from a runtime perspective, you cannot share an object between two different runtimes (Node.js and the browser). Creating the illusion of a shared URL builder is not something we support or believe in.
### Using the URL builder in Inertia apps
The Inertia React and Vue starter kits come with the URL builder pre-configured. The URL builder (along with the API client) is generated using [Tuyau](../frontend/api_client.md) and written to the `.adonisjs/client` directory.
Import and use the URL builder in your frontend components with an identical API to the backend version.
```tsx title="inertia/pages/posts/index.tsx"
import { urlFor } from '~/client'
export default function PostsIndex({ posts }) {
return (
)
}
```
The usage API is identical to the backend URL builder service, supporting both array and object parameters, as well as query strings.
```ts
// Using positional parameters
urlFor('posts.show', [post.id])
// Using named parameters
urlFor('posts.show', { id: post.id })
// Adding query strings
urlFor('posts.index', [], {
qs: { page: 2, sort: 'title' }
})
```
### Excluding routes from frontend bundle
You can configure which routes are available in the frontend URL builder to reduce bundle size and prevent exposing internal routes. The URL builder is generated using an Assembler hook named `generateRegistry` registered in your `adonisrc.ts` file.
Define routes to exclude using the `exclude` option.
```ts title="adonisrc.ts"
import { defineConfig } from '@adonisjs/core/app'
import { generateRegistry } from '@adonisjs/assembler/hooks'
export default defineConfig({
init: [
generateRegistry({
exclude: ['admin.*'],
})
],
})
```
The exclude pattern can use one of the following approaches, tested against the route name:
:::option{name="Wildcard pattern"}
Exclude all routes starting with the prefix before the wildcard.
```ts title="adonisrc.ts"
generateRegistry({
exclude: ['admin.*', 'api.internal.*'],
})
```
:::
:::option{name="Regular expression"}
Use a custom regular expression to exclude matching routes.
```ts title="adonisrc.ts"
generateRegistry({
exclude: [/^admin\./, /^api\.internal\./],
})
```
:::
:::option{name="Custom function"}
Write a custom function for advanced filtering logic. Return `false` to skip the route and `true` to include it.
```ts title="adonisrc.ts"
generateRegistry({
exclude: [
(route) => {
// Exclude all routes on the admin domain
if (route.domain === 'admin.myapp.com') {
return false
}
// Include all other routes
return true
}
],
})
```
:::
You can combine multiple patterns for complex filtering requirements.
```ts title="adonisrc.ts"
generateRegistry({
exclude: [
'admin.*',
/^api\.internal\./,
(route) => route.domain !== 'admin.myapp.com'
],
})
```
---
# Exception Handling
This guide covers exception handling in AdonisJS applications. You will learn how to:
- Use the global exception handler to convert errors into HTTP responses
- Customize error handling for specific error types
- Report errors to logging services
- Create custom exception classes with self-contained handling logic
- Configure debug mode and status pages for different environments
## Overview
Exception handling in AdonisJS provides a centralized system for managing errors during HTTP requests. Instead of wrapping every route handler and middleware in try/catch blocks, you let errors bubble up naturally to a global exception handler that converts them into appropriate HTTP responses.
This approach keeps your code clean while ensuring consistent error handling across your application.
### The global exception handler
When you create a new AdonisJS project, the global exception handler is created in `app/exceptions/handler.ts`. It extends the base `ExceptionHandler` class and provides two primary methods:
- The `handle` converts errors into HTTP responses.
- The `report` logs errors or sends them to monitoring services.
Here's what the default handler looks like:
```ts title="app/exceptions/handler.ts"
import app from '@adonisjs/core/services/app'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
import type { StatusPageRange, StatusPageRenderer } from '@adonisjs/core/types/http'
export default class HttpExceptionHandler extends ExceptionHandler {
/**
* Controls verbose error display with stack traces.
* Automatically disabled in production to protect sensitive info.
*/
protected debug = !app.inProduction
/**
* Enables custom HTML error pages for specific status codes.
* Typically enabled in production for better user experience.
*/
protected renderStatusPages = app.inProduction
/**
* Maps status codes or ranges to view templates.
* Keys can be specific codes like '404' or ranges like '500..599'.
*/
protected statusPages: Record = {
'404': (error, { view }) => {
return view.render('pages/errors/not_found', { error })
},
'500..599': (error, { view }) => {
return view.render('pages/errors/server_error', { error })
},
}
/**
* Converts errors into HTTP responses for the client.
* Override to customize error response formatting.
*/
async handle(error: unknown, ctx: HttpContext) {
return super.handle(error, ctx)
}
/**
* Logs errors or sends them to monitoring services.
* Never attempt to send HTTP responses from this method.
*/
async report(error: unknown, ctx: HttpContext) {
return super.report(error, ctx)
}
}
```
The inline comments explain each property's purpose. We'll explore `debug`, `renderStatusPages`, and `statusPages` in detail later in this guide.
### How errors flow through the handler
When an error occurs during an HTTP request, AdonisJS automatically catches it and forwards it to the global exception handler. Let's see this in action.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { Exception } from '@adonisjs/core/exceptions'
router.get('fatal', () => {
/**
* Throwing an exception with a 500 status code
* and a custom error code for identification
*/
throw new Exception('Something went wrong', {
status: 500,
code: 'E_RUNTIME_EXCEPTION'
})
})
```
In development mode (with `debug` enabled), visiting this route displays a beautifully formatted error page powered by Youch, showing the error message, full stack trace, and request context.
In production mode (with `debug` disabled), the same error returns a simple JSON or plain text response containing only the error message, without exposing your application's internal structure.
### Handling specific error types
The global exception handler's `handle` method receives all unhandled errors. You can inspect the error type and provide custom handling for specific exceptions while letting others fall through to the default behavior.
Here's an example of handling validation errors with a custom response format.
```ts title="app/exceptions/handler.ts"
import { errors as vineJSErrors } from '@vinejs/vine'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
protected debug = !app.inProduction
protected renderStatusPages = app.inProduction
async handle(error: unknown, ctx: HttpContext) {
/**
* Check if the error is a VineJS validation error
* using instanceof to safely identify the error type
*/
if (error instanceof vineJSErrors.E_VALIDATION_ERROR) {
/**
* Return validation messages directly as JSON
* with a 422 Unprocessable Entity status
*/
ctx.response.status(422).send(error.messages)
return
}
/**
* For all other errors, delegate to the parent class
* which handles the default error conversion logic
*/
return super.handle(error, ctx)
}
}
```
This pattern of checking error types using `instanceof` and providing custom handling is powerful and flexible. You can add as many conditional branches as needed for different error types in your application.
Here's how you might use this custom validation error handling in a route.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { createPostValidator } from '#validators/post'
router.post('posts', async ({ request }) => {
/**
* If validation fails, VineJS throws E_VALIDATION_ERROR
* which is caught by our custom handler and returns
* the validation messages with a 422 status code
*/
await request.validateUsing(createPostValidator)
})
```
### Debug mode and Youch
The `debug` property controls whether errors are displayed using Youch, an error visualization tool that creates beautiful, interactive error pages. When debug mode is enabled, Youch displays the error message, complete stack trace, request details, and even shows the exact code where the error occurred with syntax highlighting.
:::media

:::
In production, debug mode should always be disabled to prevent exposing sensitive information. When disabled, errors are converted to simple responses using content negotiation (JSON for API requests, plain text for others) containing only the error message without implementation details.
The default configuration `protected debug = !app.inProduction` automatically handles this for you, enabling debug mode in development and disabling it in production.
### Status pages
Status pages allow you to display custom HTML pages for specific HTTP status codes. This feature is particularly useful for user-facing applications where you want to provide a branded, helpful error experience rather than a generic error message.
The `statusPages` property is a key-value map where keys are HTTP status codes or ranges, and values are callback functions that render and return HTML content. The callback receives the error object and the HTTP context, giving you full access to view rendering and error details.
::::tabs
:::tab{title="Edge (Hypermedia apps)"}
```ts
export default class HttpExceptionHandler extends ExceptionHandler {
protected statusPages: Record = {
'404': (error, { view }) => {
return view.render('pages/errors/not_found', { error })
},
'500..599': (error, { view }) => {
return view.render('pages/errors/server_error', { error })
},
}
}
```
:::
:::tab{title="Inertia apps"}
When using the Inertia starter kit, status pages render Inertia components instead of Edge templates. The callback receives the `inertia` object from the HTTP context, which you use to render a frontend page component.
```ts
export default class HttpExceptionHandler extends ExceptionHandler {
protected statusPages: Record = {
'404': (error, { inertia }) => {
return inertia.render('errors/not_found', { error })
},
'500..599': (error, { inertia }) => {
return inertia.render('errors/server_error', { error })
},
}
}
```
The page paths (e.g., `errors/not_found`) map to files inside `inertia/pages/` — for example, `inertia/pages/errors/not_found.tsx`.
:::
::::
Status pages are only rendered when the `renderStatusPages` property is set to `true`. The default configuration enables them in production (`app.inProduction`) where custom error pages provide a better user experience, while keeping them disabled in development where detailed Youch error pages are more useful for debugging.
:::note
Status pages only apply to requests that accept an HTML or Inertia response. For API requests that accept JSON (e.g., requests with `Accept: application/json`), the exception handler returns a JSON error response instead. This means the API starter kit does not include `renderStatusPages` or `statusPages` in its exception handler, since all responses are JSON.
:::
## Reporting errors
The `report` method logs errors or sends them to external monitoring services. Unlike `handle`, it should never send HTTP responses. These are two distinct concerns that should remain separated.
The base `ExceptionHandler` class provides a default implementation of `report` that logs errors using AdonisJS's logger. You can override this method to add custom reporting logic, such as integrating with error monitoring services.
```ts title="app/exceptions/handler.ts"
export default class HttpExceptionHandler extends ExceptionHandler {
async report(error: unknown, ctx: HttpContext) {
/**
* First call the parent report method to ensure
* the error is logged using the default behavior
*/
await super.report(error, ctx)
/**
* Add custom reporting logic here, such as
* sending to Sentry, Bugsnag, or other services
*/
}
}
```
### Adding context to error reports
The `context` method allows you to define additional data that should be included with every error report. This contextual information helps you understand the circumstances under which an error occurred, making debugging much easier.
By default, the context includes the request ID (`x-request-id` header). You can override this method to include any additional information relevant to your application.
```ts title="app/exceptions/handler.ts"
export default class HttpExceptionHandler extends ExceptionHandler {
protected context(ctx: HttpContext) {
return {
/**
* Include the unique request ID for tracking
* this specific request across logs
*/
requestId: ctx.request.id(),
/**
* Add the authenticated user's ID if available
* to identify which user encountered the error
*/
userId: ctx.auth.user?.id,
/**
* Include the IP address for security monitoring
* and identifying patterns in errors
*/
ip: ctx.request.ip(),
}
}
}
```
This context data is automatically included whenever an error is reported, giving you rich information about each error's circumstances without manually adding this data to every report call.
### Ignoring errors from reports
Not all errors need to be reported. Some errors, like validation failures or unauthorized access attempts, are expected parts of normal application flow and don't require logging or monitoring. You can configure which errors to exclude from reporting using the `ignoreStatuses` and `ignoreCodes` properties.
```ts title="app/exceptions/handler.ts"
export default class HttpExceptionHandler extends ExceptionHandler {
/**
* HTTP status codes that should not be reported.
* These are typically client errors that don't indicate
* problems with your application.
*/
protected ignoreStatuses = [400, 401, 403, 404, 422]
/**
* Error codes that should not be reported.
* These are application-specific error codes for
* expected error conditions.
*/
protected ignoreCodes = ['E_VALIDATION_ERROR', 'E_UNAUTHORIZED_ACCESS']
}
```
The base `ExceptionHandler` class checks these properties in its `shouldReport` method before reporting an error. If you implement custom reporting logic, you must respect this check.
```ts title="app/exceptions/handler.ts"
export default class HttpExceptionHandler extends ExceptionHandler {
async report(error: unknown, ctx: HttpContext) {
/**
* Convert the error to a standardized HTTP error
* format that includes status code and error code
*/
const httpError = this.toHttpError(error)
/**
* Only report the error if it passes the shouldReport check,
* which verifies it's not in ignoreStatuses or ignoreCodes
*/
if (this.shouldReport(httpError)) {
// Your custom reporting logic here
// For example: send to external monitoring service
}
}
}
```
This approach ensures consistent error filtering across your application, preventing your logs and monitoring services from being overwhelmed with expected errors.
## Custom exceptions
Custom exceptions allow you to create specialized error classes for specific error conditions in your application's business logic. A custom exception extends the base `Exception` class and can implement its own `handle` and `report` methods, encapsulating both the error condition and its handling logic in a single, self-contained class.
### Creating a custom exception
You can create a custom exception using the `make:exception` command.
```sh
node ace make:exception PaymentFailed
```
```sh
CREATE: app/exceptions/payment_failed_exception.ts
```
This generates a new exception class in the `app/exceptions` directory. Here's what a complete custom exception looks like with both handling and reporting logic.
```ts title="app/exceptions/payment_failed_exception.ts"
import { Exception } from '@adonisjs/core/exceptions'
import { HttpContext } from '@adonisjs/core/http'
export default class PaymentFailedException extends Exception {
/**
* The HTTP status code for this exception.
* Set as a static property so it can be accessed
* without instantiating the exception.
*/
static status = 400
/**
* Handle the exception by converting it to an HTTP response.
* This method is called automatically when this exception
* is thrown and not caught.
*/
handle(error: this, { response }: HttpContext) {
return response
.status(error.constructor.status)
.send('Unable to process the payment. Please try again')
}
/**
* Report the exception for logging and monitoring.
* This method is called before handle() to record
* the error occurrence.
*/
report(error: this, { logger, auth }: HttpContext) {
logger.error(
{ user: auth.user },
'Payment failed for user %s',
auth.user?.id
)
}
}
```
When you throw this custom exception anywhere in your application, AdonisJS automatically calls its `handle` method to generate the HTTP response and its `report` method to log the error. The global exception handler is bypassed entirely for custom exceptions that implement these methods.
### When to use custom exceptions
Custom exceptions are ideal when you need to throw meaningful, business-logic-specific errors throughout your application. They're particularly useful for error conditions that require specialized handling or reporting. Unlike handling errors in the global exception handler, custom exceptions encapsulate both the error condition and its handling logic, making your error handling more organized and maintainable.
The global exception handler, on the other hand, is meant to change the default behavior for how exceptions are handled application-wide. It's the right place for cross-cutting concerns like formatting all API errors consistently or integrating with monitoring services.
Use custom exceptions when the error is specific to your domain and requires unique handling. Use the global exception handler when you need to modify how a category of errors is processed across your entire application.
## Configuration reference
The exception handler class provides several configuration options that control error handling behavior:
::::options
:::option{name="debug" dataType="boolean"}
When `true`, displays detailed error pages with stack traces using Youch. Should be `false` in production. Default: `!app.inProduction`
:::
:::option{name="renderStatusPages" dataType="boolean"}
When `true`, renders custom HTML pages for configured status codes. Default: `app.inProduction`
:::
:::option{name="statusPages" dataType="Record"}
Maps HTTP status codes or ranges to view rendering callbacks for custom error pages.
:::
:::option{name="ignoreStatuses" dataType="number[]"}
Array of HTTP status codes that should not be reported via the `report` method.
:::
:::option{name="ignoreCodes" dataType="string[]"}
Array of error codes that should not be reported via the `report` method.
:::
::::
## See also
- [ExceptionHandler source code](https://github.com/adonisjs/http-server/blob/8.x/src/exception_handler.ts) - Complete implementation details of the base exception handler class
- [Make exception command](../../reference/exceptions.md#makeexception) - CLI reference for generating custom exception classes
---
# Debugging
This guide covers debugging techniques for AdonisJS applications. You will learn how to:
- Configure VSCode to debug your application with breakpoints
- Use the Node.js inspector from the command line
- View framework-level debug logs with `NODE_DEBUG`
- Inspect variables in Edge templates with `@dump` and `@dd`
- Enable pretty error pages during development
## Overview
Debugging is an essential part of development, and AdonisJS supports multiple approaches depending on your needs. For quick checks, a simple `console.log` statement often suffices. For more complex issues, you can use VSCode's integrated debugger to set breakpoints, step through code, and inspect variables. When you need to understand what's happening inside the framework itself, debug logs provide visibility into AdonisJS internals.
Edge templates have their own debugging tools with `@dump` and `@dd`, which render variable contents directly in the browser. During development, the exception handler automatically displays detailed error pages with stack traces and request information when something goes wrong.
## VSCode debugger
The VSCode debugger provides the most powerful debugging experience, allowing you to set breakpoints, step through code line by line, and inspect the call stack and variables. Use this approach when debugging complex issues that can't be resolved with simple log statements.
Create a `.vscode/launch.json` file in your project root with configurations for the dev server, test runner, and attach mode.
```json title=".vscode/launch.json"
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Dev server",
"program": "${workspaceFolder}/ace.js",
"args": ["serve", "--hmr"],
"skipFiles": ["/**"]
},
{
"type": "node",
"request": "launch",
"name": "Tests",
"program": "${workspaceFolder}/ace.js",
"args": ["test", "--watch"],
"skipFiles": ["/**"]
},
{
"type": "node",
"request": "attach",
"name": "Attach Program",
"port": 9229,
"autoAttachChildProcesses": true,
"skipFiles": ["/**"]
}
]
}
```
The **Dev server** configuration launches your application with HMR enabled, perfect for debugging HTTP request handling, middleware, and controllers. The **Tests** configuration runs your test suite in watch mode, allowing you to debug failing tests by setting breakpoints in your test files or application code.
### Debugging Ace commands
The [**Attach Program**](https://code.visualstudio.com/blogs/2018/07/12/introducing-logpoints-and-auto-attach#_autoattaching-to-node-processes) configuration uses attach mode instead of launching a specific command. This lets you debug any Ace command by starting it with the `--inspect` flag and then attaching the debugger.
To debug an Ace command:
1. Open the Command Palette with `Cmd+Shift+P` (macOS) or `Ctrl+Shift+P` (Windows/Linux)
2. Search for **Debug: Select and Start Debugging**
3. Select the **Attach Program** option
4. Run your Ace command with the `--inspect` flag.
```bash
node --inspect ace migration:run
```
The debugger will attach to the running process, and your breakpoints will be hit.
## Node.js inspector
If you're not using VSCode or prefer a different debugging interface, you can use the Node.js inspector directly. Start your dev server with the `--inspect` flag.
```bash
node ace --inspect serve --hmr
```
This starts the Node.js inspector on port 9229. You can then connect using Chrome DevTools by navigating to `chrome://inspect` in Chrome, or use any other debugger that supports the Node.js inspector protocol.
## Framework debug logs
AdonisJS packages include debug logs that provide visibility into framework internals. These logs are disabled by default because they're verbose, but they're invaluable when you need to understand what's happening at the framework level.
Enable debug logs by setting the `NODE_DEBUG` environment variable when starting your application.
```bash
NODE_DEBUG=adonisjs:* node ace serve --hmr
```
The wildcard `*` enables logs from all AdonisJS packages. If you already know which package you're investigating, specify it directly to reduce noise.
```bash
# Debug only the HTTP layer
NODE_DEBUG=adonisjs:http node ace serve --hmr
# Debug session handling
NODE_DEBUG=adonisjs:session node ace serve --hmr
# Debug the application lifecycle
NODE_DEBUG=adonisjs:app node ace serve --hmr
```
Package names follow the convention `adonisjs:`, where the package name corresponds to the AdonisJS package you want to debug.
## Edge template debugging
When working with Edge templates, you can inspect variables directly in the browser using `@dump` and `@dd`. These tags render a formatted representation of any value, making it easy to understand what data your templates are receiving.
### The @dump tag
The `@dump` tag outputs a formatted representation of a value and continues rendering the rest of the template:
```edge title="resources/views/posts/show.edge"
{{-- Inspect component props --}}
@dump($props.all())
{{-- Inspect the entire template state --}}
@dump(state)
{{-- Inspect a specific variable --}}
@dump(post)
```
### The @dd tag
The `@dd` tag (dump and die) stops template rendering immediately and displays only the dumped value. Use this when you want to focus on a specific value without the rest of the page's output:
```edge title="resources/views/posts/show.edge"
@dd(post)
{{-- Nothing below this line will render --}}
{{ post.title }}
```
### Setting up the dumper
The `@dump` and `@dd` tags require the dumper's frontend assets to be loaded. Add the `@stack('dumper')` directive to your layout's `` section.
```edge title="resources/views/layouts/main.edge"
@stack('dumper')
@!section('content')
```
Official AdonisJS starter kits include this setup by default.
### Configuring the dumper
You can customize how the dumper formats output by exporting a `dumper` configuration from `config/app.ts`.
```ts title="config/app.ts"
import { defineConfig as dumperConfig } from '@adonisjs/core/dumper'
export const dumper = dumperConfig({
/**
* Settings for console output (e.g., console.log)
*/
console: {
depth: 10,
collapse: ['DateTime', 'Date'],
inspectStaticMembers: true,
},
/**
* Settings for HTML output (@dump and @dd)
*/
html: {
depth: 10,
inspectStaticMembers: true,
},
})
```
The following options are available for both `console` and `html` printers.
::::options
:::option{name="showHidden" dataType="boolean" defaultValue="false"}
Include non-enumerable properties
:::
:::option{name="depth" dataType="number" defaultValue="5"}
Maximum depth for nested structures (objects, arrays, maps, sets)
:::
:::option{name="inspectObjectPrototype" dataType="boolean | string" defaultValue="unless-plain-object"}
Include prototype properties. Set to `true` for all objects, `false` for none, or `'unless-plain-object'` for class instances only.
:::
:::option{name="inspectArrayPrototype" dataType="boolean" defaultValue="false"}
Include array prototype properties
:::
:::option{name="inspectStaticMembers" dataType="boolean" defaultValue="false"}
Include static members of classes
:::
:::option{name="maxArrayLength" dataType="number" defaultValue="100"}
Maximum items to display for arrays, maps, and sets
:::
:::option{name="maxStringLength" dataType="number" defaultValue="1000"}
Maximum characters to display for strings
:::
:::option{name="collapse" dataType="string[]" defaultValue="[]"}
Array of constructor names that should not be expanded further
:::
::::
## Exception handler debug mode
During development, AdonisJS displays [detailed error pages](./exception_handling.md#debug-mode-and-youch) powered by Youch when an unhandled exception occurs. These pages include the full stack trace, request information, and application state, making it easy to diagnose issues.
Debug mode is enabled automatically in development and disabled in production. This behavior is controlled by the exception handler configuration.
---
# Static files server
This guide covers serving static files in AdonisJS applications. You will learn how to:
- Install and configure the static files middleware
- Understand when to use static files versus compiled assets
- Configure caching, ETags, and HTTP headers for optimal performance
- Control access to dot files for security
- Set up custom headers for specific file types
- Copy static files to production builds
## Overview
The static file server lets you serve files directly from the file system without creating route handlers for each file. This is essential for assets that don't need processing, like favicons, robots.txt files, user uploads, or downloadable PDFs.
Without a static file server, you would need to create individual routes for every file you want to serve. This quickly becomes unmaintainable:
```ts title="start/routes.ts"
// Without static middleware - tedious and error-prone
router.get('/favicon.ico', async ({ response }) => {
return response.download('public/favicon.ico')
})
router.get('/robots.txt', async ({ response }) => {
return response.download('public/robots.txt')
})
router.get('/images/logo.png', async ({ response }) => {
return response.download('public/images/logo.png')
})
// ... potentially hundreds of routes
```
With the static middleware, all files in the `public` directory are automatically available. The middleware intercepts HTTP requests before they reach your routes. If a file matching the request path exists, it serves the file with appropriate HTTP headers for caching and performance. If no file exists, the request continues to your route handlers as normal.
:::warning
The AdonisJS static file server is convenient during development, but in production you should prefer serving static files through a reverse proxy (Nginx, Caddy, Traefik, Apache) or a CDN. These tools are purpose-built for static file delivery and offer better performance, caching, and compression than a Node.js process. This frees your AdonisJS server to focus on handling dynamic requests.
See the [deployment guide](../../start/deployment.md#serving-static-files-in-production) for recommended production setups.
:::
The key distinction in AdonisJS: files in the `public` directory are served as-is without any processing, while files in the `resources` directory are processed by your assets bundler (like Vite). Use `public` for files that are already in their final form.
## Installation
The `@adonisjs/static` package comes pre-configured with the `web` starter kit. If you're using a different starter kit, you can install and configure it manually.
Install and configure the package using the following command:
```sh
node ace add @adonisjs/static
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/static` package using the detected package manager.
2. Registers the following service provider inside the `adonisrc.ts` file.
```ts
{
providers: [
// ...other providers
() => import('@adonisjs/static/static_provider')
]
}
```
3. Creates the `config/static.ts` file.
4. Registers the following middleware inside the `start/kernel.ts` file.
```ts
server.use([
() => import('@adonisjs/static/static_middleware')
])
```
:::
## Configuration
The configuration for the static middleware is stored in the `config/static.ts` file.
```ts title="config/static.ts"
import { defineConfig } from '@adonisjs/static'
const staticServerConfig = defineConfig({
enabled: true,
etag: true,
lastModified: true,
dotFiles: 'ignore',
})
export default staticServerConfig
```
::::options
:::option{name="enabled"}
The `enabled` property allows you to temporarily disable the middleware without removing it from the middleware stack. This is useful when debugging or testing different configurations. Set it to `false` to stop serving static files while keeping the middleware registered.
```ts
{
enabled: true
}
```
:::
:::option{name="etag"}
The `etag` property controls whether the server generates [ETags](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) for cache validation. ETags help browsers determine if their cached version of a file is still valid without downloading it again.
When a browser requests a file it has cached, it sends the ETag value. If the file hasn't changed, the server responds with a `304 Not Modified` status, saving bandwidth. This is enabled by default and should generally stay enabled for production.
```ts
{
etag: true
}
```
:::
:::option{name="lastModified"}
The `lastModified` property enables the [Last-Modified](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified) header. The server uses the file's modification time from the file system (the [stat.mtime](https://nodejs.org/api/fs.html#statsmtime) property) as the header value.
Browsers can use this header along with ETags for cache validation. Like ETags, this is enabled by default.
```ts
{
lastModified: true
}
```
:::
:::option{name="dotFiles"}
The `dotFiles` property defines how to handle requests for files starting with a dot (like `.env` or `.gitignore`). You can set one of three values: `'ignore'` (default), `'deny'`, or `'allow'`.
The `'ignore'` option pretends dot files don't exist and returns a `404` status code. This is the recommended setting for security. The `'deny'` option explicitly denies access with a `403` status code. The `'allow'` option serves dot files like any other file.
```ts
{
dotFiles: 'ignore' // Recommended
}
```
:::warning
Setting `dotFiles` to `'allow'` can expose sensitive files like `.env` or `.git` directories if they're accidentally placed in the public folder. The `'ignore'` setting (default) is recommended for security. It returns a `404` response as if the file doesn't exist, preventing information disclosure.
If you need to serve specific files for domain verification (like `.well-known/acme-challenge` for SSL certificates), create a subdirectory without a leading dot and configure your verification tool to use that path instead.
:::
:::
:::option{name="acceptRanges"}
The `acceptRanges` property allows browsers to resume interrupted downloads instead of restarting from the beginning. When enabled, the server adds an `Accept-Ranges` header to responses. This is particularly useful for large files like videos or software downloads. The property defaults to `true`.
```ts
{
acceptRanges: true
}
```
:::
:::option{name="cacheControl"}
The `cacheControl` property enables the [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header. This header tells browsers and CDNs how long to cache files before checking for updates. When enabled, you can use the `maxAge` and `immutable` properties to fine-tune caching behavior.
```ts
{
cacheControl: true
}
```
:::
:::option{name="maxAge"}
The `maxAge` property sets the [max-age](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#max-age) directive for the `Cache-Control` header. This tells browsers how long they can cache the file before checking for updates. You can specify the value in milliseconds or as a time expression string like `'30 mins'`, `'1 day'`, or `'1 year'`.
```ts
{
cacheControl: true,
maxAge: '30 days'
}
```
:::
:::option{name="immutable"}
The `immutable` property adds the [immutable](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#immutable) directive to the `Cache-Control` header. This tells browsers that the file will never change during its cache lifetime, allowing more aggressive caching.
Use this for files with versioned or hashed filenames (like `app-v2.css` or `bundle-abc123.js`). By default, `immutable` is disabled.
```ts
{
cacheControl: true,
maxAge: '1 year',
immutable: true
}
```
:::tip
The `immutable` directive only works when `maxAge` is also set. If you enable `immutable` without setting `maxAge`, browsers will ignore it. This prevents accidental long-term caching without an explicit expiration time.
:::
:::
:::option{name="headers"}
The `headers` property accepts a function that returns custom HTTP headers for specific files. The function receives the file path as the first argument and the [file stats](https://nodejs.org/api/fs.html#class-fsstats) object as the second argument. This allows you to set different headers based on file type, size, or other attributes.
The function should return an object where keys are header names and values are header values. If the function returns `undefined` or doesn't return anything, no additional headers are added for that file.
```ts
{
headers: (path, stats) => {
/**
* Set custom content type for .mc2 files
* since they're not recognized by default
*/
if (path.endsWith('.mc2')) {
return {
'content-type': 'application/octet-stream'
}
}
/**
* Add security headers for HTML files
* to prevent XSS attacks
*/
if (path.endsWith('.html')) {
return {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY'
}
}
}
}
```
:::
::::
## Serving static files
Once the middleware is registered, you can create files inside the `public` directory and access them in the browser using their file path. For example, the `./public/css/style.css` file can be accessed at `http://localhost:3333/css/style.css`.
Here's what a typical `public` directory looks like in production:
```sh
public/
├── favicon.ico # Browser tab icon
├── robots.txt # Search engine crawling instructions
├── sitemap.xml # SEO sitemap for search engines
├── images/
│ ├── logo.png # Static logo (doesn't need optimization)
│ └── og-image.jpg # Social media preview image
├── downloads/
│ ├── user-guide.pdf # Downloadable documentation
│ └── terms.pdf # Legal documents
└── uploads/
└── avatars/ # User-uploaded profile pictures
```
Each of these files would be accessible at its corresponding URL:
- `http://localhost:3333/favicon.ico`
- `http://localhost:3333/images/logo.png`
- `http://localhost:3333/downloads/user-guide.pdf`
## `public` directory vs `resources` directory
Understanding when to use the `public` directory versus the `resources` directory is crucial for organizing your application's assets correctly.
Use the `public` directory for files that are already in their final form and don't need any processing:
- Favicons and app icons
- `robots.txt` and `sitemap.xml` files
- Static images that don't need optimization (logos, icons)
- Downloadable files (PDFs, ZIP archives, executables)
- Third-party JavaScript libraries you want to serve as-is
- User-uploaded content (avatars, documents)
Use the `resources` directory with an assets bundler for files that need compilation or optimization:
- CSS/SCSS files that need compilation
- Modern JavaScript/TypeScript that needs transpilation
- Images that benefit from optimization and responsive variants
- Assets that need versioning or cache-busting hashes
- Any file that should be processed by Vite or your build pipeline
```sh title="❌Source files in public won't be compiled"
public/styles/main.scss # This won't be compiled
public/app.ts # This won't be transpiled
```
```sh title="✅ Source files in resources will be processed"
resources/css/main.scss # Compiled by Vite
resources/js/app.ts # Transpiled by Vite
```
:::tip
A common mistake is placing source files (like `.scss` or modern `.ts`) in the `public` directory and expecting them to be compiled. The static middleware serves files exactly as they are without any processing. Source files that need compilation should go in the `resources` directory and be processed by Vite or your assets bundler.
:::
## Copying static files to production
The files in your `public` directory are automatically copied to the `build` folder when you run the `node ace build` command. This ensures your static files are available in production alongside your compiled application code.
The rule for copying public files is defined in the `adonisrc.ts` file:
```ts title="adonisrc.ts"
{
metaFiles: [
{
pattern: 'public/**',
reloadServer: false
}
]
}
```
The `pattern` property uses glob syntax to match all files inside the `public` directory. The `reloadServer: false` setting indicates that changes to these files during development don't require restarting the development server.
If you add files to the `public` directory while your development server is running, you don't need to restart. The static middleware will serve them immediately. However, if you modify the `config/static.ts` file, you will need to restart the server for the configuration changes to take effect.
## See also
- [Middleware guide](./middleware.md) - Learn more about how middleware works in AdonisJS
- [Vite integration](../frontend/vite.md) - Set up asset compilation for the resources directory
- [File uploads guide](./file_uploads.md) - Handle user-uploaded files that go in the public directory
---
# Edge Templates
This guide covers using Edge templates in AdonisJS applications. You will learn how to render templates from controllers, pass data to templates, work with layouts and components, use the pre-built components from the Hypermedia starter kit, and debug template issues.
## Overview
Edge is a server-side templating engine for Node.js that allows you to compose HTML markup on the server and send the final static HTML to the browser. Since templates execute entirely on the server-side, they can tap into core framework features like authentication, authorization checks, and the translations system.
When you create a Hypermedia application in AdonisJS, Edge comes pre-configured and ready to use. Templates are stored in the `resources/views` directory with the `.edge` file extension, and you render them from your route handlers or controllers using the `view` property from the HTTP context.
Edge has comprehensive documentation at [edgejs.dev](https://edgejs.dev), which covers the template syntax, components system, and all available features in detail. This guide focuses specifically on using Edge within AdonisJS applications and introduces the pre-built components included in the Hypermedia starter kit.
## Your first template
Let's create a simple page that displays a list of blog posts. This example demonstrates the fundamental workflow of rendering templates in AdonisJS.
::::steps
:::step{title="Create the template file"}
Generate a new template using the Ace command.
```bash
node ace make:view pages/posts/index
# CREATE: resources/views/pages/posts/index.edge
```
:::
:::step{title="Add the template content"}
Open `resources/views/pages/posts/index.edge` and add the following content.
```edge title="resources/views/pages/posts/index.edge"
@layout()
@each(post in posts)
{{ post.title }}
{{{ excerpt(post.content, 280) }}}
@end
@end
```
A few important things to understand about this template:
- The `@layout()` component wraps your content with a complete HTML document structure (including ``, ``, and `` tags). We'll explore layouts in detail later in this guide.
- The `@each` tag loops over the `posts` array and renders the content for each post. Edge provides several tags like `@if`, `@else`, and `@elseif` for writing logic in templates. You can learn about all available tags in the [Edge syntax reference](https://edgejs.dev/docs/syntax_specification).
- The double curly braces `{{ }}` evaluate and output a JavaScript expression. The triple curly braces `{{{ }}}` do the same but don't escape HTML, which is useful for rendering rich content.
:::
:::step{title="Define the route"}
Create a route to handle requests to the posts page.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('posts', [controllers.PostsController, 'index'])
```
:::
:::step{title="Create the controller"}
Create a controller that renders the template with post data.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async index({ view }: HttpContext) {
/**
* Render the template located at resources/views/pages/posts/index.edge
* The first parameter is the template path (relative to resources/views)
* The second parameter is the template state (data to share with the template)
*/
return view.render('pages/posts/index', {
posts: await Post.all(),
})
}
}
```
:::
:::step{title="View the result"}
Visit `http://localhost:3333/posts` in your browser to see the rendered page.
:::
::::
You can also render templates directly from routes without using a controller.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
/**
* The router.on().render() shorthand renders a template directly.
* The first parameter is the template path.
*/
router.on('/').render('pages/home')
```
## Understanding template state
The data object you pass to `view.render()` is called the **template state**. All properties in this object become available as variables in your template.
In addition to the data you explicitly pass, AdonisJS automatically shares certain globals with every template:
- The `request` object for accessing request data
- The `auth` object for checking authentication status
- Edge helpers like `excerpt()`, `truncate()`, and route helpers
You can view all available helpers and global properties in the [Edge reference guide](/reference/edge).
## Template syntax refresher
Edge uses a combination of curly braces and tags to add dynamic behavior to your templates. Here's a quick refresher of the most common syntax patterns:
**Outputting variables:**
```edge
{{ post.title }}
```
**Outputting unescaped HTML:**
```edge
{{{ post.content }}}
```
**Conditionals:**
```edge
@if(user)
Welcome back, {{ user.name }}
@else
Please log in
@end
```
**Loops:**
```edge
@each(post in posts)
{{ post.title }}
@end
```
**Evaluating JavaScript expressions:**
```edge
{{ post.createdAt.toFormat('dd LLL yyyy') }}
{{ posts.length > 0 ? 'Posts available' : 'No posts yet' }}
```
For complete coverage of Edge's template syntax, including advanced features like partials, slots, and custom tags, refer to the [Edge syntax reference](https://edgejs.dev/docs/syntax_specification).
## Working with layouts and components
The `@layout()` component you saw in the first example wraps your page content with a complete HTML document structure. This component is stored at `resources/views/components/layout.edge` and contains the standard HTML boilerplate:
```edge title="resources/views/components/layout.edge"
My App
{{{ await $slots.main() }}}
```
The `$slots.main()` method call renders whatever content you place between the opening and closing `@layout()` tags in your page templates. This is Edge's slots feature, which allows components to accept content from their consumers.
### Creating components
Components in Edge are reusable template fragments stored in the `resources/views/components` directory. Any template file in this directory becomes available as an Edge tag.
For example, if you create a file at `resources/views/components/card.edge`:
```edge title="resources/views/components/card.edge"
{{{ await $slots.main() }}}
```
You can use it in your templates like this:
```edge
@card()
Card title
Card content
@end
```
Components can accept props (parameters) and have multiple named slots for more complex compositions. For a complete guide to building and using components, see the [Edge components guide](https://edgejs.dev/docs/components/introduction).
## Starter kit components
The Hypermedia starter kit includes a collection of unstyled components for building forms and common UI patterns. Each component renders at most one HTML element and passes unknown props through as HTML attributes, allowing you to apply classes and other attributes directly.
### Layout
Renders the HTML document with head and body elements.
- **Props**: None
- **Slots**: `main` (default)
```edge
@layout()
Page content goes here
@end
```
### Form
Renders an HTML form element with automatic CSRF token injection.
| Prop | Type | Description |
|------|------|-------------|
| `action` | `string` | The form action URL |
| `method` | `string` | HTTP method. Supports `PUT`, `PATCH`, and `DELETE` via method spoofing |
| `route` | `string` | Compute action URL from a named route |
| `routeParams` | `array` | Parameters for the named route |
| `routeOptions` | `object` | Additional options for URL generation (e.g., query strings) |
```edge
@form({ route: 'posts.store', method: 'POST' })
{{-- Form fields --}}
@end
@form({ route: 'posts.update', method: 'PUT', routeParams: [post.id] })
{{-- Form fields --}}
@end
```
### Field components
Form field components work together to create accessible form inputs with labels and validation error display.
#### field.root
Container that establishes context for child field components.
| Prop | Type | Description |
|------|------|-------------|
| `name` | `string` | Field name, used for error message lookup |
| `id` | `string` | Element ID, used to associate labels with inputs |
#### field.label
Renders a label element associated with the field.
| Prop | Type | Description |
|------|------|-------------|
| `text` | `string` | Label text (alternative to using the slot) |
#### field.error
Displays validation errors for the field. Automatically looks up errors by the field name.
```edge
@field.root({ name: 'email' })
@!field.label({ text: 'Email address' })
@!input.control({ type: 'email', autocomplete: 'email' })
@!field.error()
@end
```
### Input control
Renders an input element. Must be a child of `@field.root`. It passes all props as HTML attributes to the input element.
```edge
@field.root({ name: 'username' })
@!field.label({ text: 'Username' })
@!input.control({ type: 'text', minlength: '3', maxlength: '20' })
@!field.error()
@end
```
### Select control
Renders a select element with options. Must be a child of `@field.root`.
| Prop | Type | Description |
|------|------|-------------|
| `options` | `array` | Array of objects with `name` and `value` properties |
```edge
@field.root({ name: 'country' })
@!field.label({ text: 'Country' })
@!select.control({
options: countries.map((country) => ({
name: country.name,
value: country.code
}))
})
@!field.error()
@end
```
### Textarea control
Renders a textarea element. Must be a child of `@field.root`. It passes all props as HTML attributes to the textarea element.
```edge
@field.root({ name: 'bio' })
@!field.label({ text: 'Biography' })
@!textarea.control({ rows: '4' })
@!field.error()
@end
```
### Checkbox components
Checkbox components create checkbox inputs with shared naming for form submission.
#### checkbox.group
Container that establishes the shared name for child checkboxes.
| Prop | Type | Description |
|------|------|-------------|
| `name` | `string` | Shared name for all checkboxes in the group |
#### checkbox.control
Renders a checkbox input. Must be nested within both `@checkbox.group` and `@field.root`. It passes all props as HTML attributes. Use `value` to set the checkbox value.
```edge
@checkbox.group({ name: 'services' })
@field.root({ id: 'design' })
@!checkbox.control({ value: 'design' })
@!field.label({ text: 'Design' })
@end
@field.root({ id: 'development' })
@!checkbox.control({ value: 'development' })
@!field.label({ text: 'Development' })
@end
@field.root({ id: 'marketing' })
@!checkbox.control({ value: 'marketing' })
@!field.label({ text: 'Marketing' })
@end
@end
```
### Radio components
Radio components create mutually exclusive options within a group.
#### radio.group
Container that establishes the shared name for child radio buttons.
| Prop | Type | Description |
|------|------|-------------|
| `name` | `string` | Shared name for all radio buttons in the group |
#### radio.control
Renders a radio input. Must be nested within both `@radio.group` and `@field.root`. It passes all props as HTML attributes. Use `value` to set the radio value.
```edge
@radio.group({ name: 'payment_plan' })
@field.root({ id: 'free' })
@!radio.control({ value: 'free' })
@!field.label({ text: 'Free' })
@end
@field.root({ id: 'pro' })
@!radio.control({ value: 'pro' })
@!field.label({ text: 'Pro $29/month' })
@end
@field.root({ id: 'enterprise' })
@!radio.control({ value: 'enterprise' })
@!field.label({ text: 'Custom pricing' })
@end
@end
```
### Alert components
Alert components display notification messages with optional auto-dismiss behavior.
#### alert.root
Container that establishes context for alert title and description.
| Prop | Type | Description |
|------|------|-------------|
| `variant` | `string` | Alert variant (e.g., `destructive`, `success`) |
| `autoDismiss` | `boolean` | Whether the alert should dismiss automatically |
#### alert.title
Renders the alert heading.
| Prop | Type | Description |
|------|------|-------------|
| `text` | `string` | Title text (alternative to using the slot) |
#### alert.description
Renders the alert body text.
| Prop | Type | Description |
|------|------|-------------|
| `text` | `string` | Description text (alternative to using the slot) |
```edge
@alert.root({ variant: 'destructive', autoDismiss: true })
@!alert.title({ text: 'Unauthorized' })
@!alert.description({ text: 'You are not allowed to access this page' })
@end
```
### Button
Renders a button element.
| Prop | Type | Description |
|------|------|-------------|
| `text` | `string` | Button text (alternative to using the slot) |
```edge
@!button({ text: 'Sign up', type: 'submit' })
@button({ type: 'button', class: 'btn-secondary' })
Cancel
@end
```
### Link
Renders an anchor element with route-based URL generation.
| Prop | Type | Description |
|------|------|-------------|
| `text` | `string` | Link text (alternative to using the slot) |
| `route` | `string` | Compute href from a named route |
| `routeParams` | `array` | Parameters for the named route |
| `routeOptions` | `object` | Additional options for URL generation |
| `href` | `string` | Direct URL (use instead of route) |
```edge
@!link({ route: 'posts.show', routeParams: [post.id], text: 'View post' })
@link({ route: 'posts.edit', routeParams: [post.id] })
Edit
@end
```
### Avatar
Renders either an image or initials for user avatars.
| Prop | Type | Description |
|------|------|-------------|
| `src` | `string` | Avatar image URL (renders an `img` element) |
| `initials` | `string` | Fallback initials (renders a `span` element) |
```edge
{{-- With image --}}
@!avatar({ src: user.avatarUrl, alt: user.name })
{{-- With initials --}}
@!avatar({ initials: user.initials })
```
## Debugging templates
When working with templates, you may need to inspect the data available in your template or debug why certain values aren't displaying as expected. Edge provides the `@dump` tag for this purpose.
The `@dump` tag pretty-prints the value of a variable, making it easy to inspect data structures:
```edge
@dump(posts)
```
To view the entire template state (all variables available in your template), use:
```edge
@dump(state)
```
The output appears in your rendered HTML, showing the structure and values of your data. During development, templates automatically reload when you make changes, so you'll see updates immediately in your browser without restarting the server.
## Configuration
Edge comes pre-configured in Hypermedia applications and works out of the box. However, you can customize Edge by creating a preload file if you need to register custom helpers, tags, or plugins.
Create a preload file for Edge configuration:
```bash
node ace make:preload view
```
This creates a preload file where you can customize Edge before your application starts. Inside this file, you can register Edge globals, plugins, and custom tags.
If you need to customize the directory where templates are stored, you can modify the `directories` option in your `adonisrc.ts` file. See the [AdonisRC reference guide](/reference/adonisrc-rcfile) for more details on configuration options.
## See also
- [Edge syntax reference](https://edgejs.dev/docs/syntax_specification) - Learn about all template syntax features, tags, and expressions
- [Edge components guide](https://edgejs.dev/docs/components/introduction) - Deep dive into building and composing components
- [Edge reference guide](/reference/edge) - View all available helpers and global properties in Edge templates
- [Edge documentation](https://edgejs.dev) - Complete Edge documentation with advanced features and patterns
---
# Inertia
This guide covers using Inertia with AdonisJS to build single-page applications. You will learn how to:
- Render Inertia pages from controllers and routes and pass props to frontend components
- Scaffold page components with the `make:page` command
- Structure the `inertia/` directory and understand key configuration files
- Generate end-to-end types for pages and shared data
- Use data loading patterns like optional, deferred, and mergeable props
- Build forms and navigation with the `Link` and `Form` components
- Share data globally and scope validation errors with error bags
- Customize the root Edge template with the `@inertia` and `@inertiaHead` tags
- Control redirects, browser history, and history encryption
- Enable server-side rendering (SSR)
- Understand the request lifecycle in Inertia applications
## Overview
Inertia acts as a bridge between AdonisJS and frontend frameworks like React and Vue. It eliminates the need for client-side routing or complex state management libraries by embracing a server-first architecture. You write controllers and routes exactly as you would in a traditional server-rendered application, but instead of returning HTML or JSON, you render Inertia pages that your frontend framework displays.
This approach gives you the best of both worlds: the simplicity of server-side routing and data fetching combined with the rich interactivity of React or Vue for the view layer. AdonisJS officially supports both frameworks through the Inertia starter kit.
See also: [How Inertia works](https://inertiajs.com/how-it-works) on the official Inertia documentation.
## Basic example
Let's walk through rendering a posts list end-to-end. The flow has three pieces: a route, a controller that calls `inertia.render()`, and a page component inside `inertia/pages/`.
::::steps
:::step{title="Register a route"}
Routes look identical to any other AdonisJS route. There is no special routing layer for Inertia.
```ts title="start/routes.ts"
router.get('/posts', [controllers.Posts, 'index'])
```
:::
:::step{title="Render a page from the controller"}
The HTTP context exposes an `inertia` object. Call `inertia.render()` with two arguments: the page component path (relative to `inertia/pages/`) and an object of props the component receives.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ inertia }: HttpContext) {
const posts = await Post.all()
return inertia.render('posts/index', {
posts: PostTransformer.transform(posts)
})
}
}
```
Use a [transformer](./transformers.md) to serialize model instances into plain objects. Transformers also generate frontend types under the `Data` namespace, keeping props in sync with the backend.
:::
:::step{title="Create the page component"}
The string `'posts/index'` resolves to `inertia/pages/posts/index.tsx` (or `.vue`). Scaffold the file with `node ace make:page posts/index`. The component receives the props from `inertia.render()` directly.
::::tabs
:::tab{title="React"}
```tsx title="inertia/pages/posts/index.tsx"
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'
type PageProps = InertiaProps<{ posts: Data.Post[] }>
export default function PostsIndex({ posts }: PageProps) {
return (
<>
{posts.map((post) => (
```
:::
::::
The `InertiaProps` helper merges your page-specific props with [shared data](#shared-data), so global props like `user` or `flash` are typed alongside `posts`.
:::
::::
### Rendering from a route
For pages without controller logic, skip the controller and render directly from the route definition using `renderInertia()`.
```ts title="start/routes.ts"
router.on('/about').renderInertia('about')
router.on('/pricing').renderInertia('marketing/pricing', {
plans: ['starter', 'pro', 'enterprise'],
})
```
The component name is type-checked against the generated `InertiaPages` interface, so typos are caught at compile time.
### What happens behind the scenes
On the very first request to `/posts`, Inertia returns an HTML shell containing a root `
` with the page component name and serialized props as a `data-page` attribute. The frontend bundle reads that attribute and boots React or Vue.
For every subsequent navigation (link clicks, form submits) Inertia issues a `fetch` request with an `X-Inertia` header. The server runs the same controller but returns a JSON page object instead of HTML. The client swaps in the new component and updates the URL. No full page reload, no separate API.
## The inertia directory
The `inertia/` directory contains your frontend application. Here is the structure created by the starter kit:
```
inertia/
├── app.tsx (or app.vue) # Frontend application entrypoint
├── client.ts # Tuyau API client setup
├── ssr.tsx (or ssr.vue) # SSR entrypoint (when enabled)
├── tsconfig.json # TypeScript config for frontend code
├── types.ts # Shared type definitions
├── css/
│ └── app.css # Global styles
├── layouts/ # Reusable layout components
│ └── default.tsx
└── pages/ # Page components rendered by controllers
└── home.tsx
```
The `pages/` directory is where Inertia looks for components when you call `inertia.render()`. The path you pass (like `posts/index`) maps directly to a file in this directory (`inertia/pages/posts/index.tsx`).
The `app.tsx` (or `app.vue`) file is the entrypoint that boots your frontend application. It initializes Inertia with your page components and any global configuration. The `ssr.tsx` file serves the same purpose for server-side rendering.
You can create additional directories as your project grows, such as `components/` for shared UI elements or `hooks/` for custom React hooks.
## Configuration files
Two configuration files control how Inertia works in your AdonisJS application.
The `config/inertia.ts` file defines the Inertia adapter settings.
```ts title="config/inertia.ts"
import { defineConfig } from '@adonisjs/inertia'
const inertiaConfig = defineConfig({
rootView: 'inertia_layout',
ssr: {
enabled: false,
entrypoint: 'inertia/ssr.tsx',
},
})
export default inertiaConfig
```
The supported options are:
::::options
:::option{name="rootView" type="string | (ctx) => string"}
The Edge template that renders the initial HTML shell. Defaults to `inertia_layout`. Pass a function to choose a different template per request, for example to render a marketing layout for unauthenticated users.
```ts
rootView: (ctx) => ctx.auth.isAuthenticated ? 'app_layout' : 'marketing_layout'
```
:::
:::option{name="encryptHistory" type="boolean"}
Encrypts sensitive page props stored in the browser's history state. Defaults to `false`. See [history encryption](https://inertiajs.com/history-encryption) on the Inertia documentation.
:::
:::option{name="assetsVersion" type="string | number"}
Pins the asset version string used for [asset versioning](#asset-versioning). When omitted, the version is computed from the Vite manifest. Set this to override the default with a git commit hash, build timestamp, or any custom identifier.
:::
:::option{name="ssr.enabled" type="boolean"}
Enables server-side rendering. See [Server-side rendering](#server-side-rendering).
:::
:::option{name="ssr.entrypoint" type="string"}
Path to the SSR entrypoint file relative to the project root. Defaults to `inertia/ssr.tsx`.
:::
:::option{name="ssr.bundle" type="string"}
Path to the production SSR bundle generated by Vite. Defaults to `ssr/ssr.js`.
:::
:::option{name="ssr.pages" type="string[] | (ctx, page) => boolean"}
Restricts SSR to a subset of pages. Pass an array of component names, or a function that returns a boolean for each page.
```ts
ssr: {
enabled: true,
entrypoint: 'inertia/ssr.tsx',
pages: ['home', 'marketing/pricing'],
}
```
:::
::::
The `resources/views/inertia_layout.edge` template renders the initial HTML shell that contains the root `div` where your frontend application mounts. See [Root template](#root-template) for the available Edge tags.
## Root template
The Edge template configured under `rootView` is rendered for the very first request. It contains the root element where your frontend application mounts and any HTML the SSR output needs to slot into.
The Inertia package registers two Edge tags for this template.
```edge title="resources/views/inertia_layout.edge"
@inertiaHead()
@vite(['inertia/app.tsx'])
@inertia()
```
The `@inertia` tag renders a `div` with the encoded page object as a `data-page` attribute. The frontend reads this attribute to boot the SPA. By default, the element is `
`. Pass an object to override the tag, id, or class.
```edge
@inertia({ as: 'main', id: 'app-root', class: 'min-h-screen' })
```
The `@inertiaHead` tag outputs the head fragments (title, meta tags) collected during server-side rendering. Include it whenever SSR is enabled. It is a no-op for non-SSR responses.
### Passing data to the template
The third argument to `inertia.render()` is forwarded to the root template as view props. Use this for values that belong in the HTML shell rather than as page props, such as the page title or `` tags for non-SSR pages.
```ts title="app/controllers/posts_controller.ts"
return inertia.render(
'posts/show',
{ post: PostTransformer.transform(post) },
{ title: post.title, description: post.summary }
)
```
```edge title="resources/views/inertia_layout.edge"
{{ title ?? 'My App' }}
@if(description)
@end
@inertiaHead()
```
## Generated types
Inertia in AdonisJS is fully type-safe end to end. Two generated artifacts power this:
- The `Data` namespace at `.adonisjs/client/data.d.ts` mirrors transformer output, so props passed from the controller are typed in the page component. See [Transformers](./transformers.md).
- The `InertiaPages` interface at `.adonisjs/server/pages.d.ts` maps each file in `inertia/pages/` to its component prop types. This is what makes `inertia.render('posts/index', { posts })` autocomplete and type-check the component name and props.
The `InertiaPages` types are produced by the `indexPages` Assembler hook. Register it in `adonisrc.ts`, passing the framework you use.
```ts title="adonisrc.ts"
import { defineConfig } from '@adonisjs/core/app'
import { indexPages } from '@adonisjs/inertia/index_pages'
export default defineConfig({
hooks: {
onDevServerStarted: [indexPages({ framework: 'react' })],
onBuildStarting: [indexPages({ framework: 'react' })],
},
})
```
The `framework` option accepts `'vue3'` or `'react'`. Pass `source` to scan a directory other than `inertia/pages`.
### Typing shared data
Shared data returned from the Inertia middleware is available on every page through the `InertiaProps` helper. To make it type-safe, augment the `SharedProps` interface with the inferred return type of your `share()` method.
```ts title="app/middleware/inertia_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import type { InferSharedProps } from '@adonisjs/inertia/types'
export default class InertiaMiddleware {
share(ctx: HttpContext) {
return {
user: ctx.auth?.user,
flash: ctx.session?.flashMessages.all(),
}
}
}
declare module '@adonisjs/inertia/types' {
interface SharedProps extends InferSharedProps {}
}
```
Once augmented, `props.user` and `props.flash` are typed inside every page component without redeclaring them.
## Data loading patterns
Inertia provides several patterns for loading data efficiently. AdonisJS exposes helpers on the `inertia` object to support each pattern.
:::tip
Optional and deferred props look similar but behave differently. Optional props are evaluated **only** when the frontend explicitly asks for them through a partial reload. Deferred props are evaluated on a follow-up request that Inertia issues automatically right after the page mounts. Reach for `optional` when the value is rarely needed (a tab a user may never click) and `defer` when the value is always needed but slow to compute (a dashboard chart).
:::
### Optional props
Optional props are only evaluated when the frontend explicitly requests them during a partial reload. This is useful for expensive queries that aren't needed on every page load.
```ts title="app/controllers/users_controller.ts"
return inertia.render('users/index', {
/**
* The database query only runs when the frontend
* includes 'users' in a partial reload request.
*/
users: inertia.optional(async () => {
const users = await User.all()
return UserTransformer.transform(users)
})
})
```
See also: [Partial reloads](https://inertiajs.com/partial-reloads) on the Inertia documentation.
### Always props
The `always` helper ensures a prop is always included in responses, even during partial reloads that don't explicitly request it. This is the opposite of optional props.
```ts title="app/controllers/users_controller.ts"
return inertia.render('users/index', {
/**
* Permissions are always computed and included,
* regardless of what the frontend requests.
*/
permissions: inertia.always(async () => {
const permissions = await Permissions.all()
return PermissionTransformer.transform(permissions)
})
})
```
### Deferred props
Deferred props are loaded after the initial page render, allowing the page to display immediately while slower data loads in the background. The frontend shows a loading state until the deferred data arrives.
```ts title="app/controllers/dashboard_controller.ts"
return inertia.render('dashboard', {
/**
* These props load after the page renders.
* The frontend can show loading indicators.
*/
metrics: inertia.defer(async () => {
return computeMetrics()
}),
newSignups: inertia.defer(async () => {
return getNewSignups()
})
})
```
You can group deferred props so they load together in a single request.
```ts title="app/controllers/dashboard_controller.ts"
return inertia.render('dashboard', {
/**
* Both props are fetched in the same deferred request
* because they share the 'dashboard' group name.
*/
metrics: inertia.defer(async () => {
return computeMetrics()
}, 'dashboard'),
newSignups: inertia.defer(async () => {
return getNewSignups()
}, 'dashboard')
})
```
See also: [Deferred props](https://inertiajs.com/deferred-props) on the Inertia documentation.
### Mergeable props
Mergeable props are merged with existing frontend data rather than replacing it. This is useful for infinite scrolling or appending new items to a list.
```ts title="app/controllers/users_controller.ts"
return inertia.render('users/index', {
/**
* New notifications are merged with existing ones
* instead of replacing the entire array.
*/
notifications: inertia.merge(await fetchNotifications())
})
```
You can combine merging with deferred loading by chaining the `merge()` method.
```ts title="app/controllers/users_controller.ts"
return inertia.render('users/index', {
notifications: inertia.defer(() => {
return fetchNotifications()
}).merge()
})
```
By default, data is shallow merged. For nested objects that need recursive merging, use `deepMerge()` instead.
```ts title="app/controllers/users_controller.ts"
return inertia.render('users/index', {
notifications: inertia.defer(() => {
return fetchNotifications()
}).deepMerge()
})
```
See also: [Merging props](https://inertiajs.com/merging-props) on the Inertia documentation.
## Link and Form components
Inertia provides `Link` and `Form` components for navigation and form submissions. AdonisJS wraps these components with additional functionality that lets you reference routes by name instead of hardcoding URLs.
Import the components from the AdonisJS package rather than directly from Inertia.
::::tabs
:::tab{title="React"}
```tsx
// [!code --]
import { Form, Link } from '@inertiajs/react'
// [!code ++]
import { Form, Link } from '@adonisjs/inertia/react'
```
:::
:::tab{title="Vue"}
```vue
```
:::
::::
### Creating links
The `Link` component creates navigation links using route names defined in your AdonisJS routes.
```tsx
Signup
Login
```
### Creating forms
The `Form` component handles form submissions with automatic CSRF protection and error handling.
::::tabs
:::tab{title="React"}
```tsx title="inertia/pages/posts/edit.tsx"
import { Form } from '@adonisjs/inertia/react'
export default function EditPost({ post }) {
return (
)
}
```
:::
:::tab{title="Vue"}
```vue title="inertia/pages/posts/edit.vue"
```
:::
::::
The `Form` component infers the HTTP method (`POST`, `PUT`, `PATCH`, `DELETE`) from the route name automatically. You do not need to pass a `method` prop — in fact, the AdonisJS wrapper omits `method` and `action` from the accepted props since both are derived from the route definition.
When validation fails on the server, AdonisJS automatically adds validation errors to the session flash messages. The Inertia middleware then shares these errors with the frontend, making them available through the `errors` object in your form.
### Scoping errors with error bags
When a page renders multiple independent forms, errors from one form will leak into the others because they all read from the same `errors` object. To isolate them, set the `errorBag` prop on the form. Inertia sends this name in the `X-Inertia-Error-Bag` header, and the middleware nests the validation errors under that key.
```tsx
```
### Route parameters
Both `Link` and `Form` accept a `routeParams` prop for routes with dynamic segments. The keys in the object correspond to the parameter names defined in your route:
```ts title="start/routes.ts"
// Single parameter — :id
router.get('posts/:id', [PostsController, 'show']).as('posts.show')
// Multiple parameters — :userId and :postId
router.get('users/:userId/posts/:postId', [PostsController, 'show']).as('users.posts.show')
```
Pass the matching parameter values through `routeParams`:
```tsx
{/* Single parameter */}
{post.title}
{/* Multiple parameters */}
View post
```
TypeScript enforces that you provide all required parameters with the correct names. Missing or misspelled parameters are caught at compile time.
### Query parameters
The `Link` and `Form` components use the `route` prop for type-safe navigation, but they don't accept query parameters directly. To add query parameters (for example, `?page=2`), generate the URL with `urlFor` and pass it as the `href` prop instead:
```tsx
import { urlFor } from '~/client'
Page 2
```
:::note
When using `href`, you lose the type-safe route name checking that the `route` prop provides. Use `route` with `routeParams` for standard navigation and fall back to `href` with `urlFor` only when you need query parameters.
:::
## Shared data
Shared data is available to every page in your application without explicitly passing it from each controller. This is useful for global data like the authenticated user, flash messages, or application settings.
The `InertiaMiddleware` defines what data is shared. This middleware is stored at `app/middleware/inertia_middleware.ts` and contains a `share` method that returns the shared data.
```ts title="app/middleware/inertia_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import UserTransformer from '#transformers/user_transformer'
export default class InertiaMiddleware {
share(ctx: HttpContext) {
/**
* The share method may be called before all middleware runs.
* For example, during a 404 response. Always treat context
* properties as potentially undefined.
*/
const { session, auth } = ctx as Partial
const error = session?.flashMessages.get('error')
const success = session?.flashMessages.get('success')
return {
/**
* Using always() ensures these props are included
* even during partial reloads.
*/
errors: ctx.inertia.always(this.getValidationErrors(ctx)),
flash: ctx.inertia.always({
error,
success,
}),
user: ctx.inertia.always(
auth?.user ? UserTransformer.transform(auth.user) : undefined
),
}
}
}
```
:::tip
The `share` method may be called before the request passes through all middleware or reaches the controller. This happens when rendering error pages or aborting requests early. Always check that context properties exist before accessing them.
:::
### Accessing shared data
Shared data is automatically included in the props for every page. When you define page props using the `InertiaProps` type helper, it includes both your page-specific props and all shared data.
```tsx title="inertia/pages/posts/index.tsx"
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'
type PageProps = InertiaProps<{
posts: Data.Post[]
}>
export default function PostsIndex(props: PageProps) {
/**
* Access shared data alongside page-specific props.
*/
if (props.flash.error) {
console.log('Error:', props.flash.error)
}
return (
{props.user &&
Welcome, {props.user.name}
}
{/* render posts */}
)
}
```
## Pagination
Pagination in Inertia applications requires coordination between the controller, transformer, and frontend component. Here is a complete example using a posts list.
### Controller
Use a transformer's `paginate` method to serialize both the data and pagination metadata, then pass everything to `inertia.render()`:
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ request, inertia }: HttpContext) {
const page = request.input('page', 1)
const posts = await Post.query().paginate(page, 10)
return inertia.render('posts/index', {
posts: PostTransformer.paginate(posts.all(), posts.getMeta()),
})
}
}
```
### Frontend component
Type the paginated props using the `Data` namespace. The pagination metadata includes `currentPage`, `lastPage`, and other fields you can use to render controls:
```tsx title="inertia/pages/posts/index.tsx"
import { Link } from '@adonisjs/inertia/react'
import { urlFor } from '~/client'
import { InertiaProps } from '~/types'
import { Data } from '@generated/data'
type PageProps = InertiaProps<{
posts: {
data: Data.Post[]
metadata: {
total: number
perPage: number
currentPage: number
lastPage: number
firstPage: number
}
}
}>
export default function PostsIndex({ posts }: PageProps) {
const { data, metadata } = posts
return (
{data.map((post) => (
{post.title}
))}
)
}
```
The pagination links use `urlFor` with the `qs` option to generate URLs like `/posts?page=2`. See [Transformers](./transformers.md) for details on the `paginate` method and the shape of the metadata object.
## CSRF protection
CSRF protection is automatically configured in the Inertia starter kit. The `enableXsrfCookie` option in `config/shield.ts` sets a cookie that Inertia reads and includes with every request. You don't need to manually add CSRF tokens to your forms.
See also: [Shield](../security/securing_ssr_applications.md#csrf-configuration-reference) for more details on CSRF protection.
## Asset versioning
Asset versioning tells the frontend when your JavaScript or CSS bundles have changed, triggering a full page reload instead of a partial update. This ensures users always run the latest version of your frontend code after a deployment.
By default, AdonisJS computes a hash of the `.vite/manifest.json` file (created when you build your frontend assets) and uses it as the version identifier. To pin the version to a custom value, set `assetsVersion` in `config/inertia.ts` to a git commit hash, build timestamp, or any other identifier you control.
```ts title="config/inertia.ts"
const inertiaConfig = defineConfig({
assetsVersion: process.env.RELEASE_SHA,
})
```
Inertia sends the current asset version with every request in the `X-Inertia-Version` header. When the server detects a mismatch on a `GET` request, it responds with a `409` and instructs the client to perform a full page reload at the same URL. Flash messages are reflashed automatically so they survive the reload.
## Redirects and history
Inertia's redirect and history behaviour differs from a traditional server-rendered application because navigation happens over `fetch`. The `inertia` object on the HTTP context exposes helpers for the cases the framework cannot handle automatically.
### Redirects from mutations
When a `PUT`, `PATCH`, or `DELETE` request is followed by a `302` redirect, browsers replay the original method against the new URL. The Inertia middleware automatically upgrades these redirects to `303` so the browser issues a `GET` instead. You don't need to set the status code yourself.
```ts title="app/controllers/posts_controller.ts"
async update({ request, response }: HttpContext) {
await Post.updateOrFail(request.param('id'), request.all())
return response.redirect().toRoute('posts.index')
}
```
### External redirects
Inertia cannot follow redirects to a different origin over `fetch`. Use `inertia.location()` to send the client a `409` response with an `X-Inertia-Location` header, which triggers a full browser navigation to the target URL.
```ts
async checkout({ inertia }: HttpContext) {
const session = await stripe.createCheckoutSession()
return inertia.location(session.url)
}
```
### Clearing browser history
Call `inertia.clearHistory()` before rendering to clear the client-side history stack. This is useful after sign-out, where you don't want the user to navigate back to authenticated pages.
```ts
async destroy({ inertia, auth }: HttpContext) {
await auth.use('web').logout()
inertia.clearHistory()
return inertia.location('/')
}
```
### Encrypting history state
Inertia stores the page object for each visit in the browser's history state to support back/forward navigation. For pages that contain sensitive data (account settings, billing details), enable encryption so the data is unreadable from the history API.
Toggle encryption per request before calling `render()`:
```ts
async settings({ inertia, auth }: HttpContext) {
inertia.encryptHistory()
return inertia.render('account/settings', {
user: UserTransformer.transform(auth.user),
})
}
```
Or enable it globally through the [`encryptHistory`](#configuration-files) config option.
See [history encryption](https://inertiajs.com/history-encryption) on the Inertia documentation for the trade-offs.
## Server-side rendering
Server-side rendering (SSR) generates the initial HTML on the server, improving perceived performance and SEO. Enabling SSR requires configuration in both Vite and AdonisJS.
First, enable SSR in your Vite configuration. This tells Vite to create a separate SSR bundle using your `ssr.tsx` or `ssr.vue` entrypoint.
```ts title="vite.config.ts"
export default defineConfig({
plugins: [
// [!code highlight:6]
inertia({
ssr: {
enabled: true,
entrypoint: 'inertia/ssr.tsx'
}
}),
],
})
```
Then enable SSR in your AdonisJS configuration so the server knows to use the SSR bundle for rendering.
```ts title="config/inertia.ts"
import { defineConfig } from '@adonisjs/inertia'
const inertiaConfig = defineConfig({
ssr: {
// [!code highlight:2]
enabled: true,
entrypoint: 'inertia/ssr.tsx',
},
})
export default inertiaConfig
```
## Request lifecycle
Understanding how requests flow through an Inertia application helps when debugging or extending the default behavior.
When a user first visits your application, the request follows this path:
1. The request hits your AdonisJS routes and is handled by a controller
2. The controller calls `inertia.render()` with a page component and props
3. The Inertia middleware's `share()` method adds shared data to the props
4. Since this is the first visit, Inertia returns a full HTML response containing a shell layout with a `div` that holds the serialized page component name and props
5. The frontend bundle boots, reads the props from the `div`, and renders the React or Vue component
For subsequent navigation (clicking links or submitting forms):
1. Inertia intercepts the navigation and makes a `fetch` request with an `X-Inertia` header
2. The request flows through routes, controllers, and middleware as before
3. Since the `X-Inertia` header is present, Inertia returns a JSON response with just the page component name and props
4. The frontend receives the JSON and swaps the current component with the new one, updating the URL without a full page reload
This architecture gives you the developer experience of a traditional server-rendered app with the user experience of a modern SPA.
---
# Transformers
This guide covers data transformation in AdonisJS applications. You will learn how to:
- Serialize rich data types like classes, BigInt, and Lucid models to JSON while retaining type information
- Shape API responses by including or excluding fields
- Use transformer variants for different output contexts
- Handle relationships and pagination
- Generate TypeScript types that eliminate duplicate type definitions between your backend and frontend
## Overview
Transformers provide a structured way to convert your backend data into JSON responses for HTTP clients. When building APIs or full-stack applications, the data structures you work with in your backend (Lucid models, custom classes, DateTime objects) cannot be sent directly over HTTP—everything must be serialized to JSON first, which is fundamentally a string format.
Rather than letting AdonisJS handle serialization implicitly, transformers give you explicit control over this process. You define exactly which fields to include, how to format them, and what shape your responses should take. This approach offers several benefits:
- You can keep sensitive information out of responses
- Apply consistent formatting rules across your application
- Shape responses around your frontend's needs rather than your database structure
- And generate TypeScript types that your frontend can reference directly
:::tip
If you're building an Inertia application or any API that returns JSON data, we highly recommend using transformers for all HTTP responses. The generated TypeScript types eliminate the need to maintain duplicate type definitions, ensuring your frontend and backend stay in sync automatically.
:::
### Understanding JSON serialization
Before diving into transformers, it's important to understand why they exist. When you send data over HTTP, everything must be converted to a string—specifically, a JSON string. This means rich data types from your programming language cannot be transmitted directly.
Consider a Lucid model with a `createdAt` field that's a Luxon DateTime object. In JavaScript/TypeScript, this is a complex object with methods like `.toISO()` and `.diff()`. But when sent over HTTP, it must become a simple string like `"2024-01-15T10:30:00.000Z"`. Similarly, a BigInt value or a custom class instance must be converted to a JSON-compatible format.
Without explicit serialization control, you might accidentally expose sensitive data, send inconsistent date formats, or include internal implementation details that your frontend doesn't need. Transformers solve this by requiring you to be explicit about what gets serialized and how.
## Creating your first transformer
Let's start with a practical example. Suppose you have a Post model and need to return post data to your frontend. First, here's what the Post model looks like.
```ts title="app/models/post.ts"
import User from '#models/user'
import { DateTime } from 'luxon'
import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
export default class Post extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare userId: number
@column()
declare title: string
@column()
declare content: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
@belongsTo(() => User)
declare author: BelongsTo
}
```
::::steps
:::step{title="Generate the transformer"}
Run the following command to create a transformer for posts.
```bash
node ace make:transformer post
# CREATE: app/transformers/post_transformer.ts
```
This creates a file in the `app/transformers` directory with the following default structure.
```ts title="app/transformers/post_transformer.ts"
import { BaseTransformer } from '@adonisjs/core/transformers'
import Post from '#models/post'
export default class PostTransformer extends BaseTransformer {
toObject() {
return this.pick(this.resource, ['id'])
}
}
```
The transformer extends `BaseTransformer` with a generic type parameter specifying what it transforms (in this case, `Post`). The `toObject()` method defines the default output shape. The `this.resource` property gives you access to the Post instance being transformed.
:::
:::step{title="Define the output shape"}
The `toObject()` method determines what fields appear in your JSON response. The `this.pick()` helper selects specific fields from the model. Let's expand our transformer to include the fields we want.
```ts title="app/transformers/post_transformer.ts"
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Post from '#models/post'
export default class PostTransformer extends BaseTransformer {
toObject() {
return this.pick(this.resource, [
'id',
'title',
'content',
'createdAt',
'updatedAt'
])
}
}
```
This transformer explicitly includes only these five fields. Any other fields on the Post model (like internal metadata or sensitive data) will be excluded from the output.
:::
:::step{title="Use the transformer in your controller"}
Now let's use this transformer in a controller to return data. Use `node ace make:controller posts` command to create a controller, if it does not already exist.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ serialize }: HttpContext) {
const posts = await Post.all()
return serialize(PostTransformer.transform(posts))
}
async show({ serialize, params }: HttpContext) {
const post = await Post.findOrFail(params.id)
return serialize(PostTransformer.transform(post))
}
}
```
The pattern is straightforward: call `PostTransformer.transform()` with your data, then wrap the result in the `serialize()` helper from the HTTP context. The `serialize()` function handles the actual JSON conversion and sends the response.
The same transformer works for both a single post and a collection of posts. AdonisJS automatically detects whether you're transforming one item or many and structures the output accordingly.
:::
:::step{title="Register routes"}
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('posts', [controllers.Posts, 'index'])
router.get('posts/:id', [controllers.Posts, 'show'])
```
:::
:::step{title="Understanding the generated types"}
When you start your development server with `node ace serve --hmr`, AdonisJS automatically generates TypeScript types for your transformers. These types are stored in `.adonisjs/client/data.d.ts`.
```ts title=".adonisjs/client/data.d.ts"
import type { InferData, InferVariants } from '@adonisjs/core/types/transformers'
import type PostTransformer from '#transformers/post_transformer'
export namespace Data {
export type Post = InferData
export namespace Post {
export type Variants = InferVariants
}
}
```
Your frontend code can now import and use these types. In Inertia applications, there's a pre-configured alias that makes this convenient.
```ts
import { Data } from '~/generated/data'
type Post = Data.Post
```
This means your frontend automatically knows the exact shape of data coming from your API. If you add or remove fields in your transformer, the TypeScript types update automatically when the dev server reloads. You never need to manually maintain duplicate type definitions.
:::
::::
## Resource items and collections
Transformers handle both single resources and collections automatically. When you call `PostTransformer.transform()`, it returns either a `ResourceItem` or `ResourceCollection` depending on what you pass in.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async show({ serialize, params }: HttpContext) {
const post = await Post.findOrFail(params.id)
// Returns a ResourceItem (single post)
return serialize(PostTransformer.transform(post))
}
async index({ serialize }: HttpContext) {
const posts = await Post.all()
// Returns a ResourceCollection (array of posts)
return serialize(PostTransformer.transform(posts))
}
}
```
The serialized output structure differs slightly between items and collections. A single item returns your transformed object directly, while a collection wraps the items in a `data` array. Both go through the same `toObject()` method you defined in your transformer.
## Paginating data
When working with large datasets, you'll typically paginate results. Lucid's query builder provides a `paginate()` method that returns both the data rows and pagination metadata. Transformers have a dedicated method for handling paginated responses.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ serialize, request }: HttpContext) {
const page = request.input('page', 1)
const posts = await Post.query().paginate(page, 20)
const data = posts.all()
const metadata = posts.getMeta()
return serialize(PostTransformer.paginate(data, metadata))
}
}
```
The `PostTransformer.paginate()` method takes two arguments: the array of data to transform and the pagination metadata. The resulting JSON response includes both the transformed data and the pagination information.
```json
{
"data": [
{
"id": 1,
"title": "First Post",
"content": "...",
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
],
"metadata": {
"total": 100,
"perPage": 20,
"currentPage": 1,
"lastPage": 5,
"firstPage": 1,
"firstPageUrl": "/?page=1",
"lastPageUrl": "/?page=5",
"nextPageUrl": "/?page=2",
"previousPageUrl": null
}
}
```
Your frontend can use the `metadata` object to build pagination controls while the `data` array contains the transformed posts.
## Using transformers with Inertia
When building Inertia applications, you can pass transformer resources directly to `inertia.render()` without using the `serialize()` helper. The Inertia adapter automatically handles serialization of `ResourceItem` and `ResourceCollection` objects.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ inertia }: HttpContext) {
const posts = await Post.all()
return inertia.render('posts/index', {
posts: PostTransformer.transform(posts)
})
}
async show({ inertia, params }: HttpContext) {
const post = await Post.findOrFail(params.id)
return inertia.render('posts/show', {
post: PostTransformer.transform(post)
})
}
}
```
All transformer features work identically with Inertia — including variants, relationships, and custom data. Throughout the rest of this guide, we'll use the `serialize()` helper in examples, but the same principles apply when using `inertia.render()`.
## Working with relationships
Transformers can include related data by composing with other transformers. Each entity in your application should have its own transformer, and these transformers can reference each other when including relationships.
### Basic relationship inclusion
First, let's create a transformer for the User model.
```ts title="app/transformers/user_transformer.ts"
import { BaseTransformer } from '@adonisjs/core/transformers'
import type User from '#models/user'
export default class UserTransformer extends BaseTransformer {
toObject() {
return this.pick(this.resource, [
'id',
'fullName',
'email',
'createdAt',
'updatedAt',
'initials'
])
}
}
```
Now you can include the post's author in the PostTransformer by using the UserTransformer.
```ts title="app/transformers/post_transformer.ts"
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Post from '#models/post'
import UserTransformer from '#transformers/user_transformer'
export default class PostTransformer extends BaseTransformer {
toObject() {
return {
...this.pick(this.resource, [
'id',
'title',
'content',
'createdAt',
'updatedAt'
]),
// [!code highlight]
author: UserTransformer.transform(this.resource.author)
}
}
}
```
Relationships can only appear as top-level properties in your transformer output. You compose transformers by calling their `transform()` method with the related model instance.
:::note{title="Eager-load relationships"}
Transformers do not issue any database queries. They only work with data you've already loaded. If you forget to eager-load a relationship and try to transform it, you'll access `undefined` data and get a runtime error.
:::
### Conditional relationships
Sometimes a relationship might or might not be loaded depending on the request context. For example, you might only eager-load the author for specific endpoints. In these cases, you need to guard against `undefined` values explicitly.
The `this.whenLoaded()` helper checks if a relationship has been loaded before attempting to transform it.
```ts title="app/transformers/post_transformer.ts"
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Post from '#models/post'
import UserTransformer from '#transformers/user_transformer'
export default class PostTransformer extends BaseTransformer {
toObject() {
return {
...this.pick(this.resource, [
'id',
'title',
'content',
'createdAt',
'updatedAt'
]),
// [!code highlight:3]
author: UserTransformer.transform(
this.whenLoaded(this.resource.author)
)
}
}
}
```
Now if the `author` relationship hasn't been loaded, the transformer won't throw an error. The `author` field will simply be omitted from the output. You can also use a ternary operator if you prefer more explicit control.
```ts
author: this.resource.author
? UserTransformer.transform(this.resource.author)
: undefined
```
### Controlling relationship depth
By default, transformers serialize relationships up to one level deep. This prevents accidentally over-fetching nested data that your frontend might not need. For example, if a User has Posts and each Post has Comments, only the first level (User → Posts) would be serialized by default.
You can control this depth manually using the `.depth()` method.
```ts title="app/transformers/user_transformer.ts"
toObject() {
return {
...this.pick(this.resource, ['id', 'fullName', 'email']),
posts: PostTransformer
.transform(this.resource.posts)
// [!code highlight]
.depth(2) // Now serializes user → posts → comments
}
}
```
With `.depth(2)`, if you eager-load nested relationships, they'll be included in the transformation. This gives you fine-grained control over how deep the serialization goes for each relationship.
**How depth works across nested transformers**:
- The depth value you set at the top level controls the entire relationship tree
- In the example above, when `UserTransformer` sets `.depth(2)` on posts, that depth applies to all nested relationships within posts — including comments
- Even if `PostTransformer` has its own depth settings, they won't override the depth set by the parent transformer
- Depth is determined at the starting point and cascades down through the entire tree
## Using variants
A single transformer can produce different output shapes for different contexts. This is useful when the same resource needs to be displayed differently across your application — for example, a lightweight version for list views and a detailed version for single-item views.
### Defining variants
Variants are defined by creating additional methods in your transformer alongside `toObject()`. These methods can be named anything, but we recommend a consistent convention like `for` to make their intent clear.
```ts title="app/transformers/post_transformer.ts"
import type Post from '#models/post'
import UserTransformer from '#transformers/user_transformer'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class PostTransformer extends BaseTransformer {
/**
* Default variant for listing posts
*/
toObject() {
return {
...this.pick(this.resource, ['id', 'title', 'createdAt', 'updatedAt']),
author: UserTransformer.transform(this.resource.author)
}
}
/**
* Detailed variant for showing a single post
* Includes the full content with markdown converted to HTML
*/
async forDetailedView() {
return {
...this.toObject(),
content: await markdownToHtml(this.resource.content)
}
}
}
```
The default `toObject()` method returns basic fields suitable for displaying a list of posts. The `forDetailedView()` variant extends this with additional computed data—in this case, converting markdown content to HTML. Notice that variant methods can be async if they need to perform asynchronous operations.
You can reuse the default variant by calling `this.toObject()` and spreading its result, then adding or overriding specific fields. This keeps your variants DRY and ensures consistency.
### Using variants in controllers
To use a variant, call the `.useVariant()` method on the transformed resource.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
/**
* List all posts using the default variant
*/
async index({ serialize }: HttpContext) {
const posts = await Post.query().preload('author')
return serialize(PostTransformer.transform(posts))
}
/**
* Show a single post using the detailed variant
*/
async show({ serialize, params }: HttpContext) {
const post = await Post.query()
.where('id', params.id)
.preload('author')
.firstOrFail()
// [!code highlight:3]
return serialize(
PostTransformer.transform(post).useVariant('forDetailedView')
)
}
}
```
The `.useVariant()` method takes the name of the variant method as a string. When the response is serialized, it will call `forDetailedView()` instead of the default `toObject()`.
### Variant types in the frontend
The generated TypeScript types include definitions for all your variants. Your frontend code can specify which variant type it expects.
```ts
import { Data } from '~/generated/data'
import { InertiaProps } from '~/types'
export default function ShowPost(
props: InertiaProps<{
post: Data.Post.Variants['forDetailedView']
}>
) {
// TypeScript knows this post includes the 'content' field
// from the forDetailedView variant
}
```
Access variant types using `Data..Variants['']`. This ensures your frontend components receive properly typed props that match the exact shape returned by your API for that specific variant.
## Dependency injection
Transformer methods can inject dependencies from AdonisJS's IoC container using the `@inject()` decorator. This is useful when you need access to services or context information during transformation.
### Injecting HttpContext
A common use case is injecting the `HttpContext` to access the currently authenticated user. This allows you to compute authorization permissions or user-specific data during transformation.
```ts title="app/transformers/post_transformer.ts"
import type Post from '#models/post'
import { inject } from '@adonisjs/core'
import { HttpContext } from '@adonisjs/core/http'
import UserTransformer from '#transformers/user_transformer'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class PostTransformer extends BaseTransformer {
toObject() {
return {
...this.pick(this.resource, [
'id',
'title',
'createdAt',
'updatedAt'
]),
author: UserTransformer.transform(this.resource.author)
}
}
/**
* Detailed variant with authorization checks
*/
// [!code highlight:2]
@inject()
async forDetailedView({ auth }: HttpContext) {
return {
...this.toObject(),
content: await markdownToHtml(this.resource.content),
can: {
view: true,
edit: auth.user?.id === this.resource.userId,
delete: auth.user?.id === this.resource.userId
}
}
}
}
```
The `@inject()` decorator tells AdonisJS to resolve dependencies for this method. You can destructure properties from the injected `HttpContext`, such as `auth`, `request`, or any other context property you need.
### How dependency injection works
When you call `PostTransformer.transform(post)`, it returns a `ResourceItem` or `ResourceCollection` object. These objects don't immediately execute your transformer methods. Instead, when you pass them to `serialize()`, that's when the IoC container resolves dependencies and calls your transformer methods with the injected values.
This means dependency injection happens automatically during the serialization phase in your controller. You don't need to manually pass the `HttpContext` or other dependencies—the framework handles this for you.
## Passing custom data to transformers
Sometimes you need to pass additional context to your transformer beyond the resource being serialized. For example, you might want to include user-specific data like whether the current user has liked a post, or configuration that affects how the data is transformed.
You can pass custom data to a transformer by accepting additional parameters in the transformer's constructor. These parameters come after the resource parameter in the `transform()` method.
```ts title="app/transformers/post_transformer.ts"
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Post from '#models/post'
export default class PostTransformer extends BaseTransformer {
constructor(
resource: Post,
protected likedPostsIds: number[]
) {
super(resource)
}
toObject() {
return {
...this.pick(this.resource, [
'id',
'title',
'createdAt',
'updatedAt'
]),
isLiked: this.likedPostsIds.includes(this.resource.id)
}
}
}
```
When calling the transformer, pass the custom data as additional arguments after the resource.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ serialize, auth }: HttpContext) {
const posts = await Post.all()
// Get the IDs of posts the current user has liked
const likedPosts = await auth.user!
.related('likedPosts')
.query()
.select('id')
return serialize(
PostTransformer.transform(posts, likedPosts.map(({ id }) => id))
)
}
}
```
Since the custom data is stored as an instance property, it's automatically available in all variant methods as well. This pattern works seamlessly with dependency injection, variants, and all other transformer features.
## Important distinctions
Before you start using transformers in your application, it's important to understand what they are and when to use them.
### Transformers are not DTOs
It's important to understand that transformers are not Data Transfer Objects (DTOs) for request validation. They serve a different purpose:
- **DTOs define input contracts**: They validate and shape data coming into your application from requests
- **Transformers define output contracts**: They shape data going out from your application in responses
Don't use transformers to validate or transform request data. Use AdonisJS's validation system for that purpose. Transformers are exclusively for serializing your backend data structures into JSON responses for clients.
### When to use transformers
Use transformers in any situation where your backend returns JSON data to a client:
- **Inertia applications**: Full-stack TypeScript apps where both frontend and backend live in the same codebase
- **REST APIs**: APIs consumed by separate frontend applications (React, Vue, Angular SPAs)
- **Mobile APIs**: Backends serving mobile applications
- **Any JSON response**: Anywhere you're sending structured data over HTTP
The generated TypeScript types are most useful in Inertia applications or full-stack TypeScript monorepos where your frontend can directly import types from your backend. Mobile clients typically won't leverage the generated types, but they still benefit from the consistent, well-shaped JSON responses that transformers provide.
---
# Type-safe API client
This guide covers Tuyau, a type-safe HTTP client for AdonisJS applications. You will learn how to:
- Install and configure Tuyau for Inertia and monorepo setups
- Make type-safe API calls using route names
- Handle request parameters, validation, and error responses
- Work with file uploads
- Generate URLs programmatically
- Understand type-level serialization for end-to-end type safety
## Overview
Tuyau is a type-safe HTTP client that enables end-to-end type safety between your AdonisJS backend and frontend application. Instead of manually writing API client code and managing types, Tuyau automatically generates a fully typed client based on your routes, controllers, and validators.
The key benefit of Tuyau is eliminating the gap between your backend API definition and frontend consumption. When you define a route with validation in AdonisJS, Tuyau ensures your frontend calls use the exact same types for request bodies, query parameters, route parameters, and response data. This means TypeScript will catch errors at compile time rather than discovering them at runtime.
Tuyau works by analyzing your AdonisJS routes and generating a registry that maps route names to their types. Your frontend imports this registry and uses it to make type-safe API calls. Every parameter, every field in your request body, and every property in your response is fully typed and autocompleted in your IDE.
The library is built on top of [Ky](https://github.com/sindresorhus/ky), a modern fetch wrapper, which means you get all of Ky's features like automatic retries, timeout handling, and request/response hooks while maintaining full type safety.
## Installation
Tuyau installation differs depending on whether you're using Inertia (single repository) or a monorepo setup with separate frontend and backend applications.
### Inertia applications
For Inertia applications, installation is straightforward since your frontend and backend live in the same repository. **Official starter kits for [React](https://github.com/adonisjs/starter-kits/tree/main/inertia-react) and [Vue](https://github.com/adonisjs/starter-kits/tree/main/inertia-vue) come pre-configured with Tuyau, hence no manual setup is required.**
::::steps
:::step{title="Install the package"}
```bash
npm install @tuyau/core
```
:::
:::step{title="Configure the assembler hook"}
The assembler hook automatically generates the Tuyau registry whenever your codebase changes. Add the `generateRegistry` hook to your `adonisrc.ts` file. The `indexEntities` hook indexes your models and transformers for type generation, `indexPages` indexes your Inertia page components, and `generateRegistry` generates the Tuyau registry files in the `.adonisjs/client` directory.
```ts title="adonisrc.ts"
import { indexPages } from '@adonisjs/inertia'
import { indexEntities } from '@adonisjs/core'
import { defineConfig } from '@adonisjs/core/app'
import { generateRegistry } from '@tuyau/core/hooks'
export default defineConfig({
// ... other config
hooks: {
// [!code highlight:5]
init: [
indexEntities({ transformers: { enabled: true, withSharedProps: true } }),
indexPages({ framework: 'react' }),
generateRegistry(),
],
},
})
```
:::
:::step{title="Configure TypeScript paths"}
Configure path aliases in your Inertia `tsconfig.json` to import the generated registry.
```json title="inertia/tsconfig.json"
{
"compilerOptions": {
// ... other options
"paths": {
"~/*": ["./*"],
// [!code highlight]
"@generated/*": ["../.adonisjs/client/*"]
}
}
}
```
:::
:::step{title="Configure Vite aliases"}
Add matching aliases to your `vite.config.ts`.
```ts title="vite.config.ts"
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import adonisjs from '@adonisjs/vite/client'
import inertia from '@adonisjs/inertia/vite'
export default defineConfig({
plugins: [
react(),
inertia({ ssr: { enabled: false, entrypoint: 'inertia/ssr.tsx' } }),
adonisjs({ entrypoints: ['inertia/app.tsx'], reload: ['resources/views/**/*.edge'] }),
],
resolve: {
alias: {
'~/': `${import.meta.dirname}/inertia/`,
// [!code highlight]
'@generated': `${import.meta.dirname}/.adonisjs/client/`,
},
},
})
```
:::
:::step{title="Create the Tuyau client"}
Create a file to initialize your Tuyau client.
```ts title="inertia/client.ts"
import { registry } from '@generated/registry'
import { createTuyau } from '@tuyau/core/client'
export const client = createTuyau({
baseUrl: '/',
registry,
})
export const urlFor = client.urlFor
```
The `baseUrl` is set to `'/'` since the frontend and backend are served from the same origin in an Inertia application.
:::
::::
### Monorepo applications
For monorepo setups where your frontend and backend are separate packages, the setup requires additional configuration to share types between workspaces.
This guide assumes you're using npm workspaces with Turborepo (as used by the [API Starter Kit](https://github.com/adonisjs/api-starter-kit)), but the concepts apply to other monorepo tools like pnpm or Yarn workspaces with slight variations in syntax.
::::steps
:::step{title="Structure your monorepo"}
Organize your monorepo with separate workspaces for your API and frontend application.
```text title="Directory structure"
my-app/
├── apps/
│ ├── backend/ # AdonisJS backend
│ └── frontend/ # Frontend (React, Vue, etc)
└── package.json
```
:::
:::step{title="Install Tuyau in the backend"}
Install `@tuyau/core` in your backend workspace. It handles both the assembler hook (registry generation) and exposes the client for your frontend to import.
```json title="apps/backend/package.json"
{
"name": "@my-app/backend",
"private": true,
"type": "module",
"dependencies": {
"@tuyau/core": "^1.0.0" // [!code highlight]
}
}
```
Then, in your frontend workspace, add your backend as a workspace dependency so it can import the generated registry and the Tuyau client.
```json title="apps/frontend/package.json"
{
"name": "@my-app/frontend",
"private": true,
"type": "module",
"dependencies": {
"@my-app/backend": "*" // [!code highlight]
}
}
```
The `"*"` version range tells npm to resolve `@my-app/backend` from your local workspace. Make sure the package name matches the `name` field in your backend's `package.json`.
:::
:::step{title="Enable experimental decorators"}
Tuyau uses TypeScript decorators internally. Enable them in your frontend `tsconfig.json`. You also need to include your backend source files so TypeScript can resolve the shared types during type-checking.
```json title="apps/frontend/tsconfig.json"
{
"compilerOptions": {
"experimentalDecorators": true, // [!code highlight]
// ... other options
},
"include": [
"./**/*.ts",
"./**/*.tsx",
// [!code highlight:2]
"../backend/**/*.ts",
"../backend/.adonisjs/**/*.ts"
],
"exclude": [
"node_modules",
// [!code highlight:2]
"../backend/build",
"../backend/node_modules"
]
}
```
:::
:::step{title="Configure the backend"}
In your backend AdonisJS application, add the `generateRegistry` hook just like in the Inertia setup.
```ts title="apps/backend/adonisrc.ts"
import { indexEntities } from '@adonisjs/core'
import { defineConfig } from '@adonisjs/core/app'
import { generateRegistry } from '@tuyau/core/hooks'
export default defineConfig({
hooks: {
// [!code highlight:4]
init: [
indexEntities({ transformers: { enabled: true } }),
generateRegistry(),
],
},
})
```
:::
:::step{title="Export the registry"}
Configure your backend `package.json` to export the generated Tuyau files so your frontend can import them.
```json title="apps/backend/package.json"
{
"name": "@my-app/backend",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { // [!code highlight]
"./registry": "./.adonisjs/client/registry/index.ts", // [!code highlight]
"./data": "./.adonisjs/client/data.d.ts" // [!code highlight]
} // [!code highlight]
}
```
These exports allow your frontend to import the registry using `@my-app/backend/registry`.
:::
:::step{title="Create the Tuyau client"}
In your frontend, create a file to initialize Tuyau.
```ts title="apps/frontend/src/lib/client.ts"
import { createTuyau } from '@tuyau/core/client'
import { registry } from '@my-app/backend/registry'
export const client = createTuyau({
baseUrl: import.meta.env.VITE_API_URL || 'http://localhost:3333',
registry,
headers: { Accept: 'application/json' },
hooks: {
beforeRequest: [
(request) => {
const token = localStorage.getItem('auth_token')
if (token) {
request.headers.set('Authorization', `Bearer ${token}`)
}
}
]
}
})
```
The `baseUrl` should use an environment variable so you can configure different API URLs for development and production environments.
:::
::::
:::tip{title="Stuck somewhere?"}
Check out this [monorepo starter kit](https://github.com/Julien-R44/adonis-starter-kit) which uses TanStack for the frontend, alongside Tuyau for a Type-safe API client
:::
## Your first API call
Let's build a complete example showing how Tuyau provides end-to-end type safety from your backend route to your frontend API call.
::::steps
:::step{title="Define the backend route"}
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.post('posts', [controllers.Posts, 'store'])
```
The route name `posts.store` is automatically derived from the controller name and action. This is what you'll use to call the endpoint from your frontend.
:::
:::step{title="Create the validator"}
Define validation rules using [VineJS](../basics/validation.md).
```ts title="app/validators/post.ts"
import vine from '@vinejs/vine'
export const createPostValidator = vine.create({
title: vine.string().minLength(3).maxLength(255),
content: vine.string().minLength(10),
published: vine.boolean().optional(),
})
```
:::
:::step{title="Implement the controller"}
Create a controller action that uses the validator. The call to `request.validateUsing()` is essential for Tuyau to understand the shape of your request body and provide accurate types on the frontend.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import Post from '#models/post'
import { createPostValidator } from '#validators/post'
export default class PostsController {
async store({ request }: HttpContext) {
const payload = await request.validateUsing(createPostValidator)
const post = await Post.create(payload)
return post
}
}
```
:::
:::step{title="Make the API call from your frontend"}
Import your Tuyau client and call the route using its name.
```ts title="src/pages/posts/create.tsx"
import { client } from '~/client'
async function handleCreatePost() {
const post = await client.api.posts.store({
body: {
title: 'My first blog post',
content: 'This is the content of the blog post',
published: true,
},
})
console.log('Post created:', post)
}
```
Notice how the route name `posts.store` becomes a method chain `client.api.posts.store()`. The `body` parameter is fully typed based on your validator. Your IDE will autocomplete the fields and TypeScript will catch any mistakes.
:::
::::
## Making API calls
Tuyau provides three different ways to make API calls, each suited for different use cases. All three approaches provide full type safety, but they differ in syntax and flexibility.
### Using route names with proxy syntax
The recommended approach is using route names with the proxy syntax. Route names map directly to method chains on your Tuyau client.
```ts
// Route: router.post('register', [controllers.Auth, 'register'])
const result = await client.api.auth.register({
body: { email: 'foo@ok.com', password: 'password123' }
})
// Route: router.get('users/:id', [controllers.Users, 'show'])
const user = await client.api.users.show({
params: { id: '1' },
query: { include: 'posts' }
})
```
Each segment of the route name becomes a property access. The route `users.show` becomes `client.api.users.show()`. This syntax provides excellent autocomplete and keeps your code clean.
:::note
Route name segments that contain underscores are converted to camelCase in the proxy syntax. For example, a route named `auth.new_account.store` becomes `client.api.auth.newAccount.store()`.
:::
### Using the request method
The `request` method provides an alternative syntax that explicitly passes the route name as a string.
```ts
const result = await client.request('auth.register', {
body: { email: 'foo@ok.com', password: 'password123' }
})
const user = await client.request('users.show', {
params: { id: '1' },
query: { include: 'posts' }
})
```
This approach is functionally identical to the proxy syntax but provides a different API for constructing the request URL.
### Using HTTP method functions
Sometimes you need to call endpoints that are not part of your AdonisJS backend, for example a third-party API or a legacy service. In these cases, you can use HTTP method functions that accept URLs directly.
```ts
const user = await client.get('/users/:id', {
params: { id: '123' },
query: { include: 'posts' }
})
const post = await client.post('/posts', {
body: { title: 'Hello', content: 'World' }
})
const updated = await client.patch('/posts/:id', {
params: { id: '456' },
body: { title: 'Updated title' }
})
```
This syntax mirrors the fetch API but maintains type safety for parameters and responses.
## Working with parameters
API calls often require different types of parameters: route parameters for dynamic URL segments, query parameters for filtering or pagination, and request bodies for data submission. Tuyau handles all of these with full type safety.
### Route parameters
Route parameters substitute dynamic segments in your URLs. When you define a route with parameters, Tuyau automatically types them.
```ts title="start/routes.ts"
router.get('users/:id', [controllers.Users, 'show'])
router.get('users/:userId/posts/:postId', [controllers.Posts, 'show'])
```
Pass route parameters using the `params` option. TypeScript will enforce that you provide all required parameters with the correct names, and your IDE will autocomplete parameter names and catch typos at compile time.
```ts
// Single parameter
const user = await client.api.users.show({
params: { id: '123' }
})
// Multiple parameters
const post = await client.api.users.posts.show({
params: { userId: '123', postId: '456' }
})
```
### Query parameters
Query parameters append to the URL for filtering, pagination, or passing optional data. Use the `query` option to pass them. Query parameters are automatically URL-encoded, and if your backend validates query parameters, those types are inferred on the frontend.
```ts
// Route: GET /posts
const posts = await client.api.posts.index({
query: {
page: 1,
limit: 10,
status: 'published'
}
})
// Results in: GET /posts?page=1&limit=10&status=published
```
### Request body
For POST, PUT, and PATCH requests, send data using the `body` option. The request body types are automatically inferred from your validator. Every field is typed, and TypeScript will prevent you from sending fields that don't exist in your validator or with incorrect types.
```ts
const post = await client.api.posts.store({
body: {
title: 'My First Post',
content: 'This is the content',
published: true
}
})
```
### Combining parameters
You can combine route parameters, query parameters, and body in a single request. Tuyau handles building the complete URL, encoding query parameters, and serializing the body while maintaining type safety for all three parameter types.
```ts
const comment = await client.api.posts.comments.store({
params: { postId: '123' },
query: { notify: true },
body: {
content: 'Great post!',
author: 'John Doe'
}
})
```
## Request validation and type inference
The connection between your backend validators and frontend types is what makes Tuyau's type safety possible. Understanding how this works is crucial for getting the most out of Tuyau.
### The role of request.validateUsing()
For Tuyau to infer types from your validators, you must use `request.validateUsing()` in your controller actions. Without it, Tuyau cannot determine what shape your request body should have, and your frontend types will fall back to `any`.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import { createPostValidator } from '#validators/post'
export default class PostsController {
async store({ request }: HttpContext) {
const payload = await request.validateUsing(createPostValidator)
const post = await Post.create(payload)
return post
}
}
```
### Defining validators
Use [VineJS](../basics/validation.md) to define validation schemas. Every field you define becomes part of the type signature on the frontend.
```ts title="app/validators/post.ts"
import vine from '@vinejs/vine'
export const createPostValidator = vine.create({
title: vine.string().minLength(3).maxLength(255),
content: vine.string().minLength(10),
published: vine.boolean().optional(),
categoryId: vine.number(),
tags: vine.array(vine.string()).optional(),
})
```
On your frontend, the `body` parameter will have this exact shape. TypeScript will enforce required fields, prevent extra fields, and ensure correct types for each property.
```ts
await client.api.posts.store({
body: {
title: 'My Post', // string (required)
content: 'Content here', // string (required)
published: true, // boolean (optional)
categoryId: 1, // number (required)
tags: ['news', 'tech'] // string[] (optional)
}
})
```
### Query parameter validation
Query parameters can also be validated and typed. Define a validator for query parameters and use it in your controller. The frontend query parameters will be typed to match your validator, so TypeScript only allows valid values.
```ts title="app/validators/post.ts"
export const listPostsValidator = vine.create({
page: vine.number().optional(),
limit: vine.number().optional(),
status: vine.enum(['draft', 'published']).optional(),
search: vine.string().optional(),
})
```
```ts title="app/controllers/posts_controller.ts"
export default class PostsController {
async index({ request }: HttpContext) {
const filters = await request.validateUsing(listPostsValidator)
const posts = await Post.query()
.where('status', filters.status)
.paginate(filters.page || 1, filters.limit || 10)
return posts
}
}
```
```ts
const posts = await client.api.posts.index({
query: {
page: 1,
limit: 20,
status: 'published', // Only 'draft' or 'published' allowed
search: 'typescript'
}
})
```
## Error handling
Tuyau supports both throwing and non-throwing error handling.
By default, requests behave like regular promises. Successful responses resolve to the response payload, while failed requests throw. HTTP failures throw a `TuyauHTTPError`, and transport failures such as DNS issues, refused connections, or offline states throw a `TuyauNetworkError`.
### Using `.safe()`
If you prefer not to throw, call `.safe()` on the request. It returns a tuple where the first element is the data and the second element is the error. The error is always typed as `TuyauError`, giving you a single error shape for both HTTP and network failures.
```ts
const [data, error] = await client.api.posts.show({
params: { id: '123' }
}).safe()
if (error) {
console.log(error.message)
return
}
console.log(data.title)
```
### Narrowing HTTP errors with `isStatus()`
When your controller returns typed non-2xx responses, Tuyau automatically extracts those error payloads from the controller's return type and makes them available on the client. Use `isStatus()` to narrow the error to a specific status code. After `error.isStatus(404)`, TypeScript narrows `error.response` to the exact payload shape returned by `response.notFound()` in the controller.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async show({ params, response }: HttpContext) {
const post = await Post.find(params.id)
if (!post) {
return response.notFound({ message: 'Post not found', key: 'post_not_found' })
}
return response.ok({ post })
}
}
```
```ts
const [data, error] = await client.api.posts.show({
params: { id: '123' }
}).safe()
if (error?.isStatus(404)) {
// error.response is narrowed to { message: string, key: string } (inferred from the controller)
console.log(error.response.message)
console.log(error.response.key)
return
}
console.log(data.post.title)
```
### Validation errors
Routes that use `request.validateUsing()` automatically get a typed 422 error response. The type uses `SimpleError` from `@vinejs/vine/types`. Use `isValidationError()` as a shorthand for `isStatus(422)`.
```ts
const [data, error] = await client.api.posts.store({
body: { title: '', content: '' }
}).safe()
if (error?.isValidationError()) {
// error.response is typed as { errors: SimpleError[] }
for (const err of error.response.errors) {
console.log(err.field, err.message)
}
}
```
You can customize the error type or disable it via the `validationErrorType` option in the `generateRegistry` hook in case your API returns a different shape for validation errors.
```ts title="adonisrc.ts"
generateRegistry({
validationErrorType: '{ errors: { path: string; message: string }[] }',
// or set to `false` to disable
})
```
### Distinguishing HTTP and network failures
The `TuyauError` type includes a `kind` property. Use it when you need to treat network failures differently from server responses.
```ts
const [, error] = await client.api.posts.show({
params: { id: '123' }
}).safe()
if (error?.kind === 'network') {
console.log('The server is unreachable')
}
```
For network failures, `status` and `response` are `undefined` because the server did not send a response.
### Using try/catch
If you prefer the throwing flow, cast the caught error using `Route.Error` to get full type narrowing.
```ts
import type { Route } from '@tuyau/core/types'
try {
await client.api.posts.show({ params: { id: '123' } })
} catch (e) {
const error = e as Route.Error<'posts.show'>
if (error.isStatus(404)) {
// error.response is narrowed to the 404 payload from the controller
console.log(error.response.message)
return
}
if (error.kind === 'network') {
console.log('The server is unreachable')
}
}
```
## Retrieving typings
Tuyau provides two type helper namespaces, `Path` and `Route`, that let you extract request, response, and error types from your API definition. This is useful when you need to type a variable, a function parameter, or a return type based on your API schema.
Both helpers are imported from `@tuyau/core/types` and expose the same utilities: `Request`, `Response`, `Error`, `Params`, `Body`, and `Query`. `Route` extracts types by route name, while `Path` extracts types by HTTP method and URL pattern.
```ts
import type { Route, Path } from '@tuyau/core/types'
// By route name
type StoreRequest = Route.Request<'posts.store'>
type StoreResponse = Route.Response<'posts.store'>
type ShowError = Route.Error<'posts.show'>
type ShowParams = Route.Params<'posts.show'>
type StoreBody = Route.Body<'posts.store'>
type IndexQuery = Route.Query<'posts.index'>
// By HTTP method + URL pattern
type LoginRequest = Path.Request<'POST', '/auth/login'>
type LoginResponse = Path.Response<'POST', '/auth/login'>
type LoginError = Path.Error<'POST', '/auth/login'>
type UserParams = Path.Params<'GET', '/users/:id'>
type LoginBody = Path.Body<'POST', '/auth/login'>
type PostsQuery = Path.Query<'GET', '/posts'>
```
The `Error` helper resolves to `TuyauError`, which means it models both HTTP and network failures while still supporting `isStatus()` for HTTP error narrowing.
## File uploads
Tuyau automatically handles file uploads by detecting File objects in your request body and switching to FormData encoding. You don't need to manually construct FormData or change content types.
### Basic file upload
When you pass a File object in your request body, Tuyau converts the entire payload to FormData automatically. Other fields like `description` are included in the same FormData payload.
```ts title="src/pages/profile.tsx"
import { client } from '~/client'
async function uploadAvatar(file: File) {
const result = await client.api.users.avatar.update({
body: {
avatar: file,
description: 'My new avatar'
}
})
}
// In your component
function handleFileSelect(event: ChangeEvent) {
const file = event.target.files?.[0]
if (file) {
uploadAvatar(file)
}
}
```
:::note
When Tuyau detects a File object in the body, it converts the entire payload to FormData. A few things to keep in mind:
- **Field names must match your validator.** The keys in the `body` object (e.g., `avatar`, `description`) become FormData field names, which must match the corresponding keys in your VineJS validator.
- **You can mix files and regular fields.** Scalar values like strings and numbers are included in the same FormData payload alongside file fields.
- **Optional file fields.** If a file field is optional in your validator, simply omit the key from the body. Do not send `undefined` or `null`, as these may be serialized as literal strings in FormData.
:::
### Backend handling
On the backend, handle file uploads using AdonisJS's standard file validation.
```ts title="app/validators/user.ts"
import vine from '@vinejs/vine'
export const updateAvatarValidator = vine.create({
avatar: vine.file({
size: '2mb',
extnames: ['jpg', 'png', 'jpeg'],
}),
description: vine.string().optional(),
})
```
```ts title="app/controllers/users_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import { updateAvatarValidator } from '#validators/user'
export default class UsersController {
async updateAvatar({ request, auth }: HttpContext) {
const { avatar, description } = await request.validateUsing(updateAvatarValidator)
// Move the file to storage
await avatar.move('uploads/avatars', {
name: `${auth.user!.id}.${avatar.extname}`
})
return { success: true }
}
}
```
### Multiple file uploads
Upload multiple files by including multiple File objects in your payload. Tuyau handles the FormData serialization for arrays of files automatically.
```ts
const result = await client.api.posts.attachments.create({
params: { postId: '123' },
body: {
files: [file1, file2, file3],
visibility: 'public'
}
})
```
## Generating URLs
Tuyau provides the `urlFor` helper to generate URLs from route names in a type-safe way. This is useful when you need URLs for links, redirects, or sharing, rather than making an actual API call.
### Basic URL generation
The `urlFor` method searches across all HTTP methods and returns the URL as a string. TypeScript ensures you provide the correct route name and required parameters. Invalid route names or missing parameters are caught at compile time.
```ts
import { urlFor } from '~/client'
// Generate URL for a named route
const logoutUrl = urlFor('auth.logout')
// Returns: '/logout'
const profileUrl = urlFor('users.profile', { id: '123' })
// Returns: '/users/123/profile'
```
### Method-specific URL generation
For more control, use method-specific variants like `urlFor.get` or `urlFor.post`. These return an object containing both the HTTP method and URL.
```ts
const userUrl = urlFor.get('users.show', { id: 1 })
// Returns: { method: 'get', url: '/users/1' }
const createUserUrl = urlFor.post('users.store')
// Returns: { method: 'post', url: '/users' }
```
This is useful when you need to know both the URL and which HTTP method should be used, for example when building generic link components.
### Query parameters in URLs
Add query parameters to generated URLs using the `qs` option. Query parameters are automatically URL-encoded and appended to the generated URL.
```ts
const postsUrl = urlFor.get('posts.index', {}, {
qs: { page: 2, limit: 10, status: 'published' }
})
// Returns: { method: 'get', url: '/posts?page=2&limit=10&status=published' }
```
### Wildcard parameters
For routes with wildcard parameters, pass them as arrays.
```ts
// Route: router.get('docs/*', [controllers.Docs, 'show'])
const docsUrl = urlFor.get('docs.show', { '*': ['introduction', 'getting-started'] })
// Returns: { method: 'get', url: '/docs/introduction/getting-started' }
```
### Positional parameters
Instead of an object, you can pass parameters as an array in the order they appear in the route.
```ts
// Route: /users/:id/posts/:postId
// Using object syntax
const url1 = urlFor.get('users.posts.show', { id: '123', postId: '456' })
// Using array syntax (positional)
const url2 = urlFor.get('users.posts.show', ['123', '456'])
// Both return: { method: 'get', url: '/users/123/posts/456' }
```
Positional parameters can be convenient when parameter names are obvious from context.
## Route introspection
Tuyau provides two methods for inspecting routes at runtime. These are useful for building navigation components that highlight active links or conditionally rendering UI based on the available routes in your application.
### Checking if a route exists
The `has()` method checks whether a route name exists in the registry.
```ts
client.has('users.show') // true
client.has('auth.login') // true
client.has('nope') // false
```
This is useful for conditionally rendering UI elements based on whether a route is available in the current application.
### Getting the current route
The `current()` method uses `window.location` to determine which route the user is currently on. It only matches navigable routes (GET or HEAD).
**Without arguments**, it returns the current route name, or `undefined` if no route matches (or when running server-side).
```ts
// On /users/42
client.current() // 'users.show'
// On /unknown/path
client.current() // undefined
```
**With a route name**, it returns `true` if the current URL matches that route.
```ts
// On /users/42
client.current('users.show') // true
client.current('auth.login') // false
```
**With wildcard patterns**, you can match groups of routes using `*`:
```ts
// On /users/42
client.current('users.*') // true
client.current('posts.*') // false
```
**With options**, you can additionally verify that the current URL params and/or query string match expected values.
```ts
// On /users/42?foo=bar
client.current('users.show', { params: { id: 42 } }) // true
client.current('users.show', { params: { id: 99 } }) // false
client.current('users.show', { query: { foo: 'bar' } }) // true
client.current('users.show', { query: { foo: 'baz' } }) // false
```
## Type-level serialization
An important concept to understand when working with Tuyau is type-level serialization. This refers to how types are automatically transformed to match what actually gets sent over the network as JSON.
### Date serialization
When you pass a Date object from your backend to the frontend, it cannot be transmitted as a Date object through JSON. Instead, it's serialized to a string. Tuyau's types automatically reflect this transformation.
```ts title="app/controllers/posts_controller.ts"
export default class PostsController {
async show({ params }: HttpContext) {
const post = await Post.find(params.id)
return {
id: post.id,
title: post.title,
createdAt: new Date() // This is a Date object here
}
}
}
```
On the frontend, Tuyau automatically infers the type as `string`, not `Date`. This is because dates are serialized to ISO string format when sent over HTTP, and Tuyau's type system reflects this reality at compile time.
```ts
const post = await client.api.posts.show({ params: { id: '1' } })
// TypeScript knows createdAt is a string, not a Date
console.log(post.createdAt.toUpperCase()) // ✅ Works - string method
console.log(post.createdAt.getTime()) // ❌ Error - Date method doesn't exist
```
### Model serialization
A common mistake is returning [Lucid models](../database/lucid.md) directly from your controllers. When you do this, Tuyau cannot accurately infer the response types because models serialize to a generic `ModelObject` type that contains almost no useful type information.
```ts title="❌ Problematic - returns a model directly"
export default class PostsController {
async show({ params }: HttpContext) {
const post = await Post.find(params.id)
return post // Model is serialized, but types are lost
}
}
```
On the frontend, you'll get a generic `ModelObject` type with no specific fields.
```ts
const post = await client.api.posts.show({ params: { id: '1' } })
// post has type ModelObject - no autocomplete, no type safety
```
To maintain type safety, explicitly transform your models using [HTTP Transformers](./transformers.md) to plain objects before returning them.
```ts title="✅ Better - explicit serialization"
export default class PostsController {
async show({ params, serialize }: HttpContext) {
const post = await Post.find(params.id)
return serialize(PostTransformer.transform(post))
}
}
```
Now the frontend has accurate types.
```ts
const post = await client.api.posts.show({ params: { id: '1' } })
// post.title is string
// post.author.name is string
// Full autocomplete and type safety
```
## SuperJSON
As described above, when data crosses the HTTP boundary, rich JavaScript types like `Date`, `BigInt`, `Map`, or `Set` are lost — they get serialized to strings or plain objects by `JSON.stringify()`. [SuperJSON](https://github.com/flightcontrolhq/superjson) solves this by automatically preserving type information during serialization, so you receive proper typed values on the frontend instead of plain strings.
The `@tuyau/superjson` package provides both a server-side middleware and a client-side plugin that work together transparently.
### Installation
::::steps
:::step{title="Install and configure the package"}
```bash
node ace add @tuyau/superjson
```
This registers the SuperJSON middleware in your application. The middleware intercepts responses and serializes them with type metadata, then deserializes incoming request bodies on the server.
:::
:::step{title="Add the client plugin"}
On the frontend, add the `superjson` plugin when creating your Tuyau client.
```ts
import { superjson } from '@tuyau/superjson/plugin'
const client = createTuyau({
baseUrl: 'http://localhost:3333',
registry,
// [!code highlight]
plugins: [superjson()],
})
```
:::
::::
That's it for the basic setup. Native JavaScript types like `Date`, `BigInt`, `Map`, `Set`, `RegExp`, and `URL` are now automatically preserved across HTTP calls.
### Custom recipes
SuperJSON only handles native JavaScript types out of the box. If your application uses custom classes — like Luxon `DateTime` which is used by Lucid models for date fields — SuperJSON won't recognize them and they will end up as plain strings on the frontend.
You can teach SuperJSON how to handle any custom type by registering a recipe with three things: an `isApplicable` check to identify the type, a `serialize` function to convert it to a JSON-safe value, and a `deserialize` function to reconstruct it on the other side.
Since recipes must be registered on both the server and client, they should live in a shared file imported by both sides.
Here's an example with Luxon `DateTime`, which is the most common case in AdonisJS applications.
::::steps
:::step{title="Create a shared recipes file"}
```ts title="app/superjson_recipes.ts"
import { DateTime } from 'luxon'
import SuperJSON from 'superjson'
SuperJSON.registerCustom(
{
isApplicable: (v) => DateTime.isDateTime(v),
serialize: (v) => v.toISO()!,
deserialize: (v) => DateTime.fromISO(v),
},
'DateTime'
)
```
:::
:::step{title="Register as a server preload"}
Add the recipes file to your preloads so it runs when the server starts.
```ts title="adonisrc.ts"
export default defineConfig({
preloads: [
() => import('#start/routes'),
() => import('#start/kernel'),
// [!code highlight]
() => import('#app/superjson_recipes'),
],
})
```
:::
:::step{title="Import on the client side"}
Import the same file where you create your Tuyau client, so the recipes are registered before any API calls.
```ts title="inertia/client.ts"
import '#app/superjson_recipes'
import { superjson } from '@tuyau/superjson/plugin'
const client = createTuyau({
baseUrl: 'http://localhost:3333',
registry,
plugins: [superjson()],
})
```
:::
::::
### Type-level integration with transformers
When using custom SuperJSON recipes with [HTTP Transformers](./transformers.md), you need to tell the transformer type system that certain types should not be converted to strings at the type level. Without this, TypeScript would still infer your `DateTime` fields as `string` in the frontend response types, even though SuperJSON preserves them at runtime.
Add a module augmentation in your API provider to extend the allowed JSON types.
```ts title="providers/api_provider.ts"
declare module '@adonisjs/core/types/transformers' {
interface ExtendedJSONTypes {
DateTime: import('luxon').DateTime
}
}
```
With this augmentation, transformers that return `DateTime` values will preserve the `DateTime` type in the generated frontend types instead of converting it to `string`.
## Response parsing
By default, Tuyau parses responses based on the `Content-Type` header: JSON for `application/json`, `ArrayBuffer` for `application/octet-stream`, and text for everything else.
You can override this per-request with the `responseType` option (`'json'`, `'text'`, `'arrayBuffer'`, or `'blob'`).
```ts
const blob = await client.api.files.download({
params: { id: '123' },
responseType: 'blob',
})
```
## Configuration reference
The `createTuyau` function accepts several configuration options to customize how your API client behaves.
```ts
const client = createTuyau({
baseUrl: import.meta.env.VITE_API_URL || 'http://localhost:3333',
registry,
})
```
::::options
:::option{name="baseUrl" dataType="string" required}
The base URL of your API server. All requests are prefixed with this URL. Use environment variables to configure different URLs for development and production.
:::
:::option{name="registry" dataType="object" required}
The generated registry that maps route names to URLs and types. Import this from the generated files in `.adonisjs/client` or from your backend package in a monorepo setup.
:::
::::
### Recommended options
These optional settings are highly recommended for most applications.
```ts
const client = createTuyau({
baseUrl: 'http://localhost:3333',
registry,
headers: { Accept: 'application/json' },
credentials: 'include',
})
```
::::options
:::option{name="headers" dataType="object"}
Default headers sent with every request. Setting `Accept: 'application/json'` ensures your API returns JSON responses rather than HTML error pages or other formats.
```ts
headers: {
Accept: 'application/json',
'X-Custom-Header': 'value'
}
```
:::
:::option{name="credentials" dataType="string"}
Controls whether cookies are sent with cross-origin requests. Set to `'include'` to send cookies for session-based authentication where your frontend and backend are on different domains.
```ts
credentials: 'include'
```
:::
::::
:::note
When `credentials: 'include'` is set, Tuyau automatically handles CSRF protection. It reads the `XSRF-TOKEN` cookie and sends it as an `X-XSRF-TOKEN` header with every request. No extra configuration is needed.
:::
### Advanced options
Tuyau is built on top of [Ky](https://github.com/sindresorhus/ky), which means you can pass any Ky option to `createTuyau`. Some useful advanced options include:
::::options
:::option{name="timeout" dataType="number"}
Request timeout in milliseconds. Requests that exceed this duration are automatically aborted.
```ts
const client = createTuyau({
baseUrl: 'http://localhost:3333',
registry,
timeout: 30000, // 30 seconds
})
```
:::
:::option{name="retry" dataType="number | object"}
Configure automatic retry behavior for failed requests.
```ts
const client = createTuyau({
baseUrl: 'http://localhost:3333',
registry,
retry: {
limit: 3,
methods: ['get', 'post'],
statusCodes: [408, 413, 429, 500, 502, 503, 504]
}
})
```
:::
:::option{name="hooks" dataType="object"}
Add request/response interceptors for logging, authentication, or error handling.
```ts
const client = createTuyau({
baseUrl: 'http://localhost:3333',
registry,
hooks: {
beforeRequest: [
request => {
console.log('Request:', request.url)
}
],
afterResponse: [
(request, options, response) => {
console.log('Response:', response.status)
}
],
beforeError: [
error => {
console.error('Error:', error.message)
return error
}
]
}
})
```
:::
::::
### Access token authentication
For APIs that use access token authentication (like the [API Starter Kit](https://github.com/adonisjs/api-starter-kit)), use the `hooks.beforeRequest` option to dynamically attach the `Authorization` header to every request.
```ts
const client = createTuyau({
baseUrl: 'http://localhost:3333',
registry,
headers: { Accept: 'application/json' },
hooks: {
beforeRequest: [
(request) => {
const token = localStorage.getItem('auth_token')
if (token) {
request.headers.set('Authorization', `Bearer ${token}`)
}
}
]
}
})
```
This pattern reads the token from storage before each request. You can adapt it to read from a different source (e.g., a state management store or cookie) depending on where your application stores the token after login.
For a complete list of available options, see the [Ky documentation](https://github.com/sindresorhus/ky#options).
### Filtering routes
By default, `generateRegistry` includes all named routes. Use the `routes` option to include or exclude specific routes from the generated registry.
```ts title="adonisrc.ts"
generateRegistry({
routes: {
only: ['api.*'], // substring match
// or
except: [/^admin\./, (name) => name.startsWith('internal.')],
},
})
```
Filters accept strings (substring match), RegExp, or functions. You cannot use `only` and `except` together.
## Related resources
Tuyau integrates with several parts of the AdonisJS ecosystem and provides additional packages for specific use cases.
### Inertia integration
If you're using Inertia, Tuyau provides enhanced type safety for Inertia-specific features. The `@adonisjs/inertia` package exports a `` component that enables type-safe routing and other cool features.
```tsx
import { TuyauProvider } from '@adonisjs/inertia/react'
import { client } from '~/client'
function App() {
return (
Login
)
}
```
The `` component's `route` prop is fully typed. TypeScript ensures you use valid route names and provide required parameters. See the [Inertia documentation](https://docs.adonisjs.com/guides/inertia) for complete details on this integration and additional features.
### TanStack Query integration
The `@tuyau/tanstack-query` package provides React hooks that integrate Tuyau with TanStack Query (formerly React Query) for data fetching, caching, and state management. See the [TanStack Query guide](./tanstack_query.md) for instructions on setting up and using these hooks in your React components.
### Starter kits
Rather than setting up Tuyau manually, consider using one of these starter kits with Tuyau pre-configured:
- **[React Starter Kit](https://github.com/adonisjs/react-starter-kit)** - Official AdonisJS starter with React, Inertia, and Tuyau ready to use
- **[Vue Starter Kit](https://github.com/adonisjs/vue-starter-kit)** - Official AdonisJS starter with Vue, Inertia, and Tuyau ready to use
- **[Monorepo Starter Kit](https://github.com/Julien-R44/adonis-starter-kit)** - Complete monorepo setup with separate frontend and backend packages
---
# TanStack Query Integration
This guide covers the TanStack Query integration for Tuyau. You will learn how to:
- Install and configure `@tuyau/react-query` or `@tuyau/vue-query`
- Generate type-safe query and mutation options
- Implement infinite scrolling with pagination
- Manage cache invalidation at different levels of granularity
- Handle errors from failed API calls
## Overview
The `@tuyau/react-query` and `@tuyau/vue-query` packages provide seamless integration between Tuyau and [TanStack Query](https://tanstack.com/query). Instead of creating custom hooks or composables, Tuyau generates type-safe options objects that you pass directly to TanStack Query's standard primitives like `useQuery`, `useMutation`, and `useInfiniteQuery`.
This approach gives you complete control over TanStack Query's features while maintaining end-to-end type safety. Query keys are automatically generated based on your route names and parameters, and cache invalidation becomes straightforward and type-safe. The integration works exclusively with route names, ensuring that your API calls remain decoupled from URL structures.
Both adapters share the same API surface. The only differences are framework-specific imports and component syntax.
## Prerequisites
Before using the TanStack Query integration, you must have Tuyau installed and configured in your application. Follow the [Tuyau installation guide](./api_client.md) to set up your Tuyau client first.
You should be familiar with:
- [TanStack Query basics](https://tanstack.com/query/latest/docs/framework/react/overview) (understanding queries, mutations, and cache management)
- Tuyau route names and API calls
## Installation
Install the TanStack Query integration package in your frontend application.
::::tabs
:::tab{title="React"}
```bash
npm install @tanstack/react-query @tuyau/react-query
```
:::
:::tab{title="Vue"}
```bash
npm install @tanstack/vue-query @tuyau/vue-query
```
:::
::::
:::note
Make sure `@tuyau/react-query` (or `@tuyau/vue-query`) and `@tuyau/core` are on compatible versions. If you see type errors after installing, check that both packages share the same major and prerelease tag (e.g., both on `1.x` or both on `next`).
:::
## Setup
Create your Tuyau client with TanStack Query integration. The `api` object provides access to all your routes with type-safe query and mutation options. The `client` object is the core Tuyau client, and `queryClient` is the standard TanStack Query client used for cache management and invalidation.
::::tabs
:::tab{title="React"}
```ts title="src/lib/client.ts"
import { registry } from '~registry'
import { createTuyau } from '@tuyau/core/client'
import { QueryClient } from '@tanstack/react-query'
import { createTuyauReactQueryClient } from '@tuyau/react-query'
export const queryClient = new QueryClient()
export const client = createTuyau({ baseUrl: import.meta.env.VITE_API_URL, registry })
export const api = createTuyauReactQueryClient({ client })
```
:::
:::tab{title="Vue"}
```ts title="src/lib/client.ts"
import { registry } from '~registry'
import { createTuyau } from '@tuyau/core/client'
import { QueryClient } from '@tanstack/vue-query'
import { createTuyauVueQueryClient } from '@tuyau/vue-query'
export const queryClient = new QueryClient()
export const client = createTuyau({ baseUrl: import.meta.env.VITE_API_URL, registry })
export const api = createTuyauVueQueryClient({ client })
```
:::
::::
### Retry behavior
Tuyau is built on [Ky](https://github.com/sindresorhus/ky), which has automatic retry enabled by default for failed requests. When using the TanStack Query integration, Ky's retry mechanism is automatically disabled to let TanStack Query handle retries instead, since it also has built-in retry functionality.
This prevents double retries (Ky retrying, then TanStack Query retrying on top) and gives you full control over retry behavior through TanStack Query's configuration.
```ts
const postsQuery = useQuery(
api.posts.index.queryOptions(
{},
{
retry: 3, // TanStack Query handles retries
}
)
)
```
## Basic queries
Use `queryOptions()` to generate options for TanStack Query's `useQuery` hook. All queries use route names rather than URLs. The response data is fully typed based on your backend controller's return value, so TypeScript knows the exact shape of the data without any manual type annotations.
::::tabs
:::tab{title="React"}
```tsx title="src/pages/posts.tsx"
import { useQuery } from '@tanstack/react-query'
import { api } from '~/lib/client'
export default function PostsList() {
const postsQuery = useQuery(
api.posts.index.queryOptions()
)
if (postsQuery.isLoading) return
```
:::
::::
## Conditional queries with skipToken
Use `skipToken` to conditionally disable a query while preserving type safety. This is cleaner than using the `enabled` option for conditional fetching because the query function signature stays the same whether or not the query is active. When `skipToken` is passed, TanStack Query skips the query entirely. Once the value becomes truthy, the query fetches automatically.
::::tabs
:::tab{title="React"}
```tsx title="src/pages/user.tsx"
import { useQuery, skipToken } from '@tanstack/react-query'
import { api } from '~/lib/client'
export default function UserProfile({ userId }: { userId: string | null }) {
const userQuery = useQuery(
api.users.show.queryOptions(
userId ? { params: { id: userId } } : skipToken
)
)
return
```
:::
::::
## Mutations
Use `mutationOptions()` to generate options for TanStack Query's `useMutation` hook. The method accepts standard TanStack Query mutation options like `onSuccess`, `onError`, and `onSettled`. All mutation parameters (`params`, `body`) are fully typed based on your backend validator.
::::tabs
:::tab{title="React"}
```tsx title="src/pages/posts/create.tsx"
import { useMutation } from '@tanstack/react-query'
import { api, queryClient } from '~/lib/client'
export default function CreatePost() {
const createPost = useMutation(
api.posts.store.mutationOptions({
onSuccess: () => {
/**
* Invalidate the posts list query after creating a post.
* This causes the list to refetch with the new post included.
*/
queryClient.invalidateQueries({
queryKey: api.posts.list.pathKey()
})
}
})
)
const handleSubmit = (data: { title: string; content: string }) => {
createPost.mutate({
body: {
title: data.title,
content: data.content,
authorId: 1
}
})
}
return (
)
}
```
:::
:::tab{title="Vue"}
```vue title="src/pages/posts/CreatePost.vue"
```
:::
::::
## Infinite queries
For pagination and infinite scrolling, use `infiniteQueryOptions()` with TanStack Query's `useInfiniteQuery`. This requires coordination between your frontend query configuration and backend validation.
### Frontend configuration
The `pageParamKey` option specifies which query parameter holds the page number. This must match the parameter name in your backend validator.
::::tabs
:::tab{title="React"}
```tsx title="src/pages/posts.tsx"
import { useInfiniteQuery } from '@tanstack/react-query'
import { api } from '~/lib/client'
export default function InfinitePosts() {
const postsQuery = useInfiniteQuery(
api.posts.list.infiniteQueryOptions(
{
query: {
limit: 10,
search: 'typescript'
}
},
{
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.meta.nextPage,
pageParamKey: 'page',
}
)
)
const allPosts = postsQuery.data?.pages.flatMap(page => page.posts) || []
return (
```
:::
::::
### Backend validation
Define a validator that includes the pagination parameter referenced by `pageParamKey`. The `page` parameter in the validator must match the `pageParamKey` value in your frontend configuration. Tuyau automatically handles passing the page parameter from TanStack Query's pagination system to your backend.
```ts title="app/validators/post.ts"
import vine from '@vinejs/vine'
export const listPostsValidator = vine.create({
page: vine.number().optional(),
limit: vine.number().optional(),
search: vine.string().optional(),
})
```
### Backend controller
Implement pagination in your controller using the validated parameters. The `getNextPageParam` function in your frontend checks `lastPage.meta.nextPage` to determine if more pages exist. Return `null` when there are no more pages to load.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import { listPostsValidator } from '#validators/post'
export default class PostsController {
async list({ request, serialize }: HttpContext) {
const { page = 1, limit = 10, search } = await request.validateUsing(listPostsValidator)
const posts = await Post.query()
.if(search, (query) => query.where('title', 'like', `%${search}%`))
.paginate(page, limit)
return {
posts: await serialize(PostTransformer.transform(posts.all())),
meta: {
currentPage: posts.currentPage,
lastPage: posts.lastPage,
nextPage: posts.hasNextPage ? posts.currentPage + 1 : null
}
}
}
}
```
### How infinite queries work
When the component mounts, TanStack Query calls your API with `page: 1` (the `initialPageParam`). The response includes both the data and metadata about pagination. The `getNextPageParam` function examines this metadata to determine what page to fetch next.
When the user clicks "Load More", TanStack Query automatically calls your API again with the next page number, appending the results to the existing data. Tuyau handles injecting the page parameter into your query string transparently.
## Reactive queries (Vue)
In Vue, you often need queries to re-fetch when reactive state changes. Wrap your `queryOptions()` call in a getter function so TanStack Query automatically tracks dependencies and re-evaluates when they change.
This works because Vue Query's `useQuery` accepts a `MaybeRefOrGetter`. When you pass a getter function, TanStack Query calls it inside a `computed`, which tracks all reactive dependencies accessed during evaluation. When `search.value` changes, the computed re-evaluates, producing new query options with an updated query key and query function.
```vue
{{ post.title }}
```
:::note
This pattern is not needed in React, where component re-renders naturally cause `queryOptions()` to be called again with fresh values.
:::
## Cache invalidation
Tuyau provides multiple methods for cache invalidation with different levels of granularity. These methods work identically in React and Vue.
### queryKey() - Exact match
Use `queryKey()` when you know exactly which query needs to be invalidated and you have all its parameters available.
```ts
const updatePost = useMutation(
api.posts.update.mutationOptions({
onSuccess: (data, variables) => {
/**
* Invalidate only the specific post that was updated.
* This is the most precise invalidation strategy.
*/
queryClient.invalidateQueries({
queryKey: api.posts.show.queryKey({
params: { id: variables.params.id }
})
})
}
})
)
```
### pathKey() - Base path
Use `pathKey()` when you want to invalidate a specific endpoint without parameters, such as list queries that don't depend on route parameters.
```ts
const deletePost = useMutation(
api.posts.delete.mutationOptions({
onSuccess: () => {
/**
* Invalidate all queries for this exact path.
* This invalidates posts.list but not posts.show.
*/
queryClient.invalidateQueries({
queryKey: api.posts.list.pathKey()
})
}
})
)
```
### pathFilter() - Subtree matching
The `pathFilter()` method is particularly useful when a mutation might affect multiple related queries and you want to invalidate all of them at once.
```ts
const createProduct = useMutation(
api.products.store.mutationOptions({
onSuccess: () => {
/**
* Invalidate all product-related queries across any route.
* This catches products.search, products.list, products.show,
* products.byCategory, and any other product routes.
*/
queryClient.invalidateQueries(
api.products.pathFilter()
)
}
})
)
```
### queryFilter() - Custom filtering
Use `queryFilter()` with a predicate function for fine-grained control over which queries to invalidate. The predicate function receives the query state and can inspect cached data to make invalidation decisions.
```ts
const archivePost = useMutation(
api.posts.archive.mutationOptions({
onSuccess: () => {
/**
* Invalidate only queries where the post is marked as active.
* Use the predicate to inspect the cached data and decide
* whether to invalidate based on custom logic.
*/
const filter = api.posts.pathFilter({
predicate: (query) => {
const data = query.state.data
return data?.post?.status === 'active'
},
})
queryClient.invalidateQueries(filter)
}
})
)
```
## Error handling
When an API call fails, Tuyau throws an `HTTPError` (from [Ky](https://github.com/sindresorhus/ky)) that TanStack Query captures and exposes through its standard error state.
### Accessing errors in queries and mutations
TanStack Query surfaces errors through `isError` and `error` on the result object.
```tsx
const postsQuery = useQuery(api.posts.index.queryOptions())
if (postsQuery.isError) {
console.log(postsQuery.error.message)
}
```
### Inspecting HTTP status codes
The `error` object is a Ky `HTTPError` with a `response` property. Use it to inspect the status code.
```ts
import { HTTPError } from 'ky'
const postsQuery = useQuery(api.posts.index.queryOptions())
if (postsQuery.isError && postsQuery.error instanceof HTTPError) {
const status = postsQuery.error.response.status
if (status === 401) {
window.location.href = '/login'
}
}
```
### Global error handler
Rather than checking status codes in every component, configure a global handler using the `queryClient` default options. This prevents retrying unauthorized requests and lets you handle 401s consistently across your application.
```ts title="src/lib/client.ts"
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (error instanceof HTTPError && error.response.status === 401) {
return false // Don't retry on 401
}
return failureCount < 3
},
},
},
})
```
---
# Vite
This guide covers frontend asset bundling with Vite in AdonisJS. You will learn how to:
- Install and configure the Vite integration
- Define entrypoints and reference assets in Edge templates
- Process static assets like images and fonts
- Configure TypeScript for frontend code
- Enable Hot Module Replacement with React
- Deploy bundled assets to a CDN
## Overview
Vite is a modern frontend build tool that provides fast development server startup and instant hot module replacement. AdonisJS embeds Vite directly into the development server rather than running it as a separate process. This embedded approach means you manage a single server during development, and AdonisJS can access Vite's runtime API directly for features like server-side rendering.
The integration handles the complexity of connecting Vite with a backend framework. During development, AdonisJS proxies asset requests to Vite through middleware. In production, AdonisJS reads the manifest file that Vite generates to resolve the correct paths for bundled assets.
The official `@adonisjs/vite` package provides Edge helpers and tags for generating asset URLs, a dedicated Vite plugin that simplifies configuration, and access to the Vite Runtime API for server-side rendering.
See also: [Vite documentation](https://vitejs.dev/)
## Installation
Run the following command to install and configure the package. This installs both `@adonisjs/vite` and `vite`, then creates the necessary configuration files.
```sh
node ace add @adonisjs/vite
```
:::disclosure{title="See steps performed by the configure command"}
1. Registers the following service provider inside the `adonisrc.ts` file.
```ts
{
providers: [
// ...other providers
() => import('@adonisjs/vite/vite_provider')
]
}
```
2. Creates `vite.config.ts` and `config/vite.ts` configuration files.
3. Creates the frontend entry point file at `resources/js/app.js`.
:::
After installation, add the following to your `adonisrc.ts` file to integrate Vite with the build process.
```ts title="adonisrc.ts"
import { defineConfig } from '@adonisjs/core/build/standalone'
export default defineConfig({
// [!code ++:3]
hooks: {
buildStarting: [() => import('@adonisjs/vite/build_hook')],
},
})
```
The `assetsBundler` property disables the default asset bundler management in AdonisJS Assembler. The `hooks` property registers the Vite build hook to execute the Vite build process when you run `node ace build`.
See also: [Assembler hooks](../concepts/assembler_hooks.md)
## Configuration
The setup process creates two configuration files. The `vite.config.ts` file configures the Vite bundler itself, while `config/vite.ts` configures how AdonisJS interacts with Vite on the backend.
### Vite configuration
The `vite.config.ts` file is a standard Vite configuration file. You can install and register additional Vite plugins here based on your project requirements.
The AdonisJS plugin accepts the following options.
```ts title="vite.config.ts"
import { defineConfig } from 'vite'
import adonisjs from '@adonisjs/vite/client'
export default defineConfig({
plugins: [
adonisjs({
/**
* Entry point files for your frontend code. Each entry point
* produces a separate output bundle. You can define multiple
* entry points for different parts of your application.
*/
entrypoints: ['resources/js/app.js'],
/**
* Glob patterns for files that trigger a browser reload when
* changed. Useful for template files that Vite doesn't track.
*/
reload: ['resources/views/**/*.edge'],
}),
]
})
```
| Option | Description | Default |
|--------|-------------|---------|
| `entrypoints` | Array of entry point files for your frontend code. Each entry point produces a separate bundle. | Required |
| `buildDirectory` | Relative path to the output directory. Passed to Vite as `build.outDir`. | `public/assets` |
| `reload` | Array of glob patterns for files that trigger browser reload on change. | `[]` |
| `assetsUrl` | URL prefix for asset links in production. Set this to your CDN URL when deploying assets to a CDN. | `/assets` |
:::tip
If you change `buildDirectory`, you must update the same value in `config/vite.ts` to keep both configurations in sync.
:::
### AdonisJS configuration
The `config/vite.ts` file tells AdonisJS where to find Vite's build output and how to generate asset URLs.
```ts title="config/vite.ts"
import { defineConfig } from '@adonisjs/vite'
export default defineConfig({
/**
* Path to Vite's build output directory. Must match the
* buildDirectory option in vite.config.ts.
*/
buildDirectory: 'public/assets',
/**
* URL prefix for asset links. Set to your CDN URL in production
* if you deploy assets to a CDN.
*/
assetsUrl: '/assets',
})
```
| Option | Description |
|--------|-------------|
| `buildDirectory` | Path to Vite's build output directory. Must match the value in `vite.config.ts`. |
| `assetsUrl` | URL prefix for asset links in production. Set to your CDN URL when deploying assets to a CDN. |
| `scriptAttributes` | Key-value pairs of attributes to add to script tags generated by the `@vite` tag. |
| `styleAttributes` | Key-value pairs of attributes to add to link tags generated by the `@vite` tag. |
You can add custom attributes to the generated script and link tags.
```ts title="config/vite.ts"
import { defineConfig } from '@adonisjs/vite'
export default defineConfig({
buildDirectory: 'public/assets',
assetsUrl: '/assets',
// [!code ++:4]
scriptAttributes: {
defer: true,
},
})
```
For conditional attributes based on the asset being loaded, pass a function instead.
```ts title="config/vite.ts"
import { defineConfig } from '@adonisjs/vite'
export default defineConfig({
buildDirectory: 'public/assets',
assetsUrl: '/assets',
// [!code ++:7]
styleAttributes: ({ src, url }) => {
if (src === 'resources/css/admin.css') {
return {
'data-turbo-track': 'reload'
}
}
}
})
```
## Folder structure
AdonisJS does not enforce a specific folder structure for frontend assets. However, we recommend storing them in the `resources` directory with subdirectories for each asset type.
```
resources
├── css
│ └── app.css
├── js
│ └── app.js
├── fonts
└── images
```
Vite outputs bundled files to `public/assets` by default. The `/assets` subdirectory keeps Vite output separate from other static files in the `public` folder that you may not want Vite to process.
## Starting the development server
Start your application with the `--hmr` flag to enable Hot Module Replacement. AdonisJS automatically proxies asset requests to the embedded Vite server.
```sh
node ace serve --hmr
```
**Hot Module Replacement (HMR)** allows Vite to update modules in the browser without a full page reload. When you edit a CSS file or a JavaScript module, the changes appear instantly while preserving application state.
## Including entrypoints in templates
Use the `@vite` Edge tag to render script and link tags for your entrypoints. The tag accepts an array of entry point paths and generates the appropriate HTML tags.
```edge title="resources/views/layouts/main.edge"
@vite(['resources/js/app.js'])
@!section('content')
```
We recommend importing CSS files inside your JavaScript entry point rather than registering them as separate entrypoints. This approach lets Vite handle CSS processing and hot replacement automatically.
```ts title="resources/js/app.js"
/**
* Import CSS in your JavaScript entry point. Vite processes
* the CSS and handles hot replacement automatically.
*/
import '../css/app.css'
```
## Referencing assets in templates
Vite builds a dependency graph of files imported by your entry points and updates their paths in the bundled output. However, Vite cannot detect assets referenced only in Edge templates since it does not parse template files.
Use the `asset` helper to create URLs for files that Vite processes. During development, the helper returns a URL pointing to the Vite dev server. In production, it returns the path to the bundled file with the content hash in the filename.
```edge title="resources/views/pages/home.edge"
```
```html
```
```html
```
## Processing static assets
Vite ignores static assets that are not imported by your frontend code. Images, fonts, and icons referenced only in Edge templates fall into this category.
To include these assets in the build, use Vite's glob import API in your entry point file. This tells Vite to process the matched files even though they are not directly imported.
```ts title="resources/js/app.js"
import '../css/app.css'
/**
* Tell Vite to process all images in the resources/images directory.
* Without this, images referenced only in templates would be missing
* from the production build.
*/
import.meta.glob(['../images/**'])
```
After adding the glob import, you can reference these images in your templates using the `asset` helper.
```edge title="resources/views/pages/home.edge"
```
## TypeScript configuration
If you use TypeScript for frontend code, create a separate `tsconfig.json` inside the `resources` directory. Vite and your code editor will use this configuration for TypeScript files within the `resources` directory.
```json title="resources/tsconfig.json"
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"lib": ["DOM"],
"paths": {
"@/*": ["./js/*"]
}
}
}
```
If you use React, add the `jsx` option.
```json title="resources/tsconfig.json"
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"lib": ["DOM"],
"jsx": "preserve",
"paths": {
"@/*": ["./js/*"]
}
}
}
```
## React with Hot Module Replacement
To enable React Fast Refresh during development, add the `@viteReactRefresh` Edge tag before the `@vite` tag in your layout.
```edge title="resources/views/layouts/main.edge"
@viteReactRefresh()
@vite(['resources/js/app.js'])
```
Then configure the React plugin in your Vite configuration.
```ts title="vite.config.ts"
import { defineConfig } from 'vite'
import adonisjs from '@adonisjs/vite/client'
// [!code ++:1]
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
adonisjs({
entrypoints: ['resources/js/app.js'],
}),
// [!code ++:1]
react(),
],
})
```
## Deploying assets to a CDN
To serve bundled assets from a CDN in production, configure the `assetsUrl` option in both configuration files. This ensures that URLs in the manifest file and lazy-loaded chunks point to your CDN server.
```ts title="vite.config.ts"
import { defineConfig } from 'vite'
import adonisjs from '@adonisjs/vite/client'
export default defineConfig({
plugins: [
adonisjs({
entrypoints: ['resources/js/app.js'],
reload: ['resources/views/**/*.edge'],
// [!code ++:1]
assetsUrl: 'https://cdn.example.com/',
}),
]
})
```
```ts title="config/vite.ts"
import { defineConfig } from '@adonisjs/vite'
export default defineConfig({
buildDirectory: 'public/assets',
// [!code ++:1]
assetsUrl: 'https://cdn.example.com/',
})
```
After building your application with `node ace build`, upload the contents of `public/assets` to your CDN.
## Common issues
### Assets not loading in production
If assets fail to load in production, verify that Vite has generated the manifest file at `public/assets/.vite/manifest.json`. The manifest maps source files to their bundled output paths.
If the manifest is missing, ensure the build hook is registered in `adonisrc.ts` and run `node ace build` again.
### Static images missing from build
Images and other static assets referenced only in templates are not automatically included in the build. Vite only processes files that are imported by your JavaScript code.
Add a glob import to your entry point to include static assets.
```ts title="resources/js/app.js"
import '../css/app.css'
// [!code ++:2]
// Include all images in the build
import.meta.glob(['../images/**', '../fonts/**'])
```
### HMR not working
Hot Module Replacement requires the `--hmr` flag when starting the dev server.
```sh
node ace serve --hmr
```
If HMR still does not work, check your browser console for WebSocket connection errors. Firewall or proxy configurations may block the HMR WebSocket connection.
## Middleware mode
With version 3.x, Vite runs in middleware mode. Rather than spawning Vite as a separate process with its own server, AdonisJS embeds Vite and proxies matching requests through middleware.
The advantages of middleware mode include direct access to the Vite Runtime API for server-side rendering and a single development server to manage. All assets are served through AdonisJS rather than a separate Vite process.
See also: [Vite SSR documentation](https://vitejs.dev/guide/ssr#setting-up-the-dev-server)
## Manifest file
When you build for production, Vite generates a manifest file alongside the bundled assets. The manifest is a JSON file that maps source file paths to their bundled output paths, including content hashes.
AdonisJS reads this manifest to resolve asset URLs. When you call the `asset` helper or use the `@vite` tag in production, AdonisJS looks up the file in the manifest and returns the correct bundled path.
The manifest file is located at `public/assets/.vite/manifest.json` by default.
See also: [Vite backend integration](https://vitejs.dev/guide/backend-integration.html)
---
# Lucid - SQL ORM
This guide covers Lucid ORM, the official database ORM for AdonisJS. You will learn how to:
- Configure database connections
- Use the query builder and models
- Create migrations and define relationships
- Work with transactions and hooks
- Serialize models and generate test data
## Overview
Lucid ORM is an Active Record ORM built on top of Knex and deeply integrated within the AdonisJS ecosystem. Unlike standalone ORMs that require extensive configuration, Lucid works seamlessly with AdonisJS features like the validator, authentication layer, caching, rate-limiting, and queues without any additional setup.
Lucid simplifies database interactions by encapsulating common operations using language-specific objects and classes. It's built on top of Knex, which means you can express complex SQL queries using a JavaScript API when needed. Lucid supports multiple databases including MySQL, PostgreSQL, Turso, SQLite, and MSSQL. The class-based model system makes your code intuitive and type-safe, while built-in support for relationships lets you model complex data structures. The migration system provides version control for your database schema, and seeders and factories help you populate databases with test data.
This guide provides a high-level overview of Lucid's features to help you understand what's available and how the pieces fit together. For detailed API references, advanced patterns, and comprehensive documentation on specific features, refer to the [official Lucid documentation](https://lucid.adonisjs.com).
## Configuration
Lucid's configuration lives in the `config/database.ts` file at the root of your AdonisJS project. This file defines your database connections, migration paths, and other ORM settings.
```typescript title="config/database.ts"
import env from '#start/env'
import { defineConfig } from '@adonisjs/lucid'
const dbConfig = defineConfig({
connection: env.get('DB_CONNECTION'),
connections: {
postgres: {
client: 'pg',
connection: {
host: env.get('DB_HOST'),
port: env.get('DB_PORT'),
user: env.get('DB_USER'),
password: env.get('DB_PASSWORD'),
database: env.get('DB_DATABASE'),
},
migrations: {
naturalSort: true,
paths: ['database/migrations'],
},
},
},
})
export default dbConfig
```
The configuration specifies which database connection to use by default (typically set via environment variables), and defines the connection details for each database. Each connection includes the client library (like `pg` for PostgreSQL or `mysql2` for MySQL), connection credentials, and paths to migration files.
You can explore all available configuration options, connection pooling settings, and advanced features like read-write replicas in the [Lucid configuration documentation](https://lucid.adonisjs.com/docs/installation#configuration).
## Using the query builder directly
Before diving into models, you can use Lucid's query builder directly for database operations. The query builder provides a fluent JavaScript API for constructing SQL queries, which is particularly useful for complex queries or when you don't need the full Active Record pattern.
The query builder is available through the `db` service and works identically to Knex, since Lucid is built on top of it.
```typescript title="app/controllers/posts_controller.ts"
import db from '@adonisjs/lucid/services/db'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async index({ response }: HttpContext) {
/**
* Select all published posts ordered by creation date.
* This returns an array of plain objects.
*/
const posts = await db
.from('posts')
.select('*')
.where('status', 'published')
.orderBy('created_at', 'desc')
return response.json(posts)
}
async store({ request, response }: HttpContext) {
const { title, content } = request.only(['title', 'content'])
/**
* Insert a new post and return the generated ID.
* Insert queries return an array of IDs.
*/
const [id] = await db
.insertQuery()
.table('posts')
.insert({
title,
content,
status: 'draft',
created_at: new Date(),
updated_at: new Date(),
})
return response.created({ id })
}
}
```
The query builder handles parameterized queries automatically, protecting against SQL injection. You can use it for selects, inserts, updates, deletes, joins, aggregations, and any other SQL operation. When you need raw SQL for complex operations, you can use `db.rawQuery()`.
For the complete query builder API including joins, subqueries, aggregations, and advanced where clauses, see the [Lucid query builder documentation](https://lucid.adonisjs.com/docs/select-query-builder).
## Working with models
Models provide an object-oriented way to interact with database tables. Each model class represents a table, and each model instance represents a row. Lucid uses a migrations-first approach where you define your schema in migrations, and Lucid automatically generates TypeScript schema classes that your models extend.
### Creating your first migration
Migrations are the foundation of Lucid's schema management. They provide version control for your database schema, allowing you to evolve your schema incrementally over time. Each migration is a TypeScript file with `up` and `down` methods that define how to move the schema forward and how to roll it back.
Create a migration for a posts table:
```bash
node ace make:migration posts
```
This generates a timestamped migration file in `database/migrations/`. The timestamp ensures migrations run in the correct order.
```typescript title="database/migrations/1705234567890_create_posts_table.ts"
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'posts'
async up() {
/**
* The up method creates the table structure.
* Use the schema builder to define columns and constraints.
*/
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('title').notNullable()
table.text('content').notNullable()
table.string('status').defaultTo('draft')
table.timestamp('created_at')
table.timestamp('updated_at')
})
}
async down() {
/**
* The down method reverses the up method's changes.
* This enables rolling back migrations if needed.
*/
this.schema.dropTable(this.tableName)
}
}
```
Run the migration to create the table:
```bash
node ace migration:run
```
Lucid executes the migration, creates the `posts` table in your database, and automatically generates a schema class at `database/schema.ts` that contains type-safe column definitions.
:::tip
Migrations run inside transactions by default. If a migration fails, all changes are rolled back automatically, keeping your database in a consistent state.
:::
For more migration operations like altering tables, adding indexes, and working with foreign keys, see the [Lucid migrations documentation](https://lucid.adonisjs.com/docs/migrations).
### Auto-generated schema classes
After running migrations, Lucid scans your database and generates TypeScript schema classes that contain all column definitions with proper types. This migrations-first approach keeps your models clean and ensures your TypeScript types always match your actual database schema.
The generated schema class lives at `database/schema.ts`.
```typescript title="database/schema.ts"
import { DateTime } from 'luxon'
import { BaseModel, column } from '@adonisjs/lucid/orm'
export class PostsSchema extends BaseModel {
static table = 'posts'
@column({ isPrimary: true })
declare id: number
@column()
declare title: string
@column()
declare content: string
@column()
declare status: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
```
Lucid automatically converts database types to appropriate TypeScript types, snake_case column names to camelCase properties, and timestamp columns to Luxon DateTime objects. The `autoCreate` option means Lucid sets the timestamp when creating a record, and `autoUpdate` means it updates the timestamp on every save.
### Schema generation rules
The default schema generation can be steered by configuring schema generation rules. Make sure your database config enables generation and points to the `database/schema_rules.ts` file:
```typescript title="database/schema_rules.ts"
import { type SchemaRules } from '@adonisjs/lucid/types/schema_generator';
export default {
types: {
/**
* Customize all JSON columns globally to use a type-safe JSON wrapper
* instead of the default 'any' type.
*/
jsonb: {
decorator: '@column()',
tsType: 'JSON',
imports: [{ source: '#types/db', typeImports: ['JSON'] }],
},
},
tables: {
/**
* Customize the users table to make the user_role column
* a strict union type instead of a generic string.
*/
users: {
columns: {
user_role: {
decorators: [{ name: '@column' }],
tsType: `'admin' | 'editor'`,
},
},
},
},
} satisfies SchemaRules;
```
### Creating a model
Now create a model that extends the generated schema class:
```bash
node ace make:model Post
```
```typescript title="app/models/post.ts"
import { PostsSchema } from '#database/schema'
export default class Post extends PostsSchema {
// Your model is ready to use with all columns inherited from the schema
}
```
The model inherits all column definitions from the schema class. You'll add relationships, hooks, and custom methods here, while the schema class handles column definitions.
:::warning
Never modify the generated schema classes in `database/schema.ts` directly. These files are regenerated after every migration. Instead, override columns or add custom logic in your model classes, which extend the schema classes and persist your changes.
If you need to change column types or add columns, create a new migration. The schema classes will automatically update after running the migration.
:::
To learn more about customizing type mappings and schema generation rules, see the [Lucid schema classes documentation](https://lucid.adonisjs.com/docs/schema-classes).
### Basic CRUD operations
With your model ready, you can perform create, read, update, and delete operations using an intuitive API.
**Creating records:**
```typescript title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async store({ request, response }: HttpContext) {
/**
* Create a new post using the create method.
* Lucid automatically sets created_at and updated_at.
*/
const post = await Post.create({
title: request.input('title'),
content: request.input('content'),
status: 'draft',
})
return response.created(post)
}
}
```
**Reading records:**
```typescript title="app/controllers/posts_controller.ts"
export default class PostsController {
async index({ response }: HttpContext) {
/**
* Fetch all posts ordered by creation date.
* Returns an array of Post model instances.
*/
const posts = await Post.query().orderBy('created_at', 'desc')
return response.json(posts)
}
async show({ params, response }: HttpContext) {
/**
* Find a specific post by ID.
* Throws a 404 exception if not found.
*/
const post = await Post.findOrFail(params.id)
return response.json(post)
}
}
```
**Updating records:**
```typescript title="app/controllers/posts_controller.ts"
export default class PostsController {
async update({ params, request, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
/**
* Merge updates and save to database.
* The updated_at timestamp updates automatically.
*/
await post.merge({
title: request.input('title'),
content: request.input('content'),
}).save()
return response.json(post)
}
}
```
**Deleting records:**
```typescript title="app/controllers/posts_controller.ts"
export default class PostsController {
async destroy({ params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
/**
* Delete the post from the database.
* This triggers any delete hooks defined on the model.
*/
await post.delete()
return response.noContent()
}
}
```
For idempotent operations like `firstOrCreate`, `updateOrCreate`, and bulk operations like `createMany`, see the [Lucid CRUD operations documentation](https://lucid.adonisjs.com/docs/crud-operations).
### Accessing the query builder from models
While basic CRUD methods handle common scenarios, you'll often need more complex queries. Every model provides access to the query builder through the `query()` method, giving you full SQL flexibility while still working with model instances.
```typescript title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async published({ response }: HttpContext) {
/**
* Build a complex query using the query builder.
* Results are still Post model instances.
*/
const posts = await Post.query()
.where('status', 'published')
.whereNotNull('published_at')
.where('created_at', '>', new Date('2024-01-01'))
.orderBy('published_at', 'desc')
.limit(10)
return response.json(posts)
}
async search({ request, response }: HttpContext) {
const searchTerm = request.input('q')
/**
* Use where clauses with operators and multiple conditions.
* The orWhere method adds OR conditions to the query.
*/
const posts = await Post.query()
.where('title', 'ilike', `%${searchTerm}%`)
.orWhere('content', 'ilike', `%${searchTerm}%`)
.where('status', 'published')
return response.json(posts)
}
}
```
The query builder methods return model instances instead of plain objects, which means you get all model functionality like relationships, serialization, and hooks. You can chain any Knex query builder method, including joins, subqueries, grouping, and aggregations.
For the complete query builder API, see the [Lucid query builder documentation](https://lucid.adonisjs.com/docs/select-query-builder).
## Pretty printing queries during development
Understanding what SQL queries your application generates helps with debugging and optimization. Lucid provides built-in query debugging that pretty-prints SQL statements to your console during development.
Pretty printing is enabled by default in development mode. You'll see formatted SQL queries in your terminal:
```sql
select * from "posts" where "status" = 'published' order by "created_at" desc
```
The feature is configured in your database config:
```typescript title="config/database.ts"
const dbConfig = defineConfig({
prettyPrintDebugQueries: true,
connections: {
postgres: {
client: 'pg',
connection: {},
/**
* Enable debug mode to log all queries for this connection.
* Set to false in production to avoid performance overhead.
*/
debug: true,
},
},
})
```
You can also enable debugging on a per-query basis:
```typescript
const posts = await Post
.query()
.debug(true)
.where('status', 'published')
```
For more control over query debugging and logging, including listening to query events, see the [Lucid debugging documentation](https://lucid.adonisjs.com/docs/debugging).
## Pagination
Pagination prevents performance issues when working with large datasets by loading records in manageable chunks. Lucid provides offset-based pagination that integrates seamlessly with both the query builder and models.
```typescript title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async index({ request, response }: HttpContext) {
const page = request.input('page', 1)
const limit = 20
/**
* Paginate posts with 20 records per page.
* Always use orderBy to ensure consistent pagination.
*/
const posts = await Post
.query()
.where('status', 'published')
.orderBy('created_at', 'desc')
.paginate(page, limit)
/**
* Set the base URL for pagination links.
* This enables generating correct page URLs.
*/
posts.baseUrl('/posts')
return response.json(posts)
}
}
```
The paginator provides metadata about the current page:
```json
{
"meta": {
"total": 245,
"perPage": 20,
"currentPage": 1,
"lastPage": 13,
"firstPage": 1,
"firstPageUrl": "/posts?page=1",
"lastPageUrl": "/posts?page=13",
"nextPageUrl": "/posts?page=2",
"previousPageUrl": null
},
"data": [
// Post objects
]
}
```
You can use this metadata to build pagination UI in your templates:
```edge
@each(anchor in posts.getUrlsForRange(1, posts.lastPage))
{{ anchor.page }}
@endeach
```
:::tip
Always include an `orderBy` clause when paginating. Without explicit ordering, database engines may return records in random order, causing items to appear on multiple pages or be skipped entirely as users navigate.
:::
For cursor-based pagination and customizing the JSON response format, see the [Lucid pagination documentation](https://lucid.adonisjs.com/docs/pagination).
## Transactions
Database transactions ensure data integrity by grouping multiple operations into an atomic unit. If any operation fails, the entire transaction rolls back, preventing partial updates that could leave your database in an inconsistent state.
Lucid provides managed transactions that automatically commit on success and rollback on exceptions:
```typescript title="app/controllers/posts_controller.ts"
import db from '@adonisjs/lucid/services/db'
import Post from '#models/post'
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async store({ request, auth, response }: HttpContext) {
/**
* Wrap operations in a transaction.
* If any operation throws, all changes roll back automatically.
*/
const post = await db.transaction(async (trx) => {
const user = auth.getUserOrFail()
/**
* Create the post using the transaction.
* All model operations within this callback use the same transaction.
*/
const post = new Post()
post.title = request.input('title')
post.content = request.input('content')
post.useTransaction(trx)
await post.save()
/**
* Update user's post count.
* This shares the same transaction, ensuring both operations
* succeed together or fail together.
*/
user.useTransaction(trx)
user.postCount = user.postCount + 1
await user.save()
return post
})
return response.created(post)
}
}
```
For manual transaction control, isolation levels, and savepoints, see the [Lucid transactions documentation](https://lucid.adonisjs.com/docs/transactions).
## Model hooks
Hooks allow you to execute code at specific points in a model's lifecycle. You can use hooks to hash passwords before saving, validate data, update related records, or perform any logic that should happen automatically during model operations.
```typescript title="app/models/user.ts"
import { UsersSchema } from '#database/schema'
import { beforeSave } from '@adonisjs/lucid/orm'
import hash from '@adonisjs/core/services/hash'
export default class User extends UsersSchema {
/**
* Hash the password before saving to the database.
* The hook runs before both inserts and updates.
*/
@beforeSave()
static async hashPassword(user: User) {
/**
* Only hash if the password was modified.
* The $dirty object tracks which attributes changed.
*/
if (user.$dirty.password) {
user.password = await hash.make(user.password)
}
}
}
```
Lucid provides hooks for different lifecycle events. Before hooks (`@beforeSave`, `@beforeCreate`, `@beforeUpdate`, `@beforeDelete`) run before database operations and can modify data or cancel operations. After hooks (`@afterSave`, `@afterCreate`, `@afterUpdate`, `@afterDelete`) run after database operations and are useful for side effects like sending notifications. Query hooks (`@beforeFind`, `@afterFind`, `@beforeFetch`, `@afterFetch`) run during fetch operations and can automatically filter results.
```typescript title="app/models/post.ts"
import { PostsSchema } from '#database/schema'
import { beforeFind, afterCreate } from '@adonisjs/lucid/orm'
export default class Post extends PostsSchema {
/**
* Automatically exclude soft-deleted posts from queries.
* This hook modifies every find query to filter deleted records.
*/
@beforeFind()
static ignoreDeleted(query) {
query.where('isDeleted', false)
}
/**
* Send notification after creating a post.
* After hooks receive the saved model instance.
*/
@afterCreate()
static async notifyFollowers(post: Post) {
// Send notifications to followers
}
}
```
:::warning
Direct query builder updates bypass hooks entirely. When you use `await Post.query().where('id', 1).update({ title: 'New' })`, no hooks execute and timestamps don't update automatically.
This behavior exists for performance reasons when updating many records. If you need hooks to run, fetch the model instance first, modify it, and call `save()`.
:::
For complete hook lifecycle information and advanced patterns, see the [Lucid hooks documentation](https://lucid.adonisjs.com/docs/model-hooks).
## Model relationships
Relationships define how your models connect to each other, making it easy to work with related data. Lucid supports one-to-one, one-to-many, and many-to-many relationships with both lazy loading and eager loading.
### Defining relationships
A user has many posts:
```typescript title="app/models/user.ts"
import { UsersSchema } from '#database/schema'
import { hasMany } from '@adonisjs/lucid/orm'
import Post from '#models/post'
import type { HasMany } from '@adonisjs/lucid/types/relations'
export default class User extends UsersSchema {
/**
* Define a one-to-many relationship.
* A user can have multiple posts.
*/
@hasMany(() => Post)
declare posts: HasMany
}
```
A post belongs to a user:
```typescript title="app/models/post.ts"
import { PostsSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import User from '#models/user'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
export default class Post extends PostsSchema {
/**
* Define the inverse relationship.
* Each post belongs to one user.
*/
@belongsTo(() => User)
declare user: BelongsTo
}
```
### Eager loading relationships
Eager loading fetches relationships upfront, avoiding the N+1 query problem where you execute one query per related record:
```typescript title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async index({ response }: HttpContext) {
/**
* Preload the user relationship for all posts.
* This executes two queries total: one for posts, one for all users.
*/
const posts = await Post.query().preload('user')
/**
* Access the related user without additional queries.
* Each post now has a user property with the loaded data.
*/
posts.forEach(post => {
console.log(post.user.email)
})
return response.json(posts)
}
}
```
You can preload multiple relationships and nest them:
```typescript
/**
* Load user and their profile, plus all comments with their authors.
* Nested preloads work for any depth of relationships.
*/
const posts = await Post.query()
.preload('user', (query) => {
query.preload('profile')
})
.preload('comments', (query) => {
query.preload('author')
})
```
### Many-to-many relationships
For many-to-many relationships like users belonging to multiple teams:
```typescript title="app/models/user.ts"
import { UsersSchema } from '#database/schema'
import { manyToMany } from '@adonisjs/lucid/orm'
import Team from '#models/team'
import type { ManyToMany } from '@adonisjs/lucid/types/relations'
export default class User extends UsersSchema {
/**
* Define a many-to-many relationship through a pivot table.
* Lucid expects a team_user table with user_id and team_id columns.
*/
@manyToMany(() => Team, {
pivotColumns: ['role', 'joined_at'],
})
declare teams: ManyToMany
}
```
Attach and detach related records:
```typescript
const user = await User.findOrFail(1)
/**
* Attach teams with additional pivot data.
* The pivot table stores the relationship plus extra columns.
*/
await user.related('teams').attach({
1: { role: 'admin' },
2: { role: 'member' },
})
/**
* Sync keeps only specified teams, detaching others.
* Useful for "save all" style operations.
*/
await user.related('teams').sync([1, 2, 3])
```
For has-one relationships, relationship queries, and advanced patterns like polymorphic relationships, see the [Lucid relationships documentation](https://lucid.adonisjs.com/docs/relationships).
## Serializing models
When returning models from HTTP endpoints, you need to convert them to plain JavaScript objects. Lucid provides powerful serialization that controls which fields appear in the output, transforms data, and handles relationships.
```typescript title="app/controllers/users_controller.ts"
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class UsersController {
async show({ params, response }: HttpContext) {
const user = await User.query()
.where('id', params.id)
.preload('posts')
.firstOrFail()
/**
* Serialize the model to a plain object.
* This automatically excludes sensitive fields and formats dates.
*/
return response.json(user.serialize({
fields: {
omit: ['password', 'rememberMeToken'],
},
relations: {
posts: {
fields: {
pick: ['id', 'title', 'createdAt'],
},
},
},
}))
}
}
```
Control serialization at the model level using column options:
```typescript title="app/models/user.ts"
import { UsersSchema } from '#database/schema'
import { column } from '@adonisjs/lucid/orm'
export default class User extends UsersSchema {
/**
* Override password column to exclude from all serialization.
* Setting serializeAs to null prevents this field from appearing in JSON.
*/
@column({ serializeAs: null })
declare password: string
/**
* Override firstName column to rename in JSON output.
* The database column is snake_case but JSON uses camelCase.
*/
@column({ serializeAs: 'firstName' })
declare firstName: string
}
```
For custom serialization logic, computed properties, and working with transformers, see the [Lucid serialization documentation](https://lucid.adonisjs.com/docs/serializing-models) and [AdonisJS transformers documentation](https://docs.adonisjs.com/guides/frontend/transformers).
## Model factories
Factories generate fake data for testing and database seeding. Instead of manually creating test records, you define a factory once and generate realistic data on demand.
Create a factory for your model:
```bash
node ace make:factory Post
```
```typescript title="database/factories/post_factory.ts"
import Post from '#models/post'
import Factory from '@adonisjs/lucid/factories'
export const PostFactory = Factory.define(Post, ({ faker }) => {
/**
* Define how to generate fake data for each column.
* Faker provides methods for realistic fake data.
*/
return {
title: faker.lorem.sentence(),
content: faker.lorem.paragraphs(3),
status: 'draft',
}
}).build()
```
Use factories in tests or seeders:
```typescript
import { PostFactory } from '#database/factories/post_factory'
/**
* Create a single post with fake data.
* The factory generates random values for each field.
*/
const post = await PostFactory.create()
/**
* Create multiple posts at once.
* This generates 10 posts with unique fake data.
*/
const posts = await PostFactory.createMany(10)
/**
* Override specific attributes while using fake data for others.
* Useful when you need specific values for testing.
*/
const publishedPost = await PostFactory
.merge({ status: 'published' })
.create()
```
Factories support states for common variations:
```typescript title="database/factories/post_factory.ts"
export const PostFactory = Factory.define(Post, ({ faker }) => {
return {
title: faker.lorem.sentence(),
content: faker.lorem.paragraphs(3),
status: 'draft',
}
})
.state('published', (post) => {
post.status = 'published'
post.publishedAt = new Date()
})
.build()
```
```typescript
/**
* Create published posts using the state.
* States provide reusable variations of your factory.
*/
const publishedPosts = await PostFactory
.apply('published')
.createMany(5)
```
For relationship factories, stubbing database calls in tests, and advanced factory patterns, see the [Lucid factories documentation](https://lucid.adonisjs.com/docs/model-factories).
## Next steps
This guide covered the essential features of Lucid ORM to help you understand how the pieces fit together. You've learned how to configure database connections, create migrations, define models with auto-generated schemas, perform CRUD operations, work with relationships, and generate test data with factories.
For deeper knowledge on any topic, refer to the comprehensive [Lucid documentation](https://lucid.adonisjs.com), which covers advanced query builder methods, relationship customization, query scopes, soft deletes, custom naming strategies, and much more.
You might also want to explore:
- [Database validation rules](https://lucid.adonisjs.com/docs/validation) for validating unique and existing values
- [Query scopes](https://lucid.adonisjs.com/docs/model-query-scopes) for reusable query constraints
- [Custom column types](https://lucid.adonisjs.com/docs/schema-classes#customizing-types-with-schema-rules) for handling special data formats
- [Redis integration](https://docs.adonisjs.com/guides/database/redis) for caching model queries
---
# Redis
This guide covers Redis integration in AdonisJS applications. You will learn how to:
- Install and configure the package
- Execute Redis commands
- Manage multiple connections
- Use Pub/Sub messaging
- Handle connection errors
- Configure clusters and sentinels
## Overview
The `@adonisjs/redis` package is a thin wrapper on top of **ioredis** (a Node.js Redis client) with better developer experience around Pub/Sub and automatic management of multiple Redis connections.
You can use Redis for caching, session storage, job queues, rate limiting, and real-time messaging. The package provides a clean API to execute Redis commands, manage multiple named connections, and subscribe to Pub/Sub channels without manually managing subscriber connections.
## Installation
Install and configure the package using the following command:
```sh
node ace add @adonisjs/redis
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/redis` package using the detected package manager.
2. Registers the following service provider inside the `adonisrc.ts` file.
```ts
{
providers: [
// ...other providers
() => import('@adonisjs/redis/redis_provider')
]
}
```
3. Creates the `config/redis.ts` file with connection configuration for your Redis server.
4. Defines the following environment variables and their validation rules.
```dotenv
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
```
:::
## Configuration
The configuration for the Redis package is stored inside the `config/redis.ts` file.
See also: [Config file stub](https://github.com/adonisjs/redis/blob/10.x/stubs/config/redis.stub)
```ts title="config/redis.ts"
import env from '#start/env'
import { defineConfig } from '@adonisjs/redis'
const redisConfig = defineConfig({
connection: 'main',
connections: {
main: {
host: env.get('REDIS_HOST'),
port: env.get('REDIS_PORT'),
password: env.get('REDIS_PASSWORD', ''),
db: 0,
keyPrefix: '',
},
},
})
export default redisConfig
```
::::options
:::option{name="connection"}
The `connection` property defines which connection to use by default. When you run Redis commands without choosing an explicit connection, they will be executed against the default connection.
:::
:::option{name="connections"}
The `connections` property is a collection of multiple named connections. You can define one or more connections inside this object and switch between them using the `redis.connection()` method.
Every named connection config is identical to the [config accepted by ioredis](https://redis.github.io/ioredis/index.html#RedisOptions).
:::
::::
### Connecting via Unix socket
You can configure Redis to use a Unix socket for local connections. Use the `path` property to specify the socket file location.
```ts title="config/redis.ts"
import env from '#start/env'
import { defineConfig } from '@adonisjs/redis'
const redisConfig = defineConfig({
connection: 'main',
connections: {
main: {
/**
* Path to the Unix socket file.
* Remove host and port when using socket connections.
*/
path: env.get('REDIS_SOCKET_PATH'),
db: 0,
keyPrefix: '',
},
},
})
export default redisConfig
```
### Configuring clusters
The `@adonisjs/redis` package creates a [cluster connection](https://github.com/redis/ioredis#cluster) when you define an array of cluster nodes in your connection config.
**Clusters** distribute data across multiple Redis nodes for horizontal scaling and high availability. Use clusters when you need to scale beyond a single server's memory capacity or want automatic sharding of data across nodes.
```ts title="config/redis.ts"
import env from '#start/env'
import { defineConfig } from '@adonisjs/redis'
const redisConfig = defineConfig({
connection: 'main',
connections: {
main: {
// highlight-start
clusters: [
{ host: '127.0.0.1', port: 6380 },
{ host: '127.0.0.1', port: 6381 },
],
clusterOptions: {
scaleReads: 'slave',
slotsRefreshTimeout: 10 * 1000,
},
// highlight-end
},
},
})
export default redisConfig
```
::::options
:::option{name="clusters"}
An array of cluster node addresses. Each node should specify `host` and `port`. The package will discover all cluster nodes automatically after connecting to the initial nodes.
:::
:::option{name="clusterOptions"}
Cluster-specific options for controlling behavior.
**Common options:**
- `scaleReads`: How to distribute read operations (`'master'`, `'slave'`, or `'all'`)
- `slotsRefreshTimeout`: How often to refresh cluster slot information (in milliseconds)
See the [ioredis cluster documentation](https://github.com/redis/ioredis#cluster) for the complete list of options.
:::
::::
### Configuring sentinels
**Sentinels** provide high availability through automatic failover. Sentinel nodes monitor your master and replica servers and automatically promote a replica to master if the master fails.
You can configure a Redis connection to use sentinels by defining an array of sentinel nodes within the connection config.
See also: [IORedis docs on Sentinels config](https://github.com/redis/ioredis?tab=readme-ov-file#sentinel)
```ts title="config/redis.ts"
import env from '#start/env'
import { defineConfig } from '@adonisjs/redis'
const redisConfig = defineConfig({
connection: 'main',
connections: {
main: {
// highlight-start
sentinels: [
{ host: 'localhost', port: 26379 },
{ host: 'localhost', port: 26380 },
],
name: 'mymaster',
// highlight-end
password: env.get('REDIS_PASSWORD', ''),
db: 0,
},
},
})
export default redisConfig
```
::::options
:::option{name="sentinels"}
An array of sentinel node addresses. Sentinels will automatically detect which server is the current master and redirect connections accordingly.
:::
:::option{name="name"}
The name of the master group as configured in your sentinels. This must match the sentinel configuration.
:::
::::
## Usage
You can execute Redis commands using the `redis` service exported by the package. The redis service is a singleton instance configured using the settings from your `config/redis.ts` file.
:::note
The commands API is identical to [ioredis](https://redis.github.io/ioredis/classes/Redis.html). Consult the ioredis documentation to view the complete list of available methods.
:::
```ts
import redis from '@adonisjs/redis/services/main'
await redis.set('username', 'virk')
const username = await redis.get('username')
```
### Switching between connections
Commands executed using the `redis` service are invoked against the default connection defined inside the config file. You can execute commands on a specific connection by first getting an instance of it.
The `.connection()` method creates and caches a connection instance for the lifetime of the process. Subsequent calls return the same cached instance.
```ts
import redis from '@adonisjs/redis/services/main'
/**
* Get connection instance
*/
const redisMain = redis.connection('main')
await redisMain.set('username', 'virk')
const username = await redisMain.get('username')
```
### Quitting connections
Connections are long-lived and you will get the same instance every time you call the `.connection()` method. You can quit a connection gracefully using the `quit` method or force close it immediately using the `disconnect` method.
```ts
import redis from '@adonisjs/redis/services/main'
/**
* Quit the main connection gracefully
*/
await redis.quit('main')
/**
* Force quit the main connection immediately
*/
await redis.disconnect('main')
```
You can also quit using the connection instance directly.
```ts
import redis from '@adonisjs/redis/services/main'
const redisMain = redis.connection('main')
/**
* Quit using connection instance
*/
await redisMain.quit()
/**
* Force quit using connection instance
*/
await redisMain.disconnect()
```
## Error handling
Redis connections can fail at any time during your application's lifecycle. Without proper error handling, connection failures will crash your application with unhandled promise rejections.
By default, AdonisJS logs Redis connection errors using the application logger and retries connections up to 10 times before closing them permanently. The retry strategy uses exponential backoff to balance between recovering from brief outages and not wasting resources on prolonged failures.
The retry strategy is defined for each connection in your configuration.
See also: [IORedis docs on auto reconnect](https://github.com/redis/ioredis#auto-reconnect)
```ts title="config/redis.ts"
{
main: {
host: env.get('REDIS_HOST'),
port: env.get('REDIS_PORT'),
password: env.get('REDIS_PASSWORD', ''),
// highlight-start
/**
* Called each time a connection attempt fails.
* Return null to stop retrying.
* Return a number (milliseconds) to retry after that delay.
*/
retryStrategy(times) {
// Stop after 10 attempts
if (times > 10) {
return null
}
// Exponential backoff: 50ms, 100ms, 150ms, etc.
return times * 50
},
// highlight-end
},
}
```
:::warning
Without proper error handling, Redis connection failures will crash your application. The default retry strategy attempts 10 reconnections before giving up. For production deployments, customize the retry strategy based on your availability requirements and monitor Redis connection health.
**Customizing for production:**
```ts
retryStrategy(times) {
// More retries for production
if (times > 20) {
return null
}
// Exponential backoff with max 5 seconds
return Math.min(times * 50, 5000)
}
```
:::
You can disable the default error reporter using the `.doNotLogErrors()` method. This removes the `error` event listener from Redis connections.
```ts
import redis from '@adonisjs/redis/services/main'
/**
* Disable default error reporter
*/
redis.doNotLogErrors()
redis.on('connection', (connection) => {
/**
* Always define an error listener to prevent crashes.
* Without this, unhandled errors will crash your application.
*/
connection.on('error', (error) => {
console.log(error)
})
})
```
## Pub/Sub
**Pub/Sub** (Publish/Subscribe) is a messaging pattern where publishers send messages to channels without knowing who receives them, and subscribers listen to channels without knowing who sent them. This decoupling makes Pub/Sub ideal for real-time features like notifications, live updates, and chat systems.
Redis needs multiple connections for Pub/Sub. The subscriber connection cannot perform regular Redis operations other than subscribing and unsubscribing. When you call the `subscribe` method for the first time, the package automatically creates a subscriber connection for you.
### Subscribing to channels
The `subscribe` method handles both subscribing to a channel and listening for messages. This is different from the ioredis API where you need to use separate methods.
```ts
import redis from '@adonisjs/redis/services/main'
redis.subscribe('user:add', function (message) {
console.log(message)
})
```
You can handle subscription lifecycle events using the options parameter.
```ts
redis.subscribe(
'user:add',
(message) => {
console.log(message)
},
{
/**
* Called when subscription fails
*/
onError(error) {
console.log(error)
},
/**
* Called when subscription is established.
* Count is the total number of active subscriptions.
*/
onSubscription(count) {
console.log(count)
},
}
)
```
### API differences from IORedis
When using ioredis directly, you need to use two different APIs to subscribe to a channel and listen for messages. The AdonisJS wrapper combines these into a single convenient method.
```ts title="With IORedis"
redis.on('message', (channel, message) => {
console.log(message)
})
redis.subscribe('user:add', (error, count) => {
if (error) {
console.log(error)
}
})
```
```ts title="With AdonisJS wrapper"
redis.subscribe(
'user:add',
(message) => {
console.log(message)
},
{
onError(error) {
console.log(error)
},
onSubscription(count) {
console.log(count)
},
}
)
```
### Publishing messages
You can publish messages using the `publish` method. The method accepts the channel name as the first parameter and the message data as the second parameter.
```ts
redis.publish(
'user:add',
JSON.stringify({
id: 1,
username: 'virk',
})
)
```
:::tip
Make sure to subscribe before publishing messages. Messages published before a subscription is established will be lost since there are no subscribers to receive them.
:::
### Subscribing to patterns
You can subscribe to channel patterns using wildcards with the `psubscribe` method. The callback receives both the channel name and the message.
```ts
redis.psubscribe('user:*', (channel, message) => {
console.log(channel)
console.log(message)
})
redis.publish(
'user:add',
JSON.stringify({
id: 1,
username: 'virk',
})
)
```
### Unsubscribing
You can unsubscribe from channels or patterns using the `unsubscribe` and `punsubscribe` methods.
```ts
await redis.unsubscribe('user:add')
await redis.punsubscribe('user:*add*')
```
## Using Lua scripts
**Lua scripts** allow you to execute complex operations atomically on the Redis server. This is useful when you need multiple Redis commands to succeed or fail together without race conditions.
You can register Lua scripts as commands with the Redis service. These commands are automatically applied to all connections.
See also: [IORedis docs on Lua Scripting](https://github.com/redis/ioredis#lua-scripting)
```ts
import redis from '@adonisjs/redis/services/main'
redis.defineCommand('release', {
numberOfKeys: 2,
lua: `
redis.call('zrem', KEYS[2], ARGV[1])
redis.call('zadd', KEYS[1], ARGV[2], ARGV[1])
return true
`,
})
```
Once you have defined a command, you can execute it using the `runCommand` method. Keys are passed first, followed by arguments.
```ts
redis.runCommand(
'release', // command name
'jobs:completed', // key 1
'jobs:running', // key 2
'11023', // argv 1
100 // argv 2
)
```
You can execute the same command on a specific connection.
```ts
redis.connection('jobs').runCommand(
'release',
'jobs:completed',
'jobs:running',
'11023',
100
)
```
You can also define commands for specific connection instances using the `connection` event.
```ts
redis.on('connection', (connection) => {
if (connection.connectionName === 'jobs') {
connection.defineCommand('release', {
numberOfKeys: 2,
lua: `
redis.call('zrem', KEYS[2], ARGV[1])
redis.call('zadd', KEYS[1], ARGV[2], ARGV[1])
return true
`,
})
}
})
```
## Transforming arguments and replies
You can customize how arguments are sent to Redis and how replies are parsed using the `redis.Command` property. This is useful when you want to work with JavaScript objects, Maps, or custom data types.
The API is identical to the [IORedis transformers API](https://github.com/redis/ioredis#transforming-arguments--replies).
### Argument transformers
Argument transformers modify data before sending it to Redis.
```ts title="Transforming arguments for hmset"
import redis from '@adonisjs/redis/services/main'
redis.Command.setArgumentTransformer('hmset', (args) => {
if (args.length === 2) {
/**
* If second argument is a Map, convert to array
*/
if (args[1] instanceof Map) {
return [args[0], ...utils.convertMapToArray(args[1])]
}
/**
* If second argument is an object, convert to array
*/
if (typeof args[1] === 'object' && args[1] !== null) {
return [args[0], ...utils.convertObjectToArray(args[1])]
}
}
return args
})
```
### Reply transformers
Reply transformers modify data received from Redis.
```ts title="Transforming hgetall reply"
import redis from '@adonisjs/redis/services/main'
redis.Command.setReplyTransformer('hgetall', (result) => {
if (Array.isArray(result)) {
const obj = {}
/**
* Redis returns [key1, value1, key2, value2].
* Convert to object format.
*/
for (let i = 0; i < result.length; i += 2) {
obj[result[i]] = result[i + 1]
}
return obj
}
return result
})
```
## Testing
When testing code that uses Redis, you can configure a separate connection for your tests to isolate test data from development or production data.
First, define a test connection in your Redis configuration.
```ts title="config/redis.ts"
import env from '#start/env'
import { defineConfig } from '@adonisjs/redis'
const redisConfig = defineConfig({
connection: env.get('REDIS_CONNECTION', 'main'),
connections: {
main: {
host: env.get('REDIS_HOST'),
port: env.get('REDIS_PORT'),
password: env.get('REDIS_PASSWORD', ''),
db: 0,
},
// [!code highlight:6]
test: {
host: env.get('REDIS_HOST'),
port: env.get('REDIS_PORT'),
password: env.get('REDIS_PASSWORD', ''),
db: 1, // Use a different database for tests
},
},
})
export default redisConfig
```
Then, set the `REDIS_CONNECTION` environment variable to `test` when running tests within the `.env.test` file
```ts title=".env.test"
REDIS_CONNECTION=test
```
Clean up test data after each test to ensure test isolation.
```ts title="tests/functional/posts.spec.ts"
import { test } from '@japa/runner'
import redis from '@adonisjs/redis/services/main'
test.group('Posts', (group) => {
group.each.teardown(async () => {
/**
* Clear all keys in the test database
*/
await redis.flushdb()
})
test('caches post data', async ({ client }) => {
// Your test code
})
})
```
## Events
The following events are emitted by Redis connection instances. You can listen to these events using the `redis.on('connection')` method.
```ts
import redis from '@adonisjs/redis/services/main'
redis.on('connection', (connection) => {
connection.on('connect', () => {
console.log('Connected')
})
})
```
### Connection lifecycle events
::::options
:::option{name="connect / subscriber:connect"}
Emitted when a connection is established. The `subscriber:connect` event is emitted when a subscriber connection is made.
```ts
redis.on('connection', (connection) => {
connection.on('connect', () => {})
connection.on('subscriber:connect', () => {})
})
```
:::
:::option{name="wait"}
Emitted when the connection is in wait mode because the `lazyConnect` option is enabled in the config. The connection moves out of wait mode after executing the first command.
```ts
redis.on('connection', (connection) => {
connection.on('wait', () => {})
})
```
:::
:::option{name="ready / subscriber:ready"}
Emitted after the `connect` event when Redis is ready to accept commands. If you enable the `enableReadyCheck` flag in your config, this event waits for the Redis server to report readiness.
```ts
redis.on('connection', (connection) => {
connection.on('ready', () => {})
connection.on('subscriber:ready', () => {})
})
```
:::
:::option{name="error / subscriber:error"}
Emitted when unable to connect to the Redis server or when a connection error occurs. See [error handling](#error-handling) for proper error management.
```ts
redis.on('connection', (connection) => {
connection.on('error', () => {})
connection.on('subscriber:error', () => {})
})
```
:::
:::option{name="close / subscriber:close"}
Emitted when a connection is closed. IORedis might retry establishing a connection after emitting the `close` event, depending on the retry strategy.
```ts
redis.on('connection', (connection) => {
connection.on('close', () => {})
connection.on('subscriber:close', () => {})
})
```
:::
:::option{name="reconnecting / subscriber:reconnecting"}
Emitted when attempting to reconnect after a connection closes.
```ts
redis.on('connection', (connection) => {
connection.on('reconnecting', ({ waitTime }) => {
console.log(waitTime)
})
connection.on('subscriber:reconnecting', ({ waitTime }) => {
console.log(waitTime)
})
})
```
:::
:::option{name="end / subscriber:end"}
Emitted when the connection has been closed permanently and no further reconnections will be attempted.
```ts
redis.on('connection', (connection) => {
connection.on('end', () => {})
connection.on('subscriber:end', () => {})
})
```
:::
::::
### Cluster events
::::options
:::option{name="node:added"}
Emitted when a new node is added to the cluster. Only applicable to cluster connections.
```ts
redis.on('connection', (connection) => {
connection.on('node:added', () => {})
})
```
:::
:::option{name="node:removed"}
Emitted when a node is removed from the cluster. Only applicable to cluster connections.
```ts
redis.on('connection', (connection) => {
connection.on('node:removed', () => {})
})
```
:::
:::option{name="node:error"}
Emitted when unable to connect to a cluster node. Only applicable to cluster connections.
```ts
redis.on('connection', (connection) => {
connection.on('node:error', ({ error, address }) => {
console.log(error, address)
})
})
```
:::
::::
### Subscription events
::::options
:::option{name="subscription:ready / psubscription:ready"}
Emitted when a subscription is established on a channel or pattern.
```ts
redis.on('connection', (connection) => {
connection.on('subscription:ready', ({ count }) => {
console.log(count)
})
connection.on('psubscription:ready', ({ count }) => {
console.log(count)
})
})
```
:::
:::option{name="subscription:error / psubscription:error"}
Emitted when unable to subscribe to a channel or pattern.
```ts
redis.on('connection', (connection) => {
connection.on('subscription:error', () => {})
connection.on('psubscription:error', () => {})
})
```
:::
::::
---
# Authentication
This guide introduces the AdonisJS authentication system. You will learn:
- How guards and providers work together to authenticate users
- Which guard to choose for your application type
- How to install and configure the auth package
- What the Initialize auth middleware does
## Overview
AdonisJS provides a robust authentication system for logging in and authenticating users across different application types, whether you're building a server-rendered application, an API for a SPA, or a backend for mobile apps.
The authentication package is built around two core concepts:
- **Guards** are end-to-end implementations of a specific authentication method. For example, the session guard authenticates users via cookies and sessions, while the access tokens guard authenticates requests using bearer tokens.
- **Providers** handle user lookup and token verification from your database. You can use the built-in Lucid provider or implement your own for custom data sources.
The security primitives of AdonisJS are designed to protect against common vulnerabilities. Passwords and tokens are properly hashed, and the implementation guards against [timing attacks](https://en.wikipedia.org/wiki/Timing_attack) and [session fixation attacks](https://owasp.org/www-community/attacks/Session_fixation).
## What the auth package does not include
The auth package focuses specifically on authenticating HTTP requests. The following features are outside its scope:
- User registration (forms, email verification, account activation)
- Account management (password recovery, email updates)
- Authorization and permissions (use [Bouncer](./authorization.md) instead)
:::tip
Looking for a complete authentication system? [AdonisJS Kit](https://plus.adonisjs.com/kit) provides full-stack components with ready-to-use flows for user registration, email verification, password recovery, profile management, and more.
:::
## Choosing an auth guard
AdonisJS ships with three built-in guards. Use the table below to determine which guard fits your application.
| Application Type | Recommended Guard | Why |
|-----------------|-------------------|-----|
| Server-rendered web app | Session | Cookies work naturally with browser requests |
| SPA on the same domain | Session | Share cookies between `api.example.com` and `example.com` |
| SPA on a different domain | Access tokens | Cross-origin requests cannot share cookies |
| Mobile app | Access tokens | Native apps cannot use cookie-based sessions |
| Third-party API access | Access tokens | Clients need long-lived tokens they can store |
| Quick prototyping | Basic auth | Simple to set up, no database tables required |
### Session guard
The session guard uses the [@adonisjs/session](../basics/session.md) package to track logged-in users. After a successful login, the user's identifier is stored in the session, and a session cookie is sent to the browser. Subsequent requests include this cookie, allowing the server to restore the user's authenticated state.
Sessions and cookies have been the standard for web authentication for decades. They work well when your client can accept and send cookies, which is the case for server-rendered applications and SPAs hosted on the same top-level domain as your API.
See also: [Session guard documentation](./session_guard.md)
### Access tokens guard
Access tokens are cryptographically secure random strings issued to users after login. The client stores the token and includes it in the `Authorization` header of subsequent requests. AdonisJS uses opaque access tokens (not JWTs) that are stored as hashes in your database for verification.
Use access tokens when your client cannot work with cookies:
- Native mobile applications
- Desktop applications
- Web applications on a different domain than your API
- Third-party integrations that need programmatic API access
The client application is responsible for storing tokens securely. Access tokens provide unrestricted access to your application on behalf of a user, so leaking them creates security risks.
See also: [Access tokens guard documentation](./access_tokens_guard.md)
### Basic auth guard
The basic auth guard implements the [HTTP authentication framework](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). The client sends credentials as a base64-encoded string in the `Authorization` header with each request. If credentials are invalid, the browser displays a native login prompt.
Basic authentication is not recommended for production applications because credentials are sent with every request and the user experience is limited to the browser's built-in prompt. However, it can be useful during early development or for internal tools.
See also: [Basic auth guard documentation](./basic_auth_guard.md)
## Choosing a user provider
User providers handle finding users and verifying tokens during authentication. Each guard type has specific requirements for its provider.
The session guard provider finds users by their ID (stored in the session). The access tokens guard provider additionally verifies tokens against hashed values in the database. AdonisJS ships with Lucid-based providers for all built-in guards, which use your Lucid models to query the database.
## Installation
The auth package comes pre-configured with the `web` and `api` starter kits. To add it to an existing application, run one of the following commands based on your preferred guard:
```sh
# Session guard (recommended for web apps)
node ace add @adonisjs/auth --guard=session
# Access tokens guard (recommended for APIs)
node ace add @adonisjs/auth --guard=access_tokens
# Basic auth guard
node ace add @adonisjs/auth --guard=basic_auth
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/auth` package using the detected package manager.
2. Registers the following service provider inside the `adonisrc.ts` file.
```ts
{
providers: [
// ...other providers
() => import('@adonisjs/auth/auth_provider')
]
}
```
3. Creates and registers the following middleware inside the `start/kernel.ts` file.
```ts
router.use([
() => import('@adonisjs/auth/initialize_auth_middleware')
])
```
```ts
router.named({
auth: () => import('#middleware/auth_middleware'),
// Only registered when using the session guard
guest: () => import('#middleware/guest_middleware')
})
```
4. Creates the `User` model inside `app/models`.
5. Creates database migrations for the `users` table.
6. Creates additional migrations based on the selected guard (for example, `auth_access_tokens` for the access tokens guard).
:::
## The initialize auth middleware
During setup, the `@adonisjs/auth/initialize_auth_middleware` is added to your application's middleware stack. This middleware runs on every request and creates an instance of the [Authenticator](https://github.com/adonisjs/auth/blob/10.x/src/authenticator.ts) class, which it attaches to `ctx.auth`.
The initialize auth middleware does not authenticate requests or protect routes. Its only job is to set up the authenticator instance so it's available throughout the request lifecycle. To protect routes, use the [auth middleware](./session_guard.md#protecting-routes).
If your application uses Edge templates, the authenticator is also available as the `auth` variable:
```edge
@if(auth.isAuthenticated)
Hello {{ auth.user.email }}
@end
```
## Creating the users table
The `add` command creates a migration for the `users` table in `database/migrations`. You can modify this file to match your application's requirements before running the migration.
```ts title="database/migrations/TIMESTAMP_create_users_table.ts"
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'users'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
table.string('full_name').nullable()
table.string('email', 254).notNullable().unique()
table.string('password').notNullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
```
After modifying the migration, update the `User` model in `app/models/user.ts` to reflect any columns you've added, renamed, or removed.
## Next steps
With the auth package installed, you're ready to implement authentication in your application:
- [Verifying user credentials](./verifying_user_credentials.md): Learn how to safely verify passwords during login
- [Session guard](./session_guard.md): Implement cookie-based authentication for web applications
- [Access tokens guard](./access_tokens_guard.md): Implement token-based authentication for APIs and mobile apps
- [Social authentication](./social_authentication.md): Allow users to log in with GitHub, Google, and other providers
---
# Verifying user credentials
This guide covers secure credential verification in AdonisJS. You will learn:
- Why naive password verification is vulnerable to timing attacks
- How to use the AuthFinder mixin for secure credential verification
- How password hashing is handled automatically
- How to handle verification errors
## Overview
Before a user can be logged in or issued an access token, you need to verify their credentials. This typically means finding a user by their email (or username) and comparing the provided password against the stored hash.
AdonisJS provides the AuthFinder mixin to handle this securely. The mixin adds a `verifyCredentials` method to your User model that protects against timing attacks while providing a clean API for credential verification.
## Why secure verification matters
A naive approach to credential verification might look like this:
```ts title="app/controllers/session_controller.ts"
import User from '#models/user'
import hash from '@adonisjs/core/services/hash'
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async store({ request, response }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
/**
* Find user by email
*/
const user = await User.findBy('email', email)
if (!user) {
return response.abort('Invalid credentials')
}
/**
* Verify password
*/
const isPasswordValid = await hash.verify(user.password, password)
if (!isPasswordValid) {
return response.abort('Invalid credentials')
}
// Login user...
}
}
```
This code is vulnerable to [timing attacks](https://en.wikipedia.org/wiki/Timing_attack). An attacker can measure response times to determine whether an email exists in your database:
- When the email doesn't exist, the response returns quickly because no password hashing occurs.
- When the email exists but the password is wrong, the response takes longer because password hashing algorithms are intentionally slow.
This timing difference is enough for attackers to enumerate valid email addresses, which they can then target with password attacks.
## Using the AuthFinder mixin
The AuthFinder mixin solves the timing attack problem by always performing a password hash comparison, even when the user doesn't exist. This ensures consistent response times regardless of whether the email is valid.
To use the mixin, apply it to your User model:
```ts title="app/models/user.ts"
import { DateTime } from 'luxon'
import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import hash from '@adonisjs/core/services/hash'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
uids: ['email'],
passwordColumnName: 'password',
})
export default class User extends compose(BaseModel, AuthFinder) {
@column({ isPrimary: true })
declare id: number
@column()
declare fullName: string | null
@column()
declare email: string
@column()
declare password: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
```
The `withAuthFinder` method accepts two arguments. The first is a callback that returns the hasher to use for password verification (scrypt in this example, but you can use any configured hasher). The second is a configuration object with the following properties:
| Property | Description |
|----------|-------------|
| `uids` | An array of model properties that can identify a user. If your application allows login by username or phone number, include those fields here. |
| `passwordColumnName` | The model property that stores the hashed password. |
### Verifying credentials
With the mixin applied, use the `verifyCredentials` static method to verify credentials:
```ts title="app/controllers/session_controller.ts"
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async store({ request }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
const user = await User.verifyCredentials(email, password)
// Login user...
}
}
```
The `verifyCredentials` method finds the user by the provided UID (email in this case), verifies the password, and returns the user instance. If the credentials are invalid, it throws an `E_INVALID_CREDENTIALS` exception.
## Handling verification errors
When credentials are invalid, `verifyCredentials` throws the [E_INVALID_CREDENTIALS](../../reference/exceptions.md#e_invalid_credentials) exception. This exception is self-handling and converts to an appropriate HTTP response based on content negotiation:
- Requests with `Accept: application/json` receive an array of error objects with a `message` property.
- Requests with `Accept: application/vnd.api+json` receive errors formatted per the JSON API specification.
- Requests using sessions are redirected back with errors available via [flash messages](../basics/session.md#flash-messages).
- All other requests receive a plain text error response.
To customize error handling, catch the exception in your [global exception handler](../basics/exception_handling.md):
```ts title="app/exceptions/handler.ts"
import { errors } from '@adonisjs/auth'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
protected debug = !app.inProduction
protected renderStatusPages = app.inProduction
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof errors.E_INVALID_CREDENTIALS) {
return ctx.response
.status(error.status)
.send(error.getResponseMessage(error, ctx))
}
return super.handle(error, ctx)
}
}
```
## Automatic password hashing
The AuthFinder mixin registers a [beforeSave hook](https://github.com/adonisjs/auth/blob/10.x/src/mixins/lucid.ts#L88-L95) that automatically hashes passwords when creating or updating users. You don't need to manually hash passwords in your models or controllers:
```ts title="app/controllers/users_controller.ts"
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class UsersController {
async store({ request }: HttpContext) {
const data = request.only(['email', 'password', 'fullName'])
/**
* Password is automatically hashed before saving
*/
const user = await User.create(data)
return user
}
}
```
The hook only hashes the password when the `password` property has changed, so updating other user fields won't trigger unnecessary rehashing.
---
# Session guard
This guide covers session-based authentication in AdonisJS. You will learn:
- How to configure the session guard
- How to log users in and out
- How to protect routes from unauthenticated access
- How to access the authenticated user
- How to implement "Remember Me" functionality
- How to prevent logged-in users from accessing guest-only pages
## Overview
The session guard uses the [@adonisjs/session](../basics/session.md) package to track logged-in users. When a user logs in, their identifier is stored in the session, and a cookie is sent to the browser. On subsequent requests, the session middleware reads this cookie and restores the authenticated state.
Sessions and cookies have been the standard for web authentication for decades. Use the session guard when building server-rendered applications or SPAs hosted on the same top-level domain as your API (for example, your app at `example.com` with an API at `api.example.com`).
## Configuring the guard
Authentication guards are defined in `config/auth.ts`. The following example shows a session guard configuration:
```ts title="config/auth.ts"
import { defineConfig } from '@adonisjs/auth'
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
const authConfig = defineConfig({
default: 'web',
guards: {
web: sessionGuard({
useRememberMeTokens: false,
provider: sessionUserProvider({
model: () => import('#models/user'),
}),
}),
},
})
export default authConfig
```
The `sessionGuard` method creates an instance of the [SessionGuard](https://github.com/adonisjs/auth/blob/10.x/modules/session_guard/guard.ts) class. It accepts a user provider and an optional configuration object for remember me tokens.
The `sessionUserProvider` method creates an instance of [SessionLucidUserProvider](https://github.com/adonisjs/auth/blob/10.x/modules/session_guard/user_providers/lucid.ts), which uses a Lucid model to find users during authentication.
## Logging in
Use the `auth.use('web').login()` method to create a session for a user. The method accepts a User model instance and stores their identifier in the session.
```ts title="app/controllers/session_controller.ts"
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async store({ request, auth, response }: HttpContext) {
/**
* Get credentials from request body
*/
const { email, password } = request.only(['email', 'password'])
/**
* Verify credentials using the AuthFinder mixin
*/
const user = await User.verifyCredentials(email, password)
/**
* Create session for the user
*/
await auth.use('web').login(user)
/**
* Redirect to a protected page
*/
return response.redirect('/dashboard')
}
}
```
The `auth.use('web')` method returns the guard instance configured under the name `web` in your `config/auth.ts` file.
## Logging out
Use the `auth.use('web').logout()` method to destroy the user's session. If the user has an active remember me token, it will also be deleted.
```ts title="app/controllers/session_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async destroy({ auth, response }: HttpContext) {
await auth.use('web').logout()
return response.redirect('/login')
}
}
```
## Protecting routes
Use the `auth` middleware to protect routes from unauthenticated users. The middleware is registered in `start/kernel.ts` under the named middleware collection:
```ts title="start/kernel.ts"
import router from '@adonisjs/core/services/router'
export const middleware = router.named({
auth: () => import('#middleware/auth_middleware'),
})
```
Apply the middleware to routes that require authentication:
```ts title="start/routes.ts"
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
router
.get('dashboard', ({ auth }) => {
return `Welcome ${auth.user!.fullName}`
})
.use(middleware.auth())
```
By default, the auth middleware authenticates using the `default` guard from your config. To specify guards explicitly, pass them as an option:
```ts title="start/routes.ts"
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
router
.get('dashboard', ({ auth }) => {
return `Welcome ${auth.user!.fullName}`
})
.use(
middleware.auth({
guards: ['web', 'api'],
})
)
```
When multiple guards are specified, authentication succeeds if any of them authenticates the request.
### Handling authentication errors
When the auth middleware cannot authenticate a request, it throws the [E_UNAUTHORIZED_ACCESS](https://github.com/adonisjs/auth/blob/10.x/src/errors.ts#L21) exception. The exception is converted to an HTTP response using content negotiation:
- Requests with `Accept: application/json` receive an array of error objects.
- Requests with `Accept: application/vnd.api+json` receive errors formatted per the JSON API specification.
- Server-rendered applications redirect to `/login`. You can customize this path in `app/middleware/auth_middleware.ts`.
## Accessing the authenticated user
After authentication, the user instance is available via `auth.user`. This property is populated when using the `auth` middleware, the `silent_auth` middleware, or when manually calling `auth.authenticate()` or `auth.check()`.
```ts title="start/routes.ts"
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
router
.get('dashboard', async ({ auth }) => {
const user = auth.user!
return await user.getAllMetrics()
})
.use(middleware.auth())
```
### Avoiding non-null assertions
If you prefer not to use the non-null assertion operator (`!`), use the `auth.getUserOrFail()` method instead. It returns the user or throws [E_UNAUTHORIZED_ACCESS](../../reference/exceptions.md#e_unauthorized_access):
```ts title="start/routes.ts"
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
router
.get('dashboard', async ({ auth }) => {
const user = auth.getUserOrFail()
return await user.getAllMetrics()
})
.use(middleware.auth())
```
### Checking authentication status
Use the `auth.isAuthenticated` property to check if the current request is authenticated:
```ts title="start/routes.ts"
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
router
.get('dashboard', async ({ auth }) => {
if (auth.isAuthenticated) {
return await auth.user!.getAllMetrics()
}
})
.use(middleware.auth())
```
### Silent authentication
The `silent_auth` middleware works like the `auth` middleware but doesn't throw an exception when the user is unauthenticated. The request continues normally, allowing you to optionally use authentication data when available.
This is useful for pages that work for both guests and authenticated users, such as a homepage that shows personalized content for logged-in users.
Register the middleware in your router middleware stack:
```ts title="start/kernel.ts"
import router from '@adonisjs/core/services/router'
router.use([
// ...other middleware
() => import('#middleware/silent_auth_middleware'),
])
```
### Accessing the user in Edge templates
The [initialize auth middleware](./introduction.md#the-initialize-auth-middleware) shares the `ctx.auth` object with Edge templates. Access the authenticated user via `auth.user`:
```edge
@if(auth.isAuthenticated)
Hello {{ auth.user.email }}
@end
```
On public pages that aren't protected by the auth middleware, call `auth.check()` to attempt authentication before accessing `auth.user`:
```edge
{{-- Check if user is logged in without requiring authentication --}}
@eval(await auth.check())
@if(auth.isAuthenticated)
Hello {{ auth.user.email }}
@else
Sign in
@end
```
## Remember me
The "Remember Me" feature keeps users logged in after their session expires by storing a long-lived token in a cookie. When the session expires, AdonisJS uses this token to automatically recreate the session.
### Creating the tokens table
Remember me tokens are stored in the database. Create a migration for the tokens table:
```sh
node ace make:migration remember_me_tokens
```
```ts title="database/migrations/TIMESTAMP_create_remember_me_tokens_table.ts"
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'remember_me_tokens'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments()
table
.integer('tokenable_id')
.notNullable()
.unsigned()
.references('id')
.inTable('users')
.onDelete('CASCADE')
table.string('hash').notNullable().unique()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').notNullable()
table.timestamp('expires_at').notNullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
```
### Configuring the token provider
Assign the `DbRememberMeTokensProvider` to your User model:
```ts title="app/models/user.ts"
import { BaseModel } from '@adonisjs/lucid/orm'
import { DbRememberMeTokensProvider } from '@adonisjs/auth/session'
export default class User extends BaseModel {
// ...other model properties
static rememberMeTokens = DbRememberMeTokensProvider.forModel(User)
}
```
### Enabling remember me tokens
Enable the feature in your guard configuration:
```ts title="config/auth.ts"
import { defineConfig } from '@adonisjs/auth'
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
const authConfig = defineConfig({
default: 'web',
guards: {
web: sessionGuard({
useRememberMeTokens: true,
rememberMeTokensAge: '2 years',
provider: sessionUserProvider({
model: () => import('#models/user'),
}),
}),
},
})
export default authConfig
```
### Generating tokens during login
Pass a boolean as the second argument to `login()` to generate a remember me token:
```ts title="app/controllers/session_controller.ts"
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async store({ request, auth, response }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
const user = await User.verifyCredentials(email, password)
await auth.use('web').login(
user,
!!request.input('remember_me')
)
return response.redirect('/dashboard')
}
}
```
## Redirecting to the intended URL
When unauthenticated users try to access a protected page, the auth middleware redirects them to the login page. After login, you will want to send them back to the page they originally requested instead of a generic dashboard.
### Forced login flow
When the auth middleware rejects an unauthenticated request, it automatically stores the current URL in the session before redirecting to the login page. This happens for GET, navigational (non-AJAX) requests that matched a route. No changes to your auth middleware are needed.
In your login controller, use `toIntendedRoute()` to redirect after successful authentication. It reads the stored URL from the session and falls back to the provided default.
```ts title="app/controllers/session_controller.ts"
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async store({ request, auth, response }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
const user = await User.verifyCredentials(email, password)
await auth.use('web').login(user)
/**
* Redirect to the page the user was trying to access,
* or dashboard if no intended URL was stored
*/
return response.redirect().toIntendedRoute('dashboard')
}
}
```
### Voluntary login flow
When a user voluntarily clicks a login link from a public page, the current URL can be passed as a query parameter. The login page handler stores it in the session using `session.setIntendedUrl()`, which validates the URL to prevent open redirects.
```edge title="resources/views/partials/header.edge"
Login
```
```ts title="app/controllers/session_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async show({ request, session, view }: HttpContext) {
const intended = request.input('intended')
if (intended) {
session.setIntendedUrl(intended)
}
return view.render('auth/login')
}
}
```
After login, `toIntended()` works the same way regardless of how the intended URL was stored.
## Guest middleware
The guest middleware redirects authenticated users away from pages like `/login` or `/register`. This prevents users from creating multiple sessions on a single device.
```ts title="start/routes.ts"
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
router
.get('/login', () => {
// Show login form
})
.use(middleware.guest())
```
By default, the middleware checks authentication using the `default` guard. To specify guards explicitly:
```ts title="start/routes.ts"
router
.get('/login', () => {
// Show login form
})
.use(
middleware.guest({
guards: ['web', 'admin_web'],
})
)
```
Configure the redirect path for authenticated users in `app/middleware/guest_middleware.ts`.
## Events
The session guard emits events during authentication. See the [events reference guide](../../reference/events.md#session_authauthentication_succeeded) for the complete list.
---
# Access tokens guard
This guide covers token-based authentication in AdonisJS. You will learn:
- How access tokens work and when to use them
- How to configure the tokens provider on your User model
- How to issue tokens with abilities and expiration
- How to authenticate requests using tokens
- How to manage tokens (list, delete, revoke)
## Overview
Access tokens authenticate HTTP requests in contexts where the server cannot use cookies. This includes native mobile apps, desktop applications, third-party API integrations, and web applications hosted on a different domain than your API.
AdonisJS uses opaque access tokens rather than JWTs. An opaque token is a cryptographically secure random string with no embedded data. The token is hashed and stored in your database, and verification happens by comparing the provided token against the stored hash. This approach allows you to revoke tokens instantly by deleting them from the database, something that's not possible with JWTs until they expire.
A token consists of three parts: a configurable prefix (`oat_` by default), the random token value, and a CRC32 checksum. The prefix and checksum help [secret scanning tools](https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning) identify leaked tokens in codebases.
## Configuring the User model
Before using the access tokens guard, configure a tokens provider on your User model. The provider handles creating, storing, and verifying tokens.
```ts title="app/models/user.ts"
import { DateTime } from 'luxon'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
export default class User extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare fullName: string | null
@column()
declare email: string
@column()
declare password: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
static accessTokens = DbAccessTokensProvider.forModel(User)
}
```
The `DbAccessTokensProvider.forModel` method accepts the User model as its first argument and an optional configuration object as its second:
```ts title="app/models/user.ts"
static accessTokens = DbAccessTokensProvider.forModel(User, {
expiresIn: '30 days',
prefix: 'oat_',
table: 'auth_access_tokens',
type: 'auth_token',
tokenSecretLength: 40,
})
```
| Option | Description |
|--------|-------------|
| `expiresIn` | Default token lifetime. Accepts seconds as a number or a time expression like `'30 days'`. Tokens don't expire by default. Can be overridden when creating individual tokens. |
| `prefix` | Prefix for the public token value. Helps secret scanners identify your tokens. Defaults to `oat_`. Changing this invalidates existing tokens. |
| `table` | Database table for storing tokens. Defaults to `auth_access_tokens`. |
| `type` | Identifier for this token type. Useful when your application issues multiple types of tokens. Defaults to `auth_token`. |
| `tokenSecretLength` | Length of the random token value in characters. Defaults to `40`. |
## Creating the tokens table
The `add` command creates a migration for the tokens table when you select the access tokens guard. Run the migration to create the table:
```sh
node ace migration:run
```
If you're configuring access tokens manually, create the migration yourself:
```sh
node ace make:migration auth_access_tokens
```
```ts title="database/migrations/TIMESTAMP_create_auth_access_tokens_table.ts"
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'auth_access_tokens'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table
.integer('tokenable_id')
.notNullable()
.unsigned()
.references('id')
.inTable('users')
.onDelete('CASCADE')
table.string('type').notNullable()
table.string('name').nullable()
table.string('hash').notNullable()
table.text('abilities').notNullable()
table.timestamp('created_at')
table.timestamp('updated_at')
table.timestamp('last_used_at').nullable()
table.timestamp('expires_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
```
## Issuing tokens
Use the tokens provider on your User model to create tokens. The `create` method accepts a user instance and returns an [AccessToken](https://github.com/adonisjs/auth/blob/10.x/modules/access_tokens_guard/access_token.ts) object:
```ts title="start/routes.ts"
import User from '#models/user'
import router from '@adonisjs/core/services/router'
router.post('/users/:id/tokens', async ({ params }) => {
const user = await User.findOrFail(params.id)
const token = await User.accessTokens.create(user)
return {
type: 'bearer',
value: token.value!.release(),
}
})
```
The `token.value` property contains the actual token string wrapped in a [Secret](../../reference/helpers.md#secret) object. Call `.release()` to get the plain string value. This value is only available at creation time. Once the response is sent, the plain token cannot be retrieved again because only its hash is stored.
You can also return the token object directly, which serializes to JSON automatically:
```ts title="start/routes.ts"
router.post('/users/:id/tokens', async ({ params }) => {
const user = await User.findOrFail(params.id)
const token = await User.accessTokens.create(user)
return token
})
/**
* Response:
* {
* "type": "bearer",
* "value": "oat_MTA.aWFQUmo2WkQzd3M5cW0zeG5JeHdiaV9rOFQzUWM1aTZSR2xJaDZXYzM5MDE4MzA3NTU",
* "expiresAt": null
* }
*/
```
### Token abilities
Abilities let you restrict what a token can do. For example, you might issue a token that can read projects but not create or delete them.
```ts title="start/routes.ts"
const token = await User.accessTokens.create(user, ['projects:read', 'projects:list'])
```
Abilities are stored as an array of strings. Define whatever abilities make sense for your application. Common patterns include resource-based abilities (`projects:read`, `users:delete`) and role-based abilities (`admin`, `editor`).
To allow all abilities, use the wildcard:
```ts title="start/routes.ts"
const token = await User.accessTokens.create(user, ['*'])
```
Check abilities when handling requests:
```ts title="start/routes.ts"
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
router
.delete('/projects/:id', async ({ auth, response }) => {
if (!auth.user!.currentAccessToken.allows('projects:delete')) {
return response.forbidden('Token lacks projects:delete ability')
}
// Delete project...
})
.use(middleware.auth({ guards: ['api'] }))
```
The `AccessToken` class provides these methods for checking abilities:
| Method | Description |
|--------|-------------|
| `allows(ability)` | Returns `true` if the token has the specified ability or the wildcard (`*`). |
| `denies(ability)` | Returns `true` if the token does not have the specified ability. |
### Token expiration
Set an expiration time when creating a token:
```ts title="start/routes.ts"
const token = await User.accessTokens.create(user, ['*'], {
expiresIn: '7 days',
})
```
You can also set a default expiration in the provider configuration, which applies to all tokens unless overridden.
### Token names
Assign names to tokens so users can identify them in a management interface:
```ts title="start/routes.ts"
const token = await User.accessTokens.create(user, ['*'], {
name: 'CLI Tool Token',
})
```
## Configuring the guard
After setting up the tokens provider, configure the authentication guard in `config/auth.ts`:
```ts title="config/auth.ts"
import { defineConfig } from '@adonisjs/auth'
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens'
const authConfig = defineConfig({
default: 'api',
guards: {
api: tokensGuard({
provider: tokensUserProvider({
tokens: 'accessTokens',
model: () => import('#models/user'),
}),
}),
},
})
export default authConfig
```
The `tokensGuard` method creates an instance of [AccessTokensGuard](https://github.com/adonisjs/auth/blob/10.x/modules/access_tokens_guard/guard.ts).
The `tokensUserProvider` method accepts two options:
| Option | Description |
|--------|-------------|
| `model` | A function that imports your User model. |
| `tokens` | The name of the static property on your model that references the tokens provider (typically `accessTokens`). |
## Authenticating requests
Clients include the token in the `Authorization` header as a bearer token:
```
Authorization: Bearer oat_MTA.aWFQUmo2WkQzd3M5cW0zeG5JeHdiaV9rOFQzUWM1aTZSR2xJaDZXYzM5MDE4MzA3NTU
```
### Using the auth middleware
Apply the `auth` middleware to routes that require authentication:
```ts title="start/routes.ts"
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
router
.post('/projects', async ({ auth }) => {
console.log(auth.user) // User instance
console.log(auth.authenticatedViaGuard) // 'api'
console.log(auth.user!.currentAccessToken) // AccessToken instance
})
.use(middleware.auth({ guards: ['api'] }))
```
The middleware throws [E_UNAUTHORIZED_ACCESS](../../reference/exceptions.md#e_unauthorized_access) if the token is missing, invalid, or expired.
### Manual authentication
To authenticate without the middleware, call `auth.authenticate()` or `auth.authenticateUsing()`:
```ts title="start/routes.ts"
router.post('/projects', async ({ auth }) => {
/**
* Authenticate using the default guard
*/
const user = await auth.authenticate()
/**
* Authenticate using specific guards
*/
const user = await auth.authenticateUsing(['api'])
})
```
### Checking authentication status
Use `auth.isAuthenticated` to check if the request is authenticated:
```ts title="app/controllers/projects_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class ProjectsController {
async store({ auth }: HttpContext) {
if (auth.isAuthenticated) {
await auth.user!.related('projects').create(projectData)
}
}
}
```
### Avoiding non-null assertions
Use `auth.getUserOrFail()` instead of `auth.user!` to avoid the non-null assertion operator:
```ts title="app/controllers/projects_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class ProjectsController {
async store({ auth }: HttpContext) {
const user = auth.getUserOrFail()
await user.related('projects').create(projectData)
}
}
```
## The current access token
After successful authentication, the guard attaches the token to `user.currentAccessToken`. Use this to check abilities, expiration, or other token properties:
```ts title="start/routes.ts"
router
.get('/projects', async ({ auth }) => {
const token = auth.user!.currentAccessToken
console.log(token.identifier) // Token ID from database
console.log(token.abilities) // Array of abilities
console.log(token.isExpired()) // Boolean
console.log(token.lastUsedAt) // DateTime or null
})
.use(middleware.auth({ guards: ['api'] }))
```
The guard updates the `last_used_at` column each time a token is used for authentication.
If you reference the User model with `currentAccessToken` elsewhere in your codebase (such as in Bouncer abilities), declare the property on your model to avoid type errors:
```ts title="app/models/user.ts"
import { AccessToken } from '@adonisjs/auth/access_tokens'
export default class User extends BaseModel {
// ...other properties
currentAccessToken?: AccessToken
}
```
## Listing tokens
Retrieve all tokens for a user with the `all` method:
```ts title="start/routes.ts"
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
router
.get('/tokens', async ({ auth }) => {
return User.accessTokens.all(auth.user!)
})
.use(middleware.auth({ guards: ['api'] }))
```
The `all` method returns both valid and expired tokens. Filter or label expired tokens in your UI:
```edge
@each(token in tokens)
{{ token.name ?? 'Unnamed token' }}
@if(token.isExpired())
Expired
@end
Abilities: {{ token.abilities.join(', ') }}
@end
```
## Deleting tokens
Delete a token by its identifier:
```ts title="start/routes.ts"
await User.accessTokens.delete(user, tokenId)
```
## Login and logout via guard
When using access tokens as your primary authentication method (common for mobile apps), the guard provides `createToken` and `invalidateToken` methods that mirror the session guard's `login` and `logout`:
```ts title="app/controllers/session_controller.ts"
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
export default class SessionController {
async store({ request, auth }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
const user = await User.verifyCredentials(email, password)
return await auth.use('api').createToken(user)
}
async destroy({ auth }: HttpContext) {
await auth.use('api').invalidateToken()
}
}
```
```ts title="start/routes.ts"
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
const SessionController = () => import('#controllers/session_controller')
router.post('/session', [SessionController, 'store'])
router
.delete('/session', [SessionController, 'destroy'])
.use(middleware.auth({ guards: ['api'] }))
```
:::warning
When verifying credentials fails, `User.verifyCredentials` throws [E_INVALID_CREDENTIALS](../../reference/exceptions.md#e_invalid_credentials). For API clients, include an `Accept: application/json` header to receive JSON error responses instead of redirects.
:::
:::tip
If your API is accessed exclusively via access tokens (not from a browser), you may want to disable [CSRF protection](../security/securing_ssr_applications.md#csrf-protection) for API routes. See the [shield configuration reference](../security/securing_ssr_applications.md#config-reference) for details.
:::
## Events
The access tokens guard emits events during authentication. See the [events reference guide](../../reference/events.md#access_tokens_authauthentication_attempted) for the complete list.
---
# Basic auth guard
This guide covers authenticating HTTP requests using the HTTP Basic Authentication protocol. You will learn:
- How basic authentication works and when to use it
- How to configure the basic auth guard and user provider
- How to authenticate requests using basic auth
- How to protect routes with the auth middleware
## Overview
The **Basic auth guard** implements the [HTTP authentication framework](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). The client sends credentials as a base64-encoded string in the `Authorization` header with each request. For example, `Authorization: Basic am9obkBleGFtcGxlLmNvbTpzZWNyZXQ=` contains the email and password for a user.
Basic authentication is stateless because the server does not maintain any persistent sessions or issue tokens. Instead, the client must include the credentials in every request. Because credentials are sent in plain (only base64 encoded), basic authentication must always be used over HTTPS in production.
While simple to set up, basic authentication is not recommended for production applications due to the lack of modern security features like MFA or account management. It is primarily used during early development or for simple internal tools.
## Configuring the guard
First, define the basic auth guard in your `config/auth.ts` file. You must import `basicAuthGuard` and `basicAuthUserProvider` from the `@adonisjs/auth/basic_auth` module.
```ts title="config/auth.ts"
import { defineConfig } from '@adonisjs/auth'
import { basicAuthGuard, basicAuthUserProvider } from '@adonisjs/auth/basic_auth'
const authConfig = defineConfig({
default: 'basic',
guards: {
basic: basicAuthGuard({
provider: basicAuthUserProvider({
model: () => import('#models/user'),
}),
}),
},
})
export default authConfig
```
The `basicAuthUserProvider` uses your User model to find and verify credentials. It expects the model to have a `verifyCredentials` static method, which is typically provided by the [AuthFinder mixin](./verifying_user_credentials.md#using-the-authfinder-mixin).
## Configuring the User model
The `basicAuthUserProvider` works with any Lucid model that represents your user entity. During installation, the `add` command generates a `User` model with the `withAuthFinder` mixin applied.
```ts title="app/models/user.ts"
import { DateTime } from 'luxon'
import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import hash from '@adonisjs/core/services/hash'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
/**
* Applying the withAuthFinder mixin adds the verifyCredentials
* static method to your model.
*/
const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
uids: ['email'],
passwordColumnName: 'password',
})
export default class User extends compose(BaseModel, AuthFinder) {
@column({ isPrimary: true })
declare id: number
@column()
declare email: string
@column()
declare password: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
```
## Authenticating requests
Clients must include the `Authorization` header with the word `Basic` followed by a space and the base64-encoded credentials (usually `email:password`).
```text
Authorization: Basic am9obkBleGFtcGxlLmNvbTpzZWNyZXQ=
```
### Using the auth middleware
Apply the `auth` middleware to routes that require authentication. The middleware automatically reads the header, verifies the credentials using the configured provider, and attaches the user to the HTTP context.
```ts title="start/routes.ts"
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
router
.get('/projects', async ({ auth }) => {
/**
* The auth.user property is now the authenticated user.
* Use auth.getUserOrFail() to avoid non-null assertions.
*/
const user = auth.getUserOrFail()
return user.related('projects').query()
})
.use(middleware.auth({ guards: ['basic'] }))
```
The middleware throws [E_UNAUTHORIZED_ACCESS](../../reference/exceptions.md#e_unauthorized_access) if the credentials are missing or invalid.
### Manual authentication
To authenticate without the middleware, call `auth.authenticate()` or `auth.authenticateUsing()`.
```ts title="start/routes.ts"
router.get('/projects', async ({ auth }) => {
/**
* Authenticate using the default guard.
* Throws E_UNAUTHORIZED_ACCESS on failure.
*/
const user = await auth.authenticate()
return user.related('projects').query()
})
```
:::warning
Basic authentication performs a database lookup and password verification on **every** request. This is computationally expensive compared to session or token-based authentication. If performance is a concern, consider moving to the [Session guard](./session_guard.md) or [Access tokens guard](./access_tokens_guard.md) as your application grows.
:::
## Next steps
- [Verifying user credentials](./verifying_user_credentials.md): Learn how the User model handles password verification.
- [Session guard](./session_guard.md): Cookie-based authentication for web apps.
- [Access tokens guard](./access_tokens_guard.md): Token-based authentication for APIs and mobile apps.
---
# Creating a custom auth guard
This guide covers building custom authentication guards in AdonisJS. You will learn:
- When to create a custom guard instead of using built-in options
- How to design a user provider interface for your guard
- How to implement the guard contract
- How to generate and verify tokens
- How to register and use your custom guard
## Overview
AdonisJS ships with session, access token, and basic auth guards that cover most authentication needs. However, you might need a custom guard for specific requirements like JWT authentication, API keys, or integration with external identity providers.
A custom guard consists of two parts: a **user provider** interface that defines how to find users, and a **guard implementation** that handles the authentication logic. This separation allows the same guard to work with different data sources (Lucid models, Prisma, external APIs) by swapping the user provider.
This guide walks through building a JWT authentication guard as a practical example. The concepts apply to any custom authentication mechanism.
:::note
This is advanced content. Before building a custom guard, verify that the [session guard](./session_guard.md), [access tokens guard](./access_tokens_guard.md), or [basic auth guard](./basic_auth_guard.md) don't meet your needs.
:::
## Project structure
All the code in this guide goes into a single file that you can expand later. Create the file at `app/auth/guards/jwt.ts`:
```sh
mkdir -p app/auth/guards
touch app/auth/guards/jwt.ts
```
## Defining the user provider interface
Guards should not hardcode how users are fetched from the database. Instead, they define a user provider interface that describes the methods needed for authentication. This lets developers supply their own implementation based on their data layer.
For a JWT guard, the provider needs to find users by their ID (extracted from the token payload). Start by defining the interface:
```ts title="app/auth/guards/jwt.ts"
import { symbols } from '@adonisjs/auth'
/**
* Bridge between the user provider and the guard.
* Wraps the actual user object with methods the guard needs.
*/
export type JwtGuardUser = {
getId(): string | number | BigInt
getOriginal(): RealUser
}
/**
* Interface that user providers must implement
* to work with the JWT guard.
*/
export interface JwtUserProviderContract {
/**
* Property for TypeScript to infer the actual user type.
* Not used at runtime.
*/
[symbols.PROVIDER_REAL_USER]: RealUser
/**
* Create a guard user instance from the actual user object.
*/
createUserForGuard(user: RealUser): Promise>
/**
* Find a user by their ID.
*/
findById(identifier: string | number | BigInt): Promise | null>
}
```
The `JwtGuardUser` type acts as a bridge between your actual user object (a Lucid model, Prisma object, or plain object) and the guard. The guard uses `getId()` to get the user's identifier for the token payload and `getOriginal()` to return the user object after authentication.
The `RealUser` generic parameter allows the interface to work with any user type. A Lucid-based provider would return a model instance, while a Prisma-based provider would return a Prisma user object.
## Implementing the guard
The guard must implement the `GuardContract` interface from `@adonisjs/auth`. This interface defines the methods and properties that integrate the guard with AdonisJS authentication.
Start with the class structure and required properties:
```ts title="app/auth/guards/jwt.ts"
import { symbols } from '@adonisjs/auth'
import type { GuardContract } from '@adonisjs/auth/types'
export class JwtGuard>
implements GuardContract
{
/**
* Events emitted by this guard. JWT guard doesn't emit events,
* but the property is required by the interface.
*/
declare [symbols.GUARD_KNOWN_EVENTS]: {}
/**
* Unique identifier for this guard type.
*/
driverName: 'jwt' = 'jwt'
/**
* Whether authentication has been attempted during this request.
*/
authenticationAttempted: boolean = false
/**
* Whether the current request is authenticated.
*/
isAuthenticated: boolean = false
/**
* The authenticated user, if any.
*/
user?: UserProvider[typeof symbols.PROVIDER_REAL_USER]
async generate(user: UserProvider[typeof symbols.PROVIDER_REAL_USER]) {
// TODO: implement
}
async authenticate(): Promise {
// TODO: implement
}
async check(): Promise {
// TODO: implement
}
getUserOrFail(): UserProvider[typeof symbols.PROVIDER_REAL_USER] {
// TODO: implement
}
async authenticateAsClient(
user: UserProvider[typeof symbols.PROVIDER_REAL_USER]
): Promise {
// TODO: implement
}
}
```
### Accepting dependencies
The guard needs a user provider to find users and HTTP context to read request headers. It also needs configuration options like the JWT secret. Add these as constructor parameters:
```ts title="app/auth/guards/jwt.ts"
import type { HttpContext } from '@adonisjs/core/http'
export type JwtGuardOptions = {
secret: string
}
export class JwtGuard>
implements GuardContract
{
#ctx: HttpContext
#userProvider: UserProvider
#options: JwtGuardOptions
constructor(
ctx: HttpContext,
userProvider: UserProvider,
options: JwtGuardOptions
) {
this.#ctx = ctx
this.#userProvider = userProvider
this.#options = options
}
// ... rest of the class
}
```
### Generating tokens
Install the `jsonwebtoken` package to handle JWT creation and verification:
```sh
npm i jsonwebtoken @types/jsonwebtoken
```
Implement the `generate` method to create a signed JWT containing the user's ID:
```ts title="app/auth/guards/jwt.ts"
import jwt from 'jsonwebtoken'
export class JwtGuard>
implements GuardContract
{
// ... constructor and properties
async generate(user: UserProvider[typeof symbols.PROVIDER_REAL_USER]) {
const providerUser = await this.#userProvider.createUserForGuard(user)
const token = jwt.sign({ userId: providerUser.getId() }, this.#options.secret)
return {
type: 'bearer',
token: token,
}
}
}
```
The method uses the user provider to get the user's ID, then signs a JWT with that ID in the payload.
### Authenticating requests
The `authenticate` method reads the JWT from the request, verifies it, and fetches the corresponding user:
```ts title="app/auth/guards/jwt.ts"
import { errors, symbols } from '@adonisjs/auth'
export class JwtGuard>
implements GuardContract
{
// ... constructor and properties
async authenticate(): Promise {
/**
* Skip if already authenticated during this request
*/
if (this.authenticationAttempted) {
return this.getUserOrFail()
}
this.authenticationAttempted = true
/**
* Read the authorization header
*/
const authHeader = this.#ctx.request.header('authorization')
if (!authHeader) {
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
guardDriverName: this.driverName,
})
}
/**
* Extract the token from "Bearer "
*/
const [, token] = authHeader.split('Bearer ')
if (!token) {
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
guardDriverName: this.driverName,
})
}
/**
* Verify the token and extract the payload
*/
const payload = jwt.verify(token, this.#options.secret)
if (typeof payload !== 'object' || !('userId' in payload)) {
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
guardDriverName: this.driverName,
})
}
/**
* Find the user by ID from the token payload
*/
const providerUser = await this.#userProvider.findById(payload.userId)
if (!providerUser) {
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
guardDriverName: this.driverName,
})
}
/**
* Store the authenticated user and return
*/
this.user = providerUser.getOriginal()
this.isAuthenticated = true
return this.user
}
}
```
### Implementing helper methods
The `check` method is a non-throwing version of `authenticate`:
```ts title="app/auth/guards/jwt.ts"
async check(): Promise {
try {
await this.authenticate()
return true
} catch {
return false
}
}
```
The `getUserOrFail` method returns the authenticated user or throws:
```ts title="app/auth/guards/jwt.ts"
getUserOrFail(): UserProvider[typeof symbols.PROVIDER_REAL_USER] {
if (!this.user) {
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
guardDriverName: this.driverName,
})
}
return this.user
}
```
### Supporting test authentication
The `authenticateAsClient` method is used by Japa's `loginAs` helper during testing. It returns headers that the test client should include:
```ts title="app/auth/guards/jwt.ts"
import type { AuthClientResponse } from '@adonisjs/auth/types'
async authenticateAsClient(
user: UserProvider[typeof symbols.PROVIDER_REAL_USER]
): Promise {
const token = await this.generate(user)
return {
headers: {
authorization: `Bearer ${token.token}`,
},
}
}
```
## Complete implementation
Here's the complete guard implementation:
```ts title="app/auth/guards/jwt.ts"
import jwt from 'jsonwebtoken'
import { symbols, errors } from '@adonisjs/auth'
import type { HttpContext } from '@adonisjs/core/http'
import type { AuthClientResponse, GuardContract } from '@adonisjs/auth/types'
/**
* Bridge between the user provider and the guard.
*/
export type JwtGuardUser = {
getId(): string | number | BigInt
getOriginal(): RealUser
}
/**
* Interface for user providers compatible with the JWT guard.
*/
export interface JwtUserProviderContract {
[symbols.PROVIDER_REAL_USER]: RealUser
createUserForGuard(user: RealUser): Promise>
findById(identifier: string | number | BigInt): Promise | null>
}
/**
* Configuration options for the JWT guard.
*/
export type JwtGuardOptions = {
secret: string
}
/**
* JWT authentication guard implementation.
*/
export class JwtGuard>
implements GuardContract
{
declare [symbols.GUARD_KNOWN_EVENTS]: {}
driverName: 'jwt' = 'jwt'
authenticationAttempted: boolean = false
isAuthenticated: boolean = false
user?: UserProvider[typeof symbols.PROVIDER_REAL_USER]
#ctx: HttpContext
#userProvider: UserProvider
#options: JwtGuardOptions
constructor(
ctx: HttpContext,
userProvider: UserProvider,
options: JwtGuardOptions
) {
this.#ctx = ctx
this.#userProvider = userProvider
this.#options = options
}
async generate(user: UserProvider[typeof symbols.PROVIDER_REAL_USER]) {
const providerUser = await this.#userProvider.createUserForGuard(user)
const token = jwt.sign({ userId: providerUser.getId() }, this.#options.secret)
return {
type: 'bearer',
token: token,
}
}
async authenticate(): Promise {
if (this.authenticationAttempted) {
return this.getUserOrFail()
}
this.authenticationAttempted = true
const authHeader = this.#ctx.request.header('authorization')
if (!authHeader) {
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
guardDriverName: this.driverName,
})
}
const [, token] = authHeader.split('Bearer ')
if (!token) {
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
guardDriverName: this.driverName,
})
}
const payload = jwt.verify(token, this.#options.secret)
if (typeof payload !== 'object' || !('userId' in payload)) {
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
guardDriverName: this.driverName,
})
}
const providerUser = await this.#userProvider.findById(payload.userId)
if (!providerUser) {
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
guardDriverName: this.driverName,
})
}
this.user = providerUser.getOriginal()
this.isAuthenticated = true
return this.user
}
async check(): Promise {
try {
await this.authenticate()
return true
} catch {
return false
}
}
getUserOrFail(): UserProvider[typeof symbols.PROVIDER_REAL_USER] {
if (!this.user) {
throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
guardDriverName: this.driverName,
})
}
return this.user
}
async authenticateAsClient(
user: UserProvider[typeof symbols.PROVIDER_REAL_USER]
): Promise {
const token = await this.generate(user)
return {
headers: {
authorization: `Bearer ${token.token}`,
},
}
}
}
```
## Registering the guard
Register your custom guard in `config/auth.ts`. The `JwtUserProviderContract` interface is compatible with the session guard's user provider, so you can reuse it:
```ts title="config/auth.ts"
import { defineConfig } from '@adonisjs/auth'
import { sessionUserProvider } from '@adonisjs/auth/session'
import env from '#start/env'
import { JwtGuard } from '#auth/guards/jwt'
const jwtConfig = {
secret: env.get('APP_KEY'),
}
const userProvider = sessionUserProvider({
model: () => import('#models/user'),
})
const authConfig = defineConfig({
default: 'jwt',
guards: {
jwt: (ctx) => {
return new JwtGuard(ctx, userProvider, jwtConfig)
},
},
})
export default authConfig
```
The guard factory receives the HTTP context and returns a new guard instance for each request.
## Using the guard
With the guard registered, use it like any built-in guard:
```ts title="start/routes.ts"
import User from '#models/user'
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
router.post('/login', async ({ request, auth }) => {
const { email, password } = request.all()
const user = await User.verifyCredentials(email, password)
return await auth.use('jwt').generate(user)
})
router
.get('/profile', async ({ auth }) => {
return auth.getUserOrFail()
})
.use(middleware.auth({ guards: ['jwt'] }))
```
## Next steps
This implementation provides a foundation for JWT authentication. Consider extending it with:
- Token expiration (`exp` claim in the JWT payload)
- Refresh tokens for obtaining new access tokens
- Token revocation using a blocklist
- Additional claims like roles or permissions
- Custom error messages for different failure scenarios
---
# Social authentication
This guide covers social authentication in AdonisJS using the Ally package. You will learn:
- How to install and configure Ally with OAuth providers
- How to redirect users to a provider and handle callbacks
- How to access user information from the provider
- How to create or find users and log them in
- How to use stateless authentication for SPAs and mobile apps
- How to create custom social drivers
## Overview
Social authentication allows users to log in using their existing accounts from services like GitHub, Google, X (formerly Twitter), or Discord. Instead of creating a new username and password, users authorize your application to access their profile information from the provider.
AdonisJS provides the `@adonisjs/ally` package for social authentication. Ally handles the OAuth flow (redirecting users, exchanging codes for tokens, fetching user data) and provides a consistent API across different providers. It supports OAuth 1.0 (Twitter) and OAuth 2.0 (X, GitHub, Google, and most other providers).
Ally does not store users or tokens in your database. It handles the OAuth flow and returns user information, which you then use to create or find a user in your database and log them in using an [auth guard](./introduction.md).
## Installation
Install and configure the package using the `add` command:
```sh
node ace add @adonisjs/ally
# Specify providers during installation
node ace add @adonisjs/ally --providers=github --providers=google
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/ally` package using the detected package manager.
2. Registers the following service provider inside the `adonisrc.ts` file.
```ts
{
providers: [
// ...other providers
() => import('@adonisjs/ally/ally_provider')
]
}
```
3. Creates `config/ally.ts` with configuration for the selected providers.
4. Defines environment variables for `CLIENT_ID` and `CLIENT_SECRET` for each provider.
:::
## Configuration
Configure your OAuth providers in `config/ally.ts`. Each provider requires a client ID, client secret, and callback URL:
```ts title="config/ally.ts"
import env from '#start/env'
import { defineConfig, services } from '@adonisjs/ally'
export default defineConfig({
github: services.github({
clientId: env.get('GITHUB_CLIENT_ID'),
clientSecret: env.get('GITHUB_CLIENT_SECRET'),
callbackUrl: 'http://localhost:3333/github/callback',
}),
google: services.google({
clientId: env.get('GOOGLE_CLIENT_ID'),
clientSecret: env.get('GOOGLE_CLIENT_SECRET'),
callbackUrl: 'http://localhost:3333/google/callback',
}),
})
```
### Registering callback URLs with providers
OAuth providers require you to register your callback URL in their developer console. For example, to use GitHub authentication:
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Create a new OAuth App
3. Set the Authorization callback URL to match your `callbackUrl` in the config
The callback URL in your config must exactly match what you register with the provider.
## Redirecting users to the provider
Create a route that redirects users to the OAuth provider. Use `ally.use()` to get the driver instance and call `redirect()`.
Before redirecting, store `redirect.previousUrl` in the session so that error handlers and `back()` calls redirect the user to the correct page in your application instead of the OAuth provider's domain.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/github/redirect', ({ ally, session }) => {
session.put('redirect.previousUrl', '/login')
return ally.use('github').redirect()
})
```
### Requesting scopes
Scopes define what data your application can access. Each provider has different available scopes. Configure them in `config/ally.ts` or during the redirect:
```ts title="config/ally.ts"
github: services.github({
clientId: env.get('GITHUB_CLIENT_ID'),
clientSecret: env.get('GITHUB_CLIENT_SECRET'),
callbackUrl: 'http://localhost:3333/github/callback',
scopes: ['user:email', 'read:user'],
}),
```
```ts title="start/routes.ts"
router.get('/github/redirect', ({ ally }) => {
return ally.use('github').redirect((request) => {
request.scopes(['user:email', 'read:user'])
})
})
```
### Adding query parameters
Some providers accept additional parameters. For example, Google's `prompt` parameter controls the consent screen behavior:
```ts title="start/routes.ts"
router.get('/google/redirect', ({ ally }) => {
return ally.use('google').redirect((request) => {
request.param('prompt', 'select_account')
request.param('access_type', 'offline')
})
})
```
To remove a parameter set in the config, use `clearParam`:
```ts title="start/routes.ts"
router.get('/google/redirect', ({ ally }) => {
return ally.use('google').redirect((request) => {
request.clearParam('prompt')
})
})
```
## Handling the callback
After the user authorizes (or denies) access, the provider redirects them to your callback URL. Call `user()` on the driver to exchange the authorization code for an access token and fetch the user's profile.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.get('/github/callback', async ({ ally }) => {
const github = ally.use('github')
const githubUser = await github.user()
return githubUser
})
```
If something goes wrong during the callback (the user denied access, the state token doesn't match, or the authorization code is missing), the `user()` method throws a self-handled exception. The exception content-negotiates the response automatically:
- **HTML requests with a session**: the error message is flashed to the session and the user is redirected back. If you stored `redirect.previousUrl` in the session before the OAuth redirect, the user returns to that page.
- **JSON requests**: a JSON error response is returned with the appropriate status code.
- **JSONAPI requests**: a JSONAPI-formatted error response is returned.
If you need to handle specific error cases yourself, you can still check for them before calling `user()`.
```ts title="start/routes.ts"
router.get('/github/callback', async ({ ally, response }) => {
const github = ally.use('github')
if (github.accessDenied()) {
return response.redirect().toPath('/login?error=access_denied')
}
const githubUser = await github.user()
return githubUser
})
```
## User properties
The `user()` method returns a normalized user object with consistent properties across all providers:
| Property | Description |
|----------|-------------|
| `id` | Unique identifier from the provider |
| `email` | User's email address (may be `null` if not requested or not available) |
| `emailVerificationState` | One of `verified`, `unverified`, or `unsupported` |
| `name` | User's display name |
| `nickName` | Username or handle (same as `name` if provider doesn't support nicknames) |
| `avatarUrl` | URL to the user's profile picture |
| `token` | Access token object for making API calls |
| `original` | Raw response from the provider |
### Email verification state
Providers handle email verification differently. Check `emailVerificationState` before trusting the email:
- `verified`: The provider has verified this email address
- `unverified`: The email exists but isn't verified
- `unsupported`: The provider doesn't share verification status
### Access token
The `token` property contains the OAuth token for making additional API calls to the provider:
| Property | Protocol | Description |
|----------|----------|-------------|
| `token` | OAuth 1 & 2 | The access token string |
| `secret` | OAuth 1 only | Token secret (used by Twitter) |
| `type` | OAuth 2 | Token type (usually `bearer`) |
| `refreshToken` | OAuth 2 | Token for obtaining new access tokens |
| `expiresAt` | OAuth 2 | DateTime when the token expires |
| `expiresIn` | OAuth 2 | Seconds until expiration |
### Original response
Access provider-specific data through the `original` property:
```ts title="start/routes.ts"
const githubUser = await github.user()
console.log(githubUser.original)
```
## Creating users and logging in
Ally provides user information but doesn't create users or sessions. After getting the user data from the provider, you need to:
1. Find or create a user in your database
2. Log them in using an auth guard
### With the session guard
For server-rendered applications, create a session after social authentication. Errors during the callback (denied access, state mismatch) are self-handled and will redirect the user back with a flash message.
```ts title="start/routes.ts"
import User from '#models/user'
import router from '@adonisjs/core/services/router'
router.get('/github/callback', async ({ ally, auth, response }) => {
const githubUser = await ally.use('github').user()
/**
* Find existing user or create a new one
*/
const user = await User.firstOrCreate(
{ email: githubUser.email },
{
email: githubUser.email,
fullName: githubUser.name,
/**
* Generate a random password since social users
* won't use password-based login
*/
password: crypto.randomUUID(),
}
)
/**
* Create a session for the user
*/
await auth.use('web').login(user)
/**
* Redirect to the page the user was trying to access,
* or /dashboard if no intended URL was stored
*/
return response.redirect().toIntended('/dashboard')
})
```
### With the access tokens guard
For APIs and mobile apps, issue an access token after social authentication.
```ts title="start/routes.ts"
import User from '#models/user'
import router from '@adonisjs/core/services/router'
router.get('/github/callback', async ({ ally }) => {
const github = ally.use('github')
if (github.accessDenied()) {
return { error: 'access_denied' }
}
if (github.stateMisMatch()) {
return { error: 'state_mismatch' }
}
if (github.hasError()) {
return { error: github.getError() }
}
const githubUser = await github.user()
const user = await User.firstOrCreate(
{ email: githubUser.email },
{
email: githubUser.email,
fullName: githubUser.name,
password: crypto.randomUUID(),
}
)
/**
* Create an access token for the user
*/
const token = await User.accessTokens.create(user)
return {
type: 'bearer',
value: token.value!.release(),
}
})
```
## Disallow signups for a provider
Use the `disallowLocalSignup` option when a provider should only be used for login or account linking, but not for creating new local accounts. This can be helpful when you have historically allowed signups using a given provider but now want to limit it to login only.
```ts title="config/ally.ts"
export default defineConfig({
github: services.github({
clientId: env.get('GITHUB_CLIENT_ID'),
clientSecret: env.get('GITHUB_CLIENT_SECRET'),
callbackUrl: 'http://localhost:3333/github/callback',
disallowLocalSignup: true,
}),
})
```
With `disallowLocalSignup` enabled, calling `ally.use(provider, { intent: 'signup' })` will throw an exception with status code `403`.
```ts title="start/routes.ts"
router.get('/github/signup/redirect', ({ ally }) => {
return ally.use('github', { intent: 'signup' }).redirect()
})
```
## Stateless authentication
By default, Ally uses a CSRF token stored in a cookie to prevent cross-site request forgery. If your application cannot use cookies (for example, a mobile app using a webview), enable stateless mode:
```ts title="start/routes.ts"
router.get('/github/redirect', ({ ally }) => {
return ally.use('github').stateless().redirect()
})
router.get('/github/callback', async ({ ally }) => {
const github = ally.use('github').stateless()
// Handle callback...
const user = await github.user()
})
```
Both the redirect and callback must use stateless mode. Without the CSRF check, ensure your application has other protections against unauthorized OAuth flows.
## Fetching user from an existing token
If you already have an access token (for example, from a mobile app's native OAuth flow), fetch user information directly:
```ts title="start/routes.ts"
router.post('/auth/github', async ({ request, ally }) => {
const { accessToken } = request.only(['accessToken'])
const user = await ally.use('github').userFromToken(accessToken)
return user
})
```
For OAuth 1 providers (Twitter), use `userFromTokenAndSecret`:
```ts title="start/routes.ts"
const user = await ally.use('twitter').userFromTokenAndSecret(token, secret)
```
## Dynamic provider selection
Handle multiple providers with a single route using route parameters:
```ts title="start/routes.ts"
router
.get('/:provider/redirect', ({ ally, params }) => {
if (!ally.has(params.provider)) {
return 'Unknown provider'
}
return ally.use(params.provider).redirect()
})
.where('provider', /github|google|twitter/)
router
.get('/:provider/callback', async ({ ally, params }) => {
if (!ally.has(params.provider)) {
return 'Unknown provider'
}
const driver = ally.use(params.provider)
// Handle callback...
})
.where('provider', /github|google|twitter/)
```
You can also inspect configured providers at runtime:
```ts title="start/routes.ts"
router.get('/login', ({ ally }) => {
return {
providers: ally.configuredProviderNames(),
signupProviders: ally.signupProviderNames(),
}
})
```
## Provider configuration reference
Each provider accepts specific configuration options. The following examples show all available options for each built-in provider.
:::disclosure{title="GitHub"}
```ts
github: services.github({
clientId: '',
clientSecret: '',
callbackUrl: '',
disallowLocalSignup: false,
scopes: ['user', 'gist'],
login: 'adonisjs',
allowSignup: true,
})
```
:::
:::disclosure{title="Google"}
```ts
google: services.google({
clientId: '',
clientSecret: '',
callbackUrl: '',
disallowLocalSignup: false,
scopes: ['userinfo.email', 'calendar.events'],
prompt: 'select_account',
accessType: 'offline',
hostedDomain: 'adonisjs.com',
display: 'page',
})
```
:::
:::disclosure{title="Twitter"}
```ts
twitter: services.twitter({
clientId: '',
clientSecret: '',
callbackUrl: '',
disallowLocalSignup: false,
})
```
:::
:::disclosure{title="X"}
```ts
twitterX: services.twitterX({
clientId: '',
clientSecret: '',
callbackUrl: '',
disallowLocalSignup: false,
scopes: ['tweet.read', 'users.read', 'users.email'],
})
```
:::
:::disclosure{title="Discord"}
```ts
discord: services.discord({
clientId: '',
clientSecret: '',
callbackUrl: '',
disallowLocalSignup: false,
scopes: ['identify', 'email'],
prompt: 'consent',
guildId: '',
disableGuildSelect: false,
permissions: 10,
})
```
:::
:::disclosure{title="LinkedIn (OpenID Connect)"}
```ts
linkedinOpenidConnect: services.linkedinOpenidConnect({
clientId: '',
clientSecret: '',
callbackUrl: '',
disallowLocalSignup: false,
scopes: ['openid', 'profile', 'email'],
})
```
:::
:::disclosure{title="Facebook"}
```ts
facebook: services.facebook({
clientId: '',
clientSecret: '',
callbackUrl: '',
disallowLocalSignup: false,
scopes: ['email', 'user_photos'],
userFields: ['first_name', 'picture', 'email'],
display: '',
authType: '',
})
```
:::
:::disclosure{title="Spotify"}
```ts
spotify: services.spotify({
clientId: '',
clientSecret: '',
callbackUrl: '',
disallowLocalSignup: false,
scopes: ['user-read-email', 'streaming'],
showDialog: false,
})
```
:::
## Creating a custom driver
If you need to integrate with a provider not included in Ally, you can create a custom driver. AdonisJS provides a [starter kit](https://github.com/adonisjs-community/ally-driver-boilerplate) for building and publishing custom drivers. See the starter kit README for implementation details.
---
# Authorization
This guide covers authorization in AdonisJS using Bouncer. You will learn how to:
- Define authorization checks as abilities and policies
- Use authorization throughout your application (controllers, templates, APIs)
- Handle advanced scenarios like guest users and policy hooks
- Implement authorization in API and Inertia applications
## Overview
Authorization determines what authenticated users are allowed to do in your application. While authentication answers "who are you?", authorization answers "what can you do?". Bouncer provides a structured way to define and check permissions throughout your AdonisJS application.
Instead of scattering authorization checks throughout your codebase, Bouncer encourages you to extract them into dedicated locations. This keeps your authorization logic centralized, reusable, and testable.
## Installation
Install and configure Bouncer using the following command.
```sh
node ace add @adonisjs/bouncer
```
:::disclosure{title="Steps performed by the add command"}
1. Registers the `@adonisjs/bouncer/bouncer_provider` service provider and `@adonisjs/bouncer/commands` inside the `adonisrc.ts` file.
2. Creates the `app/abilities/main.ts` file to define and export abilities.
3. Creates the `initialize_bouncer_middleware.ts` file inside the middleware directory and registers it within the `start/kernel.ts` file.
:::
## Defining abilities
An **ability** is a function that checks whether a user is authorized to perform a specific action. Abilities are lightweight and work well when you have a small number of simple authorization checks.
Abilities are defined in the `app/abilities/main.ts` file using the `Bouncer.ability()` method. Each ability receives the user as the first parameter, followed by any resources needed to make the authorization decision, then returns a boolean value indicating whether the action is allowed.
```ts title="app/abilities/main.ts"
import User from '#models/user'
import Post from '#models/post'
import { Bouncer } from '@adonisjs/bouncer'
export const editPost = Bouncer.ability((user: User, post: Post) => {
return user.id === post.userId
})
export const sendEmail = Bouncer.ability((user: User) => {
return user.role === 'admin'
})
```
The `editPost` ability checks if a user owns a specific post by comparing user IDs. The `sendEmail` ability verifies if a user has an admin role. Notice that `sendEmail` only needs the user parameter since it doesn't check permissions against a specific resource.
## Using abilities in controllers
You can check abilities in your controllers using the `ctx.bouncer` object. Import the ability you want to check and pass it to one of the bouncer methods.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import router from '@adonisjs/core/services/router'
import type { HttpContext } from '@adonisjs/core/http'
// [!code highlight]
import { editPost } from '#abilities/main'
export default class PostsController {
async update({ bouncer, params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
// [!code highlight:3]
if (await bouncer.denies(editPost, post)) {
return response.forbidden('You cannot edit this post')
}
// Continue with update logic
return 'Post updated successfully'
}
}
```
Notice that you only pass the `post` parameter to `bouncer.denies()`, not the user. The bouncer is already tied to the currently logged-in user and automatically provides it as the first argument to your ability.
## Authorization methods
Bouncer provides four methods for checking authorization, each suited to different use cases.
### Using allows and denies
The `allows` method checks if the user is authorized and returns `true` if they are. The `denies` method is the opposite, returning `true` if the user is not authorized.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import { editPost } from '#abilities/main'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async update({ bouncer, params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
// [!code highlight:3]
if (await bouncer.allows(editPost, post)) {
return 'You can edit this post'
}
return response.forbidden('You cannot edit this post')
}
}
```
### Using authorize
The `authorize` method throws an `AuthorizationException` when authorization fails. This exception is automatically converted to an appropriate HTTP response based on content negotiation.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import { editPost } from '#abilities/main'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async update({ bouncer, params }: HttpContext) {
const post = await Post.findOrFail(params.id)
await bouncer.authorize(editPost, post)
// If we reach here, authorization succeeded
return 'Post updated successfully'
}
}
```
### Using execute
The `execute` method returns an `AuthorizationResponse` object that contains detailed information about the authorization check. This is useful for advanced scenarios where you need to inspect the authorization result beyond a simple boolean.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import { editPost } from '#abilities/main'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async update({ bouncer, params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
const result = await bouncer.execute(editPost, post)
if (!result.authorized) {
return response
.status(result.status || 403)
.send({ error: result.message || 'Unauthorized' })
}
return 'Post updated successfully'
}
}
```
The `AuthorizationResponse` object has three properties: `authorized` (boolean), `message` (string or undefined), and `status` (number or undefined). You can use these to create custom error responses with specific status codes and messages.
## Custom authorization responses
By default, abilities return boolean values. However, you can return an `AuthorizationResponse` object to specify custom error messages and status codes.
```ts title="app/abilities/main.ts"
import User from '#models/user'
import Post from '#models/post'
import { Bouncer, AuthorizationResponse } from '@adonisjs/bouncer'
export const editPost = Bouncer.ability((user: User, post: Post) => {
if (user.id === post.userId) {
return AuthorizationResponse.allow()
}
return AuthorizationResponse.deny('Post not found', 404)
})
```
In this example, when authorization fails, the error message will be "Post not found" with a 404 status code instead of the default 403 Forbidden. This is useful when you want to hide the existence of a resource from unauthorized users.
## Defining policies
A **policy** is a class that groups multiple authorization checks for a specific resource. Policies are recommended when you need structured authorization around specific resources or when you have many authorization checks throughout your application. For example, you might create one policy for your Post model and another for your Comment model.
Policies extend the `BasePolicy` class and are stored in the `app/policies` directory. They benefit from dependency injection through the IoC container, making it easy to inject services and other dependencies.
Generate a new policy using the `make:policy` command.
```sh
node ace make:policy post
```
This creates an empty policy class in the `app/policies` directory.
```ts title="app/policies/post_policy.ts"
import User from '#models/user'
import { BasePolicy } from '@adonisjs/bouncer'
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
}
```
Add methods to your policy for each authorization action. Each method receives the user as the first parameter, optionally followed by the resource, and returns an `AuthorizerResponse` (a boolean or `AuthorizationResponse` object).
```ts title="app/policies/post_policy.ts"
import User from '#models/user'
import Post from '#models/post'
import { BasePolicy } from '@adonisjs/bouncer'
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
create(user: User): AuthorizerResponse {
return true
}
edit(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
delete(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
}
```
:::tip
Even when multiple actions have identical logic (like `edit` and `delete` in this example), create separate methods for each action. This makes it easier to evolve the logic independently later as your requirements change.
:::
## Using policies in controllers
You can use policies in controllers by calling `bouncer.with()` to select the policy, then using the same authorization methods you use with abilities.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import PostPolicy from '#policies/post_policy'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async delete({ bouncer, params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
// [!code highlight:3]
if (await bouncer.with(PostPolicy).denies('delete', post)) {
return response.forbidden('Cannot delete this post')
}
await post.delete()
return { message: 'Post deleted successfully' }
}
}
```
The `bouncer.with()` method accepts the policy class and returns an object with the same `allows`, `denies`, `authorize`, and `execute` methods. TypeScript will provide autocomplete for the available actions from your policy.
### String-based policy references
Instead of importing the policy class, you can reference it by name as a string. This works because Bouncer maintains a barrel file of all policies at `.adonisjs/server/policies.ts`.
```ts title="app/controllers/posts_controller.ts"
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async delete({ bouncer, params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
// [!code highlight:3]
if (await bouncer.with('PostPolicy').denies('delete', post)) {
return response.forbidden('Cannot delete this post')
}
await post.delete()
return { message: 'Post deleted successfully' }
}
}
```
The [barrel file](../concepts/barrel_files.md) is automatically generated when you start your development server and stays up-to-date as you add or remove policies. This file exports an object where each key is the policy name and the value is the lazy import of that policy module.
## Policy hooks
Policies support `before` and `after` hooks that run around authorization checks. These hooks provide powerful ways to create reusable authorization logic.
### The before hook
The `before` hook runs before the actual authorization method is called. This is useful for implementing logic that applies to all actions in a policy, such as granting full access to administrators.
```ts title="app/policies/post_policy.ts"
import User from '#models/user'
import Post from '#models/post'
import { BasePolicy } from '@adonisjs/bouncer'
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
// [!code highlight:5]
before(user: User | null, action: string, ...params: any[]) {
if (user && user.role === 'admin') {
return true
}
}
edit(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
delete(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
}
```
The `before` hook receives the user, the action name being checked, and any additional parameters passed to the action method. The return value controls how authorization proceeds.
| Return Value | Behavior |
|--------------|----------|
| `true` | Authorization succeeds immediately. The action method is not called. |
| `false` | Authorization fails immediately. The action method is not called. |
| `undefined` | Continue to the action method to perform the authorization check. |
In this example, any user with the `role = 'admin'` property will bypass all authorization checks. Regular users will proceed through the normal `edit` and `delete` methods.
### The after hook
The `after` hook runs after the action method completes. This allows you to inspect or override the authorization response.
```ts title="app/policies/post_policy.ts"
import User from '#models/user'
import Post from '#models/post'
import { BasePolicy } from '@adonisjs/bouncer'
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
// [!code highlight:10]
after(
user: User | null,
action: string,
response: AuthorizerResponse,
...params: any[]
) {
if (user && user.isAdmin) {
return true
}
}
edit(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
delete(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
}
```
The `after` hook receives the user, action name, the authorization response from the action method, and any additional parameters. The return value determines the final result.
| Return Value | Behavior |
|--------------|----------|
| `true` | Authorization succeeds. The original response is discarded. |
| `false` | Authorization fails. The original response is discarded. |
| `undefined` | The original response from the action method is used. |
The `after` hook is useful for applying organization-wide policies that override resource-specific checks. For example, you might allow administrators or support staff to access all resources regardless of individual authorization rules.
## Handling guest users
By default, authorization checks automatically return `false` when there is no authenticated user. This means guests are denied access unless you explicitly allow them.
### Allowing guests in abilities
To allow guest users in an ability, pass the `allowGuest` option as the first argument to `Bouncer.ability()`. The user parameter will be typed as `User | null`.
```ts title="app/abilities/main.ts"
import User from '#models/user'
import Post from '#models/post'
import { Bouncer } from '@adonisjs/bouncer'
export const viewPost = Bouncer.ability(
{ allowGuest: true },
(user: User | null, post: Post) => {
if (post.isPublished) {
return true
}
if (!user) {
return false
}
return user.id === post.userId
}
)
```
### Allowing guests in policies
Use the `@allowGuest` decorator on policy methods that should accept guest users.
```ts title="app/policies/post_policy.ts"
import User from '#models/user'
import Post from '#models/post'
import { BasePolicy, allowGuest } from '@adonisjs/bouncer'
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
@allowGuest()
view(user: User | null, post: Post): AuthorizerResponse {
if (post.isPublished) {
return true
}
if (!user) {
return false
}
return user.id === post.userId
}
edit(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
}
```
The `@allowGuest` decorator expects the type of the user parameter to be `User | null`, and the authorization check will execute even when no user is authenticated.
## Using Bouncer in Edge templates
You can use authorization checks in Edge templates to conditionally show or hide UI elements. Edge provides `@can` and `@cannot` tags that work with both abilities and policies.
### Using abilities in templates
Reference abilities by their exported name as a string. Edge will resolve and import them automatically.
```edge title="resources/views/posts/show.edge"
@can('editPost', post)
@link({ route: 'posts.edit', routeParams: [post.id] })
Edit Post
@end
@end
@cannot('deletePost', post)
You cannot delete this post
@end
```
### Using policies in templates
Reference policy actions using the format `PolicyName.methodName`.
```edge title="resources/views/posts/show.edge"
@can('PostPolicy.edit', post)
@link({ route: 'posts.edit', routeParams: [post.id] })
Edit Post
@end
@end
@can('PostPolicy.delete', post)
@form({ method: 'delete', route: 'posts.delete', routeParams: [post.id] })
@!button({ type: 'submit', text: 'Delete Post' })
@end
@end
```
## Creating custom Bouncer instances
During HTTP requests, the `InitializeBouncerMiddleware` automatically creates a Bouncer instance for the currently logged-in user by fetching it from `ctx.auth.user`. This instance is available via `ctx.bouncer` and is also shared with Edge templates.
You can create a custom Bouncer instance for a different user, such as when sending notifications or performing background jobs.
```ts title="app/services/notification_service.ts"
import User from '#models/user'
import { Bouncer } from '@adonisjs/bouncer'
import * as abilities from '#abilities/main'
import { policies } from '#generated/policies'
export default class NotificationService {
async sendNotification(user: User, post: Post) {
const bouncer = new Bouncer(user, abilities, policies)
if (await bouncer.allows(editPost, post)) {
// Send notification about post editing
}
}
}
```
TypeScript will provide intelligent autocomplete based on the user type, suggesting only policies and abilities that accept the same user type.
## Dependency injection in policies
Policy classes are instantiated using the IoC container, which means you can inject dependencies into the constructor.
```ts title="app/policies/post_policy.ts"
import User from '#models/user'
import Post from '#models/post'
import { inject } from '@adonisjs/core'
import { BasePolicy } from '@adonisjs/bouncer'
import { Logger } from '@adonisjs/core/logger'
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
@inject()
export default class PostPolicy extends BasePolicy {
constructor(protected logger: Logger) {
super()
}
edit(user: User, post: Post): AuthorizerResponse {
this.logger.info('Checking edit permission', { userId: user.id, postId: post.id })
return user.id === post.userId
}
}
```
The `@inject()` decorator tells the IoC container to resolve the dependencies automatically.
## Authorization in API and Inertia applications
Abilities and policies are defined server-side and require access to server resources like models, databases, and services. This means you cannot share them directly with frontend code in API or Inertia applications.
However, you can compute the authorization results on the server and include them in your API responses. Your frontend application only needs to know which actions a user can perform to show or hide UI elements appropriately.
### Computing permissions in transformers
For row-level permissions where you need to check authorization for individual records, compute the permissions within transformers.
```ts title="app/transformers/post_transformer.ts"
import Post from '#models/post'
import { inject } from '@adonisjs/core'
import PostPolicy from '#policies/post_policy'
import { HttpContext } from '@adonisjs/core/http'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class PostTransformer extends BaseTransformer {
@inject()
async toObject({ bouncer }: HttpContext) {
// [!code highlight]
const policy = bouncer.with(PostPolicy)
return {
id: this.resource.id,
title: this.resource.title,
content: this.resource.content,
// [!code highlight:5]
permissions: {
edit: await policy.allows('edit', this.resource),
delete: await policy.allows('delete', this.resource),
},
}
}
}
```
### Computing permissions in Inertia middleware
For application-level permissions that don't vary by record, compute them once as shared data inside the Inertia middleware.
```ts title="app/middleware/inertia_middleware.ts"
import type { HttpContext } from '@adonisjs/core/http'
import BaseInertiaMiddleware from '@adonisjs/inertia/inertia_middleware'
export default class InertiaMiddleware extends BaseInertiaMiddleware {
async share(ctx: HttpContext) {
const postPolicy = ctx.bouncer.with('PostPolicy')
return {
// ...rest of the properties
permissions: ctx.inertia.once(async () => {
return {
post: {
create: await postPolicy.allows('create'),
}
}
}),
}
}
}
```
## Testing authorization logic
You can test your authorization logic using either unit tests for individual policies and abilities, or functional tests that verify authorization end-to-end through HTTP requests.
### Unit testing policies
Test policy classes directly by instantiating them and calling their methods with the required arguments.
```ts title="tests/unit/post_policy.spec.ts"
import { test } from '@japa/runner'
import User from '#models/user'
import Post from '#models/post'
import PostPolicy from '#policies/post_policy'
test.group('Post policy', () => {
test('allows owner to edit post', async ({ assert }) => {
const user = new User()
user.id = 1
const post = new Post()
post.userId = 1
const policy = new PostPolicy()
const canEdit = policy.edit(user, post)
assert.isTrue(canEdit)
})
test('denies non-owner from editing post', async ({ assert }) => {
const user = new User()
user.id = 1
const post = new Post()
post.userId = 2
const policy = new PostPolicy()
const canEdit = policy.edit(user, post)
assert.isFalse(canEdit)
})
})
```
### Functional testing with HTTP requests
Test authorization end-to-end by making HTTP requests and verifying that unauthorized users receive appropriate error responses.
```ts title="tests/functional/posts/update.spec.ts"
import { test } from '@japa/runner'
import User from '#models/user'
import Post from '#models/post'
test.group('Posts update', () => {
test('allows owner to update post', async ({ client }) => {
const user = await User.create({ email: 'user@example.com' })
const post = await Post.create({ userId: user.id, title: 'Test' })
const response = await client
.put(`/posts/${post.id}`)
.loginAs(user)
.json({ title: 'Updated' })
response.assertStatus(200)
})
test('denies non-owner from updating post', async ({ client }) => {
const owner = await User.create({ email: 'owner@example.com' })
const otherUser = await User.create({ email: 'other@example.com' })
const post = await Post.create({ userId: owner.id, title: 'Test' })
const response = await client
.put(`/posts/${post.id}`)
.loginAs(otherUser)
.json({ title: 'Updated' })
response.assertStatus(403)
})
})
```
---
# Hashing
This guide covers password hashing in AdonisJS applications. You will learn how to:
- Hash and verify passwords
- Choose and configure hashing algorithms
- Detect and perform rehashing after configuration changes
- Speed up tests by faking the hash service
- Create custom hash drivers
## Overview
Password hashing converts plain text passwords into irreversible strings that can be safely stored in your database. Unlike encryption, hashing is a one-way process. You cannot convert a hash back to the original password. Instead, you verify passwords by hashing the input and comparing it to the stored hash.
AdonisJS provides a hash service with built-in support for three industry-standard algorithms: Argon2, Bcrypt, and Scrypt. The service stores hashes in [PHC string format](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md), a standardized encoding that embeds the algorithm parameters directly in the hash output.
:::note
If you're using the `@adonisjs/auth` module with Lucid models, password hashing and verification are handled automatically by the `AuthFinder` mixin. This guide focuses on direct usage of the hash service for cases where you need more control or aren't using the authentication module.
:::
## Installation
The hash service is included with `@adonisjs/core` and requires no additional installation for the default Scrypt driver. Scrypt uses Node.js's built-in `crypto` module, making it available immediately without external dependencies.
For Argon2 or Bcrypt, you must install their respective npm packages.
```sh
# For Argon2 (recommended for new applications)
npm i argon2
# For Bcrypt
npm i bcrypt
```
After installing a package, update your hash configuration to use the new driver.
## Basic usage
The hash service provides two primary methods: `hash.make` for creating hashes and `hash.verify` for validating passwords against existing hashes.
### Creating hashes
The `hash.make` method accepts a plain text string and returns a hash in PHC format.
```ts title="app/services/user_service.ts"
import hash from '@adonisjs/core/services/hash'
export default class UserService {
async createUser(email: string, password: string) {
/**
* Hash the password before storing. The output includes
* the algorithm, parameters, salt, and hash in one string.
*/
const hashedPassword = await hash.make(password)
// hashedPassword looks like:
// $scrypt$n=16384,r=8,p=1$randomsalt$hashoutput...
return User.create({ email, password: hashedPassword })
}
}
```
### Verifying passwords
The `hash.verify` method compares a plain text password against a stored hash. It returns `true` if they match, `false` otherwise.
```ts title="app/services/auth_service.ts"
import hash from '@adonisjs/core/services/hash'
import User from '#models/user'
export default class AuthService {
async validateCredentials(email: string, password: string) {
const user = await User.findBy('email', email)
if (!user) {
return null
}
/**
* Compare the plain text password against the stored hash.
* The verify method extracts algorithm parameters from the
* hash itself, so it works even if you've changed your config.
*/
const isValid = await hash.verify(user.password, password)
return isValid ? user : null
}
}
```
## Choosing an algorithm
Each hashing algorithm offers different tradeoffs between security, performance, and compatibility. The right choice depends on your application's requirements.
### When to choose Argon2
Argon2 is the recommended choice for new applications. It won the 2015 Password Hashing Competition and provides configurable memory hardness, making it resistant to both GPU-based attacks and specialized hardware. The `id` variant (the default) combines protection against GPU attacks and side-channel attacks.
### When to choose Bcrypt
Bcrypt remains a solid choice when you need compatibility with existing systems or other platforms. Its security properties are well-understood after decades of analysis. However, be aware that Bcrypt truncates passwords at 72 bytes, so longer passwords are effectively shortened before hashing.
:::warning
Bcrypt silently truncates passwords longer than 72 bytes. If your application accepts very long passwords or passphrases, users may be able to authenticate with only the first 72 bytes of their password. Consider using Argon2 or Scrypt if this is a concern.
:::
### When to choose Scrypt
Scrypt is the default driver because it requires no additional npm packages. It uses Node.js's built-in `crypto` module, making it ideal for applications where minimizing dependencies matters. With proper configuration, Scrypt provides security comparable to Argon2.
## Configuration
The hash configuration lives in `config/hash.ts`. You define available drivers in the `list` object and specify which one to use by default.
```ts title="config/hash.ts"
import { defineConfig, drivers } from '@adonisjs/core/hash'
export default defineConfig({
/**
* The default driver used by hash.make() and hash.verify()
* when no driver is explicitly specified.
*/
default: 'scrypt',
list: {
scrypt: drivers.scrypt({
cost: 16384,
blockSize: 8,
parallelization: 1,
saltSize: 16,
maxMemory: 33554432,
keyLength: 64,
}),
/**
* Uncomment after installing: npm i argon2
*/
// argon: drivers.argon2({
// version: 0x13,
// variant: 'id',
// iterations: 3,
// memory: 65536,
// parallelism: 4,
// saltSize: 16,
// hashLength: 32,
// }),
/**
* Uncomment after installing: npm i bcrypt
*/
// bcrypt: drivers.bcrypt({
// rounds: 10,
// saltSize: 16,
// version: '2b',
// }),
},
})
```
### Argon2 configuration
Argon2 provides fine-grained control over memory usage, iteration count, and parallelism. These parameters directly affect both security and performance.
```ts title="config/hash.ts"
import { defineConfig, drivers } from '@adonisjs/core/hash'
export default defineConfig({
default: 'argon',
list: {
argon: drivers.argon2({
version: 0x13,
variant: 'id',
iterations: 3,
memory: 65536,
parallelism: 4,
saltSize: 16,
hashLength: 32,
}),
},
})
```
::::options
:::option{name="variant" dataType="string" defaultValue="id"}
Define the Argon2 variant.
- `'d'` resists GPU attacks (for cryptocurrency).
- `'i'` resists side-channel attacks (slower).
- `'id'` combines both protections (recommended for passwords).
:::
:::option{name="version" dataType="number" defaultValue="0x13"}
Algorithm version defined as hex. `0x10` (1.0) or `0x13` (1.3).
:::
:::option{name="iterations" dataType="number" defaultValue="3"}
Time cost. Higher values increase computation time and security.
:::
:::option{name="memory" dataType="number" defaultValue="65536"}
Memory cost in KiB. Each parallel thread uses this amount. Higher values resist GPU attacks.
:::
:::option{name="parallelism" dataType="number" defaultValue="4"}
Number of parallel threads for computing the hash.
:::
:::option{name="saltSize" dataType="number" defaultValue="16"}
Length of the random salt in bytes.
:::
:::option{name="hashLength" dataType="number" defaultValue="32"}
Length of the raw hash output in bytes. The final PHC string will be longer.
:::
::::
#### Using secrets with Argon2
Argon2 supports an optional secret (sometimes called a "pepper") that adds an additional layer of protection. Unlike the salt which is stored with the hash, the secret is kept separately, typically in environment variables. Even if an attacker obtains your database, they cannot crack the hashes without the secret.
:::warning
If you add a secret to an existing application, all previously hashed passwords become invalid because they were created without the secret and cannot be verified with it. You must either reset all passwords or implement a migration strategy that rehashes passwords on next login.
:::
```ts title="config/hash.ts"
import { defineConfig, drivers } from '@adonisjs/core/hash'
import env from '#start/env'
export default defineConfig({
default: 'argon',
list: {
argon: drivers.argon2({
variant: 'id',
iterations: 3,
memory: 65536,
parallelism: 4,
/**
* The secret adds protection beyond what's stored in the database.
* Store this in your environment variables, never in code.
*/
secret: env.get('HASH_SECRET'),
}),
},
})
```
### Bcrypt configuration
Bcrypt configuration centers on the `rounds` parameter, which controls the computational cost through exponential scaling.
```ts title="config/hash.ts"
import { defineConfig, drivers } from '@adonisjs/core/hash'
export default defineConfig({
default: 'bcrypt',
list: {
bcrypt: drivers.bcrypt({
rounds: 10,
saltSize: 16,
version: '2b',
}),
},
})
```
::::options
:::option{name="rounds" dataType="number" defaultValue="10"}
Cost factor as a power of 2. A value of 10 means 2^10 (1024) iterations. Each increment doubles the computation time.
:::
:::option{name="saltSize" dataType="number" defaultValue="16"}
Length of the random salt in bytes.
:::
:::option{name="version" dataType="string" defaultValue="2b"}
Bcrypt version identifier. Use `'2b'` (current) unless you need compatibility with older `'2a'` hashes.
:::
::::
### Scrypt configuration
Scrypt uses memory-hard functions that make attacks expensive on both GPUs and specialized hardware.
```ts title="config/hash.ts"
import { defineConfig, drivers } from '@adonisjs/core/hash'
export default defineConfig({
default: 'scrypt',
list: {
scrypt: drivers.scrypt({
cost: 16384,
blockSize: 8,
parallelization: 1,
saltSize: 16,
maxMemory: 33554432,
keyLength: 64,
}),
},
})
```
::::options
:::option{name="cost" dataType="number" defaultValue="16384"}
CPU/memory cost parameter (N). Must be a power of 2. Higher values increase security and resource usage.
:::
:::option{name="blockSize" dataType="number" defaultValue="8"}
Block size parameter (r). Increases memory usage linearly.
:::
:::option{name="parallelization" dataType="number" defaultValue="1"}
Parallelization parameter (p). Values above 1 allow parallel computation.
:::
:::option{name="saltSize" dataType="number" defaultValue="16"}
Length of the random salt in bytes.
:::
:::option{name="maxMemory" dataType="number" defaultValue="33554432"}
Maximum memory in bytes (32 MiB default). Node.js throws if computed memory exceeds this.
:::
:::option{name="keyLength" dataType="number" defaultValue="64"}
Length of the derived key in bytes.
:::
::::
## Rehashing
Security best practices evolve over time, and you may need to strengthen your hashing parameters by increasing iterations, memory usage, or switching algorithms entirely. The PHC format makes this straightforward because each hash contains the parameters used to create it.
The `hash.needsReHash` method checks whether a stored hash was created with parameters that differ from your current configuration.
```ts title="app/services/auth_service.ts"
import hash from '@adonisjs/core/services/hash'
import User from '#models/user'
export default class AuthService {
async login(email: string, password: string) {
const user = await User.findBy('email', email)
if (!user) {
return null
}
const isValid = await hash.verify(user.password, password)
if (!isValid) {
return null
}
if (hash.needsReHash(user.password)) {
user.password = await hash.make(password)
await user.save()
}
return user
}
}
```
Rehashing during login is the standard approach because it's the only time you have access to the plain text password. Over time, as users log in, their passwords gradually migrate to your updated configuration.
### Migrating between algorithms
Switching from one algorithm to another follows the same pattern as parameter updates. When you change the default driver, `needsReHash` returns `true` for any hash created with a different algorithm.
:::warning
Keep your old driver configured until all users have logged in and their passwords have been rehashed. Removing the old driver before migration completes will prevent users with old hashes from authenticating. Monitor your database to track migration progress before removing the old configuration.
:::
```ts title="config/hash.ts"
import { defineConfig, drivers } from '@adonisjs/core/hash'
export default defineConfig({
/**
* Changed from 'scrypt' to 'argon'. Existing scrypt hashes
* will return true from needsReHash().
*/
default: 'argon',
list: {
/**
* Keep the old driver configured so existing hashes
* can still be verified during the migration period.
*/
scrypt: drivers.scrypt(),
argon: drivers.argon2({
variant: 'id',
iterations: 3,
memory: 65536,
parallelism: 4,
}),
},
})
```
## Using multiple drivers
Some applications need to verify hashes created by different systems or support multiple hashing strategies simultaneously. The `hash.use` method lets you explicitly select a driver.
```ts title="app/services/migration_service.ts"
import hash from '@adonisjs/core/services/hash'
export default class MigrationService {
/**
* Verify a password that might have been hashed by
* a legacy system using bcrypt.
*/
async verifyLegacyPassword(password: string, storedHash: string) {
return hash.use('bcrypt').verify(storedHash, password)
}
/**
* Create a new hash with Argon2 regardless of the default driver.
*/
async hashWithArgon(password: string) {
return hash.use('argon').make(password)
}
}
```
Each driver specified in `hash.use()` must be configured in your `config/hash.ts` file's `list` object.
## Hashing with model hooks
If you're not using the `@adonisjs/auth` module's `AuthFinder` mixin, you can hash passwords automatically using Lucid model hooks. The `$dirty` check ensures the password is only hashed when the field has actually changed, preventing rehashing on every save.
```ts title="app/models/user.ts"
import { BaseModel, beforeSave, column } from '@adonisjs/lucid/orm'
import hash from '@adonisjs/core/services/hash'
export default class User extends BaseModel {
@column()
declare email: string
@column()
declare password: string
@beforeSave()
static async hashPassword(user: User) {
if (user.$dirty.password) {
user.password = await hash.make(user.password)
}
}
}
```
## Testing with fakes
Password hashing is intentionally slow, as that's what makes it secure. However, this can significantly slow down your test suite, especially when creating many users through factories. The `hash.fake` method replaces the real implementation with a fast, insecure version suitable only for testing.
```ts title="tests/functional/users.spec.ts"
import { test } from '@japa/runner'
import hash from '@adonisjs/core/services/hash'
import { UserFactory } from '#database/factories/user_factory'
test.group('Users list', () => {
test('lists all users', async ({ client }) => {
/**
* Replace the hash service with a fake that performs
* no actual hashing. This makes user creation instant.
* The `using` keyword automatically restores the real
* implementation when the test ends.
*/
// [!code highlight]
using _hash = hash.fake()
/**
* Without faking, creating 50 users with bcrypt (10 rounds)
* takes ~5 seconds. With faking, it's nearly instant.
*/
await UserFactory.createMany(50)
const response = await client.get('/users')
response.assertStatus(200)
})
})
```
The fake implementation stores plain text and compares strings directly. You can also call `hash.restore()` manually if you need more control over when the real implementation is restored.
## Understanding PHC format
The [PHC (Password Hashing Competition)](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md) string format is a standardized way to encode hashes that embeds all the information needed to verify them. A PHC string looks like this:
```
$scrypt$n=16384,r=8,p=1$c2FsdHZhbHVl$aGFzaG91dHB1dC4uLg==
```
The format breaks down into sections separated by `$`:
1. **Algorithm identifier**: `scrypt`, `argon2id`, `bcrypt`, etc.
2. **Parameters**: Algorithm-specific settings like cost, memory, iterations
3. **Salt**: Base64-encoded random salt
4. **Hash**: Base64-encoded hash output
This self-describing format provides several benefits. The verification process can read parameters directly from the hash, allowing you to verify old hashes even after changing your configuration. The `needsReHash` method compares embedded parameters against current settings to detect when hashes need updating. You can also switch algorithms without losing the ability to verify existing passwords.
## Creating a custom driver
For specialized requirements, you can create custom hash drivers. A driver must implement the `HashDriverContract` interface with four methods: `make`, `verify`, `isValidHash`, and `needsReHash`.
```ts title="app/hash_drivers/pbkdf2.ts"
import crypto from 'node:crypto'
import {
HashDriverContract,
ManagerDriverFactory,
} from '@adonisjs/core/types/hash'
/**
* Configuration options accepted by the driver.
*/
export type Pbkdf2Config = {
iterations: number
keyLength: number
digest: 'sha256' | 'sha512'
saltSize: number
}
/**
* Driver implementation using Node's PBKDF2.
* This is for illustration. Prefer Argon2/Bcrypt/Scrypt in production.
*/
export class Pbkdf2Driver implements HashDriverContract {
constructor(private config: Pbkdf2Config) {}
/**
* Check if a string looks like a hash from this driver.
* Used to determine which driver should verify a hash.
*/
isValidHash(value: string): boolean {
return value.startsWith('$pbkdf2$')
}
/**
* Hash a plain text value and return a PHC-formatted string.
*/
async make(value: string): Promise {
const salt = crypto.randomBytes(this.config.saltSize)
const hash = crypto.pbkdf2Sync(
value,
salt,
this.config.iterations,
this.config.keyLength,
this.config.digest
)
/**
* Encode in PHC format with all parameters needed for verification.
*/
const params = `i=${this.config.iterations},l=${this.config.keyLength},d=${this.config.digest}`
return `$pbkdf2$${params}$${salt.toString('base64')}$${hash.toString('base64')}`
}
/**
* Verify a plain text value against a stored hash.
*/
async verify(hashedValue: string, plainValue: string): Promise {
const parts = hashedValue.split('$')
const params = this.#parseParams(parts[2])
const salt = Buffer.from(parts[3], 'base64')
const storedHash = Buffer.from(parts[4], 'base64')
const computedHash = crypto.pbkdf2Sync(
plainValue,
salt,
params.iterations,
params.keyLength,
params.digest
)
/**
* Use timing-safe comparison to prevent timing attacks.
*/
return crypto.timingSafeEqual(storedHash, computedHash)
}
/**
* Check if a hash needs to be regenerated because
* the configuration has changed.
*/
needsReHash(value: string): boolean {
const parts = value.split('$')
const params = this.#parseParams(parts[2])
return (
params.iterations !== this.config.iterations ||
params.keyLength !== this.config.keyLength ||
params.digest !== this.config.digest
)
}
#parseParams(paramString: string) {
const params: Record = {}
for (const pair of paramString.split(',')) {
const [key, val] = pair.split('=')
params[key] = val
}
return {
iterations: parseInt(params.i, 10),
keyLength: parseInt(params.l, 10),
digest: params.d as 'sha256' | 'sha512',
}
}
}
/**
* Factory function for referencing the driver in config.
* Returns a closure that creates driver instances lazily.
*/
export function pbkdf2Driver(config: Pbkdf2Config): ManagerDriverFactory {
return () => new Pbkdf2Driver(config)
}
```
Register your custom driver in the hash configuration.
```ts title="config/hash.ts"
import { defineConfig, drivers } from '@adonisjs/core/hash'
import { pbkdf2Driver } from '#app/hash_drivers/pbkdf2'
export default defineConfig({
default: 'pbkdf2',
list: {
pbkdf2: pbkdf2Driver({
iterations: 100000,
keyLength: 64,
digest: 'sha512',
saltSize: 16,
}),
},
})
```
---
# Encryption
This guide covers encryption and decryption in AdonisJS applications. You will learn how to:
- Encrypt and decrypt sensitive data
- Choose and configure encryption algorithms
- Use purpose-bound encryption for added security
- Set expiration times on encrypted values
- Sign data without encrypting using the message verifier
- Implement key rotation for seamless secret updates
## Overview
Encryption transforms readable data into ciphertext that can only be decrypted with the correct secret key. Unlike hashing, encryption is a reversible process. You encrypt data to protect it during storage or transmission, then decrypt it when you need to read the original value.
AdonisJS provides an encryption service with built-in support for three industry-standard algorithms: ChaCha20-Poly1305, AES-256-GCM, and AES-256-CBC. All three are authenticated encryption algorithms, meaning they not only protect confidentiality but also detect tampering. If someone modifies the encrypted data, decryption will fail rather than return corrupted data.
The encryption service produces output in a structured format that includes the driver identifier, ciphertext, initialization vector, and authentication tag. This self-describing format allows you to switch algorithms or rotate keys while maintaining the ability to decrypt older values.
## Basic usage
The encryption service provides two primary methods: `encryption.encrypt` for encrypting values and `encryption.decrypt` for retrieving the original data.
### Encrypting values
The `encryption.encrypt` method accepts any serializable value and returns an encrypted string.
```ts title="app/services/api_token_service.ts"
import encryption from '@adonisjs/core/services/encryption'
export default class ApiTokenService {
createToken(userId: number, permissions: string[]) {
/**
* Encrypt the token payload. The service handles
* serialization, so you can pass objects directly.
*/
const token = encryption.encrypt({
userId,
permissions,
createdAt: new Date(),
})
// token looks like:
// cbc.base64Ciphertext.base64IV.base64Tag
return token
}
}
```
The encryption service supports encrypting strings, numbers, booleans, arrays, objects, and dates. Complex nested structures are automatically serialized before encryption.
### Decrypting values
The `encryption.decrypt` method takes an encrypted string and returns the original value, or `null` if decryption fails.
```ts title="app/services/api_token_service.ts"
import encryption from '@adonisjs/core/services/encryption'
export default class ApiTokenService {
verifyToken(token: string) {
/**
* Attempt to decrypt the token. Returns null if the token
* is invalid, tampered with, or encrypted with a different key.
*/
const payload = encryption.decrypt(token)
if (!payload) {
return null
}
return payload as { userId: number; permissions: string[] }
}
}
```
The decryption method returns `null` rather than throwing exceptions when decryption fails. This design prevents timing attacks and simplifies error handling. You should always check for `null` before using the decrypted value.
## Purpose-bound encryption
Purpose-bound encryption ensures that encrypted values can only be decrypted when the same purpose is provided. This prevents token reuse across different contexts in your application.
```ts title="app/services/token_service.ts"
import encryption from '@adonisjs/core/services/encryption'
export default class TokenService {
createPasswordResetToken(userId: number) {
/**
* The purpose option specifies the encryption purpose.
* This token can only be decrypted with the same purpose.
*/
return encryption.encrypt(
{ userId },
{ purpose: 'password-reset' } // [!code highlight]
)
}
createEmailVerificationToken(userId: number) {
return encryption.encrypt(
{ userId },
{ purpose: 'email-verification' } // [!code highlight]
)
}
verifyPasswordResetToken(token: string) {
/**
* Must provide the same purpose to decrypt.
* A token created for email verification won't work here.
*/
return encryption.decrypt(token, 'password-reset')
}
}
```
Without purpose binding, an attacker who obtains a password reset token could potentially reuse it as an email verification token if both contain the same data structure. Purpose-bound encryption prevents this attack by cryptographically binding the purpose to the encrypted value.
```ts title="app/services/token_service.ts"
const token = encryption.encrypt(
{ userId: 1 },
{ purpose: 'password-reset' }
)
encryption.decrypt(token, 'password-reset') // => { userId: 1 }
/**
* Attempting to decrypt with the wrong purpose returns null.
*/
encryption.decrypt(token, 'email-verification') // => null
encryption.decrypt(token) // => null
```
## Expiring encrypted values
You can set a time-to-live on encrypted values. After the specified duration, the decryption method returns `null` even if the encrypted data is valid.
```ts title="app/services/invitation_service.ts"
import encryption from '@adonisjs/core/services/encryption'
export default class InvitationService {
createInvitationLink(email: string, teamId: number) {
const token = encryption.encrypt({ email, teamId }, {
expiresIn: '24h' // [!code highlight]
})
return `https://app.example.com/invitations/${token}`
}
acceptInvitation(token: string) {
/**
* Returns null if the token has expired,
* even if the encrypted data is still valid.
*/
const payload = encryption.decrypt(token)
if (!payload) {
return { error: 'Invalid or expired invitation' }
}
return payload as { email: string; teamId: number }
}
}
```
Supported duration formats include:
| Format | Example | Description |
|---------|---------|-----------------------|
| Minutes | `'30m'` | Expires in 30 minutes |
| Hours | `'1h'` | Expires in 1 hour |
| Days | `'7d'` | Expires in 7 days |
You can combine purpose binding with expiration for maximum security.
```ts title="app/services/token_service.ts"
/**
* Create a password reset token that expires in 1 hour
* and can only be used for password reset operations.
*/
const token = encryption.encrypt(
{ userId: 1 },
{ expiresIn: '1h', purpose: 'password-reset' }
)
/**
* Must provide the correct purpose to decrypt.
* Returns null if expired or purpose doesn't match.
*/
const payload = encryption.decrypt(token, 'password-reset')
```
## Encrypting database columns
Encrypt sensitive data before storing it in your database.
```ts title="app/models/user.ts"
import { UserSchema } from '#database/schema'
import { beforeSave } from '@adonisjs/lucid/orm'
import encryption from '@adonisjs/core/services/encryption'
export default class User extends UserSchema {
@beforeSave()
static encryptSensitiveData(user: User) {
if (user.$dirty.ssn && user.ssn) {
user.ssn = encryption.encrypt(user.ssn)
}
}
decryptSsn(): string | null {
if (!this.ssn) {
return null
}
return encryption.decrypt(this.ssn)
}
}
```
## Choosing an algorithm
Each encryption algorithm offers different tradeoffs between security, performance, and compatibility. The right choice depends on your application's requirements.
### When to choose ChaCha20-Poly1305
ChaCha20-Poly1305 is the recommended choice for new applications. It provides consistent high performance across all platforms, including those without hardware AES acceleration. It's widely deployed in modern protocols including TLS 1.3 and WireGuard.
### When to choose AES-256-GCM
AES-256-GCM is an excellent choice when you need compatibility with systems that specifically require AES or when running on hardware with AES-NI acceleration. It's the default cipher in many ecosystems, which simplifies interoperability even without explicit AES requirements.
### When to choose AES-256-CBC
AES-256-CBC is provided primarily for legacy compatibility, mainly to decrypt existing data from older systems. Unlike AEAD ciphers, CBC requires separate HMAC authentication using the Encrypt-then-MAC pattern. For new encryption needs, prefer ChaCha20-Poly1305 or AES-256-GCM.
:::warning
CBC mode has a history of implementation pitfalls, including padding oracle attacks. AdonisJS handles these concerns internally, but if you're implementing CBC elsewhere, ensure you use Encrypt-then-MAC and constant-time comparison for the authentication tag.
:::
### When to choose Legacy
The legacy driver is designed for migrating from AdonisJS v6 to v7. It can only decrypt data that was encrypted with the v6 encryption service. Use this driver when you have existing encrypted data in your database from a v6 application that needs to be migrated.
The recommended migration strategy is:
1. Configure the legacy driver alongside a modern driver
2. Read encrypted data using the legacy driver
3. Re-encrypt the data using a modern driver (ChaCha20-Poly1305 or AES-256-GCM)
4. Once all data has been migrated, remove the legacy driver
```ts title="app/services/migration_service.ts"
import encryption from '@adonisjs/core/services/encryption'
export default class MigrationService {
async migrateEncryptedField(encryptedValue: string) {
/**
* Decrypt using the legacy driver (v6 format)
*/
const decrypted = encryption.use('legacy').decrypt(encryptedValue)
if (!decrypted) {
return null
}
/**
* Re-encrypt using the modern driver
*/
return encryption.encrypt(decrypted)
}
}
```
## Configuration
The encryption configuration lives in `config/encryption.ts`. You define available drivers in the `list` object and specify which one to use by default.
```ts title="config/encryption.ts"
import env from '#start/env'
import { defineConfig, drivers } from '@adonisjs/core/encryption'
export default defineConfig({
/**
* The default driver used by encryption.encrypt() and
* encryption.decrypt() when no driver is explicitly specified.
*/
default: 'chacha',
list: {
chacha: drivers.chacha20({
id: 'chacha',
keys: [env.get('APP_KEY')],
}),
/**
* AES-256-GCM: Industry-standard authenticated encryption.
*/
// gcm: drivers.aes256gcm({
// id: 'gcm',
// keys: [env.get('APP_KEY')],
// }),
/**
* AES-256-CBC: Legacy support with HMAC authentication.
*/
// cbc: drivers.aes256cbc({
// id: 'cbc',
// keys: [env.get('APP_KEY')],
// }),
/**
* Legacy: Decrypt data encrypted with AdonisJS v6.
* Use this driver to migrate encrypted data from v6 to v7.
*/
// legacy: drivers.legacy({
// keys: [env.get('APP_KEY')],
// }),
},
})
```
### Driver configuration options
All drivers accept the same configuration options.
::::options
:::option{name="id" dataType="string"}
A unique identifier for this driver configuration. This ID is embedded in the encrypted output, allowing the decryption process to identify which driver was used.
:::
:::option{name="keys" dataType="string[]"}
An array of secret keys. The first key is used for encryption, while all keys are tried during decryption. This enables seamless key rotation.
:::
::::
## Key rotation
The encryption service supports multiple keys for seamless key rotation. The first key in the array is used for encrypting new values, while all keys are tried during decryption. This allows you to rotate keys without invalidating existing encrypted data.
```ts title="config/encryption.ts"
import env from '#start/env'
import { defineConfig, drivers } from '@adonisjs/core/encryption'
export default defineConfig({
default: 'chacha',
list: {
chacha: drivers.chacha20({
id: 'chacha',
// [!code highlight:4]
keys: [
env.get('APP_KEY'), // New key: used for encryption
env.get('OLD_APP_KEY'), // Old key: only used for decryption
],
}),
},
})
```
When rotating keys, follow this process:
1. Generate a new secret key
2. Add the new key as the first element in the `keys` array
3. Move the old key to the second position
4. Deploy your application
5. After sufficient time (when all old encrypted values have been re-encrypted or expired), remove the old key
```ts title="app/services/rotation_example.ts"
import encryption from '@adonisjs/core/services/encryption'
/**
* New encryptions automatically use the first key.
*/
const newToken = encryption.encrypt({ userId: 1 })
/**
* Decryption tries all keys, so old tokens still work.
*/
const oldPayload = encryption.decrypt(tokenEncryptedWithOldKey) // Works
const newPayload = encryption.decrypt(newToken) // Works
```
:::warning
Never remove an old key until you're certain all data encrypted with that key has either been re-encrypted with the new key or is no longer needed. Removing a key makes all data encrypted with it permanently unreadable.
:::
## Message verifier
When you need to ensure data integrity without hiding the content, use the message verifier. Unlike encryption, the message verifier doesn't encrypt data. It base64-encodes the payload and signs it with HMAC, allowing anyone to read the data while ensuring it hasn't been tampered with.
```ts title="app/services/state_service.ts"
import encryption from '@adonisjs/core/services/encryption'
export default class StateService {
createOAuthState(returnUrl: string, provider: string) {
/**
* Sign the state without encrypting it.
* The data is readable but tamper-proof.
*/
return encryption.verifier.sign({ returnUrl, provider })
}
verifyOAuthState(state: string) {
/**
* Verify the signature and return the payload.
* Returns null if the signature is invalid.
*/
return encryption.verifier.unsign(state)
}
}
```
The message verifier is useful for scenarios where you want to detect tampering but don't need to hide the data, such as OAuth state parameters, CSRF tokens, or webhook signatures.
### Verifier with purpose and expiration
The message verifier supports the same purpose binding and expiration features as encryption.
```ts title="app/services/csrf_service.ts"
import encryption from '@adonisjs/core/services/encryption'
export default class CsrfService {
createToken(sessionId: string) {
/**
* Create a CSRF token that expires in 1 hour
* and is bound to the 'csrf' purpose.
*/
return encryption.verifier.sign({ sessionId }, '1h', 'csrf')
}
verifyToken(token: string, expectedSessionId: string) {
const payload = encryption.verifier.unsign(token, 'csrf')
if (!payload || payload.sessionId !== expectedSessionId) {
return false
}
return true
}
}
```
## Using multiple drivers
Some applications need to encrypt data with different algorithms for different purposes. The `encryption.use` method lets you explicitly select a driver.
```ts title="app/services/multi_driver_service.ts"
import encryption from '@adonisjs/core/services/encryption'
export default class MultiDriverService {
/**
* Use ChaCha20-Poly1305 for high-performance encryption.
*/
encryptSessionData(data: object) {
return encryption.use('chacha').encrypt(data)
}
/**
* Use AES-256-GCM for compatibility with external systems.
*/
encryptForExternalApi(data: object) {
return encryption.use('gcm').encrypt(data)
}
}
```
Each driver specified in `encryption.use()` must be configured in your `config/encryption.ts` file's `list` object.
## Generating the app key
The encryption service requires a cryptographically secure secret key stored in the `APP_KEY` environment variable. You can generate a new key using the Ace CLI.
```sh
node ace generate:key
```
This command generates a random 32-character key and writes it to your `.env` file. The key uses a cryptographically secure random number generator to ensure it cannot be predicted or guessed.
The `APP_KEY` is critical to your application's security. If this key is compromised, attackers can decrypt all your encrypted data and forge signed cookies. Store it securely, never commit it to version control, and use different keys for each environment.
If you change or lose your `APP_KEY`, all existing encrypted data becomes permanently unreadable. Cookies signed with the old key will be rejected, and encrypted database values cannot be recovered. Always back up your production keys securely.
---
# CORS
This guide covers Cross-Origin Resource Sharing (CORS) in AdonisJS applications. You will learn how to:
- Install and configure the CORS middleware
- Control which origins, methods, and headers are allowed
- Handle credentials in cross-origin requests
- Debug common CORS errors
## Overview
When a browser makes a request to a different domain than the one serving the current page, it enforces Cross-Origin Resource Sharing (CORS) restrictions. This security mechanism prevents malicious scripts from making unauthorized requests to your API on behalf of users.
For example, if your frontend runs on `app.example.com` and your API runs on `api.example.com`, the browser will block requests from the frontend unless your API explicitly allows that origin. The same applies during local development when your frontend runs on `localhost:3000` and your API on `localhost:3333`.
Before making certain cross-origin requests, browsers send a **preflight request** using the `OPTIONS` HTTP method. This preflight asks your server which origins, methods, and headers are permitted. Your server must respond with the appropriate CORS headers, and only then will the browser proceed with the actual request.
AdonisJS handles CORS through the `@adonisjs/cors` package, which provides middleware that automatically responds to preflight requests and attaches the correct headers to all responses.
## Installation
Install and configure the package using the following command:
```sh
node ace add @adonisjs/cors
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/cors` package using the detected package manager.
2. Registers the following service provider inside the `adonisrc.ts` file.
```ts title="adonisrc.ts"
{
providers: [
// ...other providers
() => import('@adonisjs/cors/cors_provider')
]
}
```
3. Creates the `config/cors.ts` file. This file contains the configuration settings for CORS.
4. Registers the following middleware inside the `start/kernel.ts` file.
```ts title="start/kernel.ts"
server.use([
() => import('@adonisjs/cors/cors_middleware')
])
```
:::
## Configuration
The CORS configuration lives in `config/cors.ts`. Here is the default configuration with all available options:
```ts title="config/cors.ts"
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/cors'
const corsConfig = defineConfig({
enabled: true,
origin: app.inDev ? true : [],
methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],
headers: true,
exposeHeaders: [],
credentials: true,
maxAge: 90,
})
export default corsConfig
```
In development, `origin` is set to `true` to allow all origins for easy local frontend/backend setup. In production, it defaults to an empty array `[]`, which blocks all cross-origin requests until you explicitly configure allowed origins.
### Enabling and disabling CORS
The `enabled` option turns the middleware on or off without removing it from the middleware stack. This is useful when you want to disable CORS temporarily during debugging or in specific environments.
```ts title="config/cors.ts"
{
enabled: process.env.NODE_ENV !== 'test'
}
```
### Configuring allowed origins
The `origin` option controls which domains can make cross-origin requests to your API. This sets the `Access-Control-Allow-Origin` response header.
To allow all origins dynamically (the response header will mirror the requesting origin):
```ts title="config/cors.ts"
{
origin: true
}
```
To disallow all cross-origin requests:
```ts title="config/cors.ts"
{
origin: false
}
```
To allow specific domains, provide an array of origins:
```ts title="config/cors.ts"
{
origin: ['https://app.example.com', 'https://admin.example.com']
}
```
To allow any origin using the wildcard:
```ts title="config/cors.ts"
{
origin: '*'
}
```
:::warning
When `credentials` is set to `true`, the wildcard `*` cannot be used as the `Access-Control-Allow-Origin` header value. Browsers reject this combination for security reasons. AdonisJS automatically handles this by reflecting the requesting origin instead of sending the literal `*` when both `origin: '*'` and `credentials: true` are configured.
:::
For dynamic origin validation, provide a callback function. This is useful when allowed origins are stored in a database or when you need custom validation logic:
```ts title="config/cors.ts"
{
origin: (requestOrigin, ctx) => {
/**
* requestOrigin is the value of the Origin header.
* Return true to allow, false to deny.
*/
const allowedOrigins = ['https://app.example.com']
return allowedOrigins.includes(requestOrigin)
}
}
```
### Configuring allowed methods
The `methods` option specifies which HTTP methods are permitted for cross-origin requests. The browser's preflight request includes an `Access-Control-Request-Method` header, and the server checks this value against the allowed methods.
```ts title="config/cors.ts"
{
methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE']
}
```
### Configuring allowed headers
The `headers` option controls which request headers are permitted in cross-origin requests. The browser's preflight request includes an `Access-Control-Request-Headers` header listing the headers the client wants to send.
To allow all headers:
```ts title="config/cors.ts"
{
headers: true
}
```
To allow specific headers:
```ts title="config/cors.ts"
{
headers: ['Content-Type', 'Accept', 'Authorization']
}
```
For dynamic header validation, provide a callback:
```ts title="config/cors.ts"
{
headers: (requestHeaders, ctx) => {
return true
}
}
```
### Exposing response headers
By default, browsers only expose a limited set of response headers to JavaScript. The `exposeHeaders` option lets you specify additional headers that should be accessible to the client.
```ts title="config/cors.ts"
{
exposeHeaders: ['X-Request-Id', 'X-RateLimit-Remaining']
}
```
### Allowing credentials
The `credentials` option controls whether cookies, authorization headers, and TLS client certificates can be included in cross-origin requests. When enabled, the server sends the `Access-Control-Allow-Credentials: true` header.
```ts title="config/cors.ts"
{
credentials: true
}
```
:::tip
Enable `credentials` when your frontend needs to send authentication cookies or the `Authorization` header to your API. Without this, browsers strip credentials from cross-origin requests.
:::
### Caching preflight responses
The `maxAge` option specifies how long (in seconds) browsers should cache preflight responses. This reduces the number of preflight requests for repeated cross-origin calls.
```ts title="config/cors.ts"
{
maxAge: 90
}
```
Setting `maxAge` to `null` omits the `Access-Control-Max-Age` header entirely. Setting it to `-1` sends the header but disables caching.
## Common scenarios
### API serving a single-page application
When your API and frontend are deployed on different domains, configure CORS to allow your frontend's origin with credentials:
```ts title="config/cors.ts"
import { defineConfig } from '@adonisjs/cors'
const corsConfig = defineConfig({
enabled: true,
origin: ['https://app.example.com'],
methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],
headers: true,
credentials: true,
maxAge: 90,
})
export default corsConfig
```
### Local development with different ports
During development, your frontend and backend often run on different ports. Configure CORS to allow your local frontend origin:
```ts title="config/cors.ts"
import { defineConfig } from '@adonisjs/cors'
const corsConfig = defineConfig({
enabled: true,
origin: (requestOrigin) => {
/**
* Allow localhost on any port during development.
*/
return requestOrigin.startsWith('http://localhost')
},
methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],
headers: true,
credentials: true,
maxAge: 90,
})
export default corsConfig
```
### Public API with no credentials
If your API is public and does not require cookies or authentication headers, you can use a permissive configuration:
```ts title="config/cors.ts"
import { defineConfig } from '@adonisjs/cors'
const corsConfig = defineConfig({
enabled: true,
origin: '*',
methods: ['GET', 'HEAD', 'POST'],
headers: true,
credentials: false,
maxAge: 86400,
})
export default corsConfig
```
## See also
- [MDN CORS documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) for an in-depth explanation of how CORS works
- [Middleware](../basics/middleware.md) to learn about the AdonisJS middleware system
- [Session](../basics/session.md) for handling cookies in cross-origin requests
---
# Securing server-rendered applications
This guide covers security features for AdonisJS server-rendered applications. You will learn how to:
- Protect forms from CSRF (Cross-Site Request Forgery) attacks
- Define CSP (Content Security Policy) rules to prevent XSS attacks
- Configure HSTS to enforce HTTPS connections
- Prevent clickjacking with X-Frame-Options headers
- Disable MIME sniffing to avoid content-type attacks
## Overview
Web applications face constant security threats. Attackers exploit vulnerabilities like form submission forgery, malicious script injection, and clickjacking to compromise your users. The `@adonisjs/shield` package provides a unified defense layer that protects your server-rendered AdonisJS applications from these common attacks.
Shield works by adding security-focused HTTP headers and middleware to your application. Rather than configuring each protection separately, Shield gives you a single package with sensible defaults that you can customize as needed. All protections are configured through `config/shield.ts`, making it easy to audit and adjust your security posture.
The package comes pre-configured with the web starter kit. If you need to install it manually, ensure you have the `@adonisjs/session` package configured first, as Shield depends on sessions to store CSRF tokens.
```sh
node ace add @adonisjs/shield
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/shield` package using the detected package manager.
2. Registers the following service provider inside the `adonisrc.ts` file.
```ts
{
providers: [
// ...other providers
() => import('@adonisjs/shield/shield_provider'),
]
}
```
3. Creates the `config/shield.ts` file.
4. Registers the following middleware inside the `start/kernel.ts` file.
```ts
router.use([() => import('@adonisjs/shield/shield_middleware')])
```
:::
## CSRF protection
CSRF (Cross-Site Request Forgery) attacks trick authenticated users into submitting malicious requests without their knowledge. Imagine a user is logged into your banking application. While browsing another site, that malicious site includes a hidden form that submits a money transfer request to your bank. Because the user's browser automatically includes their session cookie, the bank processes the transfer as if the user intended it.
Shield prevents CSRF attacks by requiring a secret token with every form submission. This token is generated server-side and embedded in your forms. Since attackers cannot access this token from their malicious site, their forged requests will be rejected.
### Protecting forms
Once Shield is configured, all form submissions without a valid CSRF token will fail automatically. You must include the token in every form using the `csrfField` Edge helper, which renders a hidden input field containing the token.
```edge title="resources/views/posts/create.edge"
```
The helper generates a hidden input field that Shield's middleware validates on submission.
```html title="Output HTML"
```
### Handling CSRF errors
Shield raises an `E_BAD_CSRF_TOKEN` exception when a token is missing or invalid. By default, AdonisJS redirects the user back to the form with an error flash message. You can display this message in your template using the `@error` tag.
```edge title="resources/views/posts/create.edge"
@error('E_BAD_CSRF_TOKEN')
{{ $message }}
@end
```
For custom error handling, you can catch the exception in your global exception handler. This is useful when you want to render a custom error page or return a specific response format.
```ts title="app/exceptions/handler.ts"
import app from '@adonisjs/core/services/app'
import { errors } from '@adonisjs/shield'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
async handle(error: unknown, ctx: HttpContext) {
/**
* Check if the error is a CSRF token error and return
* a custom response instead of the default redirect.
*/
if (error instanceof errors.E_BAD_CSRF_TOKEN) {
return ctx.response
.status(error.status)
.send('Your session has expired. Please refresh the page and try again.')
}
return super.handle(error, ctx)
}
}
```
### Enabling CSRF tokens for Ajax requests
Single-page applications and interactive interfaces often submit forms via JavaScript instead of traditional form submissions. For these cases, Shield can expose the CSRF token in a cookie that your frontend code can read.
When `enableXsrfCookie` is enabled, Shield stores the token in an encrypted cookie named `XSRF-TOKEN`. Frontend libraries like Axios automatically read this cookie and include it as an `X-XSRF-TOKEN` header with every request.
```ts title="config/shield.ts"
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
csrf: {
enabled: true,
exceptRoutes: [],
enableXsrfCookie: true, // [!code highlight]
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
},
})
export default shieldConfig
```
:::tip
Only enable `enableXsrfCookie` if your application makes Ajax requests. For traditional server-rendered forms that use full page submissions, the hidden input field is sufficient and more secure.
:::
### Exempting routes from CSRF protection
API endpoints that receive webhooks or requests from external services cannot include CSRF tokens. You can exempt specific routes using the `exceptRoutes` option.
```ts title="config/shield.ts"
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
csrf: {
enabled: true,
exceptRoutes: [
'/api/webhooks/*',
'/api/payments/callback',
],
enableXsrfCookie: false,
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
},
})
export default shieldConfig
```
For dynamic exemption logic, pass a function that receives the HTTP context and returns a boolean.
```ts title="config/shield.ts"
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
csrf: {
enabled: true,
exceptRoutes: (ctx) => {
/**
* Exempt all routes starting with /api/ since these
* are consumed by external services with their own
* authentication mechanisms.
*/
return ctx.request.url().startsWith('/api/')
},
enableXsrfCookie: false,
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
},
})
export default shieldConfig
```
### CSRF configuration reference
| Option | Type | Description |
|--------|------|-------------|
| `enabled` | `boolean` | Turn CSRF protection on or off. |
| `exceptRoutes` | `string[]` or `function` | Routes to exempt from CSRF protection. Accepts route patterns or a function receiving `HttpContext`. |
| `enableXsrfCookie` | `boolean` | When `true`, stores the CSRF token in an `XSRF-TOKEN` cookie for Ajax requests. |
| `methods` | `string[]` | HTTP methods that require CSRF token validation. Defaults to `POST`, `PUT`, `PATCH`, `DELETE`. |
| `cookieOptions` | `object` | Configuration for the `XSRF-TOKEN` cookie. See [cookies configuration](../basics/response.md#cookie-options). |
## CSP (Content Security Policy)
XSS (Cross-Site Scripting) attacks inject malicious scripts into your pages. An attacker might exploit a comment form that doesn't sanitize input, injecting JavaScript that steals user cookies or redirects them to phishing sites. Even with proper input sanitization, XSS vulnerabilities can slip through.
CSP provides a second line of defense by telling browsers which sources of content are trusted. When you define a CSP policy, browsers will block any scripts, styles, or other resources that don't match your allowed sources. Even if an attacker manages to inject a script tag, the browser refuses to execute it because it wasn't loaded from a trusted source.
### Enabling CSP
CSP is disabled by default because policies must be tailored to your application's needs. Enable it and define your directives in the configuration file.
```ts title="config/shield.ts"
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
csp: {
enabled: true,
directives: {
defaultSrc: [`'self'`],
scriptSrc: [`'self'`, 'https://cdnjs.cloudflare.com'],
styleSrc: [`'self'`, 'https://fonts.googleapis.com'],
fontSrc: [`'self'`, 'https://fonts.gstatic.com'],
imgSrc: [`'self'`, 'data:', 'https://images.example.com'],
},
reportOnly: false,
},
})
export default shieldConfig
```
The `defaultSrc` directive acts as a fallback for any resource type you don't explicitly configure. The `'self'` keyword allows resources from your own domain. Each directive controls a specific resource type: `scriptSrc` for JavaScript, `styleSrc` for CSS, `fontSrc` for fonts, and so on.
You can find the complete list of available directives at [content-security-policy.com](https://content-security-policy.com/#directive).
### Using nonces for inline scripts and styles
Inline scripts and styles are blocked by default under CSP because they're a common XSS attack vector. However, you may need inline code for legitimate purposes. Nonces (number used once) allow specific inline blocks while keeping the general policy strict.
Add the `@nonce` keyword to your directives, then include the `nonce` attribute on your inline script and style tags using the `cspNonce` variable available in Edge templates.
```ts title="config/shield.ts"
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
csp: {
enabled: true,
directives: {
defaultSrc: [`'self'`],
scriptSrc: [`'self'`, '@nonce'],
styleSrc: [`'self'`, '@nonce'],
},
reportOnly: false,
},
})
export default shieldConfig
```
```edge title="resources/views/pages/home.edge"
```
Shield generates a unique nonce for each request. Attackers cannot predict this value, so even if they inject a script tag, it won't have a valid nonce and the browser will block it.
### Configuring CSP for Vite
When using Vite for asset bundling, you need to allow assets from the Vite dev server during development. Shield provides special keywords for this purpose.
```ts title="config/shield.ts"
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
csp: {
enabled: true,
directives: {
defaultSrc: [`'self'`, '@viteDevUrl'],
connectSrc: [`'self'`, '@viteHmrUrl'],
scriptSrc: [`'self'`, '@nonce'],
styleSrc: [`'self'`, '@nonce'],
},
reportOnly: false,
},
})
export default shieldConfig
```
The `@viteDevUrl` keyword resolves to the Vite development server URL, while `@viteHmrUrl` allows the WebSocket connection for hot module replacement.
If you deploy bundled assets to a CDN, replace `@viteDevUrl` with `@viteUrl`. This keyword allows assets from both the development server and your production CDN.
```ts title="config/shield.ts"
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
csp: {
enabled: true,
directives: {
defaultSrc: [`'self'`, '@viteUrl'], // [!code highlight]
connectSrc: [`'self'`, '@viteHmrUrl'],
scriptSrc: [`'self'`, '@nonce'],
styleSrc: [`'self'`, '@nonce'],
},
reportOnly: false,
},
})
export default shieldConfig
```
:::warning
Vite currently does not support adding `nonce` attributes to style tags it injects into the DOM during development. This is a [known limitation](https://github.com/vitejs/vite/pull/11864) being addressed by the Vite team. Until resolved, you may need to use `'unsafe-inline'` for `styleSrc` during development, then switch to nonce-based policies in production.
:::
### Testing policies with report-only mode
A misconfigured CSP can break your application by blocking legitimate resources. Use `reportOnly` mode to test your policy without enforcement. In this mode, browsers report violations but don't block resources.
```ts title="config/shield.ts"
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
csp: {
enabled: true,
directives: {
defaultSrc: [`'self'`],
reportUri: ['/csp-report'],
},
reportOnly: true,
},
})
export default shieldConfig
```
Create an endpoint to collect violation reports. This helps you identify resources you forgot to whitelist before enabling enforcement.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
router.post('/csp-report', async ({ request, logger }) => {
const report = request.input('csp-report')
logger.warn({ report }, 'CSP violation detected')
})
```
Once you've verified your policy isn't blocking legitimate resources, set `reportOnly` to `false` to enable enforcement.
### CSP configuration reference
| Option | Type | Description |
|--------|------|-------------|
| `enabled` | `boolean` | Turn CSP on or off. |
| `directives` | `object` | CSP directives defining allowed sources for each resource type. |
| `reportOnly` | `boolean` | When `true`, violations are reported but not blocked. Use for testing policies. |
## HSTS (HTTP Strict Transport Security)
When users type your domain without `https://`, browsers first connect over insecure HTTP before redirecting to HTTPS. This brief window allows attackers to intercept the initial request through man-in-the-middle attacks, potentially downgrading the connection or stealing sensitive data.
HSTS tells browsers to always use HTTPS for your domain, even when users type `http://` or click plain HTTP links. After receiving the HSTS header, browsers automatically upgrade all requests to HTTPS for the specified duration, eliminating the insecure redirect window.
```ts title="config/shield.ts"
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
hsts: {
enabled: true,
maxAge: '180 days',
includeSubDomains: true,
},
})
export default shieldConfig
```
The `maxAge` option tells browsers how long to remember the HTTPS-only policy. The `includeSubDomains` option extends this protection to all subdomains, preventing attackers from exploiting insecure subdomains to compromise your main domain.
:::warning
Only enable HSTS after confirming HTTPS works correctly across your entire domain and all subdomains. Once browsers cache the HSTS policy, they will refuse to connect over HTTP for the duration of `maxAge`. If your HTTPS configuration breaks, users won't be able to access your site until you fix it or the cached policy expires.
Start with a short `maxAge` (like `1 day`) during testing, then increase it to `180 days` or longer once you're confident in your HTTPS setup.
:::
### HSTS configuration reference
| Option | Type | Description |
|--------|------|-------------|
| `enabled` | `boolean` | Turn HSTS on or off. |
| `maxAge` | `number` or `string` | How long browsers should remember the HTTPS-only policy. Accepts seconds as a number or a time expression like `'180 days'`. |
| `includeSubDomains` | `boolean` | When `true`, applies the HTTPS-only policy to all subdomains. |
## X-Frame-Options (clickjacking protection)
Clickjacking attacks embed your site in an invisible iframe on a malicious page. The attacker overlays deceptive content, tricking users into clicking buttons on your hidden site. A user might think they're clicking a "Play Video" button, but they're actually clicking "Delete Account" on your application.
The X-Frame-Options header prevents your pages from being embedded in frames on other sites.
```ts title="config/shield.ts"
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
xFrame: {
enabled: true,
action: 'DENY',
},
})
export default shieldConfig
```
The `DENY` action blocks all framing. If you need to embed your site in frames on your own domain (like for an admin panel preview), use `SAMEORIGIN` instead.
```ts title="config/shield.ts"
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
xFrame: {
enabled: true,
action: 'SAMEORIGIN',
},
})
export default shieldConfig
```
To allow a specific external domain to frame your content, use `ALLOW-FROM` with the domain.
```ts title="config/shield.ts"
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
xFrame: {
enabled: true,
action: 'ALLOW-FROM',
domain: 'https://trusted-partner.com',
},
})
export default shieldConfig
```
:::tip
If you've configured CSP, you can use the `frame-ancestors` directive instead of X-Frame-Options. The CSP directive offers more flexibility, including support for multiple domains. When using `frame-ancestors`, you can disable the `xFrame` guard to avoid redundant headers.
:::
### X-Frame configuration reference
| Option | Type | Description |
|--------|------|-------------|
| `enabled` | `boolean` | Turn X-Frame protection on or off. |
| `action` | `string` | The framing policy: `'DENY'`, `'SAMEORIGIN'`, or `'ALLOW-FROM'`. |
| `domain` | `string` | Required when `action` is `'ALLOW-FROM'`. The domain allowed to frame your content. |
## Content-Type sniffing protection
Browsers try to be helpful by guessing content types when servers don't specify them correctly. If your server accidentally serves a user-uploaded file with the wrong content type, browsers might "sniff" the content and execute it as a script. An attacker could upload a file that looks like an image but contains JavaScript, and the browser might execute it.
The `X-Content-Type-Options: nosniff` header tells browsers to trust the `Content-Type` header and never guess. This prevents content-type confusion attacks.
```ts title="config/shield.ts"
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
contentTypeSniffing: {
enabled: true,
},
})
export default shieldConfig
```
This guard has no additional configuration options. When enabled, Shield adds the `X-Content-Type-Options: nosniff` header to all responses.
## See also
- [Session configuration](../basics/session.md) for setting up the session package required by Shield
- [Exception handling](../basics/exception_handling.md) for customizing error responses
- [Vite integration](../frontend/vite.md) for configuring asset bundling with CSP
---
# Rate limiting
This guide covers rate limiting in AdonisJS applications. You will learn how to:
- Install and configure the limiter package with Redis, database, or memory stores
- Create throttle middleware for HTTP requests
- Apply dynamic rate limits based on user authentication
- Use rate limiting directly for login protection and job queues
- Handle rate limit exceptions and customize error messages
- Create custom storage providers
## Overview
Rate limiting controls how many requests a user can make to your application within a given time period. When a user exceeds their limit, subsequent requests are rejected until the time window resets.
You need rate limiting to protect your application from abuse. Without it, a single user (or bot) can overwhelm your server with requests, consuming resources meant for legitimate users. Rate limiting also helps prevent brute-force attacks on login forms, protects expensive API endpoints from overuse, and ensures fair access to shared resources.
The `@adonisjs/limiter` package is built on top of [node-rate-limiter-flexible](https://github.com/animir/node-rate-limiter-flexible), which provides one of the fastest rate-limiting APIs and uses atomic increments to avoid race conditions.
## Installation
Install and configure the package using the following command:
```sh
node ace add @adonisjs/limiter
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/limiter` package using the detected package manager.
2. Registers the following service provider inside the `adonisrc.ts` file.
```ts
{
providers: [
// ...other providers
() => import('@adonisjs/limiter/limiter_provider')
]
}
```
3. Creates the `config/limiter.ts` file.
4. Creates the `start/limiter.ts` file for defining HTTP throttle middleware.
5. Defines the following environment variable and its validation inside the `start/env.ts` file.
```ts
LIMITER_STORE=redis
```
6. Optionally creates the database migration for the `rate_limits` table if using the `database` store.
:::
## Configuration
The rate limiter configuration is stored in the `config/limiter.ts` file. You define which storage backends are available and which one to use by default.
```ts title="config/limiter.ts"
import env from '#start/env'
import { defineConfig, stores } from '@adonisjs/limiter'
const limiterConfig = defineConfig({
/**
* The default store is selected via environment variable,
* allowing different stores in different environments.
*/
default: env.get('LIMITER_STORE'),
stores: {
redis: stores.redis({}),
database: stores.database({
tableName: 'rate_limits'
}),
memory: stores.memory({}),
},
})
export default limiterConfig
declare module '@adonisjs/limiter/types' {
export interface LimitersList extends InferLimiters {}
}
```
The `default` property specifies which store to use for rate limiting. The `stores` object defines all available storage backends. We recommend always configuring the `memory` store so you can use it during testing.
See also: [Rate limiter config stub](https://github.com/adonisjs/limiter/blob/2.x/stubs/config/limiter.stub)
### Environment variables
The default store is controlled by the `LIMITER_STORE` environment variable, allowing you to switch stores between environments. For example, you might use `memory` during testing and `redis` in production.
The environment variable must be validated in `start/env.ts` to ensure only configured stores are allowed:
```ts title="start/env.ts"
{
LIMITER_STORE: Env.schema.enum(['redis', 'database', 'memory'] as const),
}
```
### Shared options
All storage backends accept the following options:
```ts title="config/limiter.ts"
{
duration: '1 minute',
requests: 10,
/**
* After 12 requests, block the key in memory
* and stop querying the database.
*/
inMemoryBlockOnConsumed: 12,
inMemoryBlockDuration: '1 min'
}
```
::::options
:::option{name="keyPrefix"}
Prefix for keys in storage. The database store ignores this since separate tables provide isolation.
:::
:::option{name="execEvenly"}
Adds artificial delay to spread requests evenly across the time window. See [smooth out traffic peaks](https://github.com/animir/node-rate-limiter-flexible/wiki/Smooth-out-traffic-peaks) for details.
:::
:::option{name="inMemoryBlockOnConsumed"}
Number of requests after which to block the key in memory, reducing database queries from abusive users.
:::
:::option{name="inMemoryBlockDuration"}
How long to block keys in memory. Reduces database load by checking memory first. The `inMemoryBlockOnConsumed` option is useful when users continue making requests after exhausting their quota. Instead of querying the database for every rejected request, you can block them in memory:
:::
::::
### Redis store
The Redis store requires the `@adonisjs/redis` package to be configured first.
```ts title="config/limiter.ts"
{
redis: stores.redis({
connectionName: 'main',
rejectIfRedisNotReady: false,
}),
}
```
::::options
:::option{name="connectionName"}
The Redis connection from `config/redis.ts`. We recommend using a separate database for the limiter.
:::
:::option{name="rejectIfRedisNotReady"}
When `true`, rejects rate-limiting requests if Redis connection status is not `ready`.
:::
::::
### Database store
The database store requires the `@adonisjs/lucid` package to be configured first.
:::warning
The database store only supports MySQL, PostgreSQL, and SQLite. Other databases like MongoDB are not compatible and will throw an error at runtime.
:::
```ts title="config/limiter.ts"
{
database: stores.database({
connectionName: 'mysql',
dbName: 'my_app',
tableName: 'rate_limits',
schemaName: 'public',
clearExpiredByTimeout: false,
}),
}
```
:::options
:::option{name="connectionName"}
The database connection from `config/database.ts`. Uses the default connection if not specified.
:::
:::option{name="dbName"}
The database name for SQL queries. Inferred from connection config, but required when using a connection string.
:::
:::option{name="tableName"}
The table for storing rate limit data.
:::
:::option{name="schemaName"}
The schema for SQL queries (PostgreSQL only).
:::
:::option{name="clearExpiredByTimeout"}
When `true`, clears expired keys every 5 minutes. Only keys expired for more than 1 hour are removed.
:::
:::
## Throttling HTTP requests
The most common use case is throttling HTTP requests with middleware. The `limiter.define` method creates reusable throttle middleware that you can apply to routes.
Open the `start/limiter.ts` file to see the pre-defined global throttle middleware. This middleware allows users to make 10 requests per minute based on their IP address:
```ts title="start/limiter.ts"
import limiter from '@adonisjs/limiter/services/main'
export const throttle = limiter.define('global', () => {
return limiter.allowRequests(10).every('1 minute')
})
```
Apply the middleware to any route:
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { throttle } from '#start/limiter'
router
.get('/', () => {})
.use(throttle)
```
When a user exceeds 10 requests within a minute, they receive a `429 Too Many Requests` response until the time window resets.
### Using a custom key
By default, requests are rate-limited by the user's IP address. You can specify a different key using the `usingKey` method. This is useful when you want to limit by user ID, API key, or any other identifier:
```ts title="start/limiter.ts"
export const throttle = limiter.define('global', (ctx) => {
return limiter
.allowRequests(10)
.every('1 minute')
.usingKey(`user_${ctx.auth.user.id}`)
})
```
### Switching the backend store
You can override the default store for specific middleware using the `store` method:
```ts title="start/limiter.ts"
limiter
.allowRequests(10)
.every('1 minute')
.store('redis')
```
### Blocking abusive users
The `blockFor` method extends the lockout period when users continue making requests after exhausting their quota. This discourages abuse more effectively than simply resetting the counter:
```ts title="start/limiter.ts"
limiter
.allowRequests(10)
.every('1 minute')
/**
* If a user sends an 11th request within one minute,
* block them for 30 minutes instead of just waiting
* for the 1-minute window to reset.
*/
.blockFor('30 mins')
```
## Dynamic rate limiting
Different users often need different rate limits. Authenticated users might get higher limits than guests, or premium subscribers might get unlimited access while free users are restricted.
The callback passed to `limiter.define` receives the HTTP context, allowing you to apply different limits based on request properties:
```ts title="start/limiter.ts"
export const apiThrottle = limiter.define('api', (ctx) => {
/**
* Authenticated users get 100 requests per minute,
* tracked by their user ID.
*/
if (ctx.auth.user) {
return limiter
.allowRequests(100)
.every('1 minute')
.usingKey(`user_${ctx.auth.user.id}`)
}
/**
* Guest users get 10 requests per minute,
* tracked by their IP address.
*/
return limiter
.allowRequests(10)
.every('1 minute')
.usingKey(`ip_${ctx.request.ip()}`)
})
```
```ts title="start/routes.ts"
import { apiThrottle } from '#start/limiter'
router
.get('/api/repos/:id/stats', [RepoStatusController])
.use(apiThrottle)
```
## Handling ThrottleException
When a user exhausts their rate limit, the middleware throws the `E_TOO_MANY_REQUESTS` exception. The exception is automatically converted to an HTTP response using content negotiation:
- Requests with `Accept: application/json` receive a JSON error object.
- Requests with `Accept: application/vnd.api+json` receive a JSON API formatted error.
- All other requests receive a plain text message. You can use [status pages](../basics/exception_handling.md#status-pages) to show a custom error page.
See also: [E_TOO_MANY_REQUESTS exception reference](../../reference/exceptions.md#e_too_many_requests)
The middleware will also add the following response headers:
- `X-RateLimit-Limit`: Total number of requests that can be made
- `X-RateLimit-Remaining` Number of requests remaining
- `Retry-After`: Number of seconds after which the client can retry
- `X-RateLimit-Reset`: Timestamp after which the client can retry
### Customizing the error response
You can customize the error message without handling the exception globally using the `limitExceeded` hook:
```ts title="start/limiter.ts"
export const throttle = limiter.define('global', () => {
return limiter
.allowRequests(10)
.every('1 minute')
.limitExceeded((error) => {
error
.setStatus(400)
.setMessage('Cannot process request. Try again later')
})
})
```
### Using translations
If you have configured the [@adonisjs/i18n](../digging_deeper/i18n.md) package, define a translation using the `errors.E_TOO_MANY_REQUESTS` key:
```json title="resources/lang/fr/errors.json"
{
"E_TOO_MANY_REQUESTS": "Trop de demandes"
}
```
You can also use a custom translation key with interpolated values:
```ts title="start/limiter.ts"
limitExceeded((error) => {
error.t('errors.rate_limited', {
limit: error.response.limit,
remaining: error.response.remaining,
})
})
```
### Handling the exception globally
For more control, handle the exception in your [global exception handler](../basics/exception_handling.md#handling-exceptions):
```ts title="app/exceptions/handler.ts"
import { errors } from '@adonisjs/limiter'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
protected debug = !app.inProduction
protected renderStatusPages = app.inProduction
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof errors.E_TOO_MANY_REQUESTS) {
const message = error.getResponseMessage(ctx)
const headers = error.getDefaultHeaders()
Object.keys(headers).forEach((header) => {
ctx.response.header(header, headers[header])
})
return ctx.response.status(error.status).send(message)
}
return super.handle(error, ctx)
}
}
```
## Direct usage
Beyond HTTP middleware, you can use the limiter directly in any part of your application. This is useful for protecting login forms from brute-force attacks, limiting background job execution, or controlling access to expensive operations.
### Creating a limiter instance
Use the `limiter.use` method to create a limiter instance with specific settings:
```ts title="app/services/reports_service.ts"
import limiter from '@adonisjs/limiter/services/main'
const reportsLimiter = limiter.use('redis', {
requests: 1,
duration: '1 hour'
})
```
| Option | Description |
|--------|-------------|
| `requests` | Number of requests allowed within the duration. |
| `duration` | Time window in seconds or as a [time expression](../../reference/helpers.md#seconds) string. |
| `blockDuration` | Optional. Duration to block the key after all requests are exhausted. |
| `inMemoryBlockOnConsumed` | Optional. See [shared options](#shared-options). |
| `inMemoryBlockDuration` | Optional. See [shared options](#shared-options). |
To use the default store, omit the first parameter:
```ts
const reportsLimiter = limiter.use({
requests: 1,
duration: '1 hour'
})
```
### Limiting expensive operations
The `attempt` method executes a callback only if the rate limit hasn't been exceeded. It returns the callback's result, or `undefined` if the limit was reached:
```ts title="app/services/reports_service.ts"
import limiter from '@adonisjs/limiter/services/main'
const reportsLimiter = limiter.use({
requests: 1,
duration: '1 hour'
})
export async function generateUserReport(userId: number) {
const key = `reports_user_${userId}`
const executed = await reportsLimiter.attempt(key, async () => {
await generateReport(userId)
return true
})
if (!executed) {
const availableIn = await reportsLimiter.availableIn(key)
throw new Error(`Too many requests. Try after ${availableIn} seconds`)
}
return 'Report generated'
}
```
### Preventing brute-force login attacks
The `penalize` method is designed for scenarios where you want to consume a request only when an operation fails. This is perfect for login protection where you want to track failed attempts, not successful ones.
```ts title="app/controllers/session_controller.ts"
import User from '#models/user'
import { HttpContext } from '@adonisjs/core/http'
import limiter from '@adonisjs/limiter/services/main'
export default class SessionController {
async store({ request, response, session }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
/**
* Create a limiter that allows 5 failed attempts per minute,
* then blocks for 20 minutes.
*/
const loginLimiter = limiter.use({
requests: 5,
duration: '1 min',
blockDuration: '20 mins'
})
/**
* Use IP + email combination as the key. This ensures that if
* an attacker is trying multiple emails, we block the attacker's
* IP without affecting legitimate users trying to log in with
* their own email from different IPs.
*/
const key = `login_${request.ip()}_${email}`
/**
* The penalize method consumes one request only if
* the callback throws an error.
*/
const [error, user] = await loginLimiter.penalize(key, () => {
return User.verifyCredentials(email, password)
})
if (error) {
session.flashAll()
session.flashErrors({
E_TOO_MANY_REQUESTS: `Too many login attempts. Try again after ${error.response.availableIn} seconds`
})
return response.redirect().back()
}
/**
* Login successful - proceed with creating the session
*/
}
}
```
## Manual request consumption
For fine-grained control, you can manually check and consume requests instead of using `attempt` or `penalize`.
:::warning
Calling `remaining` and `increment` separately creates a race condition where multiple concurrent requests might both pass the check before either increments the counter. Use the `consume` method instead, which performs an atomic check-and-increment.
:::
The `consume` method increments the counter and throws an exception if the limit has been reached. You can optionally pass an **amount** as the second argument to consume multiple slots at once (useful for "weighted" rate limiting).
```ts title="app/services/api_service.ts"
import { errors } from '@adonisjs/limiter'
import limiter from '@adonisjs/limiter/services/main'
const requestsLimiter = limiter.use({
requests: 10,
duration: '1 minute'
})
export async function handleApiRequest(userId: number) {
const key = `api_user_${userId}`
try {
/**
* Consume 5 slots at once for a heavy operation
*/
await requestsLimiter.consume(key, 5)
return await performAction()
} catch (error) {
if (error instanceof errors.E_TOO_MANY_REQUESTS) {
throw new Error('Rate limit exceeded')
}
throw error
}
}
```
## Incrementing without throwing
The `increment` method works like `consume` but does not throw an exception when the limit is exceeded, it also accepts an optional amount. Instead of throwing an exception, it returns the limiter response object, allowing you to check the remaining requests and decide how to handle the situation:
```ts title="app/services/api_service.ts"
import limiter from '@adonisjs/limiter/services/main'
const requestsLimiter = limiter.use({
requests: 10,
duration: '1 minute'
})
export async function handleApiRequest(userId: number) {
const key = `api_user_${userId}`
const response = await requestsLimiter.increment(key, 5)
if (response.remainingPoints < 0) {
throw new Error('Rate limit exceeded')
}
return await performAction()
}
```
:::info
Validation of the amount parameter
The amount parameter must be a positive integer. To prevent logic errors or security bypasses, if you provide a value less than or equal to `0`, the limiter will automatically fallback to `1`.
:::
## Blocking keys
You can extend the lockout period for users who continue making requests after exhausting their quota. This is more punitive than standard rate limiting and discourages abuse.
Automatic blocking occurs when you create a limiter with the `blockDuration` option:
```ts title="app/services/api_service.ts"
import limiter from '@adonisjs/limiter/services/main'
const requestsLimiter = limiter.use({
requests: 10,
duration: '1 minute',
blockDuration: '30 mins'
})
/**
* A user can make 10 requests per minute. If they send
* an 11th request, they're blocked for 30 minutes.
* The consume, attempt, and penalize methods all
* enforce this behavior automatically.
*/
await requestsLimiter.consume('a_unique_key')
```
You can also block a key manually:
```ts
await requestsLimiter.block('a_unique_key', '30 mins')
```
## Resetting attempts
Sometimes you need to restore requests to a user. For example, if a background job completes, you might want to let the user queue another one.
The decrement method reduces the request count. By default, it decrements by 1, but you can specify a custom amount as the second argument.
```ts title="app/jobs/process_report.ts"
import limiter from '@adonisjs/limiter/services/main'
const jobsLimiter = limiter.use({
requests: 10,
duration: '5 mins',
})
export async function processReportJob(userId: number) {
const key = `jobs_user_${userId}`
await jobsLimiter.attempt(key, async () => {
await processJob()
/**
* Job completed - give the slot back so
* another job can be queued.
*/
await jobsLimiter.decrement(key, 5)
})
}
```
:::info
Just like increment and consume, providing an amount `<= 0` will cause the method to fallback to `1`.
:::
:::tip
The `decrement` method is not atomic. Under high concurrency, the request count might briefly go to `-1`. Use the `delete` method if you need to completely reset a key.
:::
The `delete` method removes a key entirely:
```ts
await requestsLimiter.delete('unique_key')
```
## Testing
During testing, you typically want to use the `memory` store instead of Redis or a database. Set the environment variable in your `.env.test` file:
```dotenv title=".env.test"
LIMITER_STORE=memory
```
Clear the rate-limiting storage between tests using the `limiter.clear` method:
```ts title="tests/functional/reports.spec.ts"
import limiter from '@adonisjs/limiter/services/main'
test.group('Reports', (group) => {
group.each.setup(() => {
return () => limiter.clear(['memory'])
})
})
```
You can also call `clear` without arguments to flush all configured stores:
```ts
return () => limiter.clear()
```
:::warning
When using Redis, the `clear` method flushes the entire database. Use a separate Redis database for the rate limiter to avoid clearing application data. Configure this in `config/redis.ts` by creating a dedicated connection.
:::
## Creating a custom storage provider
You can create custom storage providers by implementing the `LimiterStoreContract` interface. This is useful when you need to use a database not supported by the built-in stores.
```ts title="app/limiter/mongodb_store.ts"
import string from '@adonisjs/core/helpers/string'
import { LimiterResponse } from '@adonisjs/limiter'
import {
LimiterStoreContract,
LimiterConsumptionOptions
} from '@adonisjs/limiter/types'
export type MongoDbLimiterConfig = {
client: MongoDBConnection
}
export class MongoDbLimiterStore implements LimiterStoreContract {
readonly name = 'mongodb'
declare readonly requests: number
declare readonly duration: number
declare readonly blockDuration: number
constructor(config: MongoDbLimiterConfig & LimiterConsumptionOptions) {
this.requests = config.requests
this.duration = string.seconds.parse(config.duration)
this.blockDuration = string.seconds.parse(config.blockDuration)
}
/**
* Consume one request for the key. Throws an error
* when all requests have been consumed.
*/
async consume(key: string | number, amount?: number): Promise {}
/**
* Consume one request without throwing when exhausted.
*/
async increment(key: string | number, amount?: number): Promise {}
/**
* Restore one request to the key.
*/
async decrement(key: string | number, amount?: number): Promise {}
/**
* Block a key for the specified duration.
*/
async block(
key: string | number,
duration: string | number
): Promise {}
/**
* Set the consumed request count for a key.
*/
async set(
key: string | number,
requests: number,
duration?: string | number
): Promise {}
/**
* Delete a key from storage.
*/
async delete(key: string | number): Promise {}
/**
* Flush all keys from storage.
*/
async clear(): Promise {}
/**
* Get the limiter response for a key, or null if
* the key doesn't exist.
*/
async get(key: string | number): Promise {}
}
```
### Creating the config helper
Create a helper function to use your store in the config file. The helper should return a `LimiterManagerStoreFactory` function:
```ts title="app/limiter/mongodb_store.ts"
import { LimiterManagerStoreFactory } from '@adonisjs/limiter/types'
export function mongoDbStore(config: MongoDbLimiterConfig) {
const storeFactory: LimiterManagerStoreFactory = (runtimeOptions) => {
return new MongoDbLimiterStore({
...config,
...runtimeOptions
})
}
return storeFactory
}
```
### Using your custom store
```ts title="config/limiter.ts"
import env from '#start/env'
import { mongoDbStore } from '#app/limiter/mongodb_store'
import { defineConfig } from '@adonisjs/limiter'
const limiterConfig = defineConfig({
default: env.get('LIMITER_STORE'),
stores: {
mongodb: mongoDbStore({
client: mongoDb
})
},
})
```
### Wrapping rate-limiter-flexible drivers
If you're wrapping an existing driver from [node-rate-limiter-flexible](https://github.com/animir/node-rate-limiter-flexible), use the `RateLimiterBridge` class for simpler implementation:
```ts title="app/limiter/mongodb_store.ts"
import { RateLimiterBridge } from '@adonisjs/limiter'
import { RateLimiterMongo } from 'rate-limiter-flexible'
export class MongoDbLimiterStore extends RateLimiterBridge {
readonly name = 'mongodb'
constructor(config: MongoDbLimiterConfig & LimiterConsumptionOptions) {
super(
new RateLimiterMongo({
storeClient: config.client,
points: config.requests,
duration: string.seconds.parse(config.duration),
blockDuration: string.seconds.parse(config.blockDuration)
})
)
}
/**
* The bridge handles most methods, but you must
* implement clear() yourself.
*/
async clear() {
await this.config.client.collection('rate_limits').deleteMany({})
}
}
```
---
# Application Lifecycle
This guide covers the application lifecycle in AdonisJS. You will learn:
- The three lifecycle phases (boot, start, and termination)
- When each phase executes and what happens during it
- How to hook into phases using service providers and preload files
## Overview
The application lifecycle in AdonisJS consists of three distinct phases: **boot**, **start**, and **termination**. Each phase serves a specific purpose in preparing your application, running it, and gracefully shutting it down.
Understanding the lifecycle is essential when you need to execute code at specific points during your application's runtime. For example, you might want to register custom validation rules before your application starts handling requests, or perform cleanup operations before your application terminates.
The lifecycle flows chronologically from boot to start, and eventually to termination when the process receives a shutdown signal. Each phase has clearly defined responsibilities and happens in a predictable order, allowing you to hook into the exact moment you need.
## Boot phase
The boot phase is the initial stage where AdonisJS prepares your application for execution. During this phase, you can use the IoC container to fetch bindings and extend parts of the framework.
Service providers register their bindings into the container and execute their `boot` methods. The framework itself is being configured, but your application isn't yet ready to handle requests or execute commands.
The boot phase completes before any preload files are imported or application-specific code runs. Think of it as the foundation-laying phase where the framework assembles all the pieces it needs.
:::media

:::
## Start phase
The start phase is where your application comes to life. During this phase, AdonisJS imports preload files and executes the `start` and `ready` methods from service providers.
Application-specific initialization happens here. Routes are registered, event listeners are attached, and setup code runs. By the end of this phase, your application is fully operational and ready to handle HTTP requests, execute Ace commands, or run tests depending on the environment.
:::media

:::
The start phase is environment-aware, meaning you can configure different behavior for the HTTP server, Ace commands, or test environments. All preload files configured for the current environment are imported in parallel for optimal performance.
## Termination phase
The termination phase happens when AdonisJS begins graceful shutdown. This usually occurs when the process receives the `SIGTERM` signal, such as when you stop your development server or during a deployment.
During this phase, service providers execute their `shutdown` methods, allowing them to perform cleanup operations like closing database connections, flushing logs, or canceling pending background jobs.
:::media

:::
Graceful shutdown ensures your application stops cleanly rather than abruptly terminating mid-operation, helping prevent data corruption.
## Hooking into lifecycle phases
You can hook into different phases of the application lifecycle using service providers and preload files. Service providers offer lifecycle methods (`boot`, `start`, `ready`, and `shutdown`) that execute at specific points, while preload files run during the start phase.
### Hooking into the boot phase
Use the `boot` method in a service provider to execute code during the boot phase. This is where you should extend the framework or configure services that other parts of your application depend on.
The following example extends VineJS with a custom phoneNumber validation rule. This rule will be available throughout your application.
```ts title="providers/app_provider.ts"
import { VineString } from '@vinejs/vine'
import type { ApplicationService } from '@adonisjs/core/types'
export default class AppProvider {
constructor(protected app: ApplicationService) {}
async boot() {
VineString.macro('phoneNumber', function (this: VineString) {
return this.use((value, field) => {
if (typeof value !== 'string') {
return
}
if (!/^\d{10}$/.test(value)) {
field.report('The {{ field }} must be a valid 10-digit phone number', field)
}
})
})
}
}
```
### Hooking into the start phase
You can hook into the start phase using either service provider methods or preload files. Service providers offer `start` and `ready` methods, while preload files provide a simpler approach for application-specific initialization.
#### Using service provider methods
The `start` method executes after the boot phase completes but before the application is ready. The `ready` method executes once the application is fully started and ready to handle requests or commands.
```ts title="providers/app_provider.ts"
import type { ApplicationService } from '@adonisjs/core/types'
export default class AppProvider {
constructor(protected app: ApplicationService) {}
async start() {
const database = await this.app.container.make('lucid.db')
/**
* Verify database connection is working
*/
await database.connection().select(1)
}
async ready() {
if (this.app.getEnvironment() === 'web') {
const logger = await this.app.container.make('logger')
logger.info('HTTP server is ready to accept requests')
}
}
}
```
#### Using preload files
Preload files offer a simpler way to run code during the start phase without creating a service provider. They're ideal for application-specific initialization like registering routes, attaching event listeners, or configuring middleware.
Create a preload file using the `make:preload` command.
```sh
node ace make:preload events
```
This command creates a new file in the `start` directory and automatically registers it in your `adonisrc.ts` configuration file.
```ts title="start/events.ts"
import emitter from '@adonisjs/core/services/emitter'
import logger from '@adonisjs/core/services/logger'
emitter.on('user:registered', function (user) {
logger.info({ userId: user.id }, 'New user registered')
})
emitter.on('order:placed', function (order) {
logger.info({ orderId: order.id }, 'New order placed')
})
```
You can configure preload files to load only in specific runtime environments.
```ts title="adonisrc.ts"
{
preloads: [
() => import('#start/routes'),
() => import('#start/kernel'),
{
file: () => import('#start/events'),
environment: ['web', 'console']
}
]
}
```
The `environment` property accepts an array of values: `web` (HTTP server), `console` (Ace commands), `test` (test runner), and `repl` (REPL environment).
### Hooking into the termination phase
Use the `shutdown` method in a service provider to execute cleanup operations during graceful shutdown. This ensures resources are properly released before your application terminates.
```ts title="providers/app_provider.ts"
import type { ApplicationService } from '@adonisjs/core/types'
export default class AppProvider {
constructor(protected app: ApplicationService) {}
async shutdown() {
const redis = await this.app.container.make('redis')
/**
* Close all Redis connections
*/
await redis.quit()
const logger = await this.app.container.make('logger')
logger.info('Application shutdown complete')
}
}
```
## See also
- [Service providers guide](link-to-service-providers) for learning how to create and register service providers, and understand the complete lifecycle of the `register`, `boot`, `start`, `ready`, and `shutdown` methods
- [AdonisRC file reference](link-to-adonisrc-reference) for complete reference on configuring preload files and other application settings
---
# Dependency injection and the IoC container
This guide covers dependency injection and the IoC container in AdonisJS. You will learn:
- How to use the `@inject` decorator for automatic dependency resolution
- The difference between constructor and method injection
- When and how to use the IoC container manually
- How to register bindings and singletons for complex dependencies
- How to implement the adapter pattern using abstract classes
- How to swap dependencies during testing
## Overview
Dependency injection is a design pattern that eliminates the need to manually create and manage class dependencies. Instead of creating dependencies inside a class, you declare them as constructor parameters or method parameters, and the IoC container resolves them automatically.
AdonisJS includes a powerful IoC (Inversion of Control) container that handles dependency injection throughout your application. When you type-hint a class as a dependency, the container automatically creates an instance of that class and injects it where needed.
The IoC container is already integrated into core parts of AdonisJS including [controllers](../basics/controllers.md), [middleware](../basics/middleware.md), [event listeners](../digging_deeper/emitter.md), and [Ace commands](../ace/introduction.md). This means you can type-hint dependencies in these classes and they'll be resolved automatically when the framework constructs them.
:::note
AdonisJS uses TypeScript's `experimentalDecorators` and `emitDecoratorMetadata` compiler options to enable dependency injection. These are pre-configured in the `tsconfig.json` file of new AdonisJS projects.
:::
## Your first dependency injection
Let's start with a practical example. We'll create an `AvatarService` that generates Gravatar URLs for users, then inject it into a controller.
::::steps
:::step{title="Create the service"}
First, create a service class that will be injected. This service generates Gravatar avatar URLs based on user email addresses.
```ts title="app/services/avatar_service.ts"
import User from '#models/user'
import { createHash } from 'node:crypto'
export class AvatarService {
protected getGravatarAvatar(user: User) {
const emailHash = createHash('md5').update(user.email).digest('hex')
const url = new URL(emailHash, 'https://gravatar.com/avatar/')
url.searchParams.set('size', '200')
return url.toString()
}
getAvatarFor(user: User) {
return this.getGravatarAvatar(user)
}
}
```
:::
:::step{title="Inject the service into a controller"}
Next, create a controller that uses the `AvatarService`. The `@inject()` decorator tells the container to automatically resolve and inject the service.
```ts title="app/controllers/users_controller.ts"
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import { AvatarService } from '#services/avatar_service'
import User from '#models/user'
// [!code highlight]
@inject()
export default class UsersController {
/**
* The AvatarService is automatically injected by the container
* when this controller is constructed
*/
// [!code highlight]
constructor(protected avatarService: AvatarService) {}
async store({ request }: HttpContext) {
/**
* Create a new user (simplified for demonstration)
*/
const user = await User.create(request.only(['email', 'username']))
/**
* Use the injected service to generate and save the avatar URL
*/
// [!code highlight]
const avatarUrl = this.avatarService.getAvatarFor(user)
user.avatarUrl = avatarUrl
await user.save()
return user
}
}
```
:::
:::step{title="Register the route"}
Finally, connect your controller to a route. When you visit this endpoint, AdonisJS automatically constructs the controller using the container.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.post('/users', [controllers.Users, 'store'])
```
The `@inject()` decorator is required on the controller class. Without it, the container won't know to resolve dependencies. The decorator uses TypeScript's reflection capabilities to detect constructor dependencies at runtime.
:::
::::
## Method injection
Method injection works similarly to constructor injection, but instead of resolving dependencies for the entire class, the container resolves dependencies for a specific method. This is useful when only one method needs a particular dependency, or when you want to keep the class constructor simple.
The `@inject()` decorator must be placed before the method when using method injection.
```ts title="app/controllers/users_controller.ts"
import { inject } from '@adonisjs/core'
import { HttpContext } from '@adonisjs/core/http'
import { AvatarService } from '#services/avatar_service'
import User from '#models/user'
export default class UsersController {
/**
* The @inject decorator on the method tells the container
* to resolve the avatarService parameter automatically
*/
// [!code highlight:2]
@inject()
async store({ request }: HttpContext, avatarService: AvatarService) {
const user = await User.create(request.only(['email', 'username']))
/**
* Use the injected service directly as a method parameter
*/
// [!code highlight]
const avatarUrl = avatarService.getAvatarFor(user)
user.avatarUrl = avatarUrl
await user.save()
return user
}
}
```
Notice that `HttpContext` is always the first parameter for controller methods, followed by any dependencies you want to inject. The container automatically distinguishes between the HTTP context and injectable dependencies.
## What can be injected?
You can type-hint and inject only classes inside other classes. Since TypeScript types and interfaces are removed at compile time and are not visible to the runtime code, there is no way for the container to resolve them.
If a class has other dependencies like configuration objects that cannot be auto-resolved, you must register the class as a binding within the container. We'll cover bindings [later in this guide](#bindings).
### The import type pitfall
A common issue that causes dependency injection to fail silently is when classes are accidentally imported using TypeScript's `import type` syntax. This happens frequently because code editors with auto-import features often default to importing classes as types.
When you use `import type`, TypeScript strips the import entirely during compilation. The container has no class constructor to resolve at runtime, so your dependency becomes `undefined`.
```ts title="❌ Wrong: Imported as a type"
import { inject } from '@adonisjs/core'
// [!code highlight]
import type { AvatarService } from '#services/avatar_service'
@inject()
export default class UsersController {
constructor(protected avatarService: AvatarService) {}
}
```
```ts title="✅ Correct: Imported as a value"
import { inject } from '@adonisjs/core'
// [!code highlight]
import { AvatarService } from '#services/avatar_service'
@inject()
export default class UsersController {
constructor(protected avatarService: AvatarService) {}
}
```
## Which classes support dependency injection
The following classes are automatically constructed by the container, allowing you to use constructor injection and, in some cases, method injection.
| Class Type | Constructor Injection | Method Injection |
|---|---|---|
| Controllers | ✅ | ✅ |
| Middleware | ✅ | ❌ |
| Event listeners | ✅ | ✅ (only `handle` method) |
| Bouncer policies | ✅ | ❌ |
| Transformers | ❌ | ✅ |
| Ace commands | ❌ | ✅ (only lifecycle methods) |
For any other classes you create, you'll need to use the container manually to construct them, which we'll cover in the next section.
## Using the container manually
While AdonisJS automatically constructs controllers, middleware, and other framework classes using the container, you may need to manually construct your own classes in certain scenarios. For example, if you're implementing a queue system and want each job class to benefit from dependency injection, you'll need to use the container's API directly.
### Constructing classes with container.make
The `container.make` method accepts a class constructor and returns an instance of it, automatically resolving all constructor dependencies marked with `@inject()`.
Let's demonstrate this with a queue job scenario. We'll create two services:
- A `LoggerService` for logging and a `UserService` that depends on it.
- Then we'll use the container manually within a queue job to construct the `UserService`, which will automatically resolve its `LoggerService` dependency.
The container instance is available throughout your AdonisJS application via the `app` service.
::::tabs
:::tab{title="Services"}
```ts
import { inject } from '@adonisjs/core'
class LoggerService {
log(message: string) {
console.log(message)
}
}
@inject()
export class UserService {
constructor(protected logger: LoggerService) {}
createUser(data: any) {
this.logger.log('Creating user...')
}
}
```
:::
:::tab{title="Queue job"}
```ts
import app from '@adonisjs/core/services/app'
import { UserService } from '#services/user_service'
export default class ProcessUserJob {
async handle(userData: any) {
/**
* Manually construct UserService using the container.
* Its LoggerService dependency is automatically resolved.
*/
const userService = await app.container.make(UserService)
await userService.createUser(userData)
}
}
```
:::
::::
The container recursively resolves the entire dependency tree. If `UserService` had dependencies, and those dependencies had their own dependencies, the container would resolve them all automatically.
### Calling methods with container.call
You can perform method injection on any class method using the `container.call` method. This is useful when you want dependencies injected into a specific method rather than the entire class.
The `container.call` method accepts the class instance, the method name, and an array of runtime values. Runtime values are passed as the initial parameters, followed by any auto-resolved dependencies.
::::tabs
:::tab{title="Services"}
```ts
import { inject } from '@adonisjs/core'
class EmailService {
send(to: string, message: string) {
console.log(`Sending email to ${to}`)
}
}
export class NotificationService {
@inject()
notify(userId: string, message: string, emailService: EmailService) {
emailService.send(`user-${userId}@example.com`, message)
}
}
```
:::
:::tab{title="Usage"}
```ts
import app from '@adonisjs/core/services/app'
import { NotificationService } from '#services/notification_service'
/**
* Create the service instance
*/
const notificationService = await app.container.make(NotificationService)
/**
* Call the method using the container.
*
* The EmailService is automatically resolved and injected.
* The first two arguments are runtime values we provide.
*/
await app.container.call(
notificationService,
'notify',
['user-123', 'Welcome to our platform!']
)
```
:::
::::
## Bindings
Bindings are the mechanism you use when classes require dependencies that cannot be auto-resolved with the `@inject()` decorator. For example, when a class needs a configuration object or a primitive value alongside its class dependencies.
When a binding exists for a class, the container disables its auto-resolution logic and uses your factory function to create instances instead.
### Creating a binding
Bindings must be registered in the `register` method of a [Service Provider](./service_providers.md). You can create a new provider using the `node ace make:provider` command.
::::steps
:::step{title="Define the class that needs custom construction"}
Let's create a `Cache` class that requires both a `RedisConnection` and a configuration object. Since we're registering this class as a binding, there's no need to use the `@inject` decorator. The container will use our factory function instead.
```ts title="app/services/cache.ts"
import type { RedisConnection } from '@adonisjs/redis'
export type CacheConfig = {
ttl: string | number
grace: boolean
}
export class Cache {
constructor(
public store: RedisConnection,
public config: CacheConfig
) {}
async get(key: string) {
// Cache implementation
}
async set(key: string, value: any) {
// Cache implementation
}
}
```
:::
:::step{title="Register the binding in a provider"}
Create a provider and register the binding in its `register` method. The factory function receives a resolver that can create instances of other classes.
```ts title="providers/cache_provider.ts"
import type { ApplicationService } from '@adonisjs/core/types'
import redis from '@adonisjs/redis/services/main'
import { Cache } from '#services/cache'
export default class CacheProvider {
constructor(protected app: ApplicationService) {}
register() {
this.app.container.bind(Cache, async (resolver) => {
/**
* Resolve redis dependency from the container
*/
const redis = await resolver.make('redis')
const store = redis.connection()
/**
* Get configuration from the app config
*/
const config = this.app.config.get('cache')
/**
* Manually construct and return the Cache instance
* with all its dependencies
*/
return new Cache(store, config)
})
}
}
```
:::
:::step{title="Use the binding"}
The container uses your factory function from the binding instead of trying to auto-resolve dependencies.
```ts
import app from '@adonisjs/core/services/app'
import { Cache } from '#services/cache'
const cache = await app.container.make(Cache)
await cache.set('user:1', { name: 'Virk' })
const user = await cache.get('user:1')
```
:::
::::
Bindings give you complete control over how a class is constructed. The factory function receives a resolver that you can use to create other dependencies, allowing you to build complex dependency trees with custom logic.
### Singletons
Singletons are bindings that are constructed only once and then cached. Multiple calls to `container.make` for a singleton will return the same instance. This is useful for services that should be shared across your application, like database connections or caching layers.
```ts title="providers/cache_provider.ts"
import type { ApplicationService } from '@adonisjs/core/types'
import redis from '@adonisjs/redis/services/main'
import { Cache } from '#services/cache'
export default class CacheProvider {
constructor(protected app: ApplicationService) {}
register() {
/**
* Use singleton instead of bind.
* The Cache instance will be created once and reused.
*/
this.app.container.singleton(Cache, async (resolver) => {
const redis = await resolver.make('redis')
const store = redis.connection()
const config = this.app.config.get('cache')
return new Cache(store, config)
})
}
}
```
Now every call to `app.container.make(Cache)` returns the exact same `Cache` instance, making it efficient and ensuring shared state when needed.
### Aliases
Aliases provide alternate string-based names for bindings, allowing you to request dependencies using descriptive names instead of class constructors.
To create an alias, register it using `container.alias()` and update the `ContainerBindings` interface using TypeScript module augmentation for type safety.
```ts title="providers/cache_provider.ts"
import type { ApplicationService } from '@adonisjs/core/types'
import redis from '@adonisjs/redis/services/main'
import { Cache } from '#services/cache'
/**
* Declare the alias type for TypeScript
*/
declare module '@adonisjs/core/types' {
interface ContainerBindings {
cache: Cache
}
}
export default class CacheProvider {
constructor(protected app: ApplicationService) {}
register() {
/**
* Register the Cache binding as a singleton
*/
this.app.container.singleton(Cache, async (resolver) => {
const store = redis.connection()
const config = this.app.config.get('cache')
return new Cache(store, config)
})
/**
* Create an alias so we can reference Cache by the string 'cache'
*/
this.app.container.alias('cache', Cache)
}
}
```
Now you can request the cache using the string alias. TypeScript will know that `app.container.make('cache')` returns a `Cache` instance, giving you full autocomplete and type checking.
```ts
import app from '@adonisjs/core/services/app'
/**
* Request the cache using the string alias instead of the class
*/
const cache = await app.container.make('cache')
```
### Binding existing values
Sometimes you already have an instance and want to register it directly with the container rather than providing a factory function. The `bindValue` method binds an existing value to a class constructor or alias, and the container returns that exact value whenever it's requested.
```ts title="providers/app_provider.ts"
import type { ApplicationService } from '@adonisjs/core/types'
import { Config } from '#services/config'
export default class AppProvider {
constructor(protected app: ApplicationService) {}
register() {
/**
* Create the instance ourselves with specific configuration
*/
const config = new Config({
environment: 'production',
debug: false,
})
/**
* Bind the existing instance directly.
* Every request for Config returns this exact object.
*/
this.app.container.bindValue(Config, config)
}
}
```
Unlike `bind` or `singleton` which accept factory functions, `bindValue` accepts the instance itself. This is useful when you need to register objects that were created outside the container, such as configuration objects parsed at startup or instances received from external libraries.
The `bindValue` method is also available on the request-scoped resolver, which is how request-specific instances like `HttpContext` and `Logger` are registered during HTTP requests. See [Dependency injection during HTTP requests](#dependency-injection-during-http-requests) for an example.
## Dependency injection during HTTP requests
The `HttpContext` object receives an isolated container resolver for each HTTP request. This allows you to register singleton instances that exist only for the duration of that specific request, which is useful for request-scoped services like loggers or user sessions.
These request-scoped bindings should be registered in the `app/middleware/container_bindings_middleware.ts` file, which is pre-created and automatically registered in new AdonisJS applications.
```ts title="app/middleware/container_bindings_middleware.ts"
import { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import { Logger } from '@adonisjs/core/logger'
export default class ContainerBindingsMiddleware {
handle(ctx: HttpContext, next: NextFn) {
/**
* Register the HttpContext itself as a binding.
* Any class that type-hints HttpContext will receive
* this exact context instance for the current request.
*/
ctx.containerResolver.bindValue(HttpContext, ctx)
/**
* Register the request-specific logger.
* All classes resolved during this request that depend
* on Logger will receive this logger instance.
*/
ctx.containerResolver.bindValue(Logger, ctx.logger)
return next()
}
}
```
Throughout the current HTTP request, any classes that type-hint `HttpContext` or `Logger` as dependencies will receive the exact instances registered here. This ensures consistency and proper scoping for request-specific data.
## Abstract classes as interfaces
Since TypeScript interfaces are removed at runtime, you cannot use them for type-hinting dependencies. However, you can use abstract classes to achieve the same polymorphic behavior. This enables you to implement the Adapter design pattern, where multiple implementations conform to a common contract.
Let's build a payment system that can work with different payment providers while keeping your business logic provider-agnostic.
### Defining the contract
First, create an abstract class that defines the contract all payment providers must implement. This acts as your interface but remains available at runtime for the container to use as an injection token.
```ts title="app/services/payment_service.ts"
/**
* Abstract class acts as the interface.
* Different payment providers will implement this.
*/
export default abstract class PaymentService {
abstract charge(amount: number): Promise
abstract refund(amount: number): Promise
}
```
### Creating concrete implementations
Now create concrete implementations for different payment providers. Each provider implements the abstract class's methods with their specific logic, but they all conform to the same contract.
:::codegroup
```ts title="app/services/stripe_provider.ts"
import PaymentService from './payment_service.js'
export default class StripeProvider implements PaymentService {
async charge(amount: number) {
console.log(`Charging ${amount} via Stripe`)
}
async refund(amount: number) {
console.log(`Refunding ${amount} via Stripe`)
}
}
```
```ts title="app/services/paypal_provider.ts"
import PaymentService from './payment_service.js'
export default class PaypalProvider implements PaymentService {
async charge(amount: number) {
console.log(`Charging ${amount} via PayPal`)
}
async refund(amount: number) {
console.log(`Refunding ${amount} via PayPal`)
}
}
```
:::
Notice that both implementations follow the exact same contract. Your application code can depend on `PaymentService` without knowing which specific provider is being used.
### Configuring which implementation to use
Register a binding that tells the container which concrete implementation to inject when someone requests the abstract `PaymentService`. This is where you decide whether your application uses Stripe, PayPal, or any other provider.
```ts title="providers/app_provider.ts"
import type { ApplicationService } from '@adonisjs/core/types'
import PaymentService from '#services/payment_service'
import StripeProvider from '#services/stripe_provider'
export default class AppProvider {
constructor(protected app: ApplicationService) {}
register() {
/**
* Bind the abstract PaymentService to the concrete StripeProvider.
* Any class that type-hints PaymentService will receive StripeProvider.
*/
this.app.container.bind(PaymentService, () => {
return new StripeProvider()
})
}
}
```
### Using the abstraction in your code
Now your business logic can depend on the abstract `PaymentService` without being coupled to any specific provider.
```ts title="app/controllers/checkout_controller.ts"
import { inject } from '@adonisjs/core'
import { HttpContext } from '@adonisjs/core/http'
import PaymentService from '#services/payment_service'
export default class CheckoutController {
/**
* Type-hint the abstract PaymentService.
* The container injects StripeProvider (based on our binding).
*/
@inject()
async store({ request }: HttpContext, paymentService: PaymentService) {
const amount = request.input('amount')
/**
* We call methods on PaymentService, but the actual
* implementation is StripeProvider. Our code doesn't
* know or care which provider is used.
*/
await paymentService.charge(amount)
return { success: true }
}
}
```
The power of this pattern is in its flexibility. To switch from Stripe to PayPal, you only need to change the binding in `AppProvider` from `new StripeProvider()` to `new PaypalProvider()`. Your controller and all other business logic remains completely unchanged. This separation makes your code more maintainable and testable.
## Contextual dependencies
Contextual dependencies allow you to inject different implementations of the same abstract class based on which class is requesting it. This is useful when different parts of your application need different configurations or implementations of the same service.
Building on the `PaymentService` example from the previous section, imagine you have different business requirements for different parts of your application. User subscriptions should process through Stripe (for recurring billing features), while one-time product purchases should go through PayPal (for broader payment method support).
### Setting up services with different needs
Let's create two services that both need a `PaymentService`, but they should use different payment providers based on their specific business requirements.
:::codegroup
```ts title="app/services/subscription_service.ts"
import { inject } from '@adonisjs/core'
import PaymentService from '#services/payment_service'
@inject()
export default class SubscriptionService {
/**
* SubscriptionService will receive a PaymentService instance
* configured for Stripe (for recurring billing)
*/
constructor(protected paymentService: PaymentService) {}
async createSubscription(userId: number, plan: string) {
await this.paymentService.charge(999)
}
}
```
```ts title="app/services/order_service.ts"
import { inject } from '@adonisjs/core'
import PaymentService from '#services/payment_service'
@inject()
export default class OrderService {
/**
* OrderService will receive a PaymentService instance
* configured for PayPal (for one-time purchases)
*/
constructor(protected paymentService: PaymentService) {}
async createOrder(userId: number, items: any[]) {
await this.paymentService.charge(2499)
}
}
```
:::
Both services type-hint `PaymentService`, but they need different implementations. Without contextual dependencies, you could only bind one provider globally, forcing both services to use the same implementation.
### Registering contextual bindings
Register contextual dependencies in a Service Provider using the `when().asksFor().provide()` API. This tells the container when a specific class asks for a dependency, provide a particular implementation.
```ts title="providers/app_provider.ts"
import type { ApplicationService } from '@adonisjs/core/types'
import PaymentService from '#services/payment_service'
import StripeProvider from '#services/stripe_provider'
import PaypalProvider from '#services/paypal_provider'
import SubscriptionService from '#services/subscription_service'
import OrderService from '#services/order_service'
export default class AppProvider {
constructor(protected app: ApplicationService) {}
register() {
/**
* When SubscriptionService asks for PaymentService,
* provide the Stripe implementation (for recurring billing)
*/
this.app.container
.when(SubscriptionService)
.asksFor(PaymentService)
.provide(() => {
return new StripeProvider()
})
/**
* When OrderService asks for PaymentService,
* provide the PayPal implementation (for one-time purchases)
*/
this.app.container
.when(OrderService)
.asksFor(PaymentService)
.provide(() => {
return new PaypalProvider()
})
}
}
```
Now `SubscriptionService` automatically receives `StripeProvider` when it's constructed, while `OrderService` receives `PaypalProvider`, all through the same `PaymentService` type hint. This keeps your service code clean and focused on business logic while giving you fine-grained control over which dependencies are injected based on context.
The contextual binding pattern is particularly powerful when combined with the adapter pattern from the previous section. Each service remains completely agnostic about which payment provider it's using, yet each gets exactly the implementation it needs for its specific use case.
## Resolution priority
When the container resolves a dependency, it checks multiple sources in a specific order. Understanding this priority helps you predict which implementation will be injected and debug unexpected behavior.
The container resolves dependencies in this order, stopping at the first match:
| Priority | Source | Description |
|----------|--------|-------------|
| 1 | Swaps | Test overrides registered with `container.swap()` |
| 2 | Contextual bindings | Bindings registered with `when().asksFor().provide()` |
| 3 | Resolver values | Values bound to a scoped resolver (e.g., request-scoped bindings) |
| 4 | Container values | Values registered with `container.bindValue()` |
| 5 | Container bindings | Factory functions registered with `container.bind()` or `container.singleton()` |
| 6 | Auto-construction | The container constructs the class itself using `@inject()` |
Swaps have the highest priority because they're designed for testing. When you swap a dependency, you want the fake implementation to be used regardless of any other bindings. This ensures your tests remain isolated and predictable.
Contextual bindings come next because they represent intentional, context-specific overrides. When you explicitly say "when ClassA asks for ServiceB, provide this specific implementation," that intent should override general bindings.
If no swap or contextual binding matches, the container checks for values bound to the current resolver scope. During HTTP requests, the `HttpContext` and request-scoped `Logger` are bound this way, ensuring each request gets its own instances.
Container-level values and bindings are checked next. These are your application-wide singletons and factories registered in service providers.
Finally, if no binding exists at all, the container attempts auto-construction. It uses TypeScript's reflection metadata to identify constructor dependencies marked with `@inject()` and recursively resolves them.
This priority order means you can layer your dependency configuration: define general bindings in providers, override them contextually for specific classes, and swap them entirely during tests—all without modifying the original code.
## Swapping dependencies during testing
The container provides a straightforward API for swapping dependencies with fake implementations during tests. This allows you to test your code in isolation without hitting real external services.
The `container.swap` method replaces a binding with a temporary implementation, and `container.restore` reverts it back to the original. During the swap, any part of your codebase that type-hints the swapped class will receive the fake implementation instead.
```ts title="tests/functional/users/list.spec.ts"
import { test } from '@japa/runner'
import app from '@adonisjs/core/services/app'
import UserService from '#services/user_service'
test('get all users', async ({ client, cleanup }) => {
/**
* Create a fake implementation that extends the real service
*/
class FakeUserService extends UserService {
/**
* Override the all() method to return fake data
* instead of querying the database
*/
all() {
return [
{ id: 1, username: 'virk', email: 'virk@adonisjs.com' },
{ id: 2, username: 'romain', email: 'romain@adonisjs.com' }
]
}
}
/**
* Swap UserService with our fake implementation.
* Any code that resolves UserService during this test
* will receive FakeUserService instead.
*/
app.container.swap(UserService, () => {
return new FakeUserService()
})
/**
* Restore the original binding after the test completes.
* The cleanup hook ensures this runs even if the test fails.
*/
cleanup(() => app.container.restore(UserService))
/**
* Make the HTTP request. The controller will receive
* FakeUserService, which returns our fake data.
*/
const response = await client.get('/users')
response.assertStatus(200)
response.assertBodyContains({
users: [
{ username: 'virk' },
{ username: 'romain' }
]
})
})
```
Swapping is particularly valuable when testing code that depends on external APIs, payment gateways, email services, or any other resource you don't want to interact with during automated tests. The fake implementation can simulate various scenarios (success, failure, edge cases) without requiring real infrastructure.
## Container events
The container emits events when it resolves bindings, allowing you to observe and react to dependency resolution. This can be useful for debugging, monitoring, or implementing cross-cutting concerns.
The container emits a single event type: `container_binding:resolved`. This event is triggered every time the container successfully resolves a class instance, whether through auto-resolution, bindings, or singletons.
You can listen to this event using the application's event emitter.
```ts title="start/events.ts"
import emitter from '@adonisjs/core/services/emitter'
emitter.on('container_binding:resolved', (event) => {
/**
* event.binding contains the class constructor or string alias
* that was resolved
*/
console.log('Resolved binding:', event.binding)
/**
* event.value contains the actual instance that was created
*/
console.log('Instance:', event.value)
})
```
The event object provides two properties. First, `event.binding` contains the binding key (either a class constructor or string alias) that was resolved. Second, `event.value` contains the actual instance that the container created and returned.
This event is fired for every resolution, including nested dependencies. For example, if the container resolves `UserService`, which depends on `LoggerService`, you'll receive two events: one for `LoggerService` and one for `UserService`.
## See also
- [Service Providers](./service_providers.md) - Learn how to register bindings and organize application bootstrapping
- [The IoC Container README](https://github.com/adonisjs/fold/blob/develop/README.md) - Comprehensive API documentation in a framework-agnostic context
- [Why Do You Need an IoC Container?](https://github.com/thetutlage/meta/discussions/4) - The framework creator's reasoning for using dependency injection
- [TypeScript Decorators](https://www.typescriptlang.org/docs/handbook/decorators.html) - Understanding the decorator syntax used by `@inject()`
- [Container API docs](https://api.adonisjs.com/modules/_adonisjs_fold.index) - To get a full view of available classes, methods and properties.
---
# Service Providers
This guide covers service providers in AdonisJS applications. You will learn how to:
- Use lifecycle hooks to execute code at specific points during application startup and shutdown
- Create custom service providers
- Register bindings into the IoC container
## Overview
Service providers are JavaScript classes with lifecycle hooks that execute at specific points during application startup and shutdown. This allows you to register bindings to the IoC container, extend framework classes using Macros, perform initialization at precise moments, and clean up resources during graceful shutdown.
The key advantage is centralized initialization logic that runs at predictable times, without modifying core framework code or scattering setup code throughout your application. Every AdonisJS application and package uses service providers to hook into the application lifecycle, making them fundamental to understanding the framework.
## Understanding service providers
Before creating your own service providers, it's helpful to understand how they work within an AdonisJS application.
### Where service providers are registered
Service providers are registered in the `adonisrc.ts` file at the root of your project. This file defines which providers should load and in which runtime environments they should execute.
```ts title="adonisrc.ts"
import { defineConfig } from '@adonisjs/core/app'
export default defineConfig({
providers: [
() => import('@adonisjs/core/providers/app_provider'),
() => import('@adonisjs/core/providers/hash_provider'),
{
file: () => import('@adonisjs/core/providers/repl_provider'),
environment: ['repl', 'test'],
},
() => import('@adonisjs/core/providers/http_provider'),
],
})
```
Providers use lazy imports with the `() => import()` syntax, ensuring they're only loaded when needed.
### Built-in service providers
A typical AdonisJS application includes several framework providers that handle core functionality.
- **app_provider** - Registers fundamental application services and helpers that every AdonisJS app needs.
- **hash_provider** - Registers the hash service used for password hashing and verification.
- **repl_provider** - Adds REPL-specific bindings. Notice it only runs in the `repl` and `test` environments, demonstrating environment restrictions.
- **http_provider** - Sets up the HTTP server and related services for handling web requests.
When you install additional packages like `@adonisjs/lucid` for database access or `@adonisjs/auth` for authentication, these packages include their own service providers that you add to this array.
### Execution order and environments
AdonisJS calls lifecycle hooks in phases across all registered providers. First, the `register` hook runs for all providers in the order they are registered. Then the `boot` hook runs for all providers in the order they are registered, followed by `start`, `ready`, and finally `shutdown`.
Environment restrictions determine whether a provider runs at all. For instance, a WebSocket provider configured for the `web` environment won't execute when you run console commands.
This combination of execution order and environment filtering gives you precise control over what runs and when.
## When to create a service provider
Create a custom service provider when you need to register services into the IoC container, extend framework classes with macros, perform initialization at specific lifecycle points, set up resources that require cleanup during shutdown, or configure third-party packages application-wide.
You typically don't need a service provider for simple utility functions, one-off setup that only runs in a single place, or services used within a single controller or middleware. In these cases, use regular modules or inject dependencies directly.
## Creating a custom service provider
Now that you understand when service providers are appropriate, let's build one that registers a `Cache` service into the IoC container.
::::steps
:::step{title="Generate the provider"}
AdonisJS includes a command to generate service provider files.
```bash
node ace make:provider cache
```
```bash
# Output:
# CREATE: providers/cache_provider.ts
```
This command creates the provider file and automatically registers it in your `adonisrc.ts` file.
:::
:::step{title="Understand the generated code"}
Open the generated `providers/cache_provider.ts` file. You'll see a basic provider structure.
```ts title="providers/cache_provider.ts"
import type { ApplicationService } from '@adonisjs/core/types'
export default class CacheProvider {
constructor(protected app: ApplicationService) {}
/**
* Called when the provider is registered
*/
register() {}
/**
* Called when the application boots
*/
async boot() {}
/**
* Called when the application starts
*/
async start() {}
/**
* Called when the application is ready
*/
async ready() {}
/**
* Called during graceful shutdown
*/
async shutdown() {}
}
```
The provider receives the `ApplicationService` through its constructor, giving you access to the IoC container and other application services. All lifecycle methods are optional. You only implement the hooks you need.
:::
:::step{title="Register a container binding"}
Let's register a simple Cache class into the container using the `register` method. For this example, we'll create a minimal Cache class in the same file, though in a real-world package this class would typically live elsewhere.
```ts title="providers/cache_provider.ts"
import type { ApplicationService } from '@adonisjs/core/types'
/**
* A simple Cache service.
* In real-world packages, this would be in a separate
* file like src/cache.ts
*/
export class Cache {
get(key: string) {
// Implementation would go here
return null
}
set(key: string, value: any) {
// Implementation would go here
}
}
export default class CacheProvider {
constructor(protected app: ApplicationService) {}
register() {
this.app.container.bind(Cache, () => {
return new Cache()
})
}
}
```
:::
:::step{title="Use your registered service"}
Once registered, you can inject the Cache service into controllers or other container-managed classes.
```ts title="app/controllers/posts_controller.ts"
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
@inject()
constructor(protected cache: Cache) {}
async index({ response }: HttpContext) {
const cachedPosts = this.cache.get('posts')
if (cachedPosts) {
return response.json(cachedPosts)
}
// Fetch from database and cache...
return response.json([])
}
}
```
:::
::::
## Understanding all lifecycle hooks
Service providers offer five lifecycle hooks that run at different stages of your application's lifetime. Here's when each hook executes:
| Hook | Type | When It Runs | Common Use Cases |
|------|------|-------------|------------------|
| `register` | Sync | Immediately on provider import | Register IoC container bindings |
| `boot` | Async | After all providers registered | Extend framework classes, configure services |
| `start` | Async | Before HTTP server starts / command runs | Register routes, warm caches |
| `ready` | Async | After HTTP server ready / before command runs | Attach to running server (WebSockets) |
| `shutdown` | Async | During graceful termination | Close connections, cleanup resources |
### The register hook
The `register` method is called as soon as AdonisJS imports your provider, very early in the boot process before any other hooks run. Its primary purpose is to register bindings into the IoC container.
```ts title="providers/database_provider.ts"
import type { ApplicationService } from '@adonisjs/core/types'
export default class DatabaseProvider {
constructor(protected app: ApplicationService) {}
register() {
this.app.container.singleton('database', () => {
return new Database(this.app.config.get('database'))
})
}
}
```
The `register` hook is synchronous and must remain synchronous. Don't attempt to resolve bindings, perform I/O operations, or access framework services that might not be ready yet.
### The boot hook
The `boot` method runs after all providers have finished registering their bindings. At this point, the container is fully populated and you can safely resolve any binding. This makes `boot` the natural place to extend framework classes or configure services that depend on other registered bindings.
```ts title="providers/response_extension_provider.ts"
import { HttpResponse } from '@adonisjs/core/http'
import type { ApplicationService } from '@adonisjs/core/types'
export default class ResponseExtensionProvider {
constructor(protected app: ApplicationService) {}
async boot() {
HttpResponse.macro('apiSuccess', function (data: any) {
return this.json({
success: true,
data,
})
})
}
}
```
Use `boot` to configure validators with custom rules, register Edge template helpers, or set up any service that depends on other container bindings being available.
### The start hook
The `start` method runs just before the HTTP server starts (in the web environment) or before an Ace command executes (in the console environment). Preload files are imported after this hook completes.
```ts title="providers/routes_provider.ts"
import router from '@adonisjs/core/services/router'
import type { ApplicationService } from '@adonisjs/core/types'
export default class RoutesProvider {
constructor(protected app: ApplicationService) {}
async start() {
/**
* Load routes from database or external configuration
*/
const dynamicRoutes = await this.loadRoutesFromDatabase()
dynamicRoutes.forEach((route) => {
router.get(route.path, route.handler)
})
}
private async loadRoutesFromDatabase() {
// Implementation would fetch routes from database
return []
}
}
```
Use `start` to register routes, warm caches, or perform health checks on external services.
### The ready hook
The `ready` method runs after the HTTP server has started accepting connections (in the web environment) or just before executing an Ace command's `run` method (in the console environment). This is your last opportunity to perform setup that requires a fully initialized application.
```ts title="providers/websocket_provider.ts"
import type { ApplicationService } from '@adonisjs/core/types'
import { Server } from 'socket.io'
export default class WebSocketProvider {
constructor(protected app: ApplicationService) {}
async ready() {
if (this.app.getEnvironment() === 'web') {
const httpServer = await this.app.container.make('server')
const io = new Server(httpServer, {
cors: {
origin: '*',
},
})
this.app.container.singleton('websocket', () => io)
io.on('connection', (socket) => {
console.log('Client connected:', socket.id)
})
}
}
}
```
Use `ready` to integrate services that must attach to the running HTTP server, such as WebSocket servers, or to perform post-startup tasks like sending notifications that the application is online.
### The shutdown hook
The `shutdown` method runs when AdonisJS receives a signal to terminate gracefully. This is your opportunity to clean up resources, close connections, and ensure your application shuts down without losing data or leaving dangling processes.
```ts title="providers/database_provider.ts"
import type { ApplicationService } from '@adonisjs/core/types'
export default class DatabaseProvider {
constructor(protected app: ApplicationService) {}
register() {
this.app.container.singleton('database', () => {
return new Database(this.app.config.get('database'))
})
}
async shutdown() {
const database = await this.app.container.make('database')
await database.closeAllConnections()
console.log('Database connections closed')
}
}
```
Use `shutdown` to close database connection pools, flush pending log writes, disconnect from Redis, close file handles, or perform any other cleanup necessary for graceful termination. The framework waits for all `shutdown` hooks to complete before exiting.
---
# Container Services
This guide covers container services in AdonisJS. You will learn:
- What container services are and how they work
- How to use existing services in your application
- When to use services versus dependency injection
- How to create your own services for packages
## Overview
Container services are a convenience pattern in AdonisJS that simplifies how you access framework functionality. When you need to use features like routing, hashing, or logging, you can import a ready-to-use instance instead of manually constructing classes or interacting with the IoC container directly.
This pattern exists because many framework components require dependencies that the IoC container already knows how to provide. Rather than making you resolve these dependencies yourself in every file, AdonisJS packages expose pre-configured instances as standard ES module exports. You import them like any other module, and they work immediately.
## Understanding container services
Without container services, you have two options for using framework classes. You could import a class and construct it yourself, manually providing all its dependencies.
```ts title="Manual construction"
import { Router } from '@adonisjs/core/http'
export const router = new Router(/** Router dependencies */)
```
Alternatively, you could use the IoC container's `make` method to construct the class, letting the container handle dependency resolution.
```ts title="Using app.make()"
import app from '@adonisjs/core/services/app'
import { Router } from '@adonisjs/core/http'
export const router = await app.make(Router)
```
Container services eliminate this ceremony by doing exactly what the second approach does, but packaging it as a convenient import. The service module uses the IoC container internally and exports the resolved instance.
```ts title="Using a container service"
import router from '@adonisjs/core/services/router'
import hash from '@adonisjs/core/services/hash'
import logger from '@adonisjs/core/services/logger'
```
When you import a service, you're getting a singleton instance that was constructed by the IoC container with all its dependencies properly injected. The service itself is just a thin wrapper that makes this instance available as a standard module export.
## Using container services
Container services are available automatically when you install AdonisJS packages. No configuration or registration is required. You simply import the service and use it.
Here's an example using the Drive service to upload a file to S3.
```ts title="app/controllers/posts_controller.ts"
import drive from '@adonisjs/drive/services/main'
export class PostsController {
async store(post: Post, coverImage: File) {
const coverImageName = 'random_name.jpg'
/**
* The drive service gives you direct access to the
* DriveManager instance. Use it to select a disk
* and perform file operations.
*/
const disk = drive.use('s3')
await disk.put(coverImageName, coverImage)
post.coverImage = coverImageName
await post.save()
}
}
```
This approach is straightforward and requires no setup beyond importing the service. The Drive service is a singleton, so the same instance is shared across your entire application.
## Using dependency injection instead
For applications that prefer dependency injection, you can inject the underlying class directly into your services or controllers. This approach makes your code more testable since dependencies can be easily mocked or stubbed.
Here's the same file upload functionality using constructor injection.
```ts title="app/services/post_service.ts"
import { Disk } from '@adonisjs/drive'
import { inject } from '@adonisjs/core'
@inject()
export class PostService {
/**
* The Disk instance is injected by the IoC container.
* This makes it easy to swap implementations during
* testing or use different disk configurations.
*/
constructor(protected disk: Disk) {
}
async save(post: Post, coverImage: File) {
const coverImageName = 'random_name.jpg'
await this.disk.put(coverImageName, coverImage)
post.coverImage = coverImageName
await post.save()
}
}
```
With dependency injection, the IoC container automatically resolves and injects the Disk instance. Your class declares what it needs, and the container provides it. This pattern is particularly valuable when writing business logic that needs to remain decoupled from framework specifics.
## Available services
AdonisJS core and official packages expose the following container services. Each service corresponds to a container binding and provides access to the fully constructed class instance.
## Creating your own services
If you're building a package or want to expose your own container bindings as services, you can follow the same pattern that AdonisJS uses internally. A container service is simply a module that resolves a binding from the container and exports it.
You can view the [complete implementation on GitHub](https://github.com/adonisjs/drive/blob/4.x/services/main.ts#L19-L21) to see how the Drive package creates its service.
```ts title="Example service structure"
import app from '@adonisjs/core/services/app'
let drive: DriveManager
await app.booted(async () => {
drive = await app.container.make('drive')
})
export { drive as default }
```
The service waits for the application to boot, then resolves the binding from the container and exports it. This ensures all service providers have registered their bindings before the service attempts to resolve them.
---
# Barrel Files
This guide covers barrel files in AdonisJS and how they reduce import clutter in your codebase. You will learn about:
- What barrel files are and where they're stored
- Why they exist (reducing import clutter)
- How auto-generation works
- How to disable them if needed
## Overview
A barrel file is an auto-generated collection of exports for a specific entity type in your application. AdonisJS creates barrel files for controllers, bouncer policies, events, and event listeners, storing them in the `.adonisjs/server` directory.
Barrel files are completely optional. You can continue using direct imports if you prefer, and [disable barrel file generation](#disabling-barrel-files) entirely through configuration.
## The problem: Import clutter
As your application grows, files like `start/routes.ts` accumulate dozens of controller imports.
In the following example, with just four controllers, the imports already consume significant vertical space. In production applications with 20+ controllers, you spend more time scrolling past imports than working with routes.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
const NewAccountController = () => import('#controllers/new_account_controller')
const SessionController = () => import('#controllers/session_controller')
const PostsController = () => import('#controllers/posts_controller')
const PostCommentsController = () => import('#controllers/post_comments_controller')
router.get('signup', [NewAccountController, 'create'])
router.post('signup', [NewAccountController, 'store'])
router.get('login', [SessionController, 'create'])
router.post('login', [SessionController, 'store'])
router.get('posts', [PostsController, 'index'])
router.get('posts/:id', [PostsController, 'show'])
router.get('posts/:id/comments', [PostCommentsController, 'index'])
```
## The solution: Barrel files
Barrel files consolidate all those individual imports into a single import statement. Here's the same routes file using the controllers barrel file:
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('signup', [controllers.NewAccount, 'create'])
router.post('signup', [controllers.NewAccount, 'store'])
router.get('login', [controllers.Session, 'create'])
router.post('login', [controllers.Session, 'store'])
router.get('posts', [controllers.Posts, 'index'])
router.get('posts/:id', [controllers.Posts, 'show'])
router.get('posts/:id/comments', [controllers.PostComments, 'index'])
```
The difference is immediately visible. Four imports become one, and your routes are right at the top of the file where you need them. The only change to your route definitions is using the `controllers` namespace to access each controller.
## How barrel files work
The barrel file itself is remarkably simple. It's just a JavaScript object mapping controller names to lazy import functions. Here's what `.adonisjs/server/controllers.ts` looks like:
```ts title=".adonisjs/server/controllers.ts"
export const controllers = {
NewAccount: () => import('#controllers/new_account_controller'),
Session: () => import('#controllers/session_controller'),
Posts: () => import('#controllers/posts_controller'),
PostComments: () => import('#controllers/post_comments_controller')
}
```
The dev server generates this file automatically when you run `node ace serve`. As you create or delete controllers during development, the dev server's watcher updates the barrel file to stay in sync with your codebase.
### File locations and import aliases
Barrel files are organized in the `.adonisjs/server` directory with corresponding import aliases:
| Barrel File | Import Path | Purpose |
|-------------|-------------|---------|
| `controllers.ts` | `#generated/controllers` | Controller exports |
| `policies.ts` | `#generated/policies` | Bouncer policies exports |
| `events.ts` | `#generated/events` | Event exports |
| `listeners.ts` | `#generated/listeners` | Event listener exports |
The `.adonisjs/server` directory is registered as a subpath import alias in your `package.json`, allowing you to use the `#generated` prefix.
```ts
import { controllers } from '#generated/controllers'
import { events } from '#generated/events'
import { listeners } from '#generated/listeners'
```
:::note
The `.adonisjs` directory contains auto-generated files managed by the framework. You should not manually edit files in this directory, as your changes will be overwritten when the dev server regenerates them.
:::
:::tip
You should commit the `.adonisjs` directory to version control. These files are required for TypeScript to resolve imports like `#generated/controllers`, and without them your production builds and CI pipelines will fail.
:::
## Performance and lazy loading
You might wonder if importing all controllers at once hurts performance. The answer is no, because barrel files use **lazy imports**.
Each controller in the barrel file is wrapped in a function that returns a dynamic import.
```ts
{
Posts: () => import('#controllers/posts_controller')
}
```
The `() => import()` function is only called when you actually use that controller in a route. Until then, the controller module is never loaded. This means barrel files have zero performance impact. Controllers are still loaded on-demand, exactly as they would be with direct imports.
## Disabling barrel files
If you prefer not to use barrel files, you can disable their generation through the `adonisrc.ts` configuration file. The generation is managed using the `init` assembler hook.
```ts title="adonisrc.ts"
import { indexEntities } from '@adonisjs/core'
import { defineConfig } from '@adonisjs/core/app'
export default defineConfig({
// ...other config
hooks: {
init: [
// [!code highlight:11]
indexEntities({
controllers: {
enabled: false,
},
events: {
enabled: false,
},
listeners: {
enabled: false,
}
})
]
}
})
```
After disabling barrel file generation, existing barrel files will remain in the `.adonisjs/server` directory. You'll need to manually remove them and update any code that references them to use direct imports instead.
---
# Assembler hooks
This guide covers Assembler hooks in AdonisJS. You will learn how to:
- Register hooks to respond to lifecycle events during development, testing, and builds
- React to file changes in watch mode
- Hook into the routes scanning pipeline
- Generate barrel files and type declarations using the IndexGenerator
- Create custom code generation workflows
## Overview
Assembler is the build tooling layer of AdonisJS that manages your application as a child process. It handles starting the development server, running tests, and creating production builds. Hooks let you tap into this lifecycle to run custom actions at specific moments, such as generating barrel files when controllers change, creating type declarations when routes are scanned, or displaying custom information when the server starts.
Because Assembler runs in a separate process from your AdonisJS application, hooks do not have access to framework features like the IoC container, router service, or database connections. Instead, hooks receive purpose-built utilities like the `IndexGenerator` for code generation and scanner instances for route analysis.
Common use cases for Assembler hooks include generating barrel files for lazy-loading controllers, creating type-safe API clients from route metadata, running code generators when files change, and displaying custom startup information.
## Hooks reference
The following table lists all available hooks, when they execute, and what parameters they receive.
| Hook | Triggered by | Description |
|------|--------------|-------------|
| `init` | DevServer, TestRunner, Bundler | First hook executed. Use for initialization tasks |
| `devServerStarting` | DevServer | Before the child process starts |
| `devServerStarted` | DevServer | After the child process is running |
| `testsStarting` | TestRunner | Before tests begin executing |
| `testsFinished` | TestRunner | After tests complete |
| `buildStarting` | Bundler | Before production build begins |
| `buildFinished` | Bundler | After production build completes |
| `fileChanged` | DevServer, TestRunner | When a file is modified in watch mode |
| `fileAdded` | DevServer, TestRunner | When a file is created |
| `fileRemoved` | DevServer, TestRunner | When a file is deleted |
| `routesCommitted` | DevServer | When routes are registered by the app |
| `routesScanning` | DevServer | Before route type scanning begins |
| `routesScanned` | DevServer | After route type scanning completes |
## Creating and registering hooks
Hooks are registered in the `adonisrc.ts` file under the `hooks` property. Each hook accepts an array of lazy-loaded imports, allowing you to split hook logic into separate files and only load them when needed.
```ts title="adonisrc.ts"
import { defineConfig } from '@adonisjs/core/app'
export default defineConfig({
hooks: {
devServerStarted: [() => import('./hooks/on_server_started.ts')],
fileChanged: [() => import('./hooks/on_file_changed.ts')],
},
})
```
The hook file must export a default function that receives the hook's parameters. Each hook has a typed helper available from `@adonisjs/core/app` that provides full TypeScript support for the parameters.
```ts title="hooks/on_server_started.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.devServerStarted((devServer, info, instructions) => {
/**
* info.host - The host address the server is bound to
* info.port - The port number the server is running on
* instructions - UI helper for displaying formatted output
*/
console.log(`Server running at http://${info.host}:${info.port}`)
})
```
You can register multiple hooks for the same event. They execute in the order they are registered.
```ts title="adonisrc.ts"
import { defineConfig } from '@adonisjs/core/app'
export default defineConfig({
hooks: {
devServerStarted: [
() => import('./hooks/log_server_info.ts'),
() => import('./hooks/notify_external_service.ts'),
],
},
})
```
:::warning
Assembler hooks run in a separate process from your AdonisJS application. They do not have access to the IoC container, router, database, or any other framework services. If you need to interact with your application, use the routes scanning hooks to extract metadata or communicate via HTTP/IPC.
:::
## Init hook
The `init` hook is the first hook executed when Assembler starts any operation. It receives the parent instance (DevServer, TestRunner, or Bundler), a hooks manager for registering additional runtime hooks, and the IndexGenerator for code generation tasks.
```ts title="hooks/init.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.init((parent, hooksManager, indexGenerator) => {
/**
* Determine what operation is running by checking the parent type.
* Use indexGenerator to set up barrel file or type generation.
*/
console.log('Assembler initialized')
})
```
The `init` hook is the recommended place to configure the IndexGenerator for barrel file and type generation, as it runs before any other operations begin.
## Dev server hooks
The dev server hooks execute when starting and running the development server. The `devServerStarting` hook fires before the child process launches, and `devServerStarted` fires once the server is accepting connections.
```ts title="hooks/on_dev_server_starting.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.devServerStarting((devServer) => {
/**
* Perform setup tasks before the server starts.
* The child process has not been spawned yet.
*/
console.log('Preparing to start dev server...')
})
```
```ts title="hooks/on_dev_server_started.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.devServerStarted((devServer, info, instructions) => {
/**
* The server is now running and accepting connections.
* Use instructions to add custom UI output.
*/
instructions.add('custom', `API docs: http://${info.host}:${info.port}/docs`)
})
```
These hooks re-trigger every time the child process restarts, such as when a full reload occurs due to file changes.
## Test runner hooks
The test runner hooks execute before and after running your test suite. Use `testsStarting` to set up test fixtures or databases, and `testsFinished` to generate reports or clean up resources.
```ts title="hooks/on_tests_starting.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.testsStarting((testRunner) => {
console.log('Preparing test environment...')
})
```
```ts title="hooks/on_tests_finished.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.testsFinished((testRunner) => {
console.log('Tests complete, generating coverage report...')
})
```
When running tests in watch mode, these hooks re-trigger each time the test suite re-runs.
## Bundler hooks
The bundler hooks execute when creating a production build with `node ace build`. Use `buildStarting` for pre-build tasks like asset optimization, and `buildFinished` to display build statistics or run post-build scripts.
```ts title="hooks/on_build_starting.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.buildStarting((bundler) => {
console.log('Starting production build...')
})
```
```ts title="hooks/on_build_finished.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.buildFinished((bundler, instructions) => {
instructions.add('deploy', 'Run `npm run start` in the build folder to start the server')
})
```
## Watcher hooks
The watcher hooks fire when files change during development or test watch mode. In HMR mode, Assembler relies on hot-hook to detect changes; otherwise, the built-in file watcher handles detection.
Each watcher hook receives both the relative path (from your application root, using Unix-style slashes) and the absolute path to the affected file.
```ts title="hooks/on_file_changed.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.fileChanged((relativePath, absolutePath, info, parent) => {
/**
* info.source - Either 'hot-hook' or 'watcher'
* info.hotReloaded - True if the file was hot reloaded without restart
* info.fullReload - True if a full server restart is required
*/
if (relativePath.startsWith('app/controllers/')) {
console.log(`Controller changed: ${relativePath}`)
}
})
```
```ts title="hooks/on_file_added.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.fileAdded((relativePath, absolutePath, parent) => {
console.log(`New file created: ${relativePath}`)
})
```
```ts title="hooks/on_file_removed.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.fileRemoved((relativePath, absolutePath, parent) => {
console.log(`File deleted: ${relativePath}`)
})
```
## Routes hooks
The routes hooks provide access to your application's route definitions and their associated types. These hooks only execute during dev server operation, not during builds or tests.
### Routes committed
The `routesCommitted` hook fires when your AdonisJS application registers its routes. The routes are transmitted from the child process to Assembler via IPC, giving you access to route metadata without parsing files yourself.
```ts title="hooks/on_routes_committed.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.routesCommitted((devServer, routes) => {
/**
* routes is an object with domains as keys.
* Each domain contains an array of route definitions.
*/
const defaultRoutes = routes['root'] || []
console.log(`${defaultRoutes.length} routes registered`)
})
```
### Routes scanning
The `routesScanning` hook fires before Assembler begins analyzing your routes to extract request and response types. Use this hook to configure the scanner, such as skipping certain routes from analysis.
```ts title="hooks/on_routes_scanning.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.routesScanning((devServer, routesScanner) => {
/**
* Skip routes that don't need type extraction,
* such as authentication endpoints with complex flows.
*/
routesScanner.skip(['session.create', 'session.store', 'oauth.callback'])
})
```
### Routes scanned
The `routesScanned` hook fires after route analysis completes. The scanner contains extracted type information that you can use to generate API clients or type declarations.
```ts title="hooks/on_routes_scanned.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.routesScanned((devServer, routesScanner) => {
const routes = routesScanner.getRoutes()
const controllers = routesScanner.getControllers()
/**
* Use this metadata to generate type-safe API clients.
* The types are internal import references, not standalone types.
*/
console.log(`Scanned ${routes.length} routes from ${controllers.length} controllers`)
})
```
:::note
The types extracted by the routes scanner are internal import references pointing to your controllers and validators. They are not fully resolved standalone types that can be used in a separate project. Generating standalone types requires additional tooling like [Tuyau](https://tuyau.julr.dev/docs/introduction).
:::
## IndexGenerator
The IndexGenerator is a utility for watching directories and generating barrel files or type declarations from their contents. It handles file watching automatically, regenerating output files when source files are added or removed.
### Configuring the IndexGenerator
Register IndexGenerator configurations in the `init` hook. Each configuration specifies a source directory to watch, an output file to generate, and how to transform the source files.
```ts title="hooks/init.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.init((parent, hooksManager, indexGenerator) => {
indexGenerator.add('controllers', {
source: './app/controllers',
importAlias: '#controllers',
as: 'barrelFile',
exportName: 'controllers',
removeSuffix: 'controller',
output: './.adonisjs/server/controllers.ts',
})
})
```
The configuration options are:
| Option | Description |
|--------|-------------|
| `source` | Directory to scan for source files |
| `importAlias` | The import alias to use in generated imports (e.g., `#controllers`) |
| `as` | Either `'barrelFile'` for automatic generation or a callback for custom output |
| `exportName` | Name of the exported constant in barrel files |
| `removeSuffix` | Suffix to strip from file names when generating property keys |
| `output` | Path where the generated file will be written |
### Barrel file generation
When `as` is set to `'barrelFile'`, the IndexGenerator scans the source directory recursively and generates a barrel file that exports lazy-loaded imports. The directory structure is preserved as nested objects.
Given this controller structure:
```
app/controllers/
├── auth/
│ ├── login_controller.ts
│ └── register_controller.ts
├── blog/
│ ├── posts_controller.ts
│ └── post_comments_controller.ts
└── users_controller.ts
```
The IndexGenerator produces:
```ts title=".adonisjs/server/controllers.ts"
export const controllers = {
auth: {
Login: () => import('#controllers/auth/login_controller'),
Register: () => import('#controllers/auth/register_controller'),
},
blog: {
Posts: () => import('#controllers/blog/posts_controller'),
PostComments: () => import('#controllers/blog/post_comments_controller'),
},
Users: () => import('#controllers/users_controller'),
}
```
This barrel file enables lazy-loading controllers in your routes without manual import management. The file automatically updates when you add or remove controllers.
### Custom type generation
For generating type declarations or other custom output, pass a callback function to the `as` option. The callback receives a collection of files and a writer utility for building the output string.
The files collection is a key-value object where each key is the relative path (without extension) from the source directory, and each value contains the file's `importPath`, `relativePath`, and `absolutePath`.
```ts title="hooks/init.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.init((parent, hooksManager, indexGenerator) => {
indexGenerator.add('inertiaPages', {
source: './inertia/pages',
as: (files, writer) => {
writer.writeLine(`declare module '@adonisjs/inertia' {`).indent()
writer.writeLine(`export interface Pages {`).indent()
for (const [filePath, file] of Object.entries(files)) {
writer.writeLine(
`'${filePath}': InferPageProps`
)
}
writer.dedent().writeLine('}')
writer.dedent().writeLine('}')
return writer.toString()
},
output: './.adonisjs/server/inertia_pages.d.ts',
})
})
```
This generates a type declaration file that maps page component paths to their prop types, enabling type-safe Inertia.js page rendering.
### Complete IndexGenerator example
The following example shows a complete `init` hook that configures barrel file generation for controllers, events, and listeners—the default setup used by the AdonisJS framework.
```ts title="hooks/init.ts"
import { hooks } from '@adonisjs/core/app'
export default hooks.init((parent, hooksManager, indexGenerator) => {
/**
* Generate a barrel file for controllers.
* Enables lazy-loading in routes: [controllers.Posts, 'index']
*/
indexGenerator.add('controllers', {
source: './app/controllers',
importAlias: '#controllers',
as: 'barrelFile',
exportName: 'controllers',
removeSuffix: 'controller',
output: './.adonisjs/server/controllers.ts',
})
/**
* Generate a barrel file for event classes.
* Enables type-safe event emission.
*/
indexGenerator.add('events', {
source: './app/events',
importAlias: '#events',
as: 'barrelFile',
exportName: 'events',
output: './.adonisjs/server/events.ts',
})
/**
* Generate a barrel file for event listeners.
* Enables lazy-loading listeners in event bindings.
*/
indexGenerator.add('listeners', {
source: './app/listeners',
importAlias: '#listeners',
as: 'barrelFile',
exportName: 'listeners',
output: './.adonisjs/server/listeners.ts',
})
})
```
---
# Scaffolding and codemods
This guide covers the scaffolding and codemods system in AdonisJS. You will learn how to:
- Create a configure hook for your AdonisJS package
- Use codemods to modify the host application's source files
- Create stubs to scaffold configuration files and other source code
- Customize stub templates with generators and variables
- Eject and modify stubs from existing packages
## Overview
When you run `node ace configure @adonisjs/lucid`, the package automatically registers its provider, sets up environment variables, and creates a config file in your project. This seamless setup experience is powered by AdonisJS's scaffolding and codemods system.
**Scaffolding** refers to generating source files from templates called stubs. **Codemods** are programmatic transformations that modify existing TypeScript source files by parsing and manipulating the AST (Abstract Syntax Tree). Together, they allow package authors to provide the same polished configure experience that official AdonisJS packages offer.
The codemods API is powered by [ts-morph](https://github.com/dsherret/ts-morph) and lives in the `@adonisjs/assembler` package. Since assembler is a development dependency, ts-morph never bloats your production bundle.
## Building blocks
Before diving into the tutorial, let's briefly define the key components you'll work with.
**Stubs** are template files (with a `.stub` extension) that generate source files. They use [Tempura](https://github.com/lukeed/tempura), a lightweight handlebars-style template engine.
**Generators** are helper functions that enforce AdonisJS naming conventions. They transform input like `user` into properly formatted names like `UsersController` or `users_controller.ts`.
**Codemods** are high-level APIs for common modifications like registering providers, adding middleware, or defining environment variables. They handle the complexity of AST manipulation for you.
**Configure hooks** are functions exported by packages that run when a user executes `node ace configure `. This is where you combine stubs and codemods to set up your package.
## Creating a configure hook
The most common use of scaffolding and codemods is creating a configure hook for an AdonisJS package. Let's build one step-by-step using a cache package as our example.
::::steps
:::step{title="Set up the package structure"}
A typical AdonisJS package with a configure hook has this structure:
```
my-cache-package/
├── src/
│ └── ...
├── stubs/
│ ├── config.stub
│ └── main.ts
├── configure.ts
├── index.ts
└── package.json
```
The `stubs` directory contains your template files, `configure.ts` holds the configure function, and `index.ts` exports everything including the configure hook.
:::
:::step{title="Install @adonisjs/assembler as a peer dependency"}
The codemods API requires `@adonisjs/assembler`, which must be installed as a **peer dependency** in your package. This is important because the host application already has assembler installed as a dev dependency, and it should be shared across all configured packages rather than duplicated.
```json title="package.json"
{
"name": "@adonisjs/cache",
"peerDependencies": {
"@adonisjs/assembler": "^7.0.0"
}
}
```
When users install your package and run `node ace configure`, the assembler from their project will be used.
:::
:::step{title="Export the stubs root"}
Create a `stubs/main.ts` file that exports the path to your stubs directory. This path is needed when calling `makeUsingStub`.
```ts title="stubs/main.ts"
export const stubsRoot = import.meta.url
```
:::
:::step{title="Write the configure function"}
The configure function receives the Configure command instance, which provides access to the codemods API. Here's a complete example for a cache package:
```ts title="configure.ts"
import type Configure from '@adonisjs/core/commands/configure'
import { stubsRoot } from './stubs/main.ts'
export async function configure(command: Configure) {
const codemods = await command.createCodemods()
/**
* Register the provider and commands in the adonisrc.ts file
*/
await codemods.updateRcFile((rcFile) => {
rcFile
.addProvider('@adonisjs/cache/cache_provider')
.addCommand('@adonisjs/cache/commands')
})
/**
* Add environment variables to .env and .env.example files
*/
await codemods.defineEnvVariables({
CACHE_STORE: 'redis',
})
/**
* Add validation rules to start/env.ts
*/
await codemods.defineEnvValidations({
variables: {
CACHE_STORE: `Env.schema.string()`,
},
})
/**
* Create the config/cache.ts file from a stub
*/
await codemods.makeUsingStub(stubsRoot, 'config.stub', {
store: 'redis',
})
}
```
:::
:::step{title="Export from the package entry point"}
Export the configure function from your package's main entry point so the `node ace configure` command can find it:
```ts title="index.ts"
export { configure } from './configure.ts'
```
When users run `node ace configure @adonisjs/cache`, AdonisJS imports this file and executes the exported `configure` function.
:::
::::
## Creating stubs
Stubs are template files that generate source code. They combine static content with dynamic values computed at runtime.
### Basic stub syntax
Stubs use double curly braces for variable interpolation. Here's a simple config stub.
:::tip
Since Tempura's syntax is compatible with Handlebars, configure your editor to use Handlebars syntax highlighting for `.stub` files.
:::
```handlebars title="stubs/config.stub"
{{{
exports({
to: app.configPath('cache.ts')
})
}}}
import { defineConfig, stores } from '@adonisjs/cache'
export default defineConfig({
default: '{{ store }}',
stores: {
redis: stores.redis({}),
},
})
```
The `exports` function at the top defines metadata about the generated file, most importantly the destination path. The `app` variable provides access to application paths like `configPath`, `makePath`, and `httpControllersPath`.
### Using generators for naming conventions
When creating stubs that need to follow AdonisJS naming conventions, use the generators module. Generators transform user input into properly formatted names.
```handlebars title="stubs/resource.stub"
{{#var entity = generators.createEntity(name)}}
{{#var modelName = generators.modelName(entity.name)}}
{{#var modelReference = string.camelCase(modelName)}}
{{#var resourceFileName = string(modelName).snakeCase().suffix('_resource').ext('.ts').toString()}}
{{{
exports({
to: app.makePath('app/api_resources', entity.path, resourceFileName)
})
}}}
export default class {{ modelName }}Resource {
serialize({{ modelReference }}: {{ modelName }}) {
return {{ modelReference }}.toJSON()
}
}
```
The `{{#var ...}}` syntax creates inline variables within the stub. This approach keeps all the naming logic inside the stub itself, which becomes important when users eject stubs to customize them.
### Passing data to stubs
When calling `makeUsingStub`, pass a data object as the third argument. These values become available in the stub template:
```ts title="configure.ts"
await codemods.makeUsingStub(stubsRoot, 'config.stub', {
store: 'dynamodb',
region: 'us-east-1',
})
```
```handlebars title="stubs/config.stub"
{{{
exports({
to: app.configPath('cache.ts')
})
}}}
export default defineConfig({
default: '{{ store }}',
region: '{{ region }}',
})
```
### Global stub variables
Every stub has access to these built-in variables:
| Variable | Description |
|----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `app` | Reference to the [application class](../../reference/application.md) instance with path helpers. |
| `generators` | Reference to the [generators module](https://github.com/adonisjs/application/blob/-/src/generators.ts) for naming conventions. |
| `randomString` | Reference to the [randomString](../../reference/helpers.md#string) helper function. |
| `string` | A function to create a [string builder](../../reference/helpers.md) instance for transformations. |
| `flags` | Command-line flags passed when running the ace command. |
## Using stubs in commands
Beyond configure hooks, you can use stubs in your own Ace commands. This is useful for creating scaffolding commands like `make:resource` or `make:service`.
```ts title="commands/make_resource.ts"
import { join } from 'node:path'
import { BaseCommand, args } from '@adonisjs/core/ace'
const STUBS_ROOT = join(import.meta.dirname, '../stubs')
export default class MakeResource extends BaseCommand {
static commandName = 'make:resource'
static description = 'Create a new API resource'
@args.string({ description: 'Name of the resource' })
declare name: string
async run() {
const codemods = await this.createCodemods()
await codemods.makeUsingStub(STUBS_ROOT, 'resource.stub', {
name: this.name,
})
}
}
```
## Ejecting stubs
Host applications can customize stub templates by ejecting them. The `node ace eject` command copies stubs from a package into the project's `stubs` directory.
### Ejecting a single stub
```sh
node ace eject make/controller/main.stub
```
This copies the controller stub from `@adonisjs/core` to `stubs/make/controller/main.stub` in your project. Any future `make:controller` calls will use your customized version.
### Ejecting directories
Copy an entire directory of stubs:
```sh
# All make stubs
node ace eject make
# All controller-related stubs
node ace eject make/controller
```
### Ejecting from other packages
By default, `eject` copies from `@adonisjs/core`. Use the `--pkg` flag for other packages:
```sh
node ace eject make/migration/main.stub --pkg=@adonisjs/lucid
```
### Using CLI flags to customize output
Scaffolding commands share CLI flags with stub templates through the `flags` variable. You can use this to create custom workflows:
```sh
node ace make:controller invoice --feature=billing
```
```handlebars title="stubs/make/controller/main.stub"
{{#var controllerName = generators.controllerName(entity.name)}}
{{#var featureDirectoryName = flags.feature}}
{{#var controllerFileName = generators.controllerFileName(entity.name)}}
{{{
exports({
to: app.makePath('features', featureDirectoryName, controllerFileName)
})
}}}
// import type { HttpContext } from '@adonisjs/core/http'
export default class {{ controllerName }} {
}
```
### Finding stubs to eject
Each package stores its stubs in a `stubs` directory at the package root. Visit the package's GitHub repository to see what's available.
## Stubs execution flow
When you call `makeUsingStub`, the following happens:
1. AdonisJS first checks for an ejected stub in the host project's `stubs` directory
2. If not found, it uses the original stub from your package
3. The stub template is processed with Tempura, evaluating all variables and expressions
4. The `exports()` function determines the output path
5. The generated file is written to the destination

## Codemods API reference
The codemods API provides high-level methods for common source file modifications. All methods are available on the codemods instance returned by `command.createCodemods()`.
:::note
The codemods API relies on AdonisJS's default file structure and naming conventions. If you've made significant changes to your project structure, some codemods may not work as expected.
:::
### updateRcFile
Register providers, commands, meta files, and command aliases in `adonisrc.ts`.
```ts
await codemods.updateRcFile((rcFile) => {
rcFile
.addProvider('@adonisjs/lucid/db_provider')
.addCommand('@adonisjs/lucid/commands')
.setCommandAlias('migrate', 'migration:run')
})
```
**Output:**
```ts title="adonisrc.ts"
import { defineConfig } from '@adonisjs/core/app'
export default defineConfig({
commands: [
() => import('@adonisjs/lucid/commands')
],
providers: [
() => import('@adonisjs/lucid/db_provider')
],
commandAliases: {
migrate: 'migration:run'
}
})
```
### defineEnvVariables
Add environment variables to `.env` and `.env.example` files.
```ts
await codemods.defineEnvVariables({
REDIS_HOST: 'localhost',
REDIS_PORT: '6379',
})
```
To omit the value from `.env.example` (useful for secrets), use the `omitFromExample` option:
```ts
await codemods.defineEnvVariables({
API_KEY: 'secret-key-here',
}, {
omitFromExample: ['API_KEY']
})
```
This inserts `API_KEY=secret-key-here` in `.env` and `API_KEY=` in `.env.example`.
### defineEnvValidations
Add validation rules to `start/env.ts`. The codemod does not overwrite existing rules, respecting any customizations the user has made.
```ts
await codemods.defineEnvValidations({
leadingComment: 'Cache environment variables',
variables: {
CACHE_STORE: 'Env.schema.string()',
CACHE_TTL: 'Env.schema.number.optional()',
}
})
```
**Output:**
```ts title="start/env.ts"
import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {
/**
* Cache environment variables
*/
CACHE_STORE: Env.schema.string(),
CACHE_TTL: Env.schema.number.optional(),
})
```
### registerMiddleware
Register middleware to one of the middleware stacks: `server`, `router`, or `named`.
```ts
// Router middleware
await codemods.registerMiddleware('router', [
{
path: '@adonisjs/core/bodyparser_middleware'
}
])
// Named middleware
await codemods.registerMiddleware('named', [
{
name: 'auth',
path: '@adonisjs/auth/auth_middleware'
}
])
```
**Output:**
```ts title="start/kernel.ts"
import router from '@adonisjs/core/services/router'
router.use([
() => import('@adonisjs/core/bodyparser_middleware')
])
export const middleware = router.named({
auth: () => import('@adonisjs/auth/auth_middleware')
})
```
### registerJapaPlugin
Register a Japa testing plugin in `tests/bootstrap.ts`.
```ts
await codemods.registerJapaPlugin(
'sessionApiClient(app)',
[
{
isNamed: false,
module: '@adonisjs/core/services/app',
identifier: 'app'
},
{
isNamed: true,
module: '@adonisjs/session/plugins/api_client',
identifier: 'sessionApiClient'
}
]
)
```
**Output:**
```ts title="tests/bootstrap.ts"
import app from '@adonisjs/core/services/app'
import { sessionApiClient } from '@adonisjs/session/plugins/api_client'
export const plugins: Config['plugins'] = [
sessionApiClient(app)
]
```
### registerPolicies
Register bouncer policies in `app/policies/main.ts`.
```ts
await codemods.registerPolicies([
{
name: 'PostPolicy',
path: '#policies/post_policy'
}
])
```
**Output:**
```ts title="app/policies/main.ts"
export const policies = {
PostPolicy: () => import('#policies/post_policy')
}
```
### registerVitePlugin
Register a Vite plugin in `vite.config.ts`.
```ts
await codemods.registerVitePlugin(
'vue({ jsx: true })',
[
{
isNamed: false,
module: '@vitejs/plugin-vue',
identifier: 'vue'
}
]
)
```
**Output:**
```ts title="vite.config.ts"
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
vue({ jsx: true })
]
})
```
### installPackages
Install npm packages using the project's detected package manager.
```ts
await codemods.installPackages([
{ name: 'vinejs', isDevDependency: false },
{ name: '@types/lodash', isDevDependency: true }
])
```
---
# Extending the framework
This guide covers how to extend AdonisJS with custom functionality. You will learn how to:
- Add custom methods to framework classes using macros
- Create computed properties with getters
- Ensure type safety with TypeScript declaration merging
- Organize extension code in your application
- Extend specific framework modules like Hash, Session, and Authentication
## Overview
AdonisJS provides a powerful extension system that lets you add custom methods and properties to framework classes without modifying the framework's source code. This means you can enhance the `HttpRequest` class with custom validation logic, add utility methods to the `HttpResponse` class, or extend any other framework class to fit your application's specific needs.
The extension system is built on two core concepts: **macros** (custom methods) and **getters** (computed properties). Both are added at runtime and integrate seamlessly with TypeScript through declaration merging, giving you full type safety and autocomplete in your editor.
This same extension API is used throughout AdonisJS's own first-party packages, making it a proven pattern for building reusable functionality. Whether you're adding a few helper methods for your application or building a package to share with the community, the extension system provides a clean, type-safe way to enhance the framework.
## Why extend the framework?
Before diving into the mechanics, let's understand when and why you'd want to extend framework classes.
Without extensions, you'd need to write the same logic repeatedly across your application. For example, checking if a request expects JSON responses:
```ts title="app/controllers/posts_controller.ts"
export default class PostsController {
async index({ request, response }: HttpContext) {
// Repeated in every action that returns different formats
const acceptHeader = request.header('accept', '')
const wantsJSON = acceptHeader.includes('application/json') ||
acceptHeader.includes('+json')
if (wantsJSON) {
return response.json({ posts: [] })
}
return view.render('posts/index')
}
}
```
With a macro, you write this logic once and use it everywhere:
```ts title="src/extensions.ts"
import { HttpRequest } from '@adonisjs/core/http'
/**
* Check if the request expects a JSON response based on Accept header
*/
HttpRequest.macro('wantsJSON', function (this: HttpRequest) {
const firstType = this.types()[0]
if (!firstType) {
return false
}
return firstType.includes('/json') || firstType.includes('+json')
})
```
```ts title="app/controllers/posts_controller.ts"
export default class PostsController {
async index({ request, response }: HttpContext) {
if (request.wantsJSON()) {
return response.json({ posts: [] })
}
return view.render('posts/index')
}
}
```
Extensions are ideal when you:
- Have framework-specific logic reused across your application
- Want to maintain AdonisJS's fluent API style
- Are building a package that integrates deeply with the framework
- Need type-safe custom functionality with autocomplete support
## Understanding macros and getters
Before we start adding extensions, let's clarify what macros and getters are and when to use each.
**Macros** are custom methods you add to a class. They work like regular methods and can accept parameters, perform computations, and return values. Use macros when you need functionality that requires input or performs actions.
**Getters** are computed properties that look like regular properties when you access them. They're calculated on-demand and can optionally cache their result. Use getters for read-only derived data that doesn't require parameters.
Both macros and getters use **declaration merging**, a TypeScript feature that extends existing type definitions to include your custom additions. This ensures your extensions have full type safety and autocomplete support.
Under the hood, AdonisJS uses the [macroable](https://github.com/poppinss/macroable) package to implement this functionality. If you want to understand the implementation details, you can refer to that package's documentation.
## Creating your first macro
Let's build a simple macro step-by-step. We'll add a method to the `HttpRequest` class that checks if the incoming request is from a mobile device.
::::steps
:::step{title="Create the extensions file"}
Create a dedicated file to hold all your framework extensions. This keeps your extension code organized in one place.
```ts title="src/extensions.ts"
// This file contains all framework extensions for your application
```
The file can be named anything you like, but `extensions.ts` clearly communicates its purpose.
:::
:::step{title="Import the class you want to extend"}
Import the framework class you want to add functionality to. For our example, we'll extend the `HttpRequest` class.
```ts title="src/extensions.ts"
import { HttpRequest } from '@adonisjs/core/http'
```
:::
:::step{title="Add the macro method"}
Use the `macro` method to add your custom functionality. The method receives the class instance as `this`, giving you access to all the class's existing properties and methods.
```ts title="src/extensions.ts"
import { HttpRequest } from '@adonisjs/core/http'
HttpRequest.macro('isMobile', function (this: HttpRequest) {
/**
* Get the User-Agent header, defaulting to empty string if not present
*/
const userAgent = this.header('user-agent', '')
/**
* Check if the User-Agent contains common mobile identifiers
*/
return /mobile|android|iphone|ipad|phone/i.test(userAgent)
})
```
The `function (this: HttpRequest)` syntax is important because it gives you the correct `this` context. Don't use arrow functions here, as they don't preserve the `this` binding.
:::
:::step{title="Add TypeScript type definitions"}
Tell TypeScript about your new method using declaration merging. Add this at the end of your extensions file.
```ts title="src/extensions.ts"
declare module '@adonisjs/core/http' {
interface HttpRequest {
isMobile(): boolean
}
}
```
The module path in `declare module` must exactly match the import path you use. The interface name must exactly match the class name.
:::
:::step{title="Load extensions in your provider"}
Import your extensions file in a service provider's `boot` method to ensure the extensions are registered when your application starts.
```ts title="providers/app_provider.ts"
export default class AppProvider {
async boot() {
await import('../src/extensions.ts')
}
}
```
:::
:::step{title="Use your macro"}
Your macro is now available throughout your application with full type safety and autocomplete.
```ts title="app/controllers/home_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class HomeController {
async index({ request, view }: HttpContext) {
/**
* TypeScript knows about isMobile() and provides autocomplete
*/
if (request.isMobile()) {
return view.render('mobile/home')
}
return view.render('home')
}
}
```
:::
::::
## Creating your first getter
Getters are computed properties that work like regular properties but are calculated on-demand. Let's add a getter to the `HttpRequest` class that provides a cleaned version of the request path.
```ts title="src/extensions.ts"
import { HttpRequest } from '@adonisjs/core/http'
HttpRequest.getter('cleanPath', function (this: HttpRequest) {
/**
* Get the current URL path
*/
const path = this.url()
/**
* Remove trailing slashes and convert to lowercase
*/
return path.replace(/\/+$/, '').toLowerCase()
})
```
```ts title="src/extensions.ts"
declare module '@adonisjs/core/http' {
interface HttpRequest {
cleanPath: string // Note: property, not a method
}
}
```
Notice the type declaration differs from macros. Getters are properties, not methods, so you don't include `()` in the type definition.
You can use getters like regular properties:
```ts title="app/middleware/log_middleware.ts"
export default class LogMiddleware {
async handle({ request, logger }: HttpContext, next: NextFn) {
/**
* Access the getter like a property, not a method
*/
logger.info('Request path: %s', request.cleanPath)
await next()
}
}
```
:::note
Getter callbacks cannot be async because [JavaScript getters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) are synchronous by design. If you need async computation, use a macro instead.
:::
## Singleton getters
By default, getters recalculate their value every time you access them. For expensive computations, you can make a getter a **singleton**, which caches the result after the first calculation.
```ts title="src/extensions.ts"
import { HttpRequest } from '@adonisjs/core/http'
/**
* The third parameter (true) makes this a singleton getter
*/
HttpRequest.getter('ipAddress', function (this: HttpRequest) {
/**
* Check for proxy headers first, fall back to direct IP
* This only runs once per request instance
*/
return this.header('x-forwarded-for') ||
this.header('x-real-ip') ||
this.ips()[0] ||
this.ip()
}, true)
```
```ts title="src/extensions.ts"
declare module '@adonisjs/core/http' {
interface HttpRequest {
ipAddress: string
}
}
```
With singleton getters, the function executes once per instance of the class, and the return value is cached for that instance:
```ts
const ip1 = request.ipAddress // Executes the getter function
const ip2 = request.ipAddress // Returns cached value, doesn't re-execute
const ip3 = request.ipAddress // Still returns cached value
```
:::tip
Use singleton getters when the computed value won't change during the instance's lifetime. For example, a request's IP address won't change during a single HTTP request, so caching it makes sense.
Don't use singleton getters for values that might change, like computed properties based on mutable state.
:::
## When to use macros vs getters
Choosing between macros and getters depends on your use case. Here's a practical guide.
**Use macros when you need to:**
- Accept parameters
- Perform actions with side effects
- Return different values based on input
- Execute async operations
```ts title="src/extensions.ts"
/**
* Macro example: Accepts a role parameter
*/
HttpRequest.macro('hasRole', function (this: HttpRequest, role: string) {
const user = this.ctx.auth.user
return user?.role === role
})
// Usage: request.hasRole('admin')
```
**Use getters when you need to:**
- Provide computed read-only properties
- Calculate derived data from existing properties
- Cache expensive computations (with singleton)
- Maintain a property-like API
```ts title="src/extensions.ts"
/**
* Getter example: Computed property with no parameters
*/
HttpRequest.getter('isAuthenticated', function (this: HttpRequest) {
return this.ctx.auth.isAuthenticated
})
// Usage: request.isAuthenticated
```
Both can coexist on the same class. Choose based on the API you want to provide.
## Understanding declaration merging
Declaration merging is how TypeScript learns about your runtime extensions. Getting this right is crucial for type safety.
The module path in your `declare module` statement must exactly match the path you use to import the class:
```ts title="src/extensions.ts"
// If you import like this:
import { HttpRequest } from '@adonisjs/core/http'
// You must declare like this (exact same path):
declare module '@adonisjs/core/http' {
interface HttpRequest {
isMobile(): boolean
}
}
```
:::warning
**Why this matters**: TypeScript uses the module path to determine which type definition to merge with.
**What happens**: If the paths don't match, TypeScript won't recognize your extension. You'll see errors like "Property 'isMobile' does not exist on type 'Request'" even though your code runs correctly.
**Solution**: Always copy the exact import path when writing your declaration:
```ts
// ✅ Correct: Paths match
import { HttpRequest } from '@adonisjs/core/http'
declare module '@adonisjs/core/http' { ... }
// ❌ Wrong: Paths don't match
import { HttpRequest } from '@adonisjs/core/http'
declare module '@adonisjs/http-server' { ... }
```
:::
You can declare multiple extensions in the same `declare module` block:
```ts title="src/extensions.ts"
declare module '@adonisjs/core/http' {
interface HttpRequest {
isMobile(): boolean
hasRole(role: string): boolean
cleanPath: string
ipAddress: string
}
}
```
Or split them across multiple blocks if you prefer:
```ts title="src/extensions.ts"
declare module '@adonisjs/core/http' {
interface HttpRequest {
isMobile(): boolean
}
}
declare module '@adonisjs/core/http' {
interface HttpRequest {
hasRole(role: string): boolean
}
}
```
Both approaches work identically. Choose based on your organization preferences.
## Common mistakes
Here are the most common issues developers encounter when extending the framework and how to fix them.
:::tip
**Mistake**: Using arrow functions for macros
**Why it fails**: Arrow functions don't have their own `this` binding, so you can't access the class instance.
```ts
// ❌ Wrong: Arrow function
HttpRequest.macro('isMobile', () => {
return this.header('user-agent') // `this` is undefined!
})
// ✅ Correct: Regular function
HttpRequest.macro('isMobile', function (this: HttpRequest) {
return this.header('user-agent') // `this` is the Request instance
})
```
:::
:::tip
**Mistake**: Forgetting the singleton parameter defaults to `false`
**What happens**: Your getter recalculates every time it's accessed, even if the value won't change.
```ts
// This executes the function every single time
HttpRequest.getter('expensiveCalculation', function (this: HttpRequest) {
return someExpensiveOperation()
})
// Add true for singleton to cache the result
HttpRequest.getter('expensiveCalculation', function (this: HttpRequest) {
return someExpensiveOperation()
}, true) // Caches after first access
```
:::
:::tip
**Mistake**: Treating getters like methods
**What happens**: You'll get errors because getters are properties, not functions.
```ts
HttpRequest.getter('ipAddress', function (this: HttpRequest) {
return this.ip()
})
// ❌ Wrong: Calling it like a method
const ip = request.ipAddress()
// ✅ Correct: Accessing it like a property
const ip = request.ipAddress
```
:::
## Macroable classes
The following framework classes support macros and getters. Each entry includes the import path and typical use cases.
::::options
:::option{name="Application" import="@adonisjs/core/app"}
The main application instance. Extend this to add application-level utilities.
**Common use cases**: Add custom environment checks, application state getters, or global configuration accessors.
**Example**: Add a getter to check if the app is running in a specific mode.
```ts
import app from '@adonisjs/core/services/app'
app.getter('isProduction', function () {
return this.inProduction
})
```
[View source](https://github.com/adonisjs/application/blob/9.x/src/application.ts)
:::
:::option{name="HttpRequest" import="@adonisjs/core/http"}
The HTTP request class. Extend this to add request validation or parsing logic.
**Common use cases**: Add methods for checking request characteristics, parsing custom headers, or validating request types.
**Example**: Add a method to check if the request is an AJAX request.
```ts
import { HttpRequest } from '@adonisjs/core/http'
HttpRequest.macro('isAjax', function (this: HttpRequest) {
return this.header('x-requested-with') === 'XMLHttpRequest'
})
```
[View source](https://github.com/adonisjs/http-server/blob/8.x/src/request.ts)
:::
:::option{name="HttpResponse" import="@adonisjs/core/http"}
The HTTP response class. Extend this to add custom response methods or formatters.
**Common use cases**: Add methods for sending formatted responses, setting common headers, or handling specific response types.
**Example**: Add a method for sending paginated JSON responses.
```ts
import { HttpResponse } from '@adonisjs/core/http'
HttpResponse.macro('paginated', function (this: HttpResponse, data: any, meta: any) {
return this.json({ data, meta })
})
```
[View source](https://github.com/adonisjs/http-server/blob/8.x/src/response.ts)
:::
:::option{name="HttpContext" import="@adonisjs/core/http"}
The HTTP context class passed to route handlers and middleware. Extend this to add context-level utilities.
**Common use cases**: Add helpers that combine request and response logic, or add shortcuts for common operations.
**Example**: Add a method to get the current user or fail.
```ts
import { HttpContext } from '@adonisjs/core/http'
HttpContext.macro('getCurrentUser', async function (this: HttpContext) {
return await this.auth.getUserOrFail()
})
```
[View source](https://github.com/adonisjs/http-server/blob/8.x/src/http_context/main.ts)
:::
:::option{name="Route" import="@adonisjs/core/http"}
Individual route instances. Extend this to add custom route configuration methods.
**Common use cases**: Add methods for applying common middleware patterns, setting route metadata, or configuring routes in specific ways.
**Example**: Add a method to mark routes as requiring authentication.
```ts
import { Route } from '@adonisjs/core/http'
Route.macro('protected', function (this: Route) {
return this.middleware('auth')
})
```
[View source](https://github.com/adonisjs/http-server/blob/8.x/src/router/route.ts)
:::
:::option{name="RouteGroup" import="@adonisjs/core/http"}
Route group instances. Extend this to add custom group-level configuration.
**Common use cases**: Add methods for applying common patterns to groups of routes.
**Example**: Add a method to apply API versioning to a group.
```ts
import { RouteGroup } from '@adonisjs/core/http'
RouteGroup.macro('apiVersion', function (this: RouteGroup, version: number) {
return this.prefix(`/api/v${version}`)
})
```
[View source](https://github.com/adonisjs/http-server/blob/8.x/src/router/group.ts)
:::
:::option{name="RouteResource" import="@adonisjs/core/http"}
Resourceful route instances. Extend this to customize resource route behavior.
**Common use cases**: Add methods for customizing which resource routes are created or adding resource-specific middleware.
[View source](https://github.com/adonisjs/http-server/blob/8.x/src/router/resource.ts)
:::
:::option{name="BriskRoute" import="@adonisjs/core/http"}
Brisk (quick) route instances used for simple route definitions. Extend this for shortcuts.
**Common use cases**: Add convenience methods for quick route configurations.
[View source](https://github.com/adonisjs/http-server/blob/8.x/src/router/brisk.ts)
:::
:::option{name="ExceptionHandler" import="@adonisjs/core/http"}
The global exception handler. Extend this to add custom error handling methods.
**Common use cases**: Add methods for handling specific error types or formatting error responses.
**Example**: Add a method to handle validation errors consistently.
```ts
import { ExceptionHandler } from '@adonisjs/core/http'
ExceptionHandler.macro('handleValidationError', function (error: any) {
return this.ctx.response.status(422).json({ errors: error.messages })
})
```
[View source](https://github.com/adonisjs/http-server/blob/8.x/src/exception_handler.ts)
:::
:::option{name="MultipartFile" import="@adonisjs/core/bodyparser"}
Uploaded file instances. Extend this to add file validation or processing methods.
**Common use cases**: Add methods for validating file types, processing images, or generating thumbnails.
**Example**: Add a method to check if a file is an image.
```ts
import { MultipartFile } from '@adonisjs/core/bodyparser'
MultipartFile.macro('isImage', function (this: MultipartFile) {
return this.type?.startsWith('image/')
})
```
[View source](https://github.com/adonisjs/bodyparser/blob/11.x/src/multipart/file.ts)
:::
::::
## Extending specific modules
Beyond macros and getters, many AdonisJS modules provide dedicated extension APIs for adding custom implementations. These are designed for more complex integrations like custom drivers or loaders.
The following modules can be extended with custom implementations:
- [Creating a custom hash driver](../security/hashing.md#creating-a-custom-hash-driver) - Add support for custom password hashing algorithms
- [Creating a custom session store](../basics/session.md#creating-a-custom-session-store) - Store sessions in custom backends like MongoDB or Redis
- [Creating a custom social auth driver](../auth/social_authentication.md#creating-a-custom-social-driver) - Add OAuth providers beyond the built-in ones
- [Adding custom REPL methods](../ace/repl.md#adding-custom-methods-to-repl) - Extend the REPL with custom commands
- [Creating a custom translations loader](../digging_deeper/i18n.md#creating-a-custom-translation-loader) - Load translations from custom sources
- [Creating a custom translations formatter](../digging_deeper/i18n.md#creating-a-custom-translation-formatter) - Format translations with custom logic
These extension points go beyond simple methods and properties, allowing you to deeply integrate custom functionality into the framework.
## Next steps
Now that you understand how to extend the framework, you can:
- Learn about [Service Providers](./service_providers.md) to organize extension code in packages
- Explore [Dependency Injection](./dependency_injection.md) to understand how the container works
- Read about [Testing](../testing/introduction.md) to learn how to test your extensions
- Study the [first-party packages](https://github.com/adonisjs) to see real-world extension examples
---
# Cache
This guide covers caching in AdonisJS applications. You will learn how to:
- Configure cache stores with different drivers (Redis, Memory, Database, DynamoDB)
- Store, retrieve, and invalidate cached data
- Use multi-tier caching with L1 (memory) and L2 (distributed) layers
- Organize cache entries with namespaces and tags
- Improve resilience with grace periods, stampede protection, and timeouts
- Use Ace commands to manage your cache
## Overview
The `@adonisjs/cache` package provides a unified caching API for your AdonisJS application. Built on top of [Bentocache](https://bentocache.dev), it goes beyond simple key-value storage by offering multi-tier caching, cache stampede protection, grace periods, and more.
The package introduces two key concepts. A **driver** is the underlying storage mechanism (Redis, in-memory, database). A **store** is a configured caching layer that combines one or more drivers. You can configure multiple stores in your application, each with different drivers and settings, and switch between them at runtime.
Multi-tier caching is the standout feature. By combining an in-memory L1 cache with a distributed L2 cache (like Redis), you get the speed of local memory with the persistence and scalability of a shared cache. This setup can deliver responses between 2,000x and 5,000x faster compared to single-tier approaches.
## Installation
Install and configure the package using the following command:
```sh
node ace add @adonisjs/cache
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/cache` package using the detected package manager.
2. Registers the following service provider and command inside the `adonisrc.ts` file.
```ts title="adonisrc.ts"
{
commands: [
// ...other commands
() => import('@adonisjs/cache/commands')
],
providers: [
// ...other providers
() => import('@adonisjs/cache/cache_provider')
]
}
```
3. Creates the `config/cache.ts` file.
4. Defines the environment variables and their validations for the selected drivers.
:::
## Configuration
The cache configuration lives in `config/cache.ts`. This file defines your stores, the default store, and driver-specific settings.
See also: [Config stub](https://github.com/adonisjs/cache/blob/2.x/stubs/config.stub)
```ts title="config/cache.ts"
import { defineConfig, store, drivers } from '@adonisjs/cache'
const cacheConfig = defineConfig({
/**
* The store to use when none is specified
*/
default: 'redis',
/**
* Default TTL for all cached entries.
* Can be overridden per-store or per-operation.
*/
ttl: '30s',
/**
* Configure one or more stores. Each store defines
* its caching layers and driver settings.
*/
stores: {
/**
* A multi-tier store combining in-memory speed
* with Redis persistence and cross-instance sync.
*/
redis: store()
.useL1Layer(drivers.memory({ maxSize: '100mb' }))
.useL2Layer(drivers.redis({ connectionName: 'main' }))
.useBus(drivers.redisBus({ connectionName: 'main' })),
/**
* A simple in-memory store for single-instance apps
*/
memory: store()
.useL1Layer(drivers.memory({ maxSize: '100mb' })),
/**
* A database-backed store using your Lucid connection
*/
database: store()
.useL2Layer(drivers.database({ connectionName: 'default' })),
},
})
export default cacheConfig
```
### Available drivers
:::disclosure{title="Redis"}
Uses Redis as a distributed cache. Requires the `@adonisjs/redis` package to be installed and configured. Compatible with Redis, Upstash, Vercel KV, Valkey, KeyDB, and DragonFly.
```ts title="config/cache.ts"
{
stores: {
redis: store()
.useL2Layer(drivers.redis({
connectionName: 'main',
}))
}
}
```
See also: [Redis setup guide](../database/redis.md)
:::
:::disclosure{title="Memory"}
Uses an in-memory LRU (Least Recently Used) cache. Best suited as an L1 layer in a multi-tier setup or for single-instance applications.
```ts title="config/cache.ts"
{
stores: {
memory: store()
.useL1Layer(drivers.memory({
maxSize: '100mb',
maxItems: 1000,
}))
}
}
```
:::
:::disclosure{title="Database"}
Uses your database as a cache store. Requires `@adonisjs/lucid`. The cache table is created automatically by default.
```ts title="config/cache.ts"
{
stores: {
database: store()
.useL2Layer(drivers.database({
connectionName: 'default',
tableName: 'cache',
autoCreateTable: true,
}))
}
}
```
:::
:::disclosure{title="DynamoDB"}
Uses AWS DynamoDB as a cache store. Requires `@aws-sdk/client-dynamodb`. You must create the table beforehand with a string partition key named `key` and TTL enabled on the `ttl` attribute.
```sh
npm i @aws-sdk/client-dynamodb
```
```ts title="config/cache.ts"
{
stores: {
dynamo: store()
.useL2Layer(drivers.dynamodb({
table: { name: 'cache' },
region: 'us-east-1',
credentials: {
accessKeyId: env.get('AWS_ACCESS_KEY_ID'),
secretAccessKey: env.get('AWS_SECRET_ACCESS_KEY'),
},
}))
}
}
```
:::
## Storing and retrieving data
Import the cache service to interact with your cache. All cache operations are available through the `cache` object.
```ts title="app/controllers/posts_controller.ts"
import cache from '@adonisjs/cache/services/main'
```
### Getting and setting values
The most common pattern is `getOrSet`. It tries to find a value in the cache and, if missing, executes the factory function to compute the value, stores it, and returns it.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import cache from '@adonisjs/cache/services/main'
import Post from '#models/post'
export default class PostsController {
async index({ request }: HttpContext) {
const page = request.input('page', 1)
const posts = await cache.getOrSet({
key: `posts:page:${page}`,
ttl: '10m',
factory: () => Post.query().paginate(page, 20),
})
return posts
}
}
```
You can also use `get` and `set` independently when you need more control over the flow.
```ts title="app/services/settings_service.ts"
import cache from '@adonisjs/cache/services/main'
/**
* Store a value with a 5-minute TTL
*/
await cache.set({
key: 'app:settings',
value: { maintenance: false, theme: 'dark' },
ttl: '5m',
})
/**
* Retrieve a value. Returns undefined if the key
* does not exist.
*/
const settings = await cache.get({ key: 'app:settings' })
/**
* Store a value that never expires
*/
await cache.setForever({
key: 'app:version',
value: '2.0.0',
})
```
:::warning
Cached data must be serializable to JSON. If you are caching Lucid models, call `.toJSON()` or `.serialize()` before storing them, or use `getOrSet` which handles serialization automatically.
:::
### Checking for existence
Use `has` and `missing` to check whether a key exists in the cache without retrieving its value.
```ts title="app/controllers/products_controller.ts"
import cache from '@adonisjs/cache/services/main'
if (await cache.has({ key: 'products:featured' })) {
// Key exists in cache
}
if (await cache.missing({ key: 'products:featured' })) {
// Key does not exist
}
```
### Pulling values
The `pull` method retrieves a value and immediately deletes it from the cache. This is useful for one-time-use data like flash messages or temporary tokens.
```ts title="app/controllers/auth_controller.ts"
import cache from '@adonisjs/cache/services/main'
/**
* Get the token and remove it from cache in one operation
*/
const token = await cache.pull({ key: `email-verify:${userId}` })
```
## Deleting data
Remove individual entries with `delete`, multiple entries with `deleteMany`, or all entries with `clear`.
```ts title="app/controllers/posts_controller.ts"
import cache from '@adonisjs/cache/services/main'
/**
* Delete a single key
*/
await cache.delete({ key: 'posts:page:1' })
/**
* Delete multiple keys at once
*/
await cache.deleteMany({
keys: ['posts:page:1', 'posts:page:2', 'posts:page:3'],
})
/**
* Delete all entries in the cache
*/
await cache.clear()
```
## Tagging
Tags let you group related cache entries so you can invalidate them together. This is especially useful when a change affects multiple cached values. For example, when a post is updated, you can invalidate all pages that might display it.
```ts title="app/controllers/posts_controller.ts"
import cache from '@adonisjs/cache/services/main'
import Post from '#models/post'
export default class PostsController {
async index({ request }: HttpContext) {
const page = request.input('page', 1)
/**
* Tag the cached page with "posts" so we can
* invalidate all pages when any post changes.
*/
const posts = await cache.getOrSet({
key: `posts:page:${page}`,
ttl: '10m',
tags: ['posts'],
factory: () => Post.query().paginate(page, 20),
})
return posts
}
async update({ params, request }: HttpContext) {
const post = await Post.findOrFail(params.id)
post.merge(request.all())
await post.save()
/**
* Invalidate all cache entries tagged with "posts".
* Every paginated page will be refreshed on next request.
*/
await cache.deleteByTag({ tags: ['posts'] })
return post
}
}
```
You can assign multiple tags to a single entry. An entry is invalidated when any of its tags is invalidated.
```ts title="app/services/dashboard_service.ts"
import cache from '@adonisjs/cache/services/main'
await cache.getOrSet({
key: `dashboard:user:${userId}`,
ttl: '5m',
tags: ['dashboard', `user:${userId}`],
factory: () => buildDashboard(userId),
})
/**
* Invalidate a specific user's dashboard
*/
await cache.deleteByTag({ tags: [`user:${userId}`] })
/**
* Or invalidate all dashboards
*/
await cache.deleteByTag({ tags: ['dashboard'] })
```
:::tip
Avoid using too many tags per entry. BentoCache uses client-side tagging, which means each retrieval checks tag invalidation timestamps. A large number of tags per entry can slow down lookups.
:::
## Namespaces
Namespaces group cache keys under a common prefix, allowing you to clear all entries in a namespace without affecting the rest of your cache.
```ts title="app/services/user_service.ts"
import cache from '@adonisjs/cache/services/main'
const usersCache = cache.namespace('users')
/**
* Keys are automatically prefixed with "users:"
* This stores the value under "users:42"
*/
await usersCache.set({ key: '42', value: { name: 'John' } })
await usersCache.set({ key: '43', value: { name: 'Jane' } })
/**
* Retrieve from the namespace
*/
const user = await usersCache.get({ key: '42' })
/**
* Clear only the "users" namespace.
* Other cache entries remain untouched.
*/
await usersCache.clear()
```
## Switching stores
When you configure multiple stores, you can switch between them using the `use` method. Without it, the default store is used.
```ts title="app/controllers/products_controller.ts"
import cache from '@adonisjs/cache/services/main'
/**
* Use the default store
*/
await cache.getOrSet({
key: 'products:featured',
factory: () => Product.query().where('featured', true).exec(),
})
/**
* Use the "memory" store for short-lived data
*/
await cache.use('memory').set({
key: 'rate-limit:user:42',
value: 1,
ttl: '1m',
})
/**
* Use the "database" store for long-lived data
*/
await cache.use('database').setForever({
key: 'site:config',
value: { theme: 'dark' },
})
```
## Multi-tier caching
Multi-tier caching combines a fast in-memory L1 cache with a persistent distributed L2 cache. This is the recommended setup for production applications running multiple instances.
### How it works
When you read a value, the cache checks the L1 (in-memory) layer first. If the value is found, it returns immediately without any network call. If missing, it fetches from the L2 (Redis) layer, stores a copy in L1, and returns it.
When you write or delete a value, both layers are updated. A **bus** notifies other application instances to evict their stale L1 entries, so every instance stays consistent.
### Configuration
Combine `useL1Layer`, `useL2Layer`, and `useBus` to create a multi-tier store.
```ts title="config/cache.ts"
import { defineConfig, store, drivers } from '@adonisjs/cache'
const cacheConfig = defineConfig({
default: 'multitier',
stores: {
multitier: store()
.useL1Layer(drivers.memory({ maxSize: '100mb' }))
.useL2Layer(drivers.redis({ connectionName: 'main' }))
.useBus(drivers.redisBus({ connectionName: 'main' })),
},
})
export default cacheConfig
```
:::tip
If your application runs on a single instance, you can omit the bus. The bus is only necessary when multiple instances need to synchronize their L1 caches.
:::
The bus sends only invalidation messages (not the actual values) to other instances. When an instance receives an invalidation message, it removes the key from its L1 cache. The next read will fetch the updated value from L2.
## Grace periods
Grace periods allow you to serve slightly stale cached data while refreshing the value in the background. This makes your application resilient to temporary outages in your data source (database downtime, API failures).
When a cached entry expires but remains within its grace period, the cache returns the stale value to the caller and triggers a background refresh. If the refresh fails (because the database is down, for example), the stale value continues to be served instead of returning an error.
```ts title="app/controllers/products_controller.ts"
import cache from '@adonisjs/cache/services/main'
import Product from '#models/product'
const products = await cache.getOrSet({
key: 'products:featured',
/**
* Data is "fresh" for 10 minutes
*/
ttl: '10m',
/**
* After expiring, stale data remains available for 6 hours.
* If the factory fails during this window, the stale
* value is returned instead of an error.
*/
grace: '6h',
factory: () => Product.query().where('featured', true).exec(),
})
```
### Backoff strategy
When a factory call fails during the grace period, you probably do not want to retry on every subsequent request. The `graceBackoff` option sets a delay between retry attempts.
```ts title="app/controllers/products_controller.ts"
const products = await cache.getOrSet({
key: 'products:featured',
ttl: '10m',
grace: '6h',
/**
* After a failed refresh, wait 5 minutes before
* trying again. Stale data is served in the meantime.
*/
graceBackoff: '5m',
factory: () => Product.query().where('featured', true).exec(),
})
```
You can also enable grace periods globally in your configuration so you do not have to repeat them on every operation.
```ts title="config/cache.ts"
const cacheConfig = defineConfig({
default: 'redis',
ttl: '10m',
grace: '6h',
graceBackoff: '30s',
stores: {
// ...
},
})
```
## Stampede protection
A **cache stampede** occurs when a cache entry expires and many concurrent requests all try to regenerate it at the same time, overwhelming your data source. BentoCache prevents this automatically.
When the first request finds a missing or expired key, it acquires a lock and executes the factory function. All other concurrent requests for the same key wait for the lock to release and then receive the cached result. This means only one factory execution happens, regardless of how many requests arrive simultaneously.
For example, if 10,000 requests hit an expired key at the same time, only one database query is made. The other 9,999 requests receive the cached result once it is available. This protection is built in and requires no configuration.
## Timeouts
Timeouts prevent slow factory functions from blocking your responses. BentoCache supports two types.
### Soft timeouts
A soft timeout works alongside grace periods. If the factory takes longer than the timeout and a stale entry exists in the grace window, the stale value is returned immediately while the factory continues running in the background.
```ts title="app/controllers/products_controller.ts"
const products = await cache.getOrSet({
key: 'products:featured',
ttl: '10m',
grace: '6h',
/**
* If the factory takes more than 200ms, return the
* stale value immediately. The factory keeps running
* in the background to update the cache.
*/
timeout: '200ms',
factory: () => Product.query().where('featured', true).exec(),
})
```
:::note
Soft timeouts only take effect when a stale entry is available in the grace period. If no stale entry exists (first-time cache population), the request waits for the factory to complete.
:::
### Hard timeouts
A hard timeout sets an absolute limit on how long to wait for the factory. If exceeded, an exception is thrown. The factory continues executing in the background so the cache will be populated for subsequent requests.
```ts title="app/controllers/products_controller.ts"
const products = await cache.getOrSet({
key: 'products:featured',
ttl: '10m',
/**
* If the factory takes more than 1 second,
* throw an error.
*/
hardTimeout: '1s',
factory: () => Product.query().where('featured', true).exec(),
})
```
You can combine both timeouts. The soft timeout returns stale data quickly, and the hard timeout acts as a safety net.
```ts title="app/controllers/products_controller.ts"
const products = await cache.getOrSet({
key: 'products:featured',
ttl: '10m',
grace: '6h',
timeout: '200ms',
hardTimeout: '1s',
factory: () => Product.query().where('featured', true).exec(),
})
```
## Adaptive caching
Adaptive caching lets you dynamically adjust cache options based on the data being cached. This is useful when the ideal TTL depends on the actual value.
The factory function receives a context object with helper methods to adjust cache behavior after inspecting the fetched data.
```ts title="app/services/auth_service.ts"
import cache from '@adonisjs/cache/services/main'
const token = await cache.getOrSet({
key: `auth:token:${provider}`,
ttl: '1h',
factory: async (ctx) => {
const token = await fetchOAuthToken(provider)
/**
* Set the TTL based on the token's actual expiration
* rather than using a fixed value
*/
ctx.setOptions({ ttl: `${token.expiresIn}s` })
return token
},
})
```
The context object also provides:
- `ctx.skip()` to prevent caching the returned value
- `ctx.fail()` to prevent caching and throw an error
- `ctx.setTags([...])` to dynamically set tags based on the cached value
- `ctx.gracedEntry` to access the stale value from the grace period (if any)
## Edge integration
The cache service is available in your Edge templates. You can use it to display cached values directly in your views.
```edge title="resources/views/pages/home.edge"
Hello {{ await cache.get({ key: 'username' }) }}
```
## Ace commands
The `@adonisjs/cache` package provides Ace commands for managing your cache from the terminal.
### cache:clear
Remove all entries from a store, namespace, or tag.
```sh
# Clear the default store
node ace cache:clear
# Clear a specific store
node ace cache:clear redis
# Clear a specific namespace
node ace cache:clear --namespace=users
# Clear entries matching specific tags
node ace cache:clear --tags=products --tags=users
```
### cache:delete
Remove a specific key from the cache.
```sh
# Delete from the default store
node ace cache:delete posts:page:1
# Delete from a specific store
node ace cache:delete posts:page:1 redis
```
### cache:prune
Remove expired entries from drivers that do not support automatic TTL expiration (like the database and filesystem drivers). Redis handles expiration natively and does not need pruning.
```sh
# Prune the default store
node ace cache:prune
# Prune a specific store
node ace cache:prune database
```
## Method reference
All methods are available on the `cache` object imported from `@adonisjs/cache/services/main`. They are also available on instances returned by `cache.use()` and `cache.namespace()`.
::::options
:::option{name="getOrSet" dataType="Promise"}
Try to get a value from cache. If missing, execute the factory, cache the result, and return it.
```ts
await cache.getOrSet({
key: 'users:1',
ttl: '10m',
grace: '6h',
tags: ['users'],
factory: () => User.find(1),
})
```
:::
:::option{name="get" dataType="Promise"}
Retrieve a value from the cache. Returns `undefined` if the key does not exist.
```ts
const user = await cache.get({ key: 'users:1' })
```
:::
:::option{name="set" dataType="Promise"}
Store a value in the cache with an optional TTL.
```ts
await cache.set({ key: 'users:1', value: user, ttl: '10m' })
```
:::
:::option{name="setForever" dataType="Promise"}
Store a value in the cache that never expires.
```ts
await cache.setForever({ key: 'app:version', value: '2.0.0' })
```
:::
:::option{name="has" dataType="Promise"}
Check if a key exists in the cache.
```ts
const exists = await cache.has({ key: 'users:1' })
```
:::
:::option{name="missing" dataType="Promise"}
Check if a key does not exist in the cache.
```ts
const notCached = await cache.missing({ key: 'users:1' })
```
:::
:::option{name="pull" dataType="Promise"}
Retrieve a value from the cache and delete it immediately.
```ts
const token = await cache.pull({ key: 'verify:token:123' })
```
:::
:::option{name="delete" dataType="Promise"}
Remove a single key from the cache.
```ts
await cache.delete({ key: 'users:1' })
```
:::
:::option{name="deleteMany" dataType="Promise"}
Remove multiple keys from the cache.
```ts
await cache.deleteMany({ keys: ['users:1', 'users:2'] })
```
:::
:::option{name="deleteByTag" dataType="Promise"}
Remove all entries associated with the given tags.
```ts
await cache.deleteByTag({ tags: ['users'] })
```
:::
:::option{name="clear" dataType="Promise"}
Remove all entries from the cache (or from the current namespace).
```ts
await cache.clear()
```
:::
:::option{name="namespace" dataType="CacheNamespace"}
Return a namespace instance. All operations on the returned instance are scoped to the namespace.
```ts
const usersCache = cache.namespace('users')
await usersCache.set({ key: '1', value: user })
```
:::
:::option{name="use" dataType="CacheStore"}
Return a specific store instance by name.
```ts
const redisCache = cache.use('redis')
await redisCache.get({ key: 'users:1' })
```
:::
::::
---
# Drive
This guide covers file storage management in AdonisJS using Drive. You will learn how to:
- Install and configure Drive for your application
- Upload files to local or cloud storage using `moveToDisk`
- Display files using public URLs and signed URLs
- Configure multiple storage services (S3, GCS, R2, DigitalOcean Spaces, Supabase)
- Implement direct uploads from the browser to cloud storage
- Test file uploads using the Drive fakes API
## Overview
AdonisJS Drive is a wrapper on top of [FlyDrive](https://flydrive.dev) (created and maintained by the AdonisJS core team). It provides a unified API for managing user-uploaded files across multiple storage providers, including the local filesystem, Amazon S3, Google Cloud Storage, Cloudflare R2, DigitalOcean Spaces, and Supabase Storage.
The key benefit of Drive is that you can switch between storage services without changing your application code. During development, you might store files on the local filesystem for convenience. In production, you switch to a cloud provider by changing an environment variable. Your controllers, services, and templates remain unchanged.
:::note
Drive handles file storage operations like reading, writing, and deleting files. It does not handle HTTP multipart parsing. You should read the [file uploads guide](../basics/file_uploads.md) first to understand how AdonisJS processes uploaded files from HTTP requests.
:::
## Installation
Install and configure the `@adonisjs/drive` package using the following command:
```sh
node ace add @adonisjs/drive
```
The command prompts you to select one or more storage services.
:::disclosure{title="Steps performed by the add command"}
1. Installs the `@adonisjs/drive` package and any required peer dependencies for your selected services.
2. Registers the Drive service provider in `adonisrc.ts`.
3. Creates the `config/drive.ts` configuration file with your selected services.
4. Adds environment variables for your selected services to `.env` and `start/env.ts`.
:::
:::tip{title="Non-interactive installation"}
The `node ace add` command requires interactive service selection. If you need to install Drive non-interactively (for example, in CI scripts), you can perform the steps manually:
1. Install the package: `npm install @adonisjs/drive`
2. Install peer dependencies for your storage service (e.g., `npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner` for S3)
3. Register the provider in `adonisrc.ts`: add `() => import('@adonisjs/drive/drive_provider')` to the `providers` array
4. Create `config/drive.ts` with your service configuration (see the [Configuration](#configuration) section below)
5. Add the required environment variables to `.env` and `start/env.ts`
:::
## Configuration
The configuration for Drive is stored in `config/drive.ts`. The file contents depend on which services you selected during installation.
The `default` property in the config file determines which service is used when you don't explicitly specify one. The `DRIVE_DISK` environment variable controls this, allowing you to use `fs` locally and switch to `s3` in production.
```ts title="config/drive.ts"
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, services } from '@adonisjs/drive'
const driveConfig = defineConfig({
default: env.get('DRIVE_DISK'),
services: {
// Service configurations go here
},
})
export default driveConfig
declare module '@adonisjs/drive/types' {
export interface DriveDisks extends InferDriveDisks {}
}
```
### Local filesystem
The local filesystem driver stores files on your server's disk and can serve them via the AdonisJS HTTP server.
:::disclosure{title="Environment variables"}
```dotenv title=".env"
DRIVE_DISK=fs
```
:::
:::disclosure{title="Configuration"}
```ts title="config/drive.ts"
{
services: {
fs: services.fs({
/**
* The directory where files are stored. Use app.makePath
* to create an absolute path from your application root.
*/
location: app.makePath('storage'),
/**
* When true, Drive registers a route to serve files
* from the local filesystem via your AdonisJS server.
*/
serveFiles: true,
/**
* The URL path prefix for serving files. A file stored
* as "avatars/1.jpg" becomes accessible at "/uploads/avatars/1.jpg".
*/
routeBasePath: '/uploads',
/**
* The default visibility for files. Public files are
* accessible via URL. Private files require signed URLs.
*/
visibility: 'public',
}),
}
}
```
:::
:::tip
When `serveFiles` is enabled, you can verify the route is registered by running `node ace list:routes`. You should see a route like `/uploads/*` with the handler `drive.fs.serve`.
:::
### Amazon S3
:::disclosure{title="Environment variables"}
```dotenv title=".env"
DRIVE_DISK=s3
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_REGION=us-east-1
S3_BUCKET=your_bucket_name
```
:::
:::disclosure{title="Configuration"}
```ts title="config/drive.ts"
{
services: {
s3: services.s3({
credentials: {
accessKeyId: env.get('AWS_ACCESS_KEY_ID'),
secretAccessKey: env.get('AWS_SECRET_ACCESS_KEY'),
},
region: env.get('AWS_REGION'),
bucket: env.get('S3_BUCKET'),
visibility: 'public',
}),
}
}
```
:::
### Google Cloud Storage
:::disclosure{title="Environment variables"}
```dotenv title=".env"
DRIVE_DISK=gcs
GCS_KEY=file://gcs_key.json
GCS_BUCKET=your_bucket_name
```
The `GCS_KEY` variable points to a JSON key file for your Google Cloud service account. The `file://` prefix indicates the path is relative to your application root.
:::
:::disclosure{title="Configuration"}
```ts title="config/drive.ts"
{
services: {
gcs: services.gcs({
credentials: env.get('GCS_KEY'),
bucket: env.get('GCS_BUCKET'),
visibility: 'public',
}),
}
}
```
:::
### Cloudflare R2
Cloudflare R2 uses the S3-compatible API. The `region` must be set to `'auto'`.
:::disclosure{title="Environment variables"}
```dotenv title=".env"
DRIVE_DISK=r2
R2_KEY=your_access_key
R2_SECRET=your_secret_key
R2_BUCKET=your_bucket_name
R2_ENDPOINT=https://your_account_id.r2.cloudflarestorage.com
```
:::
:::disclosure{title="Configuration"}
```ts title="config/drive.ts"
{
services: {
r2: services.s3({
credentials: {
accessKeyId: env.get('R2_KEY'),
secretAccessKey: env.get('R2_SECRET'),
},
region: 'auto',
bucket: env.get('R2_BUCKET'),
endpoint: env.get('R2_ENDPOINT'),
visibility: 'public',
}),
}
}
```
:::
### DigitalOcean Spaces
DigitalOcean Spaces uses the S3-compatible API with a custom endpoint.
:::disclosure{title="Environment variables"}
```dotenv title=".env"
DRIVE_DISK=spaces
SPACES_KEY=your_access_key
SPACES_SECRET=your_secret_key
SPACES_REGION=nyc3
SPACES_BUCKET=your_bucket_name
SPACES_ENDPOINT=https://${SPACES_REGION}.digitaloceanspaces.com
```
:::
:::disclosure{title="Configuration"}
```ts title="config/drive.ts"
{
services: {
spaces: services.s3({
credentials: {
accessKeyId: env.get('SPACES_KEY'),
secretAccessKey: env.get('SPACES_SECRET'),
},
region: env.get('SPACES_REGION'),
bucket: env.get('SPACES_BUCKET'),
endpoint: env.get('SPACES_ENDPOINT'),
visibility: 'public',
}),
}
}
```
:::
### Supabase Storage
Supabase Storage uses the S3-compatible API.
:::disclosure{title="Environment variables"}
```dotenv title=".env"
DRIVE_DISK=supabase
SUPABASE_STORAGE_KEY=your_access_key
SUPABASE_STORAGE_SECRET=your_secret_key
SUPABASE_STORAGE_REGION=your_region
SUPABASE_STORAGE_BUCKET=your_bucket_name
SUPABASE_ENDPOINT=https://your_project.supabase.co/storage/v1/s3
```
:::
:::disclosure{title="Configuration"}
```ts title="config/drive.ts"
{
services: {
supabase: services.s3({
credentials: {
accessKeyId: env.get('SUPABASE_STORAGE_KEY'),
secretAccessKey: env.get('SUPABASE_STORAGE_SECRET'),
},
region: env.get('SUPABASE_STORAGE_REGION'),
bucket: env.get('SUPABASE_STORAGE_BUCKET'),
endpoint: env.get('SUPABASE_ENDPOINT'),
visibility: 'public',
}),
}
}
```
:::
## Basic usage
Drive extends the AdonisJS `MultipartFile` class and adds the `moveToDisk` method. This method moves an uploaded file from its temporary location to your configured storage service.
The following example shows a complete flow for uploading and displaying a user avatar.
::::steps
:::step{title="Define routes"}
Create routes for displaying the profile page and handling avatar uploads.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('profile', [controllers.Profile, 'show'])
router.post('profile/avatar', [controllers.Profile, 'updateAvatar'])
```
:::
:::step{title="Create the controller"}
The controller handles the file upload using `moveToDisk`. Generate a unique filename using a UUID to avoid collisions when multiple users upload files with the same name.
```ts title="app/controllers/profile_controller.ts"
import string from '@adonisjs/core/helpers/string'
import type { HttpContext } from '@adonisjs/core/http'
import { updateAvatarValidator } from '#validators/user'
export default class ProfileController {
async show({ view, auth }: HttpContext) {
const user = auth.getUserOrFail()
return view.render('pages/profile/show', { user })
}
async updateAvatar({ request, auth, response, session }: HttpContext) {
const user = auth.getUserOrFail()
const { avatar } = await request.validateUsing(updateAvatarValidator)
/**
* Generate a unique filename using a UUID to avoid
* collisions when multiple users upload files with
* the same name.
*/
const key = `${string.uuid()}.${avatar.extname ?? 'txt'}`
/**
* Move the uploaded file to the default disk.
* The file is moved from its temporary location
* to your configured storage service.
*/
await avatar.moveToDisk(key)
/**
* Store only the key in the database, not the full URL.
* This allows you to switch storage services without
* updating database records.
*/
user.avatar = key
await user.save()
session.flash('success', 'Avatar updated successfully!')
return response.redirect().back()
}
}
```
:::
:::step{title="Create the template"}
The template displays the current avatar using the `driveUrl` helper and provides a form for uploading a new one.
```edge title="resources/views/pages/profile/show.edge"
@layout()
@end
```
The `driveUrl` Edge helper generates the public URL for a file. For the local filesystem, this returns a path like `/uploads/abc-123.jpg`. For cloud providers, it returns the full URL to the file.
:::
::::
### Specifying a disk
By default, `moveToDisk` uses the disk specified in the `DRIVE_DISK` environment variable. You can explicitly specify a different disk as the second argument:
```ts title="app/controllers/profile_controller.ts"
// Move to the default disk
await avatar.moveToDisk(key)
// Move to a specific disk
await avatar.moveToDisk(key, 's3')
await avatar.moveToDisk(key, 'gcs')
await avatar.moveToDisk(key, 'r2')
```
:::warning
The `moveToDisk` method is different from the `move` method. The `move` method moves files within the local filesystem only. The `moveToDisk` method moves files to your configured Drive storage service, which could be local or cloud-based.
:::
## Using the Drive service
For operations beyond file uploads, you can use the Drive service directly. Import it from `@adonisjs/drive/services/main` to read files, write files, delete files, and more.
```ts title="app/services/file_service.ts"
import drive from '@adonisjs/drive/services/main'
export class FileService {
/**
* Get the default disk instance
*/
async readFile(key: string) {
const disk = drive.use()
return disk.get(key)
}
/**
* Get a specific disk instance
*/
async readFromS3(key: string) {
const disk = drive.use('s3')
return disk.get(key)
}
/**
* Write content directly to storage
*/
async writeReport(content: string) {
const disk = drive.use()
await disk.put('reports/monthly.txt', content)
}
/**
* Delete a file
*/
async deleteFile(key: string) {
const disk = drive.use()
await disk.delete(key)
}
/**
* Check if a file exists
*/
async fileExists(key: string) {
const disk = drive.use()
return disk.exists(key)
}
}
```
The Drive service provides many more methods for file operations. See the [FlyDrive Disk API documentation](https://flydrive.dev/docs/disk_api) for the complete list of available methods.
## Generating URLs
Drive provides two methods for generating file URLs: `getUrl` for public files and `getSignedUrl` for private files.
### Public URLs
Use `getUrl` to generate URLs for files with `public` visibility. Anyone with the URL can access these files.
```ts title="app/controllers/files_controller.ts"
import drive from '@adonisjs/drive/services/main'
export default class FilesController {
async show({ params }: HttpContext) {
const disk = drive.use()
const url = await disk.getUrl(params.key)
return { url }
}
}
```
In Edge templates, use the `driveUrl` helper:
```edge title="resources/views/files/show.edge"
{{-- Specify a disk --}}
```
### Signed URLs
Use `getSignedUrl` to generate temporary URLs for files with `private` visibility. These URLs expire after a specified duration.
Signed URLs are useful when you want to control access to files. For example, you might store invoices with private visibility and generate a signed URL only when an authorized user requests to download one.
```ts title="app/controllers/invoices_controller.ts"
import drive from '@adonisjs/drive/services/main'
export default class InvoicesController {
async download({ params, auth }: HttpContext) {
const user = auth.getUserOrFail()
const invoice = await Invoice.query()
.where('id', params.id)
.where('userId', user.id)
.firstOrFail()
const disk = drive.use()
/**
* Generate a signed URL that expires in 30 minutes.
* The user can only download the file while the URL is valid.
*/
const url = await disk.getSignedUrl(invoice.fileKey, {
expiresIn: '30 mins',
})
return { url }
}
}
```
In Edge templates, use the `driveSignedUrl` helper:
```edge title="resources/views/invoices/show.edge"
Download Invoice
{{-- With expiration --}}
Download Invoice
{{-- Specify a disk --}}
Download Invoice
```
## Direct uploads
Direct uploads allow the browser to upload files directly to your cloud storage provider, bypassing your AdonisJS server. This is useful for large files because the data doesn't flow through your server, reducing memory usage and bandwidth costs.
The flow works as follows:
1. The browser requests a signed upload URL from your server.
2. Your server generates a signed URL using Drive and returns it.
3. The browser uploads the file directly to the cloud provider using the signed URL.
4. The browser notifies your server that the upload is complete.
::::steps
:::step{title="Define routes"}
Create routes for generating signed upload URLs and handling upload completion.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.post('uploads/presign', [controllers.Uploads, 'presign'])
router.post('uploads/complete', [controllers.Uploads, 'complete'])
```
:::
:::step{title="Create the controller"}
The controller generates signed upload URLs using `getSignedUploadUrl` and handles upload completion notifications.
```ts title="app/controllers/uploads_controller.ts"
import string from '@adonisjs/core/helpers/string'
import drive from '@adonisjs/drive/services/main'
import type { HttpContext } from '@adonisjs/core/http'
export default class UploadsController {
/**
* Generate a signed URL for direct upload
*/
async presign({ request, auth }: HttpContext) {
const user = auth.getUserOrFail()
const { filename, contentType } = request.only(['filename', 'contentType'])
/**
* Generate a unique key for the file
*/
const key = `uploads/${user.id}/${string.uuid()}-${filename}`
const disk = drive.use('s3')
/**
* Generate a signed URL that allows the browser
* to upload directly to S3
*/
const signedUrl = await disk.getSignedUploadUrl(key, {
expiresIn: '15 mins',
contentType,
})
return {
key,
url: signedUrl,
}
}
/**
* Handle upload completion notification
*/
async complete({ request, auth }: HttpContext) {
const user = auth.getUserOrFail()
const { key } = request.only(['key'])
/**
* Verify the file exists
*/
const disk = drive.use('s3')
const exists = await disk.exists(key)
if (!exists) {
return { error: 'File not found' }
}
/**
* Save the file reference to your database
*/
await user.related('files').create({ key })
return { success: true }
}
}
```
:::
:::step{title="Implement client-side upload"}
On the frontend, request a signed URL and then upload the file directly to the cloud provider.
```ts title="resources/js/upload.ts"
async function uploadFile(file: File) {
/**
* Step 1: Request a signed upload URL from your server
*/
const presignResponse = await fetch('/uploads/presign', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filename: file.name,
contentType: file.type,
}),
})
const { key, url } = await presignResponse.json()
/**
* Step 2: Upload the file directly to cloud storage
* using the signed URL
*/
const uploadResponse = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': file.type,
},
body: file,
})
if (!uploadResponse.ok) {
throw new Error('Upload failed')
}
/**
* Step 3: Notify your server that the upload is complete
*/
await fetch('/uploads/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key }),
})
return key
}
```
:::
::::
## Testing
Drive provides a fakes API for testing file uploads without interacting with real storage. When you fake a disk, all operations are redirected to a temporary local directory instead of the configured storage service.
```ts title="tests/functional/profile/update_avatar.spec.ts"
import { test } from '@japa/runner'
import { fileGenerator } from '@poppinss/file-generator'
import drive from '@adonisjs/drive/services/main'
import { UserFactory } from '#database/factories/user_factory'
test.group('Profile | update avatar', () => {
test('user can upload an avatar', async ({ client }) => {
/**
* Fake the default disk. All file operations will now
* use a temporary local directory instead of the
* configured storage service. The `using` keyword
* automatically restores the real disk when the test ends.
*/
// [!code highlight]
using fakeDisk = drive.fake()
const user = await UserFactory.create()
/**
* Generate a buffer with valid PNG magic bytes.
* AdonisJS uses magic byte detection to determine file types,
* so plain Buffer.from('fake-image') will be rejected.
*/
const pngFile = await fileGenerator.generatePng(1)
await client
.post('/profile/avatar')
.file('avatar', pngFile.contents, {
filename: pngFile.name,
contentType: pngFile.mime,
})
.loginAs(user)
.assertStatus(200)
/**
* Assert the file was stored
*/
fakeDisk.assertExists(`${user.id}.png`)
})
test('rejects invalid file types', async ({ client }) => {
// [!code highlight]
using fakeDisk = drive.fake()
const user = await UserFactory.create()
const pdfFile = await fileGenerator.generatePdf(1)
await client
.post('/profile/avatar')
.file('avatar', pdfFile.contents, {
filename: pdfFile.name,
contentType: pdfFile.mime,
})
.loginAs(user)
.assertStatus(422)
/**
* Assert no file was stored
*/
fakeDisk.assertMissing(`${user.id}.pdf`)
})
})
```
:::note
AdonisJS uses [magic byte detection](https://en.wikipedia.org/wiki/Magic_number_(programming)) to determine file types, not the filename or content type you pass. A plain `Buffer.from('fake-image')` has no valid magic bytes, so VineJS file validation will reject it with `"Invalid file extension undefined"`. Use the `@poppinss/file-generator` package (included in AdonisJS projects) to generate buffers with correct magic bytes for your tests.
:::
You can also fake a specific disk:
```ts title="tests/functional/uploads.spec.ts"
// Fake a specific disk
using fakeDisk = drive.fake('s3')
// Fake multiple disks
using _s3 = drive.fake('s3')
using _gcs = drive.fake('gcs')
```
You can also call `drive.restore()` manually if you need more control over when the real disk is restored.
## Troubleshooting
### Files are corrupted after upload
Some cloud storage providers have issues with streaming uploads. If your files are corrupted after upload, try using the `moveAs: 'buffer'` option to read the file into memory before uploading:
```ts title="app/controllers/uploads_controller.ts"
await file.moveToDisk(key, 's3', {
moveAs: 'buffer',
})
```
This reads the entire file into memory before sending it to the storage provider, which can resolve compatibility issues with certain providers.
### Understanding file visibility
Drive supports two visibility levels:
- **public**: Files are accessible via URL by anyone. Use `getUrl` to generate URLs.
- **private**: Files are not publicly accessible. Use `getSignedUrl` to generate temporary URLs with an expiration time.
The visibility is set at the disk level in your configuration. All files uploaded to that disk inherit the visibility setting unless overridden.
```ts title="config/drive.ts"
{
services: {
// All files on this disk are public
publicFiles: services.s3({
// ...credentials
visibility: 'public',
}),
// All files on this disk are private
privateFiles: services.s3({
// ...credentials
visibility: 'private',
}),
}
}
```
## See also
- [File uploads guide](../basics/file_uploads.md) for handling multipart HTTP requests
- [FlyDrive documentation](https://flydrive.dev/docs/introduction) for the complete Disk API reference
---
# Event Emitter
This guide covers the event emitter in AdonisJS applications. You will learn how to:
- Define and emit type-safe events
- Register listeners using callbacks or classes
- Handle errors and fake events during tests
## Overview
The event emitter enables event-driven architecture in AdonisJS applications. When you emit an event, all registered listeners execute asynchronously without blocking the code that triggered the event. This pattern is useful for decoupling side effects from your main application logic.
A common example is user registration: after creating a user account, you might need to send a verification email, provision resources with a payment provider, and log the signup for analytics. Rather than executing all these tasks sequentially in your controller, you can emit a single `user:registered` event and let separate listeners handle each concern independently.
AdonisJS provides two approaches for defining events. String-based events use TypeScript module augmentation for type-safety, while class-based events encapsulate the event identifier and data in a single class.
:::note
If you're looking for a list of events emitted by AdonisJS and its official packages, see the [events reference guide](../../reference/events.md).
:::
## Defining events and event data
An event consists of two parts: an identifier and associated data. The identifier is typically a string like `user:registered`, and the data is whatever payload you want to pass to listeners (for example, an instance of the `User` model).
Class-based events encapsulate both the identifier and the data within a single class. The class itself serves as the identifier, and instances of the class hold the event data. This approach provides built-in type-safety without additional configuration.
## String-based events
String-based events use a string identifier like `user:registered` or `order:shipped`. To make these events type-safe, you define the event names and their payload types using TypeScript module augmentation.
::::steps
:::step{title="Define event types"}
Create a `types/events.ts` file and augment the `EventsList` interface to declare your events and their payload types.
```ts title="types/events.ts"
import User from '#models/user'
declare module '@adonisjs/core/types' {
interface EventsList {
'user:registered': User
}
}
```
The `EventsList` interface maps event names to their payload types. In this example, the `user:registered` event carries a `User` model instance as its payload. TypeScript will enforce this contract when you emit events or register listeners.
:::
:::step{title="Listen for the event"}
Create a preload file to register your event listeners. Run the following command to generate the file.
```sh
node ace make:preload events
```
This creates `start/events.ts`, which is loaded automatically when your application boots. Register listeners using the `emitter.on` method.
```ts title="start/events.ts"
import emitter from '@adonisjs/core/services/emitter'
emitter.on('user:registered', function (user) {
console.log(user.email)
})
```
The listener callback receives the event payload as its argument. Because you defined the payload type in `EventsList`, TypeScript knows that `user` is an instance of the `User` model.
:::
:::step{title="Emit the event"}
Emit events from anywhere in your application using `emitter.emit`. The first argument is the event name, and the second is the payload.
```ts title="app/controllers/users_controller.ts"
import emitter from '@adonisjs/core/services/emitter'
import User from '#models/user'
export default class UsersController {
async store({ request }: HttpContext) {
const data = request.only(['email', 'password'])
const user = await User.create(data)
// [!code highlight]
emitter.emit('user:registered', user)
return user
}
}
```
The `emitter.emit` method is type-safe. TypeScript will error if you pass an incorrect payload type or use an event name that isn't defined in `EventsList`.
:::
::::
## Class-based events
Class-based events provide type-safety without module augmentation. The event class acts as both the identifier and a container for the event data.
::::steps
:::step{title="Create an event class"}
Generate an event class using the `make:event` command.
```sh
node ace make:event UserRegistered
```
This creates an event class that extends `BaseEvent`. Accept event data through the constructor and expose it as instance properties.
```ts title="app/events/user_registered.ts"
import { BaseEvent } from '@adonisjs/core/events'
import User from '#models/user'
export default class UserRegistered extends BaseEvent {
constructor(public user: User) {
super()
}
}
```
The event class has no behavior. It's purely a data container where the constructor parameters define what data the event carries.
:::
:::step{title="Listen for the event"}
Import the event class from the `#generated/events` [barrel file](../concepts/barrel_files.md) and use it as the first argument to `emitter.on`.
```ts title="start/events.ts"
import emitter from '@adonisjs/core/services/emitter'
import { events } from '#generated/events'
emitter.on(events.UserRegistered, function (event) {
console.log(event.user.email)
})
```
The listener receives an instance of the event class. Access the event data through the instance properties you defined in the constructor.
:::
:::step{title="Dispatch the event"}
Class-based events are dispatched using the static `dispatch` method instead of `emitter.emit`.
```ts title="app/controllers/users_controller.ts"
import User from '#models/user'
import { events } from '#generated/events'
export default class UsersController {
async store({ request }: HttpContext) {
const data = request.only(['email', 'password'])
const user = await User.create(data)
// [!code highlight]
events.UserRegistered.dispatch(user)
return user
}
}
```
The `dispatch` method accepts the same arguments as the event class constructor. There's no need to define types in `EventsList` since the class itself provides complete type information.
:::
::::
## Listeners
Listeners can be defined as inline callbacks or as dedicated listener classes. Inline callbacks work well for simple logic, while listener classes are better for complex operations that benefit from dependency injection and testability.
### Inline callbacks
Pass a function directly to `emitter.on` for simple listeners.
```ts title="start/events.ts"
import emitter from '@adonisjs/core/services/emitter'
emitter.on('user:registered', function (user) {
console.log(`New user: ${user.email}`)
})
```
The same approach works with class-based events.
```ts title="start/events.ts"
import emitter from '@adonisjs/core/services/emitter'
import { events } from '#generated/events'
emitter.on(events.UserRegistered, function (event) {
console.log(`New user: ${event.user.email}`)
})
```
### Listener classes
Create a listener class using the `make:listener` command.
```sh
node ace make:listener SendVerificationEmail
```
This generates a class with a `handle` method that executes when the event fires.
```ts title="app/listeners/send_verification_email.ts"
export default class SendVerificationEmail {
async handle() {
// Send email
}
}
```
Update the `handle` method to accept the event payload. For class-based events, type the parameter as the event class.
```ts title="app/listeners/send_verification_email.ts"
import { events } from '#generated/events'
export default class SendVerificationEmail {
async #sendEmail(to: string) {
}
async handle(event: events.UserRegistered) {
await this.#sendEmail(event.user.email)
}
}
```
Register the listener by importing it from the `#generated/listeners` [barrel file](../concepts/barrel_files.md).
```ts title="start/events.ts"
import emitter from '@adonisjs/core/services/emitter'
import { events } from '#generated/events'
import { listeners } from '#generated/listeners'
emitter.on(events.UserRegistered, listeners.SendVerificationEmail)
```
### Dependency injection in listeners
Listener classes are instantiated through the IoC container, so you can inject dependencies via the constructor using the `@inject` decorator.
See also [dependency injection guide](../concepts/dependency_injection.md).
```ts title="app/listeners/send_verification_email.ts"
import { inject } from '@adonisjs/core'
import { events } from '#generated/events'
import TokensService from '#services/tokens_service'
@inject()
export default class SendVerificationEmail {
constructor(protected tokensService: TokensService) {}
async handle(event: events.UserRegistered) {
const token = this.tokensService.generate(event.user.email)
}
}
```
## Listening methods
The emitter provides several methods for registering listeners, each suited to different use cases.
### Persistent listeners with `on`
The `on` method registers a listener that fires every time the event is emitted throughout the application lifecycle.
```ts
emitter.on('user:registered', function (user) {
// Runs every time the event fires
})
```
### One-time listeners with `once`
The `once` method registers a listener that fires only once, then automatically unsubscribes.
```ts
emitter.once('user:registered', function (user) {
// Runs only the first time the event fires
})
```
### Multiple listeners with `listen`
The `listen` method registers multiple listeners for a single event in one call.
```ts title="start/events.ts"
import emitter from '@adonisjs/core/services/emitter'
import { events } from '#generated/events'
import { listeners } from '#generated/listeners'
emitter.listen(events.UserRegistered, [
listeners.SendVerificationEmail,
listeners.RegisterWithPaymentProvider,
listeners.ProvisionAccount,
])
```
All listeners execute in parallel when the event fires.
### Wildcard listeners with `onAny`
The `onAny` method registers a listener that fires for every event emitted in the application.
```ts
emitter.onAny((name, event) => {
console.log(`Event fired: ${name}`)
console.log(event)
})
```
This is useful for logging, debugging, or implementing cross-cutting concerns that apply to all events.
## Unsubscribing from events
You can remove listeners when they're no longer needed.
### Using the unsubscribe function
The `on` and `once` methods return an unsubscribe function. Call it to remove the listener.
```ts
import emitter from '@adonisjs/core/services/emitter'
const unsubscribe = emitter.on('user:registered', () => {
})
// [!code highlight]
unsubscribe()
```
### Using the `off` method
The `off` method removes a specific listener. You need a reference to the exact function or class that was registered.
```ts
import emitter from '@adonisjs/core/services/emitter'
function sendEmail() {
}
emitter.on('user:registered', sendEmail)
// [!code highlight]
emitter.off('user:registered', sendEmail)
```
This works with listener classes too.
```ts
import emitter from '@adonisjs/core/services/emitter'
import { events } from '#generated/events'
import { listeners } from '#generated/listeners'
emitter.on(events.UserRegistered, listeners.SendVerificationEmail)
emitter.off(events.UserRegistered, listeners.SendVerificationEmail)
```
### Clearing listeners
Remove all listeners for a specific event with `clearListeners`.
```ts
emitter.clearListeners('user:registered')
emitter.clearListeners(events.UserRegistered)
```
Remove all listeners for all events with `clearAllListeners`.
```ts
emitter.clearAllListeners()
```
## Error handling
When a listener throws an error, it doesn't affect other listeners since they run in parallel. However, unhandled errors will trigger Node.js [unhandledRejection](https://nodejs.org/api/process.html#event-unhandledrejection) events, which can crash your application or cause unexpected behavior.
Define a global error handler to catch and process errors from all listeners.
```ts title="start/events.ts"
import emitter from '@adonisjs/core/services/emitter'
emitter.onError((event, error, eventData) => {
console.error(`Error in listener for ${event}:`, error)
// Report to error tracking service
})
```
The error handler receives three arguments: the event name (or class), the error that was thrown, and the event data that was passed to the listener.
## Faking events during tests
When testing code that emits events, you often want to verify the event was emitted without actually running the listeners. For example, when testing user registration, you might want to confirm the `user:registered` event fires without sending real emails.
The `emitter.fake` method prevents listeners from running and returns an `EventBuffer` that captures emitted events for assertions.
```ts
import emitter from '@adonisjs/core/services/emitter'
import { events } from '#generated/events'
test.group('User signup', () => {
test('create a user account', async ({ client }) => {
// [!code highlight:2]
using fakeEmitter = emitter.fake()
await client
.post('signup')
.form({
email: 'foo@bar.com',
password: 'secret',
})
// [!code highlight]
fakeEmitter.assertEmitted(events.UserRegistered)
})
})
```
The `using` keyword automatically restores the emitter when the variable goes out of scope (at the end of the test function). You can also call `emitter.restore()` manually if you need more control over when restoration happens.
### Faking specific events
Pass event names or classes to `fake` to only fake specific events. Other events will continue to trigger their listeners normally.
```ts
emitter.fake('user:registered')
emitter.fake([events.UserRegistered, events.OrderUpdated])
```
### Assertions
The `EventBuffer` returned by `fake` provides several assertion methods.
```ts
const fakeEmitter = emitter.fake()
// Assert an event was emitted
fakeEmitter.assertEmitted('user:registered')
fakeEmitter.assertEmitted(events.UserRegistered)
// Assert an event was not emitted
fakeEmitter.assertNotEmitted(events.OrderUpdated)
// Assert an event was emitted a specific number of times
fakeEmitter.assertEmittedCount(events.OrderUpdated, 1)
// Assert no events were emitted at all
fakeEmitter.assertNoneEmitted()
```
### Conditional assertions
Pass a callback to `assertEmitted` to assert that an event was emitted with specific data.
```ts
fakeEmitter.assertEmitted(events.OrderUpdated, ({ data }) => {
/**
* Only consider the event as emitted if
* the orderId matches
*/
return data.order.id === orderId
})
```
The callback receives the event data and should return `true` if the event matches your criteria.
---
# Health checks
This guide covers health checks in AdonisJS applications. You will learn how to:
- Understand the difference between liveness and readiness probes
- Configure health checks using the built-in setup command
- Expose endpoints for monitoring services and orchestrators
- Use built-in checks for disk space, memory, database, and Redis
- Cache health check results for performance optimization
- Create custom health checks for application-specific needs
## Overview
Health checks allow your application to report its operational status to external systems like load balancers, container orchestrators (Kubernetes, Docker Swarm), and monitoring services. These systems periodically probe your application to determine whether it should receive traffic or be restarted.
AdonisJS provides a health check system built into the core framework with several ready-made checks for common infrastructure concerns. You can also create custom checks for application-specific requirements like verifying external API connectivity or queue connections.
## Liveness vs readiness
Before implementing health checks, it's important to understand the two types of probes and when each should be used.
A **liveness probe** answers the question: "Is the process alive and responsive?" This is a simple check that verifies your application can respond to HTTP requests. If a liveness probe fails repeatedly, the orchestrator will restart the container. Liveness probes should be lightweight and avoid checking external dependencies, since a database outage shouldn't cause your application to enter a restart loop.
A **readiness probe** answers the question: "Is the application ready to accept traffic?" This check verifies that your application and its dependencies (database connections, Redis, external services) are functioning correctly. If a readiness probe fails, the orchestrator removes the instance from the load balancer but does not restart it. This allows the application time to recover, for example, when a database connection is temporarily unavailable.
| Probe | Purpose | On failure | Should check dependencies |
|-------|---------|------------|---------------------------|
| Liveness | Is the process alive? | Restart container | No |
| Readiness | Can it handle requests? | Remove from load balancer | Yes |
## Configuring health checks
Run the following command to set up health checks in your application. This creates the configuration file and a controller with both liveness and readiness endpoints.
```sh
node ace configure health_checks
```
The command creates two files. The first is the health checks configuration where you register which checks to run.
```ts title="start/health.ts"
import { HealthChecks, DiskSpaceCheck, MemoryHeapCheck } from '@adonisjs/core/health'
export const healthChecks = new HealthChecks().register([
new DiskSpaceCheck(),
new MemoryHeapCheck(),
])
```
The second is a controller that exposes both probe endpoints.
```ts title="app/controllers/health_checks_controller.ts"
import { healthChecks } from '#start/health'
import type { HttpContext } from '@adonisjs/core/http'
export default class HealthChecksController {
/**
* Liveness probe: Returns 200 if the process is running.
* Does not check dependencies.
*/
async live({ response }: HttpContext) {
return response.ok()
}
/**
* Readiness probe: Runs all registered health checks
* and returns the detailed report.
*/
async ready({ response }: HttpContext) {
const report = await healthChecks.run()
if (report.isHealthy) {
return response.ok(report)
}
return response.serviceUnavailable(report)
}
}
```
The liveness method simply returns a 200 status code, proving the process is alive and can handle HTTP requests. The readiness method runs all registered health checks and returns 200 when healthy or 503 (Service Unavailable) when any check fails.
## Exposing endpoints
Register the health check routes in your routes file. Using separate paths for each probe allows orchestrators to configure them independently.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('/health/live', [controllers.HealthChecks, 'live'])
router.get('/health/ready', [controllers.HealthChecks, 'ready'])
```
With these routes in place, your monitoring system can probe `/health/live` for liveness and `/health/ready` for readiness checks.
### Understanding the readiness report
The readiness endpoint returns a detailed JSON report containing the results of all registered checks.
```json
{
"isHealthy": true,
"status": "warning",
"finishedAt": "2024-06-20T07:09:35.275Z",
"debugInfo": {
"pid": 16250,
"ppid": 16051,
"platform": "darwin",
"uptime": 16.271809083,
"version": "v21.7.3"
},
"checks": [
{
"name": "Disk space check",
"isCached": false,
"message": "Disk usage is 76%, which is above the threshold of 75%",
"status": "warning",
"finishedAt": "2024-06-20T07:09:35.275Z",
"meta": {
"sizeInPercentage": {
"used": 76,
"failureThreshold": 80,
"warningThreshold": 75
}
}
},
{
"name": "Memory heap check",
"isCached": false,
"message": "Heap usage is under defined thresholds",
"status": "ok",
"finishedAt": "2024-06-20T07:09:35.265Z",
"meta": {
"memoryInBytes": {
"used": 41821592,
"failureThreshold": 314572800,
"warningThreshold": 262144000
}
}
}
]
}
```
The report contains the following properties:
::::options
:::option{name="isHealthy"}
Boolean indicating whether all checks passed. Set to `false` if one or more checks fail.
:::
:::option{name="status"}
Overall status: `ok` (all passed), `warning` (warnings present), or `error` (failures present).
:::
:::option{name="finishedAt"}
Timestamp when the checks completed.
:::
:::option{name="debugInfo"}
Process information including PID, platform, uptime in seconds, and Node.js version.
:::
:::option{name="checks"}
Array containing the detailed result of each registered check.
Each check in the `checks` array includes its name, status, message, whether the result was cached, and any metadata specific to that check type.
:::
::::
### Protecting the readiness endpoint
The readiness report contains detailed information about your infrastructure that you may not want exposed publicly. You can protect the endpoint using a secret header that your monitoring system includes with each request.
Kubernetes and most monitoring tools support custom HTTP headers on probe requests, so you can configure them to include your secret.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
const HealthChecksController = () => import('#controllers/health_checks_controller')
router.get('/health/live', [HealthChecksController, 'live'])
router
.get('/health/ready', [HealthChecksController, 'ready'])
// [!code ++:6]
.use(({ request, response }, next) => {
if (request.header('x-monitoring-secret') === 'some_secret_value') {
return next()
}
return response.unauthorized({ message: 'Unauthorized access' })
})
```
In Kubernetes, configure the probe to include the header.
```yaml
readinessProbe:
httpGet:
path: /health/ready
port: 3333
httpHeaders:
- name: x-monitoring-secret
value: some_secret_value
```
## Available health checks
AdonisJS provides several built-in health checks that you can register in your `start/health.ts` file.
### DiskSpaceCheck
Monitors available disk space and reports warnings or errors when usage exceeds configured thresholds.
```ts title="start/health.ts"
import { HealthChecks, DiskSpaceCheck } from '@adonisjs/core/health'
export const healthChecks = new HealthChecks().register([
new DiskSpaceCheck()
])
```
The default warning threshold is 75% and the failure threshold is 80%. You can customize these values.
```ts title="start/health.ts"
export const healthChecks = new HealthChecks().register([
new DiskSpaceCheck()
.warnWhenExceeds(80)
.failWhenExceeds(90),
])
```
### MemoryHeapCheck
Monitors the heap size reported by `process.memoryUsage()` and alerts when memory consumption exceeds thresholds.
```ts title="start/health.ts"
import { HealthChecks, MemoryHeapCheck } from '@adonisjs/core/health'
export const healthChecks = new HealthChecks().register([
new MemoryHeapCheck()
])
```
The default warning threshold is 250MB and the failure threshold is 300MB. You can customize these values using human-readable strings.
```ts title="start/health.ts"
export const healthChecks = new HealthChecks().register([
new MemoryHeapCheck()
.warnWhenExceeds('300 mb')
.failWhenExceeds('700 mb'),
])
```
### MemoryRSSCheck
Monitors the Resident Set Size (total memory allocated for the process) reported by `process.memoryUsage()`.
```ts title="start/health.ts"
import { HealthChecks, MemoryRSSCheck } from '@adonisjs/core/health'
export const healthChecks = new HealthChecks().register([
new MemoryRSSCheck()
])
```
The default warning threshold is 320MB and the failure threshold is 350MB.
```ts title="start/health.ts"
export const healthChecks = new HealthChecks().register([
new MemoryRSSCheck()
.warnWhenExceeds('600 mb')
.failWhenExceeds('800 mb'),
])
```
### DbCheck
Provided by the `@adonisjs/lucid` package, this check verifies connectivity to your SQL database.
```ts title="start/health.ts"
import db from '@adonisjs/lucid/services/db'
import { DbCheck } from '@adonisjs/lucid/database'
import { HealthChecks, DiskSpaceCheck, MemoryHeapCheck } from '@adonisjs/core/health'
export const healthChecks = new HealthChecks().register([
new DiskSpaceCheck(),
new MemoryHeapCheck(),
// [!code ++:1]
new DbCheck(db.connection()),
])
```
A successful check returns a report like this.
```json
{
"name": "Database health check (postgres)",
"isCached": false,
"message": "Successfully connected to the database server",
"status": "ok",
"finishedAt": "2024-06-20T07:18:23.830Z",
"meta": {
"connection": {
"name": "postgres",
"dialect": "postgres"
}
}
}
```
To monitor multiple database connections, register the check once for each connection.
```ts title="start/health.ts"
export const healthChecks = new HealthChecks().register([
new DiskSpaceCheck(),
new MemoryHeapCheck(),
// [!code ++:3]
new DbCheck(db.connection()),
new DbCheck(db.connection('mysql')),
new DbCheck(db.connection('pg')),
])
```
### DbConnectionCountCheck
Monitors active database connections and alerts when the count exceeds thresholds. This check is supported for PostgreSQL and MySQL databases only.
```ts title="start/health.ts"
import db from '@adonisjs/lucid/services/db'
import { DbCheck, DbConnectionCountCheck } from '@adonisjs/lucid/database'
import { HealthChecks, DiskSpaceCheck, MemoryHeapCheck } from '@adonisjs/core/health'
export const healthChecks = new HealthChecks().register([
new DiskSpaceCheck(),
new MemoryHeapCheck(),
new DbCheck(db.connection()),
// [!code ++:1]
new DbConnectionCountCheck(db.connection()),
])
```
The default warning threshold is 10 connections and the failure threshold is 15 connections.
```ts title="start/health.ts"
new DbConnectionCountCheck(db.connection())
.warnWhenExceeds(4)
.failWhenExceeds(10)
```
### RedisCheck
Provided by the `@adonisjs/redis` package, this check verifies connectivity to your Redis server, including cluster configurations.
```ts title="start/health.ts"
import redis from '@adonisjs/redis/services/main'
import { RedisCheck } from '@adonisjs/redis'
import { HealthChecks, DiskSpaceCheck, MemoryHeapCheck } from '@adonisjs/core/health'
export const healthChecks = new HealthChecks().register([
new DiskSpaceCheck(),
new MemoryHeapCheck(),
// [!code ++:1]
new RedisCheck(redis.connection()),
])
```
To monitor multiple Redis connections, register the check once for each connection.
```ts title="start/health.ts"
export const healthChecks = new HealthChecks().register([
new DiskSpaceCheck(),
new MemoryHeapCheck(),
// [!code ++:3]
new RedisCheck(redis.connection()),
new RedisCheck(redis.connection('cache')),
new RedisCheck(redis.connection('locks')),
])
```
### RedisMemoryUsageCheck
Monitors memory consumption on the Redis server and alerts when usage exceeds thresholds.
```ts title="start/health.ts"
import redis from '@adonisjs/redis/services/main'
import { RedisCheck, RedisMemoryUsageCheck } from '@adonisjs/redis'
import { HealthChecks, DiskSpaceCheck, MemoryHeapCheck } from '@adonisjs/core/health'
export const healthChecks = new HealthChecks().register([
new DiskSpaceCheck(),
new MemoryHeapCheck(),
new RedisCheck(redis.connection()),
// [!code ++:1]
new RedisMemoryUsageCheck(redis.connection()),
])
```
The default warning threshold is 100MB and the failure threshold is 120MB.
```ts title="start/health.ts"
new RedisMemoryUsageCheck(redis.connection())
.warnWhenExceeds('200 mb')
.failWhenExceeds('240 mb')
```
## Caching results
Health checks run every time the readiness endpoint is called. For checks that don't need to run on every request (like disk space, which changes slowly), you can cache results for a specified duration.
```ts title="start/health.ts"
import {
HealthChecks,
MemoryRSSCheck,
DiskSpaceCheck,
MemoryHeapCheck,
} from '@adonisjs/core/health'
export const healthChecks = new HealthChecks().register([
// [!code ++:1]
new DiskSpaceCheck().cacheFor('1 hour'),
new MemoryHeapCheck(),
new MemoryRSSCheck(),
])
```
In this example, the disk space check runs at most once per hour, returning the cached result for subsequent requests. Memory checks run on every request since memory usage can change rapidly.
When a result is served from cache, the `isCached` property in the check's report will be `true`.
## Creating custom health checks
You can create custom health checks to verify application-specific requirements like external API connectivity, message queue connections, or business-critical service availability.
A custom health check is a class that extends `BaseCheck` and implements the `run` method. You can place custom checks anywhere in your project, though `app/health_checks/` is a sensible location.
```ts title="app/health_checks/payment_gateway_check.ts"
import { Result, BaseCheck } from '@adonisjs/core/health'
import type { HealthCheckResult } from '@adonisjs/core/types/health'
export class PaymentGatewayCheck extends BaseCheck {
async run(): Promise {
/**
* Attempt to reach the payment gateway's health endpoint.
* In a real implementation, you would make an HTTP request
* to verify the service is reachable.
*/
const isReachable = await this.checkGatewayConnectivity()
if (!isReachable) {
return Result.failed('Payment gateway is unreachable')
}
const latency = await this.measureLatency()
if (latency > 2000) {
return Result.warning('Payment gateway latency is high').mergeMetaData({
latencyMs: latency,
})
}
return Result.ok('Payment gateway is healthy').mergeMetaData({
latencyMs: latency,
})
}
protected async checkGatewayConnectivity(): Promise {
// Implementation here
return true
}
protected async measureLatency(): Promise {
// Implementation here
return 150
}
}
```
The `Result` class provides three factory methods for creating health check results:
| Method | Usage |
|--------|-------|
| `Result.ok(message)` | Check passed successfully |
| `Result.warning(message)` | Check passed but with concerns |
| `Result.failed(message, error?)` | Check failed, optionally include the error |
Use the `mergeMetaData` method to include additional information in the check's report, such as latency measurements, connection counts, or version numbers.
### Registering custom health checks
Import your custom check in `start/health.ts` and register it with the others.
```ts title="start/health.ts"
import { HealthChecks, DiskSpaceCheck, MemoryHeapCheck } from '@adonisjs/core/health'
// [!code ++:1]
import { PaymentGatewayCheck } from '#health_checks/payment_gateway_check'
export const healthChecks = new HealthChecks().register([
new DiskSpaceCheck().cacheFor('1 hour'),
new MemoryHeapCheck(),
// [!code ++:1]
new PaymentGatewayCheck(),
])
```
Your custom check will now run alongside the built-in checks whenever the readiness endpoint is called.
---
# Internationalization and Localization
This guide covers internationalization (i18n) and localization in AdonisJS applications. You will learn how to:
- Configure the i18n package and set up supported locales
- Store and organize translation files for multiple languages
- Resolve and format translations in controllers and Edge templates
- Translate validation error messages automatically
- Use ICU message format for dynamic content (plurals, dates, numbers, gender)
- Format values like currencies, dates, and relative times
- Create custom translation loaders and formatters
## Overview
When building applications for a global audience, you need two capabilities: **localization** (translating text into multiple languages) and **internationalization** (formatting values like dates, numbers, and currencies according to regional conventions). The `@adonisjs/i18n` package provides both.
Localization involves writing translations for each language your application supports and referencing them in Edge templates, validation messages, or directly via the i18n API. Instead of hardcoding strings like "Welcome back!" throughout your codebase, you store translations in dedicated files and look them up by key. This makes it straightforward to add new languages without modifying your application code.
Internationalization handles the formatting side. The same date might display as "January 10, 2026" in the US but "10 janvier 2026" in France. The i18n package uses the browser-standard Intl API under the hood, giving you locale-aware formatting for numbers, currencies, dates, times, and more.
The package integrates with AdonisJS through middleware that detects the user's preferred language from the `Accept-Language` header, creates a locale-specific i18n instance, and makes it available throughout the request lifecycle via HTTP Context.
## Installation
Install and configure the package using the following command:
```sh
node ace add @adonisjs/i18n
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/i18n` package using the detected package manager.
2. Registers the following service provider inside the `adonisrc.ts` file.
```ts title="adonisrc.ts"
{
providers: [
// ...other providers
() => import('@adonisjs/i18n/i18n_provider')
]
}
```
3. Creates the `config/i18n.ts` file.
4. Creates `detect_user_locale_middleware` inside the `middleware` directory.
5. Registers the following middleware inside the `start/kernel.ts` file.
```ts title="start/kernel.ts"
router.use([
() => import('#middleware/detect_user_locale_middleware')
])
```
:::
## Configuration
The configuration for the i18n package is stored within the `config/i18n.ts` file.
```ts title="config/i18n.ts"
import app from '@adonisjs/core/services/app'
import { defineConfig, formatters, loaders } from '@adonisjs/i18n'
const i18nConfig = defineConfig({
defaultLocale: 'en',
formatter: formatters.icu(),
loaders: [
loaders.fs({
location: app.languageFilesPath()
})
],
})
export default i18nConfig
```
See also: [Config stub](https://github.com/adonisjs/i18n/blob/3.x/stubs/config/i18n.stub)
### Configuration options
| Option | Description |
|--------|-------------|
| `defaultLocale` | The fallback locale when your application does not support the user's language. Translations and value formatting fall back to this locale. |
| `formatter` | The message format for storing translations. AdonisJS uses the [ICU message format](https://format-message.github.io/icu-message-format-for-translators/index.html) by default, a widely accepted standard supported by translation services like Crowdin and Lokalise. You can also [create custom formatters](#creating-a-custom-translation-formatter). |
| `fallbackLocales` | A key-value pair defining fallback relationships between locales. For example, you might show Spanish content to users who speak Catalan. |
| `supportedLocales` | An array of locales your application supports. If omitted, this is inferred from your translation files. |
| `loaders` | A collection of loaders for loading translations. The default filesystem loader reads from `resources/lang`. You can [create custom loaders](#creating-a-custom-translation-loader) to load translations from a database or remote service. |
### Configuring fallback locales
When a translation is missing for a specific locale, the i18n package can fall back to a related language before using the default locale. This is useful for regional variants.
```ts title="config/i18n.ts"
export default defineConfig({
formatter: formatters.icu(),
defaultLocale: 'en',
// [!code ++:4]
fallbackLocales: {
'de-CH': 'de', // Swiss German falls back to German
'fr-CH': 'fr', // Swiss French falls back to French
ca: 'es' // Catalan falls back to Spanish
}
})
```
### Configuring supported locales
By default, the package infers supported locales from your translation files. If you have translation directories for `en`, `fr`, and `es`, those become your supported locales automatically. To explicitly define supported locales, use the `supportedLocales` option.
```ts title="config/i18n.ts"
export default defineConfig({
formatter: formatters.icu(),
defaultLocale: 'en',
// [!code ++:1]
supportedLocales: ['en', 'fr', 'it']
})
```
## Storing translations
Translations are stored in the `resources/lang` directory. Create a subdirectory for each language using the [IETF language tag](https://en.wikipedia.org/wiki/IETF_language_tag) format (like `en`, `fr`, `de`).
```sh
resources
├── lang
│ ├── en
│ └── fr
```
For regional variants, create subdirectories with the region code. AdonisJS automatically falls back from regional to base translations when a key is missing.
```sh
resources
├── lang
│ ├── en # English (base)
│ ├── en-us # English (United States)
│ └── en-gb # English (United Kingdom)
```
See also: [ISO language codes](https://www.andiamo.co.uk/resources/iso-language-codes/)
### Translation file format
Store translations in `.json` or `.yaml` files. You can create nested directories for better organization.
```sh
resources
├── lang
│ ├── en
│ │ └── messages.json
│ └── fr
│ └── messages.json
```
Translations use the [ICU message syntax](https://format-message.github.io/icu-message-format-for-translators/index.html), which supports interpolation, pluralization, and formatting.
```json title="resources/lang/en/messages.json"
{
"greeting": "Hello world"
}
```
```json title="resources/lang/fr/messages.json"
{
"greeting": "Bonjour le monde"
}
```
## Resolving translations
To look up and format translations, create a locale-specific instance of the I18n class using the `i18nManager.locale` method.
```ts title="app/services/example_service.ts"
import i18nManager from '@adonisjs/i18n/services/main'
/**
* Create I18n instances for specific locales
*/
const en = i18nManager.locale('en')
const fr = i18nManager.locale('fr')
```
Use the `.t` method to format a translation by its key. The key follows the pattern `filename.path.to.key`.
```ts title="app/services/example_service.ts"
import i18nManager from '@adonisjs/i18n/services/main'
const i18n = i18nManager.locale('en')
i18n.t('messages.greeting') // "Hello world"
```
```ts title="app/services/example_service.ts"
import i18nManager from '@adonisjs/i18n/services/main'
const i18n = i18nManager.locale('fr')
i18n.t('messages.greeting') // "Bonjour le monde"
```
### Understanding fallback behavior
Each I18n instance has a pre-configured fallback language based on your `fallbackLocales` configuration. When a translation is missing for the main language, the fallback is used.
```ts title="config/i18n.ts"
export default defineConfig({
defaultLocale: 'en',
fallbackLocales: {
'de-CH': 'de',
'fr-CH': 'fr'
}
})
```
```ts title="app/services/example_service.ts"
import i18nManager from '@adonisjs/i18n/services/main'
const swissGerman = i18nManager.locale('de-CH')
swissGerman.fallbackLocale // "de" (from fallbackLocales)
const swissFrench = i18nManager.locale('fr-CH')
swissFrench.fallbackLocale // "fr" (from fallbackLocales)
const english = i18nManager.locale('en')
english.fallbackLocale // "en" (uses defaultLocale)
```
### Handling missing translations
When a translation is missing in both the main and fallback locales, the `.t` method returns an error string.
```ts title="app/services/example_service.ts"
import i18nManager from '@adonisjs/i18n/services/main'
const i18n = i18nManager.locale('en')
i18n.t('messages.hero_title')
// "translation missing: en, messages.hero_title"
```
To replace this with a custom fallback value, pass it as the second parameter.
```ts title="app/services/example_service.ts"
import i18nManager from '@adonisjs/i18n/services/main'
const i18n = i18nManager.locale('en')
i18n.t('messages.hero_title', '')
// "" (empty string instead of error message)
```
For a global fallback strategy, define a `fallback` function in your config. This function receives the translation path and locale code, and must return a string.
```ts title="config/i18n.ts"
import { defineConfig } from '@adonisjs/i18n'
export default defineConfig({
defaultLocale: 'en',
formatter: formatters.icu(),
// [!code ++:3]
fallback: (identifier, locale) => {
return '' // Return empty string for all missing translations
},
})
```
## Detecting user locale during HTTP requests
The `detect_user_locale_middleware` created during installation handles locale detection automatically. It reads the [`Accept-Language` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language) from incoming requests, creates an I18n instance for the detected locale, and shares it via [HTTP Context](../basics/http_context.md).
With this middleware active, you can access translations in controllers and Edge templates.
```ts title="app/controllers/posts_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async store({ i18n, session, response }: HttpContext) {
// Create post...
session.flash('success', {
message: i18n.t('post.created')
})
return response.redirect().back()
}
}
```
In Edge templates, use the `t` helper function.
```edge title="resources/views/welcome.edge"
{{ t('messages.heroTitle') }}
```
### Customizing locale detection
The middleware is part of your application codebase, so you can modify the `getRequestLocale` method to use custom detection logic. For example, you might read the locale from a user's profile, a cookie, or a URL parameter instead of the `Accept-Language` header.
## Translating validation messages
The `detect_user_locale_middleware` hooks into the [Request validator](../basics/validation.md#the-requestvalidateusing-method) to provide validation messages from your translation files.
```ts title="app/middleware/detect_user_locale_middleware.ts"
export default class DetectUserLocaleMiddleware {
static {
RequestValidator.messagesProvider = (ctx) => {
return ctx.i18n.createMessagesProvider()
}
}
}
```
Store validation translations in a `validator.json` file under the `shared` key. Define messages for validation rules or specific `field + rule` combinations.
```json title="resources/lang/en/validator.json"
{
"shared": {
"fields": {
"first_name": "first name"
},
"messages": {
"required": "Enter {field}",
"username.required": "Choose a username for your account",
"email": "The email must be valid"
}
}
}
```
```json title="resources/lang/fr/validator.json"
{
"shared": {
"fields": {
"first_name": "Prénom"
},
"messages": {
"required": "Remplisser le champ {field}",
"username.required": "Choissisez un nom d'utilisateur pour votre compte",
"email": "L'email doit être valide"
}
}
}
```
### Using translations with VineJS directly
During HTTP requests, the middleware automatically registers a [custom messages provider](https://vinejs.dev/docs/custom_error_messages#registering-messages-provider) for validation. When using VineJS outside of HTTP requests (in Ace commands or queue jobs), register the messages provider explicitly.
```ts title="app/jobs/create_user_job.ts"
import { createJobValidator } from '#validators/jobs'
import i18nManager from '@adonisjs/i18n/services/main'
/**
* Get an i18n instance for the desired locale
*/
const i18n = i18nManager.locale('fr')
await createJobValidator.validate(data, {
/**
* Register the messages provider manually
*/
// [!code ++:1]
messagesProvider: i18n.createMessagesProvider()
})
```
## ICU message format
The ICU (International Components for Unicode) message format is the standard for storing translations that need dynamic content. It supports interpolation, number formatting, date formatting, pluralization, and gender-based selection.
### Interpolation
Reference dynamic values using single curly braces.
```json title="resources/lang/en/messages.json"
{
"greeting": "Hello { username }"
}
```
```edge title="resources/views/welcome.edge"
{{ t('messages.greeting', { username: 'Virk' }) }}
{{-- Output: Hello Virk --}}
```
:::tip
The ICU messages syntax [does not support nested data sets](https://github.com/formatjs/formatjs/pull/2039#issuecomment-951550150). You can only access properties from a flat object during interpolation. If you need nested data, flatten it before passing to the translation function.
:::
To include HTML in translations, use three curly braces in Edge templates to prevent escaping.
```json title="resources/lang/en/messages.json"
{
"greeting": "
Hello { username }
"
}
```
```edge title="resources/views/welcome.edge"
{{{ t('messages.greeting', { username: 'Virk' }) }}}
```
### Number formatting
Format numeric values using the `{key, number, format}` syntax. The format can include [number skeletons](https://unicode-org.github.io/icu/userguide/format_parse/numbers/skeletons.html#overview) for precise control.
```json title="resources/lang/en/messages.json"
{
"bagel_price": "The price of this bagel is {amount, number, ::currency/USD}"
}
```
```edge title="resources/views/menu.edge"
{{ t('bagel_price', { amount: 2.49 }) }}
{{-- Output: The price of this bagel is $2.49 --}}
```
More formatting examples:
| Message | Output |
|---------|--------|
| `Length: {length, number, ::measure-unit/length-meter}` | Length: 5 m |
| `Balance: {price, number, ::currency/USD compact-long}` | Balance: $1 thousand |
### Date and time formatting
Format Date objects or [Luxon DateTime](https://moment.github.io/luxon/api-docs/index.html) instances using the `{key, date, format}` or `{key, time, format}` syntax.
```json title="resources/lang/en/messages.json"
{
"shipment_update": "Your package will arrive on {expectedDate, date, medium}"
}
```
```edge title="resources/views/order.edge"
{{ t('shipment_update', { expectedDate: luxonDateTime }) }}
{{-- Output: Your package will arrive on Oct 16, 2023 --}}
```
For time formatting:
```json title="resources/lang/en/messages.json"
{
"appointment": "You have an appointment today at {appointmentAt, time, ::h:m a}"
}
```
```txt
You have an appointment today at 2:48 PM
```
#### Supported date/time patterns
ICU provides many patterns, but only the following are available via the ECMA402 Intl API:
| Symbol | Description |
|--------|-------------|
| `G` | Era designator |
| `y` | Year |
| `M` | Month in year |
| `L` | Stand-alone month in year |
| `d` | Day in month |
| `E` | Day of week |
| `e` | Local day of week (e..eee not supported) |
| `c` | Stand-alone local day of week (c..ccc not supported) |
| `a` | AM/PM marker |
| `h` | Hour [1-12] |
| `H` | Hour [0-23] |
| `K` | Hour [0-11] |
| `k` | Hour [1-24] |
| `m` | Minute |
| `s` | Second |
| `z` | Time Zone |
### Plural rules
ICU has first-class support for pluralization. The `plural` format selects different text based on a numeric value.
```yaml title="resources/lang/en/messages.yaml"
cart_summary: >
You have {itemsCount, plural,
=0 {no items}
one {1 item}
other {# items}
} in your cart
```
```edge title="resources/views/cart.edge"
{{ t('messages.cart_summary', { itemsCount: 1 }) }}
{{-- Output: You have 1 item in your cart --}}
```
The `#` token is a placeholder for the numeric value, formatted according to locale rules.
```edge title="resources/views/cart.edge"
{{ t('messages.cart_summary', { itemsCount: 1000 }) }}
{{-- Output: You have 1,000 items in your cart --}}
```
#### Plural categories
The `{key, plural, matches}` syntax matches values to these categories:
| Category | When used |
|----------|-----------|
| `zero` | Languages with grammar for zero items (Arabic, Latvian) |
| `one` | Languages with grammar for one item (many Western languages) |
| `two` | Languages with grammar for two items (Arabic, Welsh) |
| `few` | Languages with grammar for small numbers (varies by language) |
| `many` | Languages with grammar for larger numbers (Arabic, Polish, Russian) |
| `other` | Default category when no other matches; used for "plural" in languages like English |
| `=value` | Matches a specific numeric value exactly |
### Select format
The `select` format chooses output by matching a value against multiple options. This is useful for gender-specific text or other categorical choices.
```yaml title="resources/lang/en/messages.yaml"
auto_reply: >
{gender, select,
male {He}
female {She}
other {They}
} will respond shortly.
```
```edge title="resources/views/contact.edge"
{{ t('messages.auto_reply', { gender: 'female' }) }}
{{-- Output: She will respond shortly. --}}
```
### Select ordinal format
The `selectordinal` format handles ordinal numbers (1st, 2nd, 3rd, etc.) according to locale rules.
```yaml title="resources/lang/en/messages.yaml"
anniversary_greeting: >
It's my {years, selectordinal,
one {#st}
two {#nd}
few {#rd}
other {#th}
} anniversary
```
```edge title="resources/views/profile.edge"
{{ t('messages.anniversary_greeting', { years: 2 }) }}
{{-- Output: It's my 2nd anniversary --}}
```
The ordinal categories are the same as plural categories (`zero`, `one`, `two`, `few`, `many`, `other`, `=value`).
## Formatting values
The i18n package provides methods for formatting values according to locale conventions. These methods use the [Node.js Intl API](https://nodejs.org/dist/latest/docs/api/intl.html) with optimized performance.
### formatNumber
Format numeric values using the `Intl.NumberFormat` class.
```ts title="app/services/example_service.ts"
import i18nManager from '@adonisjs/i18n/services/main'
i18nManager
.locale('en')
.formatNumber(123456.789, {
maximumSignificantDigits: 3
})
// "123,000"
```
See: [Intl.NumberFormat options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#options)
### formatCurrency
Format a numeric value as currency. This method sets `style: 'currency'` automatically.
```ts title="app/services/example_service.ts"
import i18nManager from '@adonisjs/i18n/services/main'
i18nManager
.locale('en')
.formatCurrency(200, {
currency: 'USD'
})
// "$200.00"
```
### formatDate
Format a Date object or Luxon DateTime instance using the `Intl.DateTimeFormat` class.
```ts title="app/services/example_service.ts"
import i18nManager from '@adonisjs/i18n/services/main'
import { DateTime } from 'luxon'
i18nManager
.locale('en')
.formatDate(new Date(), {
dateStyle: 'long'
})
// "January 10, 2026"
i18nManager
.locale('en')
.formatDate(DateTime.local(), {
dateStyle: 'long'
})
// "January 10, 2026"
```
See: [Intl.DateTimeFormat options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options)
### formatTime
Format a date value as a time string. This method sets `timeStyle: 'medium'` by default.
```ts title="app/services/example_service.ts"
import i18nManager from '@adonisjs/i18n/services/main'
i18nManager
.locale('en')
.formatTime(new Date())
// "2:48:30 PM"
```
### formatRelativeTime
Format a value as relative time (like "in 2 hours" or "3 days ago") using the `Intl.RelativeTimeFormat` class.
```ts title="app/services/example_service.ts"
import { DateTime } from 'luxon'
import i18nManager from '@adonisjs/i18n/services/main'
const futureDate = DateTime.local().plus({ hours: 2 })
i18nManager
.locale('en')
.formatRelativeTime(futureDate, 'hours')
// "in 2 hours"
```
Use the `'auto'` unit to automatically select the most appropriate unit.
```ts title="app/services/example_service.ts"
import { DateTime } from 'luxon'
import i18nManager from '@adonisjs/i18n/services/main'
const nearFuture = DateTime.local().plus({ hours: 2 })
i18nManager.locale('en').formatRelativeTime(nearFuture, 'auto')
// "in 2 hours"
const farFuture = DateTime.local().plus({ hours: 200 })
i18nManager.locale('en').formatRelativeTime(farFuture, 'auto')
// "in 8 days"
```
See: [Intl.RelativeTimeFormat options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat#options)
### formatPlural
Find the plural category for a number using the `Intl.PluralRules` class. This is useful when you need to handle pluralization in code rather than in translation strings.
```ts title="app/services/example_service.ts"
import i18nManager from '@adonisjs/i18n/services/main'
i18nManager.locale('en').formatPlural(0) // "other"
i18nManager.locale('en').formatPlural(1) // "one"
i18nManager.locale('en').formatPlural(2) // "other"
```
See: [Intl.PluralRules options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules/PluralRules#options)
### formatList
Format an array of strings as a human-readable list using the `Intl.ListFormat` class.
```ts title="app/services/example_service.ts"
import i18nManager from '@adonisjs/i18n/services/main'
i18nManager
.locale('en')
.formatList(['Me', 'myself', 'I'], { type: 'conjunction' })
// "Me, myself, and I"
i18nManager
.locale('en')
.formatList(['5 hours', '3 minutes'], { type: 'unit' })
// "5 hours, 3 minutes"
```
See: [Intl.ListFormat options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat/ListFormat#options)
### formatDisplayNames
Format codes for currencies, languages, regions, and calendars as human-readable names using the `Intl.DisplayNames` class.
```ts title="app/services/example_service.ts"
import i18nManager from '@adonisjs/i18n/services/main'
i18nManager
.locale('en')
.formatDisplayNames('INR', { type: 'currency' })
// "Indian Rupee"
i18nManager
.locale('en')
.formatDisplayNames('en-US', { type: 'language' })
// "American English"
```
See: [Intl.DisplayNames options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DisplayNames/DisplayNames#options)
## Configuring the i18n Ally VSCode extension
The [i18n Ally](https://marketplace.visualstudio.com/items?itemName=Lokalise.i18n-ally) extension for VSCode provides inline translation previews, missing translation detection, and quick editing. To configure it for AdonisJS, create two files in your `.vscode` directory.
```sh
mkdir -p .vscode
touch .vscode/i18n-ally-custom-framework.yml
touch .vscode/settings.json
```
```json title=".vscode/settings.json"
{
"i18n-ally.localesPaths": [
"resources/lang"
],
"i18n-ally.keystyle": "nested",
"i18n-ally.namespace": true,
"i18n-ally.editor.preferEditor": true,
"i18n-ally.refactor.templates": [
{
"templates": [
"{{ t('{key}'{args}) }}"
],
"include": [
"**/*.edge"
]
}
]
}
```
```yaml title=".vscode/i18n-ally-custom-framework.yml"
languageIds:
- edge
usageMatchRegex:
- "[^\\w\\d]t\\(['\"`]({key})['\"`]"
sortKeys: true
```
## Listening for missing translations
Subscribe to the `i18n:missing:translation` event to track missing translations in your application. This is useful for logging or alerting during development.
```ts title="start/events.ts"
import emitter from '@adonisjs/core/services/emitter'
emitter.on('i18n:missing:translation', function (event) {
console.log(event.identifier) // The translation key that was missing
console.log(event.hasFallback) // Whether a fallback was used
console.log(event.locale) // The locale that was requested
})
```
## Reloading translations
The i18n package loads translation files at startup and caches them in memory. If you modify translation files while the application is running, use the `reloadTranslations` method to refresh the cache.
```ts title="app/controllers/admin_controller.ts"
import i18nManager from '@adonisjs/i18n/services/main'
export default class AdminController {
async refreshTranslations() {
await i18nManager.reloadTranslations()
}
}
```
## Advanced: Creating a custom translation loader
A translation loader is responsible for loading translations from a persistent store. The default filesystem loader reads from `resources/lang`, but you can create custom loaders to read from a database, remote API, or any other source.
A loader must implement the `TranslationsLoaderContract` interface with a `load` method that returns translations grouped by locale.
```ts title="app/i18n/db_loader.ts"
import type {
LoaderFactory,
TranslationsLoaderContract,
} from '@adonisjs/i18n/types'
/**
* Configuration options for the loader
*/
export type DbLoaderConfig = {
connection: string
tableName: string
}
/**
* The loader implementation
*/
export class DbLoader implements TranslationsLoaderContract {
constructor(public config: DbLoaderConfig) {}
async load() {
/**
* Query your database here and return translations
* grouped by locale with flattened keys
*/
return {
en: {
'messages.greeting': 'Hello world',
'messages.farewell': 'Goodbye',
},
fr: {
'messages.greeting': 'Bonjour le monde',
'messages.farewell': 'Au revoir',
}
}
}
}
/**
* Factory function to reference the loader in config
*/
export function dbLoader(config: DbLoaderConfig): LoaderFactory {
return () => {
return new DbLoader(config)
}
}
```
### Using the custom loader
Reference the loader in your config file using the factory function.
```ts title="config/i18n.ts"
import { defineConfig } from '@adonisjs/i18n'
import { dbLoader } from '#app/i18n/db_loader'
const i18nConfig = defineConfig({
defaultLocale: 'en',
formatter: formatters.icu(),
loaders: [
// [!code ++:4]
dbLoader({
connection: 'pg',
tableName: 'translations'
})
]
})
export default i18nConfig
```
## Advanced: Creating a custom translation formatter
A translation formatter processes translation strings according to a specific format. The default ICU formatter handles ICU message syntax, but you can create custom formatters for other formats like Fluent.
A formatter must implement the `TranslationsFormatterContract` interface with a `format` method.
```ts title="app/i18n/fluent_formatter.ts"
import type {
FormatterFactory,
TranslationsFormatterContract,
} from '@adonisjs/i18n/types'
/**
* The formatter implementation
*/
export class FluentFormatter implements TranslationsFormatterContract {
format(
message: string,
locale: string,
data?: Record
): string {
/**
* Process the message using your chosen format
* and return the formatted string
*/
return message // Your formatting logic here
}
}
/**
* Factory function to reference the formatter in config
*/
export function fluentFormatter(): FormatterFactory {
return () => {
return new FluentFormatter()
}
}
```
### Using the custom formatter
Reference the formatter in your config file using the factory function.
```ts title="config/i18n.ts"
import { defineConfig } from '@adonisjs/i18n'
import { fluentFormatter } from '#app/i18n/fluent_formatter'
const i18nConfig = defineConfig({
defaultLocale: 'en',
// [!code ++:1]
formatter: fluentFormatter()
})
export default i18nConfig
```
---
# Atomic Locks
This guide covers atomic locks in AdonisJS applications. You will learn how to:
- Install and configure the `@adonisjs/lock` package
- Create and release locks to protect critical sections
- Use different acquisition methods for various scenarios
- Extend lock expiry for long-running operations
- Share locks between different processes
## Overview
Atomic locks prevent race conditions when multiple processes or parts of your codebase might perform concurrent actions on the same resource. Consider a payment processing scenario where a queue job could be enqueued twice due to a network retry. Without proper locking, the system might charge the user twice. Atomic locks ensure that only one process can execute the critical section at a time.
The `@adonisjs/lock` package is a wrapper over [Verrou](https://verrou.dev), a framework-agnostic locking library created and maintained by the AdonisJS core team. It supports three storage backends: Redis, database, and memory.
## Installation
Install and configure the package using the following command.
```sh
node ace add @adonisjs/lock
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/lock` package using the detected package manager.
2. Registers the following service provider inside the `adonisrc.ts` file.
```ts title="adonisrc.ts"
{
providers: [
// ...other providers
() => import('@adonisjs/lock/lock_provider')
]
}
```
3. Creates the `config/lock.ts` file.
4. Defines the `LOCK_STORE` environment variable and its validation inside the `start/env.ts` file.
5. Creates a database migration for the locks table (if using the database store).
:::
## Configuration
The configuration is stored in the `config/lock.ts` file.
```ts title="config/lock.ts"
import env from '#start/env'
import { defineConfig, stores } from '@adonisjs/lock'
const lockConfig = defineConfig({
default: env.get('LOCK_STORE'),
stores: {
/**
* Redis store to manage locks.
* Requires the @adonisjs/redis package.
*/
redis: stores.redis({}),
/**
* Database store to manage locks.
* Requires the @adonisjs/lucid package.
*/
database: stores.database({
tableName: 'locks'
}),
/**
* Memory store could be used during testing.
*/
memory: stores.memory()
},
})
export default lockConfig
declare module '@adonisjs/lock/types' {
export interface LockStoresList extends InferLockStores {}
}
```
### Redis store
The `redis` store has a peer dependency on the `@adonisjs/redis` package. You must [configure the Redis package](../database/redis.md) before using the Redis store.
The `connectionName` is a reference to the connection defined within the `config/redis.ts` file. If not defined, the default Redis connection is used.
```ts title="config/lock.ts"
{
redis: stores.redis({
connectionName: 'main',
}),
}
```
### Database store
The `database` store has a peer dependency on the `@adonisjs/lucid` package. You must [configure Lucid](../database/lucid.md) before using the database store.
The `connectionName` is a reference to a database connection defined within the `config/database.ts` file. If not defined, the default database connection is used.
```ts title="config/lock.ts"
{
database: stores.database({
connectionName: 'postgres',
tableName: 'my_locks',
}),
}
```
The data is stored within the `locks` table. A migration for this table is automatically created during installation. However, if needed, you can manually create a migration with the following contents.
:::disclosure{title="Migration file contents"}
```ts title="database/migrations/xxxx_create_locks_table.ts"
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'locks'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.string('key', 255).notNullable().primary()
table.string('owner').notNullable()
table.bigint('expiration').unsigned().nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
```
:::
### Environment variables
The default store is configured using the `LOCK_STORE` environment variable.
```dotenv title=".env"
LOCK_STORE=redis
```
## Creating locks
Create a lock using the `createLock` method from the lock manager service. The method accepts a unique key that identifies the resource being locked and a TTL (time-to-live) that defines how long the lock remains valid.
```ts
import lockManager from '@adonisjs/lock/services/main'
const lock = lockManager.createLock('processing_payment:order:42', '30s')
```
The lock key should uniquely identify the resource being protected. A common pattern is to use a descriptive prefix followed by an identifier, such as `processing_payment:order:${orderId}` or `sending_email:user:${userId}`.
The TTL accepts either a time expression string (like `'10s'`, `'5m'`, or `'1h'`) or a number in milliseconds. The TTL acts as a safety mechanism. If a process crashes while holding a lock, the lock will automatically expire after the TTL, preventing deadlocks.
## Acquiring locks
The package provides several methods for acquiring locks, each suited to different scenarios.
### Running code within a lock
The `run` method is the recommended way to execute code within a lock. It acquires the lock, executes your callback, and automatically releases the lock when the callback completes (or throws an error).
```ts title="app/services/payment_service.ts"
import lockManager from '@adonisjs/lock/services/main'
export default class PaymentService {
async processPayment(order: Order) {
const lock = lockManager.createLock(
`processing_payment:order:${order.id}`,
'30s'
)
const [acquired, result] = await lock.run(async () => {
/**
* This callback only executes after acquiring the lock.
* The lock is automatically released when the callback
* completes or throws an error.
*/
const charge = await this.chargeCustomer(order)
await order.merge({ status: 'paid', chargeId: charge.id }).save()
return charge
})
if (!acquired) {
return { success: false, message: 'Payment already in progress' }
}
return { success: true, charge: result }
}
}
```
By default, `run` waits indefinitely until the lock becomes available. You can configure this behavior using options.
### Running immediately or not at all
The `runImmediately` method attempts to acquire the lock without waiting. If the lock is already held by another process, the callback does not execute.
```ts
const [acquired, result] = await lock.runImmediately(async () => {
// Only runs if lock was acquired immediately
})
if (!acquired) {
// Lock was not available
}
```
### Manual lock management
For more control over the lock lifecycle, use the `acquire` and `release` methods directly. When using manual acquisition, you must ensure the lock is released, even if an error occurs.
```ts
const acquired = await lock.acquire()
if (acquired) {
try {
// Perform protected operations
} finally {
await lock.release()
}
}
```
The `acquireImmediately` method attempts to acquire the lock without waiting and returns `true` if successful.
```ts
const acquired = await lock.acquireImmediately()
if (!acquired) {
// Lock was not available, handle accordingly
}
```
:::tip
Prefer the `run` method over manual acquisition when possible. It handles lock release automatically, including when exceptions occur, which prevents accidental lock leaks.
:::
## Lock options
When acquiring a lock, you can configure retry behavior and timeouts.
::::options
:::option{name="timeout"}
Maximum time to wait before giving up on acquiring the lock. Accepts a time expression string or milliseconds.
```ts
await lock.acquire({ timeout: '5s' })
```
:::
:::option{name="attempts"}
Maximum number of retry attempts before throwing an error.
```ts
await lock.acquire({ attempts: 3 })
```
:::
:::option{name="delay"}
Delay between retry attempts. Accepts a time expression string or milliseconds.
```ts
await lock.acquire({ delay: '100ms' })
```
:::
::::
You can combine these options for fine-grained control.
```ts
const acquired = await lock.acquire({
timeout: '10s',
attempts: 5,
delay: '500ms'
})
```
## Checking lock state
You can inspect the current state of a lock using the following methods.
```ts
const lock = lockManager.createLock('my_resource', '30s')
/**
* Check if the lock is currently held by any process.
*/
const locked = await lock.isLocked()
/**
* Check if the lock has expired.
*/
const expired = await lock.isExpired()
/**
* Get the remaining time in milliseconds before the lock expires.
* Returns null if the lock is not held.
*/
const remaining = await lock.getRemainingTime()
```
## Extending locks
For long-running operations that might exceed the initial TTL, you can extend the lock duration. This is useful when you cannot predict exactly how long an operation will take.
```ts
const lock = lockManager.createLock('long_running_task', '10s')
await lock.acquire()
try {
for (const item of largeDataset) {
await processItem(item)
/**
* Extend the lock by another 10 seconds to prevent
* expiration during processing.
*/
await lock.extend('10s')
}
} finally {
await lock.release()
}
```
## Sharing locks between processes
In distributed systems, you might need to acquire a lock in one process and release it in another. The `serialize` method converts a lock into a string that can be stored and restored elsewhere.
```ts title="app/jobs/start_processing.ts"
import lockManager from '@adonisjs/lock/services/main'
const lock = lockManager.createLock('batch_job:123', '5m')
await lock.acquire()
/**
* Serialize the lock for storage. This string contains
* all information needed to restore the lock.
*/
const serialized = lock.serialize()
/**
* Store the serialized lock (e.g., in a database or cache)
* for retrieval by another process.
*/
await redis.set('batch_job:123:lock', serialized)
```
In another process, restore the lock using `restoreLock`.
```ts title="app/jobs/finish_processing.ts"
import lockManager from '@adonisjs/lock/services/main'
const serialized = await redis.get('batch_job:123:lock')
/**
* Restore the lock from its serialized form.
* This creates a lock instance with the same owner,
* allowing this process to release it.
*/
const lock = lockManager.restoreLock(serialized)
try {
// Complete the processing
} finally {
await lock.release()
}
```
## See also
- [Verrou documentation](https://verrou.dev) for advanced features and detailed API reference
- [Redis](../database/redis.md) for setting up the Redis store
- [Lucid ORM](../database/lucid.md) for setting up the database store
---
# Logger
This guide covers logging in AdonisJS applications. You will learn how to:
- Write logs during HTTP requests using the request-aware logger
- Configure pretty-printed logs for development and file-based logs for production
- Define multiple loggers for different parts of your application
- Inject the logger into services using dependency injection
- Create child loggers that inherit context from their parent
- Protect sensitive data from appearing in log output
## Overview
AdonisJS includes an inbuilt logger for writing logs to the terminal, files, and external services. Under the hood, the logger uses [Pino](https://getpino.io), one of the fastest logging libraries in the Node.js ecosystem. Logs are produced in the [NDJSON format](https://github.com/ndjson/ndjson-spec), making them easy to parse and process with standard tooling.
The logger integrates deeply with AdonisJS. During HTTP requests, each request automatically gets its own logger instance that includes the request ID in every log entry, making it straightforward to trace logs back to specific requests.
:::note
This guide focuses on logging during HTTP requests. For CLI applications, see the [Ace ANSI logger documentation](../ace/tui.md#displaying-log-messages) which provides terminal-friendly colored output designed for command-line tools.
:::
## Writing your first log
Import the logger service and call any of the logging methods to write a message. During development, logs appear in your terminal with pretty formatting that includes timestamps, colors, and readable structure.
```ts title="start/routes.ts"
import router from '@adonisjs/core/services/router'
import logger from '@adonisjs/core/services/logger'
router.get('/', async () => {
logger.info('Processing home page request')
return { hello: 'world' }
})
```
When you visit the route, you'll see output like this in your terminal:
```
[10:24:36.842] INFO: Processing home page request
```
The logger provides methods for each log level, from most to least verbose:
```ts title="app/controllers/posts_controller.ts"
import logger from '@adonisjs/core/services/logger'
export default class PostsController {
async store() {
logger.trace({ config }, 'Using config') // Most verbose, for tracing execution
logger.debug('User details: %o', { id: 1 }) // Debug information
logger.info('Creating new post') // General information
logger.warn('Rate limit approaching') // Warning conditions
logger.error({ err }, 'Failed to save post') // Error conditions
logger.fatal({ err }, 'Database connection lost') // Critical failures
}
}
```
### Adding context to logs
Pass an object as the first argument to include additional data in the log entry. The object properties are merged into the JSON output.
```ts
const user = { id: 1, email: 'virk@adonisjs.com' }
logger.info({ user }, 'User logged in')
```
When logging errors, use the `err` key so Pino's built-in serializer formats the error properly with stack traces:
```ts
try {
await riskyOperation()
} catch (error) {
logger.error({ err: error }, 'Operation failed')
}
```
### String interpolation
Log messages support printf-style interpolation for embedding values directly in the message string:
```ts
logger.info('User %s logged in from %s', username, ipAddress)
logger.debug('Request body: %o', requestBody) // %o for objects
logger.info('Processing %d items', items.length) // %d for numbers
```
## Request-aware logging
During HTTP requests, use `ctx.logger` instead of importing the logger service directly. The context logger automatically includes the request ID in every log entry, making it easy to correlate all logs from a single request.
```ts title="app/controllers/users_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import User from '#models/user'
export default class UsersController {
async show({ logger, params }: HttpContext) {
logger.info('Fetching user by id %s', params.id)
const user = await User.find(params.id)
if (!user) {
logger.warn('User not found')
return { error: 'Not found' }
}
logger.info('User retrieved successfully')
return user
}
}
```
The output includes the request ID, allowing you to filter logs for a specific request:
```
[10:24:36.842] INFO (request_id=cjkl3402k0001...): Fetching user by id 42
[10:24:36.901] INFO (request_id=cjkl3402k0001...): User retrieved successfully
```
## Configuring the logger
The logger configuration lives in `config/logger.ts`. The default setup uses pretty-printed output in development and structured JSON in production.
```ts title="config/logger.ts"
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, syncDestination, targets } from '@adonisjs/core/logger'
const loggerConfig = defineConfig({
default: 'app',
loggers: {
app: {
enabled: true,
name: env.get('APP_NAME'),
level: env.get('LOG_LEVEL'),
destination: !app.inProduction ? await syncDestination() : undefined,
transport: {
targets: [targets.file({ destination: 1 })],
},
},
},
})
export default loggerConfig
declare module '@adonisjs/core/types' {
export interface LoggersList extends InferLoggers {}
}
```
### Understanding the configuration
The `syncDestination()` helper configures synchronous, pretty-printed output for development. By default, Pino writes logs asynchronously for better performance, but this can make it harder to correlate logs with the code that produced them during debugging. The synchronous destination writes logs inline as your code executes, with human-readable formatting.
In production, the `destination` is left as `undefined`, which means logs flow through the configured transport targets. The `targets.file({ destination: 1 })` target writes JSON logs to stdout (file descriptor 1), which is the standard approach for containerized deployments where a log aggregator collects stdout.
### Configuration reference
| Property | Description |
|----------|-------------|
| `default` | The name of the logger to use when calling `logger.info()` without specifying a logger |
| `enabled` | Set to `false` to disable the logger entirely |
| `name` | A name included in every log entry, useful for identifying the source application |
| `level` | The minimum level to log. Messages below this level are ignored |
| `destination` | A custom destination stream. Use `syncDestination()` for synchronous pretty output |
| `transport` | Configuration for Pino transports that process and route logs |
### Log levels
The logger supports six levels, ordered from most to least verbose. When you set a level, the logger produces logs at that level and above.
| Level | Value | Description |
|-------|-------|-------------|
| `trace` | 10 | Extremely detailed tracing information |
| `debug` | 20 | Debug information useful during development |
| `info` | 30 | General operational information |
| `warn` | 40 | Warning conditions that should be reviewed |
| `error` | 50 | Error conditions that need attention |
| `fatal` | 60 | Critical failures that require immediate action |
Set the level in your `.env` file:
```dotenv title=".env"
LOG_LEVEL=debug
```
### Writing logs to a file
To write logs to a file instead of stdout, configure the `targets.file()` helper with a file path:
```ts title="config/logger.ts"
transport: {
targets: [
targets.file({ destination: '/var/log/apps/adonisjs.log' })
],
}
```
### File rotation
Pino does not include built-in file rotation. Use either a system tool like [logrotate](https://getpino.io/#/docs/help?id=rotate) or the [pino-roll](https://github.com/feugy/pino-roll) package.
```sh
npm i pino-roll
```
```ts title="config/logger.ts"
transport: {
targets: [
{
target: 'pino-roll',
level: 'info',
options: {
file: '/var/log/apps/adonisjs.log',
frequency: 'daily',
mkdir: true,
},
},
],
}
```
### Defining targets conditionally
Use the `targets()` helper to build the targets array with conditional logic. This is cleaner than spreading arrays with ternary operators.
```ts title="config/logger.ts"
import app from '@adonisjs/core/services/app'
import { defineConfig, targets } from '@adonisjs/core/logger'
export default defineConfig({
default: 'app',
loggers: {
app: {
enabled: true,
name: env.get('APP_NAME'),
level: env.get('LOG_LEVEL'),
transport: {
targets: targets()
.pushIf(!app.inProduction, targets.pretty())
.pushIf(app.inProduction, targets.file({ destination: 1 }))
.toArray(),
},
},
},
})
```
The `pushIf` method only adds the target when the condition is true, keeping your configuration readable.
## Using multiple loggers
Define multiple loggers in your configuration when different parts of your application need separate logging behavior. For example, you might want payment-related logs to go to a separate file with a different retention policy.
```ts title="config/logger.ts"
export default defineConfig({
default: 'app',
loggers: {
app: {
enabled: true,
name: env.get('APP_NAME'),
level: env.get('LOG_LEVEL'),
destination: !app.inProduction ? await syncDestination() : undefined,
transport: {
targets: [targets.file({ destination: 1 })],
},
},
payments: {
enabled: true,
name: 'payments',
level: 'info',
transport: {
targets: [
targets.file({ destination: '/var/log/apps/payments.log' }),
],
},
},
},
})
```
Access a specific logger using the `logger.use()` method:
```ts title="app/services/payment_service.ts"
import logger from '@adonisjs/core/services/logger'
export default class PaymentService {
async processPayment(amount: number) {
const paymentLogger = logger.use('payments')
paymentLogger.info({ amount }, 'Processing payment')
// ... payment logic
paymentLogger.info('Payment completed successfully')
}
}
```
Calling `logger.use()` without arguments returns the default logger.
## Dependency injection
When using dependency injection, type-hint the `Logger` class and the IoC container resolves an instance of the default logger. If the class is constructed during an HTTP request, the container automatically injects the request-aware logger with the request ID included.
```ts title="app/services/user_service.ts"
import { inject } from '@adonisjs/core'
import { Logger } from '@adonisjs/core/logger'
import User from '#models/user'
@inject()
export default class UserService {
constructor(protected logger: Logger) {}
async find(userId: string | number) {
this.logger.info('Fetching user by id %s', userId)
return User.find(userId)
}
}
```
## Child loggers
A child logger inherits configuration and bindings from its parent while allowing you to add additional context. This is useful when you want all logs from a particular operation to include shared metadata.
```ts
import logger from '@adonisjs/core/services/logger'
const orderLogger = logger.child({ orderId: 'order_123' })
orderLogger.info('Processing order') // Includes orderId
orderLogger.info('Validating items') // Includes orderId
orderLogger.info('Order complete') // Includes orderId
```
Every log entry from `orderLogger` automatically includes the `orderId` field. You can also override the log level for a child logger:
```ts
const verboseLogger = logger.child({}, { level: 'trace' })
```
## Conditional logging
If computing data for a log message is expensive, check whether the level is enabled before doing the work:
```ts
import logger from '@adonisjs/core/services/logger'
if (logger.isLevelEnabled('debug')) {
const data = await computeExpensiveDebugData()
logger.debug(data, 'Debug information')
}
```
The `ifLevelEnabled` method provides a callback-based alternative:
```ts
logger.ifLevelEnabled('debug', async () => {
const data = await computeExpensiveDebugData()
logger.debug(data, 'Debug information')
})
```
## Hiding sensitive values
Logs can inadvertently expose sensitive data. Use the `redact` option to automatically hide values for specific keys. Under the hood, this uses the [fast-redact](https://github.com/davidmarkclements/fast-redact) package.
```ts title="config/logger.ts"
loggers: {
app: {
enabled: true,
name: env.get('APP_NAME'),
level: env.get('LOG_LEVEL'),
redact: {
paths: ['password', '*.password', 'creditCard'],
},
},
}
```
```ts
logger.info({ username: 'virk', password: 'secret123' }, 'User signup')
// Output: {"username":"virk","password":"[Redacted]","msg":"User signup"}
```
Customize the placeholder or remove the keys entirely:
```ts title="config/logger.ts"
redact: {
paths: ['password', '*.password'],
censor: '[PRIVATE]',
}
// Or remove the property entirely
redact: {
paths: ['password'],
remove: true,
}
```
### Using the Secret class
An alternative to redaction is wrapping sensitive values in the `Secret` class. The logger automatically redacts any `Secret` instance.
See also: [Secret class documentation](../../reference/helpers.md#secret)
```ts
import { Secret } from '@adonisjs/core/helpers'
const password = new Secret(request.input('password'))
logger.info({ username, password }, 'User signup')
// Output: {"username":"virk","password":"[redacted]","msg":"User signup"}
```
## Pino statics
The `@adonisjs/core/logger` module re-exports Pino's static methods and properties for advanced use cases.
See the [Pino documentation](https://getpino.io/#/docs/api?id=statics) for details on these exports.
```ts
import {
multistream,
destination,
transport,
stdSerializers,
stdTimeFunctions,
symbols,
pinoVersion,
} from '@adonisjs/core/logger'
```
---
# Mail
This guide covers sending emails from your AdonisJS application. You will learn how to:
- Configure mail transports for services like SMTP, Resend, Mailgun, and SES
- Send emails using the fluent Message API
- Queue emails for background delivery
- Organize emails into reusable mail classes
- Add attachments, embed images, and include calendar invites
- Test email functionality with the fake mailer
## Overview
The `@adonisjs/mail` package provides a unified API for sending emails through various providers. Built on top of [Nodemailer](https://nodemailer.com/), it adds a fluent configuration API, support for organizing emails as classes, an extensive testing API, and background email delivery through messengers.
The package introduces two key concepts. A **transport** is the underlying delivery mechanism (SMTP server, API service like Resend or Mailgun). A **mailer** is a configured instance of a transport that you use to send emails. You can configure multiple mailers in your application, each using different transports or the same transport with different settings, and switch between them at runtime.
## Installation
Install and configure the package using the following command:
```sh
node ace add @adonisjs/mail
```
You can pre-select transports during installation:
```sh
node ace add @adonisjs/mail --transports=resend --transports=smtp
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/mail` package using the detected package manager.
2. Registers the following service provider and command inside the `adonisrc.ts` file.
```ts title="adonisrc.ts"
{
commands: [
// ...other commands
() => import('@adonisjs/mail/commands')
],
providers: [
// ...other providers
() => import('@adonisjs/mail/mail_provider')
]
}
```
3. Creates the `config/mail.ts` file.
4. Defines the environment variables and their validations for the selected mail services.
:::
## Configuration
The mail configuration lives in `config/mail.ts`. This file defines your mailers, default sender addresses, and transport settings.
See also: [Config stub](https://github.com/adonisjs/mail/blob/-/stubs/config/mail.stub)
```ts title="config/mail.ts"
import env from '#start/env'
import { defineConfig, transports } from '@adonisjs/mail'
const mailConfig = defineConfig({
/**
* The mailer to use when none is specified
*/
default: 'smtp',
/**
* Global "from" address used when not set on individual emails
*/
from: {
address: 'hello@example.com',
name: 'My App',
},
/**
* Global "reply-to" address used when not set on individual emails
*/
replyTo: {
address: 'support@example.com',
name: 'My App Support',
},
/**
* Configure one or more mailers. Each mailer uses a transport
* and can have its own settings.
*/
mailers: {
smtp: transports.smtp({
host: env.get('SMTP_HOST'),
port: env.get('SMTP_PORT'),
}),
resend: transports.resend({
key: env.get('RESEND_API_KEY'),
baseUrl: 'https://api.resend.com',
}),
},
})
export default mailConfig
```
| Option | Description |
|--------|-------------|
| `default` | The mailer to use when you call `mail.send()` without specifying one. |
| `from` | Global sender address. Used unless overridden on individual emails. |
| `replyTo` | Global reply-to address. Used unless overridden on individual emails. |
| `mailers` | An object containing your configured mailers. Each key is a mailer name, each value is a transport configuration. |
## Transport configuration
Each transport accepts provider-specific options. The following sections document the available transports and their configuration.
See also: [TypeScript types for config object](https://github.com/adonisjs/mail/blob/10.x/src/types.ts#L243)
:::disclosure{title="SMTP"}
SMTP configuration options are forwarded directly to Nodemailer.
See also: [Nodemailer SMTP documentation](https://nodemailer.com/smtp)
```ts title="config/mail.ts"
{
mailers: {
smtp: transports.smtp({
host: env.get('SMTP_HOST'),
port: env.get('SMTP_PORT'),
secure: false,
auth: {
type: 'login',
user: env.get('SMTP_USERNAME'),
pass: env.get('SMTP_PASSWORD')
},
tls: {},
ignoreTLS: false,
requireTLS: false,
pool: false,
maxConnections: 5,
maxMessages: 100,
})
}
}
```
:::
:::disclosure{title="Resend"}
Configuration options are sent to Resend's [`/emails`](https://resend.com/docs/api-reference/emails/send-email) API endpoint.
```ts title="config/mail.ts"
{
mailers: {
resend: transports.resend({
baseUrl: 'https://api.resend.com',
key: env.get('RESEND_API_KEY'),
/**
* Optional: Can be overridden at runtime
*/
tags: [
{
name: 'category',
value: 'confirm_email'
}
]
})
}
}
```
:::
:::disclosure{title="Mailgun"}
Configuration options are sent to Mailgun's [`/messages.mime`](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages/post-v3--domain-name--messages-mime) API endpoint.
```ts title="config/mail.ts"
{
mailers: {
mailgun: transports.mailgun({
baseUrl: 'https://api.mailgun.net/v3',
key: env.get('MAILGUN_API_KEY'),
domain: env.get('MAILGUN_DOMAIN'),
/**
* Optional: Can be overridden at runtime
*/
oDkim: true,
oTags: ['transactional', 'adonisjs_app'],
oDeliverytime: new Date(2024, 8, 18),
oTestMode: false,
oTracking: false,
oTrackingClick: false,
oTrackingOpens: false,
headers: {},
variables: {
appId: '',
userId: '',
}
})
}
}
```
:::
:::disclosure{title="SparkPost"}
Configuration options are sent to SparkPost's [`/transmissions`](https://developers.sparkpost.com/api/transmissions/#header-request-body) API endpoint.
```ts title="config/mail.ts"
{
mailers: {
sparkpost: transports.sparkpost({
baseUrl: 'https://api.sparkpost.com/api/v1',
key: env.get('SPARKPOST_API_KEY'),
/**
* Optional: Can be overridden at runtime
*/
startTime: new Date(),
openTracking: false,
clickTracking: false,
initialOpen: false,
transactional: true,
sandbox: false,
skipSuppression: false,
ipPool: '',
})
}
}
```
:::
:::disclosure{title="Amazon SES"}
SES configuration options are forwarded to Nodemailer. You must install the AWS SDK separately.
```sh
npm i @aws-sdk/client-sesv2
```
See also: [Nodemailer SES documentation](https://nodemailer.com/transports/ses)
```ts title="config/mail.ts"
{
mailers: {
ses: transports.ses({
/**
* AWS SDK configuration
*/
apiVersion: '2010-12-01',
region: 'us-east-1',
credentials: {
accessKeyId: env.get('AWS_ACCESS_KEY_ID'),
secretAccessKey: env.get('AWS_SECRET_ACCESS_KEY'),
},
/**
* Nodemailer-specific options
*/
sendingRate: 10,
maxConnections: 5,
})
}
}
```
:::
## Sending your first email
Import the mail service and call the `send` method. The callback receives a `Message` instance for configuring the email.
```ts title="app/controllers/users_controller.ts"
import type { HttpContext } from '@adonisjs/core/http'
import mail from '@adonisjs/mail/services/main'
import User from '#models/user'
export default class UsersController {
async store({ request }: HttpContext) {
const user = await User.create(request.all())
/**
* Send a welcome email after creating the user.
* The callback configures the message properties.
*/
await mail.send((message) => {
message
.to(user.email)
.from('welcome@example.com')
.subject('Welcome to our app!')
.htmlView('emails/welcome', { user })
})
return user
}
}
```
The `mail.send` method delivers the email immediately using the default mailer. For production applications with high traffic, you should queue emails for background delivery instead.
## Configuring the message
The `Message` class provides a fluent API for building emails. You receive an instance in the callback passed to `mail.send` or `mail.sendLater`.
### Subject and sender
```ts title="app/controllers/orders_controller.ts"
await mail.send((message) => {
message
.subject('Your order has shipped')
.from('orders@example.com')
})
```
The `from` method accepts either a string or an object with address and name:
```ts title="app/controllers/orders_controller.ts"
await mail.send((message) => {
message.from({
address: 'orders@example.com',
name: 'Acme Store'
})
})
```
### Recipients
Use `to`, `cc`, and `bcc` to set recipients. Each method accepts a string, an object, or an array.
```ts title="app/controllers/reports_controller.ts"
await mail.send((message) => {
message
.to(user.email)
.cc(user.manager.email)
.bcc('audit@example.com')
})
```
```ts title="app/controllers/reports_controller.ts"
await mail.send((message) => {
message
.to({
address: user.email,
name: user.fullName,
})
.cc([
{ address: 'team-lead@example.com', name: 'Team Lead' },
{ address: 'pm@example.com', name: 'Project Manager' }
])
})
```
Set the reply-to address using the `replyTo` method:
```ts title="app/controllers/support_controller.ts"
await mail.send((message) => {
message
.from('noreply@example.com')
.replyTo('support@example.com')
})
```
### Email contents
Define HTML and plain text content using `html` and `text` methods for inline content, or `htmlView` and `textView` to render Edge templates.
```ts title="app/controllers/auth_controller.ts"
await mail.send((message) => {
/**
* Inline content works for simple emails
*/
message.html(`
`)
message.text(`
Reset your password
Visit ${resetUrl} to reset your password.
`)
})
```
For most emails, Edge templates provide better organization:
```sh
node ace make:view emails/password_reset_html
node ace make:view emails/password_reset_text
```
```ts title="app/controllers/auth_controller.ts"
await mail.send((message) => {
/**
* Render Edge templates, passing data as the second argument
*/
message.htmlView('emails/password_reset_html', { user, resetUrl })
message.textView('emails/password_reset_text', { user, resetUrl })
})
```
See also: [Configuring Edge](../frontend/edgejs.md)
### Using MJML for responsive emails
[MJML](https://mjml.io/) is a markup language that compiles to responsive HTML email markup. Install the package and use the `@mjml` Edge tag.
```sh
npm i mjml
```
```edge title="resources/views/emails/welcome_html.edge"
@mjml()
Welcome, {{ user.name }}!
Verify Email
@end
```
Pass [MJML options](https://documentation.mjml.io/#inside-node-js) as props to the tag:
```edge title="resources/views/emails/welcome_html.edge"
@mjml({
keepComments: false,
fonts: {
Lato: 'https://fonts.googleapis.com/css?family=Lato:400,500,700'
}
})
{{-- MJML content --}}
@end
```
## Queueing emails
Sending emails synchronously blocks request processing while waiting for the mail provider's response. For better performance, queue emails for background delivery using `mail.sendLater`.
```ts title="app/controllers/users_controller.ts"
import mail from '@adonisjs/mail/services/main'
export default class UsersController {
async store({ request }: HttpContext) {
const user = await User.create(request.all())
/**
* Queue the email instead of sending immediately.
* The request completes without waiting for delivery.
*/
await mail.sendLater((message) => {
message
.to(user.email)
.from('welcome@example.com')
.subject('Welcome!')
.htmlView('emails/welcome', { user })
})
return user
}
}
```
By default, the mail messenger uses an in-memory queue. This means queued emails are lost if your process terminates before sending them.
:::warning
The in-memory messenger is suitable for development but not recommended for production. If your application crashes or restarts with pending emails in the queue, those emails will never be sent. Use a persistent queue like BullMQ for production deployments.
:::
### Using BullMQ for persistent queuing
For production applications, configure a persistent queue using BullMQ and Redis. This ensures emails survive process restarts.
```sh
npm i bullmq
```
Create a custom messenger that stores email jobs in Redis:
```ts title="start/mail.ts"
import { Queue } from 'bullmq'
import mail from '@adonisjs/mail/services/main'
const emailsQueue = new Queue('emails')
mail.setMessenger((mailer) => {
return {
async queue(mailMessage, config) {
/**
* Store the compiled message, config, and mailer name.
* A worker process will pick this up and send it.
*/
await emailsQueue.add('send_email', {
mailMessage,
config,
mailerName: mailer.name,
})
}
}
})
```
Create a worker to process the queue. Run this in a separate process:
```ts title="workers/mail_worker.ts"
import { Worker } from 'bullmq'
import mail from '@adonisjs/mail/services/main'
new Worker('emails', async (job) => {
if (job.name === 'send_email') {
const { mailMessage, config, mailerName } = job.data
/**
* Send the pre-compiled message using the specified mailer
*/
await mail.use(mailerName).sendCompiled(mailMessage, config)
}
})
```
## Switching between mailers
Use `mail.use()` to send emails through a specific mailer instead of the default.
```ts title="app/controllers/notifications_controller.ts"
import mail from '@adonisjs/mail/services/main'
/**
* Send transactional emails through the default mailer
*/
await mail.send((message) => {
message.subject('Order confirmed')
})
/**
* Send marketing emails through Mailgun
*/
await mail.use('mailgun').send((message) => {
message.subject('Weekly newsletter')
})
/**
* Queue emails through a specific mailer
*/
await mail.use('resend').sendLater((message) => {
message.subject('Your report is ready')
})
```
Mailer instances are cached for the process lifetime. To close a connection and remove it from the cache, use `mail.close()`:
```ts title="app/services/mail_service.ts"
import mail from '@adonisjs/mail/services/main'
/**
* Close the mailgun connection and remove from cache
*/
await mail.close('mailgun')
/**
* Next call creates a fresh instance
*/
mail.use('mailgun')
```
## Attachments
### File attachments
Attach files using the `attach` method with an absolute file path:
```ts title="app/controllers/invoices_controller.ts"
import app from '@adonisjs/core/services/app'
await mail.send((message) => {
message.attach(app.makePath('storage/invoices/inv-001.pdf'))
})
```
Customize the attachment filename and other options:
```ts title="app/controllers/invoices_controller.ts"
await mail.send((message) => {
message.attach(app.makePath('storage/invoices/inv-001.pdf'), {
filename: 'invoice-october-2024.pdf',
contentType: 'application/pdf',
})
})
```
| Option | Description |
|--------|-------------|
| `filename` | Display name for the attachment. Defaults to the file's basename. |
| `contentType` | MIME type. Inferred from extension if not set. |
| `contentDisposition` | Either `attachment` (default) or `inline`. |
| `headers` | Custom headers as key-value pairs. |
### Attachments from streams and buffers
Use `attachData` for dynamic content from streams or buffers:
```ts title="app/controllers/reports_controller.ts"
import fs from 'node:fs'
await mail.send((message) => {
/**
* Attach from a readable stream
*/
message.attachData(fs.createReadStream('./report.pdf'), {
filename: 'report.pdf'
})
/**
* Attach from a buffer
*/
message.attachData(Buffer.from('Hello world'), {
filename: 'greeting.txt',
encoding: 'utf-8',
})
})
```
:::warning
Do not use `attachData` with `mail.sendLater`. Queued emails are serialized to JSON, so streams cannot be stored and buffers significantly increase storage size. Attempting to queue an email with a stream attachment will fail. Instead, save the file to disk first and use `attach` with the file path.
:::
### Embedding images
Embed images directly in HTML content using the `embedImage` helper in your Edge template. This uses CID (Content-ID) attachments, which display reliably across email clients.
```edge title="resources/views/emails/newsletter.edge"
```
The helper returns a `cid:` URL and automatically adds the image as an attachment.
For dynamic image data, use `embedImageData`:
```edge title="resources/views/emails/report.edge"
```
## Calendar invites
Attach calendar events using the `icalEvent` method. You can provide raw iCalendar content or use the fluent API.
```ts title="app/controllers/meetings_controller.ts"
import { DateTime } from 'luxon'
await mail.send((message) => {
message.icalEvent((calendar) => {
calendar.createEvent({
summary: 'Project kickoff meeting',
start: DateTime.now().plus({ days: 1 }).set({ hour: 10 }),
end: DateTime.now().plus({ days: 1 }).set({ hour: 11 }),
location: 'Conference Room A',
})
}, {
method: 'REQUEST',
filename: 'meeting.ics',
})
})
```
The calendar object is an instance of [ical-generator](https://www.npmjs.com/package/ical-generator).
Load invite content from a file or URL:
```ts title="app/controllers/meetings_controller.ts"
import app from '@adonisjs/core/services/app'
await mail.send((message) => {
/**
* From a local file
*/
message.icalEventFromFile(
app.resourcesPath('invites/standup.ics'),
{ method: 'REQUEST', filename: 'standup.ics' }
)
/**
* From a URL
*/
message.icalEventFromUrl(
'https://example.com/calendar/event-123.ics',
{ method: 'REQUEST', filename: 'event.ics' }
)
})
```
## Custom headers
Add custom headers using the `header` method:
```ts title="app/controllers/notifications_controller.ts"
await mail.send((message) => {
message.header('X-Entity-Ref', 'order-12345')
message.header('X-Priority', '1')
})
```
For headers that should not be encoded or folded, use `preparedHeader`:
```ts title="app/controllers/notifications_controller.ts"
await mail.send((message) => {
message.preparedHeader(
'X-Custom-Data',
'value with special chars or very long content'
)
})
```
### List headers
Helper methods simplify common List-* headers for mailing list functionality:
```ts title="app/controllers/newsletters_controller.ts"
await mail.send((message) => {
message.listUnsubscribe({
url: 'https://example.com/unsubscribe?token=abc',
comment: 'Unsubscribe from this list'
})
message.listHelp('support@example.com?subject=help')
/**
* For other List-* headers
*/
message.addListHeader('post', 'https://example.com/list/post')
})
```
See also: [Nodemailer list headers documentation](https://nodemailer.com/message/list-headers)
## Class-based emails
For complex applications, organize emails into dedicated classes instead of inline callbacks. This improves testability and keeps controllers focused on request handling.
```sh
node ace make:mail verify_email
```
```ts title="app/mails/verify_email.ts"
import User from '#models/user'
import router from '@adonisjs/core/services/router'
import { BaseMail } from '@adonisjs/mail'
export default class VerifyEmailNotification extends BaseMail {
/**
* Set class properties for common message options
*/
from = 'noreply@example.com'
subject = 'Please verify your email address'
constructor(private user: User) {
super()
}
/**
* Configure the message in the prepare method.
* Called automatically before sending.
*/
prepare() {
const verifyUrl = router.makeUrl('email.verify', {
token: this.user.verificationToken
})
this.message
.to(this.user.email)
.htmlView('emails/verify_email', {
user: this.user,
verifyUrl,
})
}
}
```
Send the email by passing an instance to `mail.send` or `mail.sendLater`:
```ts title="app/controllers/auth_controller.ts"
import mail from '@adonisjs/mail/services/main'
import VerifyEmailNotification from '#mails/verify_email'
export default class AuthController {
async register({ request }: HttpContext) {
const user = await User.create(request.all())
await mail.sendLater(new VerifyEmailNotification(user))
return { message: 'Check your email to verify your account' }
}
}
```
| Property/Method | Description |
|-----------------|-------------|
| `from` | Default sender address. Override with `message.from()` in `prepare`. |
| `subject` | Default subject line. Override with `message.subject()` in `prepare`. |
| `replyTo` | Default reply-to address. |
| `prepare()` | Configure the message. Called automatically before sending. |
| `build()` | Inherited from `BaseMail`. Calls `prepare` and compiles the message. |
See also: [Make mail command](../../reference/commands.md#makemail)
## Testing
The mail package provides a fake mailer for testing email functionality without actually sending emails.
### Using the fake mailer
Call `mail.fake()` to intercept all emails. The returned object contains a `mails` property for assertions.
```ts title="tests/functional/users/register.spec.ts"
import { test } from '@japa/runner'
import mail from '@adonisjs/mail/services/main'
import VerifyEmailNotification from '#mails/verify_email'
test.group('Users | register', () => {
test('sends verification email on registration', async ({ client }) => {
/**
* Fake the mailer. The `using` keyword automatically
* restores the real mailer when the test ends.
*/
// [!code highlight]
using fake = mail.fake()
await client
.post('/register')
.form({ email: 'user@example.com', password: 'secret123' })
/**
* Assert the email was sent
*/
fake.mails.assertSent(VerifyEmailNotification, ({ message }) => {
return message
.hasTo('user@example.com')
.hasSubject('Please verify your email address')
})
})
test('does not send password reset when user not found', async ({ client }) => {
// [!code highlight]
using fake = mail.fake()
await client
.post('/forgot-password')
.form({ email: 'unknown@example.com' })
fake.mails.assertNotSent(PasswordResetNotification)
})
})
```
You can also call `mail.restore()` manually if you need more control over when the real mailer is restored.
### Assertion methods
The `mails` object provides these assertion methods:
| Method | Description |
|--------|-------------|
| `assertSent(Mail, finder?)` | Assert an email class was sent. Optional finder callback for additional checks. |
| `assertNotSent(Mail, finder?)` | Assert an email class was not sent. |
| `assertSentCount(count)` | Assert total number of emails sent. |
| `assertSentCount(Mail, count)` | Assert number of emails sent for a specific class. |
| `assertNoneSent()` | Assert no emails were sent. |
| `assertQueued(Mail, finder?)` | Assert an email was queued via `sendLater`. |
| `assertNotQueued(Mail, finder?)` | Assert an email was not queued. |
| `assertQueuedCount(count)` | Assert total number of queued emails. |
| `assertQueuedCount(Mail, count)` | Assert number of queued emails for a specific class. |
| `assertNoneQueued()` | Assert no emails were queued. |
### Testing mail classes directly
Test mail classes in isolation by building them without sending:
```ts title="tests/unit/mails/verify_email.spec.ts"
import { test } from '@japa/runner'
import { UserFactory } from '#database/factories/user_factory'
import VerifyEmailNotification from '#mails/verify_email'
test.group('VerifyEmailNotification', () => {
test('builds correct message', async () => {
const user = await UserFactory.create()
const email = new VerifyEmailNotification(user)
/**
* Build the message and render templates
*/
await email.buildWithContents()
/**
* Assert message properties
*/
email.message.assertTo(user.email)
email.message.assertFrom('noreply@example.com')
email.message.assertSubject('Please verify your email address')
/**
* Assert rendered content
*/
email.message.assertHtmlIncludes(`Hello ${user.name}`)
email.message.assertHtmlIncludes('/verify/')
})
})
```
### Accessing sent emails
Retrieve the list of sent or queued emails for custom assertions:
```ts title="tests/functional/notifications.spec.ts"
using fake = mail.fake()
/**
* Get all sent emails
*/
const sentEmails = fake.mails.sent()
/**
* Get all queued emails
*/
const queuedEmails = fake.mails.queued()
/**
* Find a specific email
*/
const verifyEmail = sentEmails.find((email) => {
return email instanceof VerifyEmailNotification
})
if (verifyEmail) {
verifyEmail.message.assertHtmlIncludes('Verify your email')
}
```
## Custom transports
Create custom transports to integrate mail providers not included in the package. A transport wraps a Nodemailer transport and normalizes its response.
```ts title="app/mail/transports/postmark.ts"
import nodemailer from 'nodemailer'
import postmarkTransport from 'nodemailer-postmark-transport'
import { MailResponse } from '@adonisjs/mail'
import type { NodeMailerMessage, MailTransportContract } from '@adonisjs/mail/types'
export type PostmarkConfig = {
auth: {
apiKey: string
}
}
export class PostmarkTransport implements MailTransportContract {
#config: PostmarkConfig
constructor(config: PostmarkConfig) {
this.#config = config
}
async send(
message: NodeMailerMessage,
config?: PostmarkConfig
): Promise {
/**
* Create nodemailer transport with merged config
*/
const transporter = nodemailer.createTransport(
postmarkTransport({ ...this.#config, ...config })
)
const response = await transporter.sendMail(message)
/**
* Return normalized response
*/
return new MailResponse(response.messageId, response.envelope, response)
}
}
```
Create a factory function for use in the config file:
```ts title="app/mail/transports/postmark.ts"
import type { MailManagerTransportFactory } from '@adonisjs/mail/types'
export function postmarkTransport(
config: PostmarkConfig
): MailManagerTransportFactory {
return () => new PostmarkTransport(config)
}
```
Register the transport in your config:
```ts title="config/mail.ts"
import env from '#start/env'
import { defineConfig } from '@adonisjs/mail'
import { postmarkTransport } from '#app/mail/transports/postmark'
const mailConfig = defineConfig({
mailers: {
postmark: postmarkTransport({
auth: {
apiKey: env.get('POSTMARK_API_KEY'),
},
}),
},
})
```
## Custom template engine
By default, the mail package uses Edge for rendering email templates. To use a different template engine, override the static `templateEngine` property on the `Message` class.
```ts title="start/mail.ts"
import { Message } from '@adonisjs/mail'
Message.templateEngine = {
async render(templatePath, data) {
/**
* Use your preferred template engine
*/
return myTemplateEngine.render(templatePath, data)
}
}
```
## Events
The mail package emits events during the email lifecycle.
See also: [Events reference](../../reference/events.md#mailsending)
---
# Queues
:::warning
The `@adonisjs/queue` package is currently experimental. Its API may change between minor releases until it reaches a stable version. Pin the package version in your `package.json` to avoid unexpected breaking changes during updates.
:::
This guide covers background job processing with queues in AdonisJS. You will learn how to:
- Install and configure the queue system with Redis or Database backends
- Create jobs and dispatch them for background processing
- Delay jobs, set priorities, and dispatch in batches
- Configure retry strategies with exponential, linear, or fixed backoff
- Schedule recurring jobs using cron expressions or intervals
- Start workers to process jobs from queues
- Test job dispatching with the fake adapter
## Overview
Web applications often need to perform tasks that are too slow or resource-intensive to run during an HTTP request. Sending emails, generating reports, processing payments, or resizing images are all examples of work that should happen in the background so your users get an immediate response.
The `@adonisjs/queue` package provides a job queue system for AdonisJS, built on top of [@boringnode/queue](https://github.com/boringnode/queue). You define **jobs** as classes with typed payloads, dispatch them from your application code, and run a separate **worker** process that picks up and executes those jobs.
The package supports multiple backends. The **Redis** adapter is recommended for production, offering atomic operations and high throughput. The **Database** adapter uses your existing SQL database (PostgreSQL, MySQL, or SQLite) through Lucid. A **Sync** adapter is also available for development and testing, executing jobs immediately without a separate worker.
## Installation
Install and configure the package using the following command:
```sh
node ace add @adonisjs/queue
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/queue` package using the detected package manager.
2. Registers the following service provider, commands, and preload file inside the `adonisrc.ts` file.
```ts title="adonisrc.ts"
{
commands: [
// ...other commands
() => import('@adonisjs/queue/commands')
],
providers: [
// ...other providers
() => import('@adonisjs/queue/queue_provider')
],
preloads: [
// ...other preloads
() => import('#start/scheduler')
]
}
```
3. Creates the `config/queue.ts` file.
4. Creates the `start/scheduler.ts` preload file for defining scheduled jobs.
5. Defines the `QUEUE_DRIVER` environment variable and its validation.
6. If you select the database driver, creates a migration to set up queue tables.
:::
## Configuration
The configuration file lives at `config/queue.ts`. It defines your adapters, the default adapter to use, worker settings, and the location of your job files.
See also: [Config stub](https://github.com/adonisjs/queue/blob/-/stubs/config/queue.stub)
```ts title="config/queue.ts"
import env from '#start/env'
import { defineConfig, drivers } from '@adonisjs/queue'
export default defineConfig({
default: env.get('QUEUE_DRIVER', 'redis'),
adapters: {
redis: drivers.redis({
connectionName: 'main',
}),
sync: drivers.sync(),
},
worker: {
concurrency: 5,
idleDelay: '2s',
},
locations: ['./app/jobs/**/*.{ts,js}'],
})
```
::::options
:::option{name="default" dataType="string"}
The name of the adapter to use by default when dispatching jobs. This value is typically set via the `QUEUE_DRIVER` environment variable.
:::
:::option{name="adapters" dataType="Record"}
A record of named adapters. Each adapter is created using one of the `drivers` helpers: `drivers.redis()`, `drivers.database()`, or `drivers.sync()`. You can configure multiple adapters and switch between them at runtime.
:::
:::option{name="worker" dataType="WorkerConfig"}
Configuration for the worker process. See [Worker configuration](#worker-configuration) for all available options.
:::
:::option{name="locations" dataType="string[]"}
An array of glob patterns that point to your job files. The queue system uses these patterns to auto-discover and register job classes.
```ts title="config/queue.ts"
{
locations: ['./app/jobs/**/*.{ts,js}'],
}
```
:::
:::option{name="retry" dataType="RetryConfig"}
Global retry configuration applied to all jobs unless overridden at the queue or job level. See [Retries and backoff](#retries-and-backoff) for details.
:::
:::option{name="queues" dataType="Record"}
Per-queue configuration allowing you to set different retry policies or default job options for specific queues.
```ts title="config/queue.ts"
{
queues: {
emails: {
retry: {
maxRetries: 5,
},
},
},
}
```
:::
:::option{name="defaultJobOptions" dataType="JobOptions"}
Default options applied to all jobs. Individual jobs can override these in their `static options` property.
:::
::::
### Adapter configuration
#### Redis
The Redis adapter uses your `@adonisjs/redis` connection. It is the recommended choice for production due to its atomic operations and high throughput.
```ts title="config/queue.ts"
import { defineConfig, drivers } from '@adonisjs/queue'
export default defineConfig({
default: 'redis',
adapters: {
redis: drivers.redis({
// Uses the 'main' connection from config/redis.ts
connectionName: 'main',
}),
},
// ...
})
```
You must have `@adonisjs/redis` installed and configured for this adapter to work.
#### Database
The Database adapter uses your `@adonisjs/lucid` connection with PostgreSQL, MySQL, or SQLite. This is a good choice when you want to avoid adding Redis to your infrastructure.
```ts title="config/queue.ts"
import { defineConfig, drivers } from '@adonisjs/queue'
export default defineConfig({
default: 'database',
adapters: {
database: drivers.database({
connectionName: 'primary',
}),
},
// ...
})
```
When selecting the database driver during installation, a migration is automatically created. If you need to create the tables manually, use `QueueSchemaService`:
```ts title="database/migrations/xxxx_create_queue_tables.ts"
import { BaseSchema } from '@adonisjs/lucid/schema'
import { QueueSchemaService } from '@boringnode/queue'
export default class extends BaseSchema {
async up() {
const schemaService = new QueueSchemaService(this.db.getWriteClient())
await schemaService.createJobsTable()
await schemaService.createSchedulesTable()
}
async down() {
const schemaService = new QueueSchemaService(this.db.getWriteClient())
await schemaService.dropSchedulesTable()
await schemaService.dropJobsTable()
}
}
```
You must have `@adonisjs/lucid` installed and configured for this adapter to work.
#### Sync
The Sync adapter executes jobs immediately in the same process, without a separate worker. This is useful for development and testing when you want to see job results right away.
```ts title="config/queue.ts"
import { defineConfig, drivers } from '@adonisjs/queue'
export default defineConfig({
default: 'sync',
adapters: {
sync: drivers.sync(),
},
// ...
})
```
:::tip
You can use the `QUEUE_DRIVER` environment variable to switch between adapters per environment. Use `redis` or `database` in production and `sync` in development.
:::
## Creating jobs
A **job** is a class that encapsulates a unit of work to be executed in the background. Each job extends the `Job` base class with a typed payload.
Generate a new job using the `make:job` Ace command:
```sh
node ace make:job process_payment
```
This creates a job class at `app/jobs/process_payment.ts`:
```ts title="app/jobs/process_payment.ts"
import { Job } from '@adonisjs/queue'
import type { JobOptions } from '@adonisjs/queue/types'
interface ProcessPaymentPayload {
orderId: number
amount: number
currency: string
}
export default class ProcessPayment extends Job {
static options: JobOptions = {
queue: 'default',
maxRetries: 3,
}
async execute() {
const { orderId, amount, currency } = this.payload
// Process the payment using your payment gateway
console.log(`Processing payment of ${amount} ${currency} for order ${orderId}`)
}
async failed(error: Error) {
console.error(`Payment processing failed for order ${this.payload.orderId}:`, error.message)
}
}
```
The `Job` class provides two methods to implement:
- **`execute()`** (required): Contains the main job logic. Any error thrown here triggers the retry mechanism.
- **`failed(error)`** (optional): Called when the job has permanently failed after all retries are exhausted. Use this for cleanup, logging, or sending notifications.
### Job options
Configure default behavior for your job by setting the `static options` property.
::::options
:::option{name="queue" dataType="string"}
The queue to dispatch this job to. Defaults to `'default'`.
:::
:::option{name="maxRetries" dataType="number"}
Maximum number of retry attempts before the job fails permanently. Defaults to `3`.
:::
:::option{name="priority" dataType="number"}
Job priority from 1 to 10. Lower numbers are processed first. Defaults to `5`.
:::
:::option{name="timeout" dataType="Duration"}
Maximum execution time before the job is timed out. Accepts a number in milliseconds or a duration string like `'30s'` or `'5m'`. No timeout by default.
:::
:::option{name="failOnTimeout" dataType="boolean"}
Whether to mark the job as permanently failed on timeout. When `false`, the job is retried instead. Defaults to `true`.
:::
:::option{name="retry" dataType="RetryConfig"}
Retry configuration specific to this job, overriding global and queue-level settings. See [Retries and backoff](#retries-and-backoff).
:::
:::option{name="removeOnComplete" dataType="JobRetention"}
Controls whether completed jobs are kept in the history. Set to `true` to remove immediately (default), `false` to keep forever, or an object with `age` and/or `count` constraints.
```ts
{ removeOnComplete: { age: '7d', count: 1000 } }
```
:::
:::option{name="removeOnFail" dataType="JobRetention"}
Controls whether failed jobs are kept in the history. Same format as `removeOnComplete`.
:::
::::
### Job context
During execution, you can access metadata about the current job through `this.context`:
```ts title="app/jobs/process_payment.ts"
export default class ProcessPayment extends Job {
async execute() {
// Access job metadata
console.log(`Job ID: ${this.context.jobId}`)
console.log(`Attempt: ${this.context.attempt}`)
console.log(`Queue: ${this.context.queue}`)
console.log(`Priority: ${this.context.priority}`)
}
}
```
### Handling timeouts
For long-running jobs, you can check `this.signal` to detect when a timeout has been reached and handle it gracefully:
```ts title="app/jobs/generate_report.ts"
import { Job } from '@adonisjs/queue'
import type { JobOptions } from '@adonisjs/queue/types'
interface GenerateReportPayload {
reportId: number
rows: number[]
}
export default class GenerateReport extends Job {
static options: JobOptions = {
timeout: '5m',
failOnTimeout: false, // Retry instead of failing permanently
}
async execute() {
for (const rowId of this.payload.rows) {
// Check if the job has timed out before processing each row
if (this.signal?.aborted) {
throw new Error('Job timed out during report generation')
}
await this.processRow(rowId)
}
}
private async processRow(rowId: number) {
// Process individual row
}
}
```
### Dependency injection
Jobs are instantiated through the AdonisJS IoC container, so you can use constructor injection to access services:
```ts title="app/jobs/process_payment.ts"
import { inject } from '@adonisjs/core'
import { Job } from '@adonisjs/queue'
import type { JobOptions } from '@adonisjs/queue/types'
import PaymentService from '#services/payment_service'
interface ProcessPaymentPayload {
orderId: number
amount: number
currency: string
}
@inject()
export default class ProcessPayment extends Job {
static options: JobOptions = {
queue: 'payments',
maxRetries: 3,
}
constructor(private paymentService: PaymentService) {
super()
}
async execute() {
await this.paymentService.charge(
this.payload.orderId,
this.payload.amount,
this.payload.currency
)
}
}
```
## Dispatching jobs
Dispatch a job from anywhere in your application using the static `dispatch` method. The job payload is type-safe, matching the generic parameter defined on the job class.
```ts title="app/controllers/orders_controller.ts"
import ProcessPayment from '#jobs/process_payment'
export default class OrdersController {
async store({ request, response }: HttpContext) {
const order = await Order.create(request.body())
// Dispatch the job for background processing
await ProcessPayment.dispatch({
orderId: order.id,
amount: order.total,
currency: 'USD',
})
return response.created(order)
}
}
```
### Dispatch options
The `dispatch` method returns a fluent builder that lets you customize job behavior before sending it to the queue:
```ts title="app/controllers/orders_controller.ts"
// Dispatch to a specific queue with high priority
await ProcessPayment.dispatch({
orderId: order.id,
amount: order.total,
currency: 'USD',
})
.toQueue('payments')
.priority(1)
```
::::options
:::option{name=".toQueue(name)" dataType="string"}
Send the job to a specific queue instead of the default one.
```ts
await ProcessPayment.dispatch(payload).toQueue('payments')
```
:::
:::option{name=".priority(level)" dataType="number"}
Set the job priority. Lower numbers are processed first (1 = highest priority, 10 = lowest).
```ts
await ProcessPayment.dispatch(payload).priority(1)
```
:::
:::option{name=".in(delay)" dataType="Duration"}
Delay job execution by a specified duration. Accepts milliseconds or a string like `'5m'`, `'1h'`, `'7d'`.
```ts
// Send a reminder in 24 hours
await SendReminder.dispatch(payload).in('24h')
```
:::
:::option{name=".group(groupId)" dataType="string"}
Assign a group identifier for organizing related jobs. Useful for tracking batch operations.
```ts
await GenerateReport.dispatch(payload).group('monthly-reports-2025')
```
:::
:::option{name=".with(adapter)" dataType="string"}
Use a specific adapter for this job instead of the default one.
```ts
await ProcessPayment.dispatch(payload).with('redis')
```
:::
::::
### Batch dispatching
When you need to dispatch many jobs of the same type, use `dispatchMany` for better performance. It uses batched operations (Redis pipelines or SQL batch inserts) under the hood.
```ts title="app/services/newsletter_service.ts"
import SendNewsletter from '#jobs/send_newsletter'
export default class NewsletterService {
async sendToAllSubscribers(subscribers: { email: string }[]) {
const payloads = subscribers.map((subscriber) => ({
email: subscriber.email,
subject: 'Monthly Newsletter',
}))
const { jobIds } = await SendNewsletter.dispatchMany(payloads)
.toQueue('emails')
.group('newsletter-march-2025')
console.log(`Dispatched ${jobIds.length} newsletter jobs`)
}
}
```
## Retries and backoff
When a job throws an error, the queue system automatically retries it according to the configured retry policy. You can configure retries at three levels, with more specific settings taking priority: **job > queue > global**.
### Backoff strategies
A backoff strategy controls the delay between retry attempts. The package provides four built-in strategies:
```ts title="config/queue.ts"
import { defineConfig, drivers } from '@adonisjs/queue'
import { exponentialBackoff } from '@adonisjs/queue'
export default defineConfig({
default: 'redis',
adapters: {
redis: drivers.redis({ connectionName: 'main' }),
},
retry: {
maxRetries: 3,
backoff: exponentialBackoff(),
},
// ...
})
```
**Exponential backoff** doubles the delay with each attempt: 1s, 2s, 4s, 8s, and so on. This is the recommended strategy for most use cases since it prevents overwhelming failing services.
```ts
import { exponentialBackoff } from '@adonisjs/queue'
// Default: 1s base, 5m max, 2x multiplier, jitter enabled
exponentialBackoff()
// Custom
exponentialBackoff({ baseDelay: '500ms', maxDelay: '1m' })
```
**Linear backoff** increases the delay by the base amount each attempt: 5s, 10s, 15s, 20s, and so on.
```ts
import { linearBackoff } from '@adonisjs/queue'
// Default: 5s base, 2m max
linearBackoff()
// Custom
linearBackoff({ baseDelay: '10s', maxDelay: '5m' })
```
**Fixed backoff** uses the same delay for every retry: 10s, 10s, 10s, and so on.
```ts
import { fixedBackoff } from '@adonisjs/queue'
// Default: 10s
fixedBackoff()
// Custom
fixedBackoff('30s')
```
**Custom backoff** gives you full control over the strategy configuration:
```ts
import { customBackoff } from '@adonisjs/queue'
customBackoff({
strategy: 'exponential',
baseDelay: '100ms',
maxDelay: '30s',
multiplier: 3,
jitter: false,
})
```
:::tip
Enable `jitter` on exponential and linear strategies to add randomness to retry delays. This prevents multiple failed jobs from retrying at the exact same time, which can overload downstream services (a pattern known as "thundering herd").
:::
### Per-job retry
You can override the retry configuration for specific jobs:
```ts title="app/jobs/process_payment.ts"
import { Job } from '@adonisjs/queue'
import { exponentialBackoff } from '@adonisjs/queue'
import type { JobOptions } from '@adonisjs/queue/types'
export default class ProcessPayment extends Job {
static options: JobOptions = {
maxRetries: 5,
retry: {
backoff: exponentialBackoff({ baseDelay: '2s', maxDelay: '10m' }),
},
}
async execute() {
// Payment processing logic
}
}
```
## Scheduled jobs
Scheduled jobs run automatically at defined intervals or cron expressions. Define your schedules in the `start/scheduler.ts` preload file.
### Cron schedules
Use a cron expression for precise scheduling:
```ts title="start/scheduler.ts"
import CleanupExpiredSessions from '#jobs/cleanup_expired_sessions'
import GenerateWeeklyReport from '#jobs/generate_weekly_report'
// Run every night at midnight
await CleanupExpiredSessions.schedule({ retentionDays: 30 })
.cron('0 0 * * *')
.timezone('Europe/Paris')
// Run every Monday at 9 AM
await GenerateWeeklyReport.schedule({ type: 'weekly' })
.cron('0 9 * * MON')
.timezone('America/New_York')
```
### Interval schedules
For simpler use cases, schedule jobs at a fixed interval:
```ts title="start/scheduler.ts"
import SyncInventory from '#jobs/sync_inventory'
// Run every 5 minutes
await SyncInventory.schedule({ source: 'warehouse-api' })
.every('5m')
```
### Schedule options
The schedule builder supports the following options:
::::options
:::option{name=".cron(expression)" dataType="string"}
A cron expression defining when the job should run. Mutually exclusive with `.every()`.
:::
:::option{name=".every(interval)" dataType="Duration"}
A duration interval between runs. Mutually exclusive with `.cron()`.
:::
:::option{name=".id(scheduleId)" dataType="string"}
A custom identifier for the schedule. Defaults to the job class name. If a schedule with this ID already exists, it will be updated.
:::
:::option{name=".timezone(tz)" dataType="string"}
An IANA timezone for evaluating cron expressions. Defaults to `'UTC'`.
:::
:::option{name=".from(date)" dataType="Date"}
Start boundary. No jobs will be dispatched before this date.
:::
:::option{name=".to(date)" dataType="Date"}
End boundary. No jobs will be dispatched after this date.
:::
:::option{name=".between(from, to)" dataType="Date, Date"}
Shorthand for setting both `.from()` and `.to()`.
:::
:::option{name=".limit(maxRuns)" dataType="number"}
Maximum number of times the schedule will run.
:::
::::
### Managing schedules
You can manage schedules programmatically using the `Schedule` class:
```ts title="app/controllers/admin_controller.ts"
import { Schedule } from '@adonisjs/queue'
export default class AdminController {
async listSchedules() {
// List all schedules
const schedules = await Schedule.list()
// List only active schedules
const active = await Schedule.list({ status: 'active' })
return schedules
}
async pauseSchedule({ params }: HttpContext) {
const schedule = await Schedule.find(params.id)
if (schedule) {
await schedule.pause()
}
}
async resumeSchedule({ params }: HttpContext) {
const schedule = await Schedule.find(params.id)
if (schedule) {
await schedule.resume()
}
}
async triggerSchedule({ params }: HttpContext) {
const schedule = await Schedule.find(params.id)
if (schedule) {
// Immediately dispatch the scheduled job
await schedule.trigger()
}
}
async deleteSchedule({ params }: HttpContext) {
const schedule = await Schedule.find(params.id)
if (schedule) {
await schedule.delete()
}
}
}
```
### Scheduler Ace commands
The package provides Ace commands for managing schedules from the terminal:
```sh
# List all scheduled jobs
node ace queue:scheduler:list
# List only active schedules
node ace queue:scheduler:list --status=active
# Remove a specific schedule
node ace queue:scheduler:remove
# Remove all schedules
node ace queue:scheduler:clear
```
## Running the worker
Jobs are not processed until you start a worker. The worker is a long-running process that polls the queue for available jobs and executes them.
Start the worker using the `queue:work` Ace command:
```sh
node ace queue:work
```
### Worker options
```sh
# Process specific queues
node ace queue:work --queue=payments,emails
# Set concurrency (number of jobs processed simultaneously)
node ace queue:work --concurrency=10
```
:::warning
You must start the worker as a separate process alongside your web server. Jobs dispatched from your application will not be processed until a worker is running.
In production, use a process manager like [PM2](https://pm2.keymetrics.io/) or a container orchestrator to keep the worker running and restart it on failure.
:::
### Worker configuration
Configure worker behavior in your `config/queue.ts` file:
```ts title="config/queue.ts"
export default defineConfig({
// ...
worker: {
concurrency: 5,
idleDelay: '2s',
stalledThreshold: '30s',
stalledInterval: '30s',
maxStalledCount: 1,
gracefulShutdown: true,
},
})
```
::::options
:::option{name="concurrency" dataType="number"}
Maximum number of jobs processed simultaneously. Defaults to `1`.
:::
:::option{name="idleDelay" dataType="Duration"}
How long the worker waits before polling again when no jobs are available. Defaults to `'2s'`.
:::
:::option{name="timeout" dataType="Duration"}
Global maximum execution time for any job. Can be overridden per job via `JobOptions.timeout`. No timeout by default.
:::
:::option{name="stalledThreshold" dataType="Duration"}
How long a job can run before it is considered stalled (the worker may have crashed). Defaults to `'30s'`.
:::
:::option{name="stalledInterval" dataType="Duration"}
How often the worker checks for stalled jobs. Defaults to `'30s'`.
:::
:::option{name="maxStalledCount" dataType="number"}
Maximum number of times a stalled job can be recovered before it is permanently failed. Defaults to `1`.
:::
:::option{name="gracefulShutdown" dataType="boolean"}
When `true`, the worker finishes running jobs before stopping on SIGINT/SIGTERM signals. Defaults to `true`.
:::
::::
## Testing
The package provides a fake adapter that records dispatched jobs in memory and exposes assertion helpers. This lets you verify that your application dispatches the right jobs without actually processing them.
### Faking the queue
Use `QueueManager.fake()` to replace all adapters with the fake adapter, and `QueueManager.restore()` to revert back:
```ts title="tests/functional/orders.spec.ts"
import { test } from '@japa/runner'
import { QueueManager } from '@adonisjs/queue'
import ProcessPayment from '#jobs/process_payment'
test.group('Orders', (group) => {
group.each.teardown(() => {
QueueManager.restore()
})
test('dispatches a payment job when creating an order', async ({ client }) => {
const fake = QueueManager.fake()
const response = await client.post('/orders').json({
product_id: 1,
quantity: 2,
})
response.assertStatus(201)
// Assert the job was dispatched
fake.assertPushed(ProcessPayment)
// Assert with payload matching
fake.assertPushed(ProcessPayment, {
payload: { orderId: 1, amount: 100, currency: 'USD' },
})
})
test('does not dispatch a payment job for free orders', async ({ client }) => {
const fake = QueueManager.fake()
await client.post('/orders').json({
product_id: 1,
quantity: 0,
})
fake.assertNotPushed(ProcessPayment)
})
})
```
### Assertion methods
The fake adapter provides the following assertion methods:
::::options
:::option{name="assertPushed(job, query?)" dataType="void"}
Assert that a job was dispatched. Optionally filter by queue, payload, or delay.
```ts
fake.assertPushed(ProcessPayment)
fake.assertPushed(ProcessPayment, { queue: 'payments' })
fake.assertPushed(ProcessPayment, {
payload: { orderId: 1 },
})
```
:::
:::option{name="assertNotPushed(job, query?)" dataType="void"}
Assert that a job was not dispatched.
```ts
fake.assertNotPushed(SendEmail)
```
:::
:::option{name="assertPushedCount(count, options?)" dataType="void"}
Assert the total number of dispatched jobs, optionally filtered by queue.
```ts
fake.assertPushedCount(3)
fake.assertPushedCount(2, { queue: 'emails' })
```
:::
:::option{name="assertNothingPushed()" dataType="void"}
Assert that no jobs were dispatched at all.
```ts
fake.assertNothingPushed()
```
:::
::::
### Advanced matching
You can use functions for more complex payload matching:
```ts title="tests/functional/newsletter.spec.ts"
import { test } from '@japa/runner'
import { QueueManager } from '@adonisjs/queue'
import SendNewsletter from '#jobs/send_newsletter'
test('dispatches newsletter jobs for all subscribers', async ({ client }) => {
const fake = QueueManager.fake()
await client.post('/newsletters/send')
// Match with a function
fake.assertPushed(SendNewsletter, {
payload: (payload) => payload.email.endsWith('@example.com'),
})
// Check delay
fake.assertPushed(SendNewsletter, {
delay: (delay) => delay !== undefined && delay > 0,
})
})
```
---
# Transmit
This guide covers real-time server-to-client communication with Transmit in AdonisJS. You will learn how to:
- Install and configure Transmit for Server-Sent Events
- Register routes and broadcast events to connected clients
- Define channels and authorize access to private channels
- Set up the client library to receive events in real time
- Synchronize events across multiple server instances using transports
- Listen to lifecycle hooks for monitoring connections
## Overview
Transmit is a native Server-Sent Events (SSE) module for AdonisJS. It provides a unidirectional communication channel from server to client, allowing you to push real-time updates without the overhead of WebSockets. Because SSE uses standard HTTP, it works through firewalls and proxies that might block WebSocket connections.
Transmit works as a publish/subscribe system built around channels. The server broadcasts messages to named channels, and clients subscribe to the channels they care about. You can protect channels with authorization callbacks to control who receives updates, making it suitable for both public broadcasts and private, user-specific notifications.
For client-to-server communication, you continue to use standard HTTP requests. Transmit only handles the server-to-client push.
## Installation
Install and configure the server-side package using the following command:
```sh
node ace add @adonisjs/transmit
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/transmit` package using the detected package manager.
2. Registers the following service provider inside the `adonisrc.ts` file.
```ts title="adonisrc.ts"
{
providers: [
// ...other providers
() => import('@adonisjs/transmit/transmit_provider')
]
}
```
3. Creates the `config/transmit.ts` file.
:::
Also install the client library in your frontend application:
```sh
npm install @adonisjs/transmit-client
```
## Configuration
The configuration file lives at `config/transmit.ts`. It controls keep-alive behavior and multi-instance synchronization.
See also: [Config stub](https://github.com/adonisjs/transmit/blob/-/stubs/config/transmit.stub)
```ts title="config/transmit.ts"
import { defineConfig } from '@adonisjs/transmit'
export default defineConfig({
pingInterval: false,
transport: null,
})
```
::::options
:::option{name="pingInterval" dataType="Duration | false"}
Controls how often ping messages are sent to keep SSE connections alive. Accepts a number in milliseconds, a duration string like `'30s'` or `'1m'`, or `false` to disable pings.
```ts title="config/transmit.ts"
import { defineConfig } from '@adonisjs/transmit'
export default defineConfig({
pingInterval: '30s',
transport: null,
})
```
:::
:::option{name="transport" dataType="object | null"}
Configures the transport layer for synchronizing events across multiple server instances. Set to `null` for single-instance deployments.
See [Multi-instance synchronization](#multi-instance-synchronization) for configuration details.
:::
::::
## Registering routes
Transmit requires three HTTP routes to handle client connections, subscriptions, and unsubscriptions. Register them in your routes file using the `registerRoutes` method.
```ts title="start/routes.ts"
import transmit from '@adonisjs/transmit/services/main'
transmit.registerRoutes()
```
This registers the following routes:
| Route | Method | Purpose |
|-------|--------|---------|
| `__transmit/events` | GET | Establishes the SSE connection |
| `__transmit/subscribe` | POST | Subscribes the client to a channel |
| `__transmit/unsubscribe` | POST | Unsubscribes the client from a channel |
### Applying middleware to routes
The `registerRoutes` method accepts an optional callback to modify each registered route. This is useful for applying middleware, such as requiring authentication for the SSE connection.
```ts title="start/routes.ts"
import transmit from '@adonisjs/transmit/services/main'
import { middleware } from '#start/kernel'
transmit.registerRoutes((route) => {
route.middleware(middleware.auth())
})
```
You can apply middleware conditionally based on the route pattern.
```ts title="start/routes.ts"
import transmit from '@adonisjs/transmit/services/main'
import { middleware } from '#start/kernel'
transmit.registerRoutes((route) => {
// Only require authentication for the SSE connection
if (route.getPattern() === '__transmit/events') {
route.middleware(middleware.auth())
}
})
```
## Broadcasting events
Import the transmit service and call the `broadcast` method to send data to all subscribers of a channel.
```ts title="app/controllers/posts_controller.ts"
import transmit from '@adonisjs/transmit/services/main'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async store({ request }: HttpContext) {
const post = await Post.create(request.all())
// Broadcast the new post to all subscribers
transmit.broadcast('posts', { id: post.id, title: post.title })
return post
}
}
```
### Excluding specific clients
Use `broadcastExcept` to send a message to all subscribers except one or more specific clients. This is useful when the sender should not receive their own message.
```ts title="app/controllers/messages_controller.ts"
import transmit from '@adonisjs/transmit/services/main'
import type { HttpContext } from '@adonisjs/core/http'
export default class MessagesController {
async store({ request }: HttpContext) {
const { uid, content } = request.all()
// Send to everyone in the chat except the sender
transmit.broadcastExcept('chats/1/messages', { content }, uid)
}
}
```
The third argument accepts a single UID string or an array of UIDs to exclude.
## Channels
Channel names are case-sensitive strings that support alphanumeric characters and forward slashes. Use forward slashes to create hierarchical structures that match your application's resources.
```ts
// Public channel for global notifications
transmit.broadcast('notifications', { message: 'System update' })
// Resource-specific channel
transmit.broadcast('chats/1/messages', { content: 'Hello!' })
// User-specific channel
transmit.broadcast('users/42', { type: 'profile_updated' })
```
### Authorizing channels
By default, any client can subscribe to any channel. Use the `authorize` method to restrict access to sensitive channels. Create a `start/transmit.ts` preload file to define your authorization rules.
```sh
node ace make:preload transmit
```
Authorization callbacks receive the current `HttpContext` and the extracted channel parameters. Return `true` to allow access or `false` to deny it.
```ts title="start/transmit.ts"
import transmit from '@adonisjs/transmit/services/main'
// Only allow users to subscribe to their own channel
transmit.authorize<{ id: string }>('users/:id', (ctx, { id }) => {
return ctx.auth.user?.id === +id
})
```
Channel patterns use the same parameter syntax as AdonisJS routes. Parameters are extracted from the channel name at subscription time and passed to the authorization callback.
```ts title="start/transmit.ts"
import transmit from '@adonisjs/transmit/services/main'
import Chat from '#models/chat'
transmit.authorize<{ chatId: string }>(
'chats/:chatId/messages',
async (ctx, { chatId }) => {
const chat = await Chat.findOrFail(+chatId)
return ctx.bouncer.allows('accessChat', chat)
}
)
```
Authorization callbacks support both synchronous and asynchronous logic. If the callback throws an error, access is denied.
:::tip
Channels without an `authorize` callback are public. Any client can subscribe to them. Only register authorization for channels that require access control.
:::
## Client-side setup
Create a new `Transmit` instance with the URL of your AdonisJS server. The client automatically establishes an SSE connection when instantiated.
```ts title="resources/js/app.ts"
import { Transmit } from '@adonisjs/transmit-client'
const transmit = new Transmit({
baseUrl: window.location.origin,
})
```
### Subscribing to channels
Use the `subscription` method to create a subscription, then call `create` to activate it. Register message handlers with `onMessage`.
```ts title="resources/js/chat.ts"
import { Transmit } from '@adonisjs/transmit-client'
const transmit = new Transmit({
baseUrl: window.location.origin,
})
const subscription = transmit.subscription('chats/1/messages')
await subscription.create()
subscription.onMessage((data) => {
console.log('New message:', data)
})
```
You can register multiple message handlers on a single subscription. Each handler receives the parsed payload from the server.
```ts title="resources/js/chat.ts"
// Register multiple handlers
subscription.onMessage((data) => {
appendMessageToUI(data)
})
subscription.onMessage((data) => {
playNotificationSound()
})
// Register a handler that runs only once
subscription.onMessageOnce((data) => {
console.log('First message received:', data)
})
```
### Removing a message handler
The `onMessage` method returns an unsubscribe function to stop a specific handler from receiving messages.
```ts title="resources/js/chat.ts"
const unsubscribe = subscription.onMessage((data) => {
console.log(data)
})
// Later, stop this specific handler
unsubscribe()
```
### Deleting a subscription
Call `delete` to unsubscribe from a channel entirely.
```ts title="resources/js/chat.ts"
await subscription.delete()
```
### Listening to connection status
The client tracks its connection status and exposes events you can listen to.
```ts title="resources/js/app.ts"
transmit.on('connected', () => {
console.log('SSE connection established')
})
transmit.on('disconnected', () => {
console.log('SSE connection lost')
})
transmit.on('reconnecting', () => {
console.log('Attempting to reconnect...')
})
```
The available status events are `initializing`, `connected`, `disconnected`, and `reconnecting`.
### Client configuration options
::::options
:::option{name="baseUrl" dataType="string"}
The URL of your AdonisJS server, including the protocol. This is the only required option.
```ts
const transmit = new Transmit({
baseUrl: 'https://my-app.com',
})
```
:::
:::option{name="maxReconnectAttempts" dataType="number"}
Maximum number of reconnection attempts when the connection drops. Defaults to `5`.
```ts
const transmit = new Transmit({
baseUrl: window.location.origin,
maxReconnectAttempts: 10,
})
```
:::
:::option{name="uidGenerator" dataType="() => string"}
Custom function to generate the client's unique identifier. Defaults to `crypto.randomUUID()`.
```ts
import { nanoid } from 'nanoid'
const transmit = new Transmit({
baseUrl: window.location.origin,
uidGenerator: () => nanoid(),
})
```
:::
:::option{name="beforeSubscribe" dataType="(request: Request) => void"}
Hook called before each subscribe request. Use it to modify the request, such as adding custom headers.
```ts
const transmit = new Transmit({
baseUrl: window.location.origin,
beforeSubscribe: (request) => {
request.headers.set('X-Custom-Header', 'value')
},
})
```
:::
:::option{name="beforeUnsubscribe" dataType="(request: Request) => void"}
Hook called before each unsubscribe request. Works the same as `beforeSubscribe`.
:::
:::option{name="onReconnectAttempt" dataType="(attempt: number) => void"}
Callback invoked on each reconnection attempt. Receives the current attempt number.
:::
:::option{name="onReconnectFailed" dataType="() => void"}
Callback invoked when the maximum number of reconnection attempts is reached and the client stops trying.
:::
:::option{name="onSubscribeFailed" dataType="(response: Response) => void"}
Callback invoked when a subscribe request fails. Receives the `Response` object from the failed request.
:::
:::option{name="onSubscription" dataType="(channel: string) => void"}
Callback invoked when a subscription is successfully created.
:::
:::option{name="onUnsubscription" dataType="(channel: string) => void"}
Callback invoked when a subscription is successfully deleted.
:::
:::option{name="eventSourceFactory" dataType="(url: string | URL, options: { withCredentials: boolean }) => EventSource"}
Custom factory for creating the `EventSource` instance. Useful for environments where the native `EventSource` is not available.
:::
:::option{name="eventTargetFactory" dataType="() => EventTarget | null"}
Custom factory for creating the `EventTarget` used for status change events. Return `null` to disable status events.
:::
:::option{name="httpClientFactory" dataType="(baseUrl: string, uid: string) => HttpClient"}
Custom factory for creating the HTTP client used for subscribe and unsubscribe requests.
:::
::::
## Lifecycle hooks
The server-side transmit instance emits lifecycle events you can listen to for monitoring and debugging.
```ts title="start/transmit.ts"
import transmit from '@adonisjs/transmit/services/main'
transmit.on('connect', ({ uid }) => {
console.log(`Client ${uid} connected`)
})
transmit.on('disconnect', ({ uid }) => {
console.log(`Client ${uid} disconnected`)
})
transmit.on('broadcast', ({ channel, payload }) => {
console.log(`Broadcast on ${channel}:`, payload)
})
transmit.on('subscribe', ({ uid, channel }) => {
console.log(`Client ${uid} subscribed to ${channel}`)
})
transmit.on('unsubscribe', ({ uid, channel }) => {
console.log(`Client ${uid} unsubscribed from ${channel}`)
})
```
The `connect`, `disconnect`, `subscribe`, and `unsubscribe` event callbacks also receive a `context` property containing the `HttpContext` of the request.
## Getting channel subscribers
Use the `getSubscribersFor` method to retrieve the UIDs of all clients currently subscribed to a channel.
```ts
import transmit from '@adonisjs/transmit/services/main'
const subscribers = transmit.getSubscribersFor('chats/1/messages')
console.log(`${subscribers.length} clients connected to this chat`)
```
## Multi-instance synchronization
When running multiple server instances behind a load balancer, events broadcast on one instance will not reach clients connected to other instances. Transmit solves this with a transport layer that synchronizes events across all instances using a message bus.
### Redis transport
Install the `ioredis` package and configure the Redis transport.
```sh
npm install ioredis
```
```ts title="config/transmit.ts"
import env from '#start/env'
import { defineConfig } from '@adonisjs/transmit'
import { redis } from '@adonisjs/transmit/transports'
export default defineConfig({
pingInterval: '30s',
transport: {
driver: redis({
host: env.get('REDIS_HOST'),
port: env.get('REDIS_PORT'),
password: env.get('REDIS_PASSWORD'),
keyPrefix: 'transmit',
}),
},
})
```
### MQTT transport
```ts title="config/transmit.ts"
import env from '#start/env'
import { defineConfig } from '@adonisjs/transmit'
import { mqtt } from '@adonisjs/transmit/transports'
export default defineConfig({
pingInterval: '30s',
transport: {
driver: mqtt({
url: env.get('MQTT_URL'),
}),
},
})
```
The transport broadcasts events to all connected instances through the configured message bus. The default broadcast channel is `'transmit::broadcast'`. You can customize it if needed.
```ts title="config/transmit.ts"
export default defineConfig({
transport: {
driver: redis({ /* ... */ }),
channel: 'my-app::transmit',
},
})
```
## Production considerations
### Disable compression for SSE
Server-Sent Events require that the response stream is not compressed. If your reverse proxy applies GZip compression, you must exclude the `text/event-stream` content type. Compressed SSE streams cause connection instability and message buffering.
:::warning
You must disable compression for the `text/event-stream` content type in your reverse proxy. Failing to do so will cause SSE connections to break or buffer messages indefinitely.
For Traefik:
```yaml
traefik.http.middlewares.gzip.compress.excludedcontenttypes=text/event-stream
```
For Nginx:
```nginx
# Do not include text/event-stream in gzip_types
gzip_types text/plain application/json application/javascript text/css;
```
:::
---
# OpenTelemetry
This guide covers OpenTelemetry integration in AdonisJS applications. You will learn how to:
- Install and configure the `@adonisjs/otel` package
- Understand traces, spans, and attributes
- Use automatic instrumentation for HTTP, database, and Redis
- Create custom spans with helpers and decorators
- Propagate trace context across services
- Test your setup locally with Jaeger
## Overview
OpenTelemetry is an open standard for collecting telemetry data from your applications: traces, metrics, and logs. The `@adonisjs/otel` package provides a seamless integration between AdonisJS and OpenTelemetry, giving you distributed tracing and automatic instrumentation with sensible defaults.
Observability is essential for understanding what happens inside your application, especially in production. When a user reports that "the checkout page is slow," tracing lets you see exactly where time is spent. Was it the database query? An external API call? A slow service? Without tracing, you're left guessing.
:::media

:::
This package handles the complexity of OpenTelemetry setup for you. Run a single command, and your application automatically traces HTTP requests, database queries, Redis operations, and more.
## OpenTelemetry concepts
Before diving into the implementation, you should understand a few core OpenTelemetry concepts. For a comprehensive introduction, see the [official OpenTelemetry documentation](https://opentelemetry.io/docs/concepts/observability-primer/).
A **trace** represents the complete journey of a request through your system. When a user hits your API, the trace captures everything that happens: the HTTP request, database queries, cache lookups, calls to external services, and the response.
A **span** is a single unit of work within a trace. Each database query, HTTP request, or function call can be a span. Spans have a start time, duration, name, and attributes (key-value metadata). Spans are nested hierarchically: a parent span for the HTTP request contains child spans for each database query made during that request.
**Attributes** are key-value pairs attached to spans that provide context. For example, an HTTP span might have attributes like `http.method: GET`, `http.route: /users/:id`, and `http.status_code: 200`.
## Installation
Install and configure the package using the following command.
```sh
node ace add @adonisjs/otel
```
:::disclosure{title="See steps performed by the add command"}
1. Installs the `@adonisjs/otel` package using the detected package manager.
2. Registers the following service provider inside the `adonisrc.ts` file.
```ts
{
providers: [
// ...other providers
() => import('@adonisjs/otel/otel_provider')
]
}
```
3. Registers the following middleware inside the `start/kernel.ts` file.
```ts
router.use([
() => import('@adonisjs/otel/otel_middleware')
])
```
4. Creates the `config/otel.ts` file.
5. Creates the `bin/otel.ts` file with OpenTelemetry initialization.
6. Adds the import statement at the top of `bin/server.ts` file.
7. Defines the following environment variables and their validation rules.
```dotenv
OTEL_EXPORTER_OTLP_ENDPOINT=
OTEL_EXPORTER_OTLP_HEADERS=
```
:::
That's it. Your application now has automatic tracing for HTTP requests, database queries, and more.
## Configuration
The configuration file is located at `config/otel.ts`.
```ts title="config/otel.ts"
import { defineConfig } from '@adonisjs/otel'
import env from '#start/env'
export default defineConfig({
serviceName: env.get('APP_NAME'),
serviceVersion: env.get('APP_VERSION'),
environment: env.get('APP_ENV'),
})
```
### Service identification
The package resolves service metadata from multiple sources.
::::options
:::option{name="serviceName" dataType="string"}
The name of your service. This value is resolved from `OTEL_SERVICE_NAME` or `APP_NAME` environment variables.
```ts
export default defineConfig({
serviceName: 'my-api'
})
```
:::
:::option{name="serviceVersion" dataType="string"}
The version of your service. This value is resolved from the `APP_VERSION` environment variable and defaults to `0.0.0`.
```ts
export default defineConfig({
serviceVersion: '1.2.3'
})
```
:::
:::option{name="environment" dataType="string"}
The environment where your service is running. This value is resolved from the `APP_ENV` environment variable and defaults to `development`.
```ts
export default defineConfig({
environment: 'production'
})
```
:::
::::
### Exporters
By default, the package exports traces using OTLP over gRPC to `localhost:4317`. This is the standard OpenTelemetry Collector endpoint. If you're running an OpenTelemetry Collector locally or in your infrastructure, traces will be sent there automatically.
You can configure the exporter endpoint using environment variables without changing any code.
```dotenv
# title: .env
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com:4317
```
For authentication or custom headers:
```dotenv
# title: .env
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=your-api-key
```
See the [OpenTelemetry environment variable specification](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/) for all available options, and check [Advanced configuration](#advanced-configuration) for even more customization.
### Multiple destinations (fan-out)
When you need to export telemetry to multiple backends at once, use the `destinations` option.
The package provides a generic OTLP destination helper via `destinations.otlp()`. Each destination can receive all signals (`traces`, `metrics`, `logs`) or only a subset.
```ts title="config/otel.ts"
import { defineConfig, destinations } from '@adonisjs/otel'
export default defineConfig({
serviceName: 'my-app',
destinations: {
grafana: destinations.otlp({
endpoint: 'https://grafana-otlp.example.com',
headers: {
Authorization: `Basic ${process.env.GRAFANA_BASIC_AUTH}`,
},
signals: 'all',
}),
honeycomb: destinations.otlp({
endpoint: 'https://api.honeycomb.io',
headers: {
'x-honeycomb-team': process.env.HONEYCOMB_API_KEY!,
'x-honeycomb-dataset': process.env.HONEYCOMB_DATASET!,
},
signals: 'all',
}),
},
})
```
When you set `endpoint`, the package automatically derives per-signal endpoints by appending:
- `/v1/traces`
- `/v1/metrics`
- `/v1/logs`
You can also provide explicit endpoints per signal:
```ts title="config/otel.ts"
import { defineConfig, destinations } from '@adonisjs/otel'
export default defineConfig({
serviceName: 'my-app',
destinations: {
custom: destinations.otlp({
signals: ['traces', 'logs'],
endpoints: {
traces: 'https://collector-a.example.com/v1/traces',
logs: 'https://collector-b.example.com/v1/logs',
},
}),
},
})
```
:::note
`destinations` is optional. If you do not define it, the package keeps the default OpenTelemetry behavior and environment variable configuration (`OTEL_EXPORTER_OTLP_*`, `OTEL_TRACES_EXPORTER`, `OTEL_METRICS_EXPORTER`, `OTEL_LOGS_EXPORTER`).
:::
### Debug mode
Enable debug mode to print spans to the console during development.
```ts title="config/otel.ts"
import { defineConfig } from '@adonisjs/otel'
export default defineConfig({
serviceName: 'my-app',
debug: true,
})
```
This adds a `ConsoleSpanExporter` that outputs spans to your terminal, helping you visualize traces without setting up a collector.
### Enabling and disabling
OpenTelemetry is automatically disabled when `NODE_ENV === 'test'` to avoid noise during tests. You can override this behavior.
```ts title="config/otel.ts"
import { defineConfig } from '@adonisjs/otel'
export default defineConfig({
serviceName: 'my-app',
/**
* Force enable in tests
*/
enabled: true,
/**
* Or force disable in any environment
*/
enabled: false,
})
```
### Sampling
In high-traffic production environments, tracing every single request generates enormous amounts of data. Sampling controls what percentage of traces are collected.
```ts title="config/otel.ts"
import { defineConfig } from '@adonisjs/otel'
export default defineConfig({
serviceName: 'my-app',
/**
* Sample 10% of traces (recommended for high-traffic production)
*/
samplingRatio: 0.1,
/**
* Sample 100% of traces (default, good for development)
*/
samplingRatio: 1.0,
})
```
The sampler uses parent-based sampling, meaning child spans inherit the sampling decision from their parent. This ensures you always get complete traces rather than fragments.
See also: [OpenTelemetry Sampling documentation](https://opentelemetry.io/docs/concepts/sampling/)
:::note
If you provide a custom `sampler` option, `samplingRatio` is ignored.
:::
### Customizing instrumentations
The package automatically instruments common libraries without any code changes. Out of the box, you get tracing for HTTP requests (incoming and outgoing), Lucid database queries (via Knex), and Redis operations.
To reduce noise, the following endpoints are excluded from tracing by default.
- `/health`, `/healthz`, `/ready`, `/readiness`
- `/metrics`, `/internal/metrics`, `/internal/healthz`
- `/favicon.ico`
You can configure individual instrumentations or add custom ignored URLs.
```ts title="config/otel.ts"
import { defineConfig } from '@adonisjs/otel'
export default defineConfig({
serviceName: 'my-app',
instrumentations: {
/**
* Add custom ignored URLs (merged with defaults)
*/
'@opentelemetry/instrumentation-http': {
ignoredUrls: ['/internal/*', '/api/ping'],
mergeIgnoredUrls: true,
},
/**
* Disable a specific instrumentation
*/
'@opentelemetry/instrumentation-pg': { enabled: false },
},
})
```
## Testing locally with Jaeger
Jaeger is a popular open-source distributed tracing platform that implements the OpenTelemetry standard. It provides a web UI for visualizing traces, making it perfect for local development.
::::steps
:::step{title="Start Jaeger using Docker"}
Run Jaeger in a Docker container. The all-in-one image includes the collector, query service, and UI.
```sh
docker run -d --name jaeger \
-e COLLECTOR_OTLP_ENABLED=true \
-p 16686:16686 \
-p 4317:4317 \
-p 4318:4318 \
jaegertracing/all-in-one:latest
```
This command exposes:
- Port `16686` for the Jaeger UI
- Port `4317` for OTLP gRPC (the default endpoint)
- Port `4318` for OTLP HTTP
:::
:::step{title="Start your application"}
Your AdonisJS application is already configured to send traces to `localhost:4317` by default, so no additional configuration is needed.
```sh
node ace serve --hmr
```
:::
:::step{title="Generate some traces"}
Make requests to your application to generate traces. You can use curl, your browser, or any HTTP client.
```sh
curl http://localhost:3333/users
curl http://localhost:3333/posts/1
```
:::
:::step{title="View traces in Jaeger"}
Open the Jaeger UI at http://localhost:16686. You should see your service listed in the dropdown. Select your service and click "Find Traces" to see all captured traces.
Click on any trace to see the complete timeline of operations: the HTTP request, database queries, and any custom spans you've created.
:::
::::
:::tip
Keep Jaeger running during development. It accumulates traces over time, making it easy to compare slow and fast requests or spot patterns in your application's behavior.
:::
## Creating custom spans
While automatic instrumentation covers most common operations, you'll often want to trace custom business logic. The package provides helpers and decorators for this purpose.
### Using the record helper
The `record` helper creates a span around a code section:
```ts title="app/services/order_service.ts"
import { record } from '@adonisjs/otel/helpers'
export default class OrderService {
async processOrder(orderId: string) {
/**
* Wrap synchronous or asynchronous code in a span
*/
// [!code highlight:6]
const result = await record('order.process', async () => {
const order = await Order.findOrFail(orderId)
await this.validateInventory(order)
await this.chargePayment(order)
return order
})
return result
}
async validateInventory(order: Order) {
/**
* Access the span to add custom attributes
*/
// [!code highlight:8]
await record('order.validate_inventory', async (span) => {
span.setAttributes({
'order.id': order.id,
'order.items_count': order.items.length,
})
// Validation logic...
})
}
}
```
### Using decorators
For class methods, decorators provide a cleaner syntax.
```ts title="app/services/user_service.ts"
import { span, spanAll } from '@adonisjs/otel/decorators'
export default class UserService {
/**
* Creates a span named "UserService.findById"
*/
// [!code highlight]
@span()
async findById(id: string) {
return User.find(id)
}
/**
* Custom span name and attributes
*/
// [!code highlight]
@span({ name: 'user.create', attributes: { operation: 'create' } })
async create(data: UserData) {
return User.create(data)
}
}
```
To automatically trace all methods of a class, use the `@spanAll` decorator.
```ts title="app/services/payment_service.ts"
import { spanAll } from '@adonisjs/otel/decorators'
/**
* All methods get spans.
*/
// [!code highlight]
@spanAll()
export default class PaymentService {
async charge(amount: number) {
// ...
}
async refund(transactionId: string) {
// ...
}
}
/**
* Custom prefix: "payment.charge", "payment.refund", etc.
*/
@spanAll({ prefix: 'payment' })
export default class PaymentService {
// ...
}
```
### Setting attributes on the current span
Use `setAttributes` to add metadata to the currently active span without creating a new one.
```ts title="app/controllers/orders_controller.ts"
import { setAttributes } from '@adonisjs/otel/helpers'
export default class OrdersController {
async store({ request }: HttpContext) {
const data = request.all()
/**
* Add business context to the HTTP span
*/
setAttributes({
'order.type': data.type,
'order.total': data.total,
'customer.tier': data.customerTier,
})
// Process order...
}
}
```
### Recording events
Events are time-stamped annotations within a span. Use them to mark significant moments.
```ts title="app/services/checkout_service.ts"
import { recordEvent } from '@adonisjs/otel/helpers'
export default class CheckoutService {
async process(cart: Cart) {
// [!code highlight]
recordEvent('checkout.started')
await this.validateCart(cart)
// [!code highlight]
recordEvent('checkout.cart_validated')
const payment = await this.processPayment(cart)
// [!code highlight:4]
recordEvent('checkout.payment_processed', {
'payment.method': payment.method,
'payment.amount': payment.amount,
})
await this.fulfillOrder(cart)
// [!code highlight]
recordEvent('checkout.completed')
}
}
```
## Context propagation
When your application calls other services or processes background jobs, you need to propagate the trace context so all operations appear in the same trace.
### Propagating to HTTP calls
Inject trace context into outgoing HTTP request headers.
```ts title="app/services/external_api_service.ts"
import { injectTraceContext } from '@adonisjs/otel/helpers'
export default class ExternalApiService {
async fetchUserData(userId: string) {
const headers: Record = {
'Content-Type': 'application/json',
}
/**
* Adds traceparent and tracestate headers.
* Mutates the original object
*/
// [!code highlight]
injectTraceContext(headers)
const response = await fetch(`https://api.example.com/users/${userId}`, {
headers,
})
return response.json()
}
}
```
### Propagating to queue jobs
When dispatching background jobs, include the trace context.
```ts title="app/controllers/orders_controller.ts"
import { injectTraceContext } from '@adonisjs/otel/helpers'
export default class OrdersController {
async store({ request }: HttpContext) {
const order = await Order.create(request.all())
/**
* Include trace context in job metadata
*/
const traceHeaders: Record = {}
// [!code highlight]
injectTraceContext(traceHeaders)
await queue.dispatch('process-order', {
orderId: order.id,
traceContext: traceHeaders,
})
return order
}
}
```
In your queue worker, extract the context and continue the trace.
```ts title="app/jobs/process_order_job.ts"
import { extractTraceContext, record } from '@adonisjs/otel/helpers'
import { context } from '@adonisjs/otel'
export default class ProcessOrderJob {
async handle(payload: { orderId: string; traceContext: Record }) {
/**
* Extract the trace context from the job payload
*/
// [!code highlight]
const extractedContext = extractTraceContext(payload.traceContext)
/**
* Run the job within the extracted context
*/
// [!code highlight]
await context.with(extractedContext, async () => {
await record('job.process_order', async () => {
/**
* This span will be a child of the original HTTP request span
*/
const order = await Order.findOrFail(payload.orderId)
await this.fulfillOrder(order)
})
})
}
}
```
## User context
When `@adonisjs/auth` is installed, the middleware automatically sets user attributes on spans if a user is authenticated. This includes `user.id`, `user.email` (if available), and `user.roles` (if available).
You can customize this behavior or add additional user attributes.
```ts title="config/otel.ts"
import { defineConfig } from '@adonisjs/otel'
export default defineConfig({
serviceName: 'my-app',
/**
* Disable automatic user context
*/
userContext: false,
/**
* Or customize with a resolver
*/
userContext: {
resolver: async (ctx) => {
if (!ctx.auth.user) {
return null
}
return {
id: ctx.auth.user.id,
tenantId: ctx.auth.user.tenantId,
plan: ctx.auth.user.plan,
}
},
},
})
```
You can also manually set user context anywhere in your code.
```ts title="app/middleware/auth_middleware.ts"
import { setUser } from '@adonisjs/otel/helpers'
export default class AuthMiddleware {
async handle({ auth }: HttpContext, next: NextFn) {
await auth.authenticate()
setUser({
id: auth.user!.id,
email: auth.user!.email,
role: auth.user!.role,
})
return next()
}
}
```
## Logging integration
The package automatically injects trace context into Pino logs, adding `trace_id` and `span_id` to each log entry. This lets you correlate logs with traces in your observability platform.
When using `pino-pretty` for development, you can hide these fields for cleaner output.
```ts title="config/logger.ts"
import app from '@adonisjs/core/services/app'
import { otelLoggingPreset } from '@adonisjs/otel/helpers'
import { defineConfig, targets } from '@adonisjs/core/logger'
export default defineConfig({
default: 'app',
loggers: {
app: {
transport: {
targets: targets()
.pushIf(!app.inProduction, targets.pretty({ ...otelLoggingPreset() }))
.toArray(),
},
},
},
})
```
To keep specific fields visible.
```ts
otelLoggingPreset({ keep: ['trace_id', 'span_id'] })
```
## Advanced configuration
The `defineConfig` function accepts all options from the [OpenTelemetry Node SDK](https://opentelemetry.io/docs/languages/js/getting-started/nodejs/), giving power users full control.
```ts title="config/otel.ts"
import { defineConfig } from '@adonisjs/otel'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
export default defineConfig({
serviceName: 'my-app',
/**
* Use HTTP instead of gRPC
*/
traceExporter: new OTLPTraceExporter({
url: 'https://otel-collector.example.com/v1/traces',
headers: { 'x-api-key': process.env.OTEL_API_KEY },
}),
/**
* Custom span processor with batching configuration
*/
spanProcessors: [
new BatchSpanProcessor(new OTLPTraceExporter(), {
maxQueueSize: 2048,
scheduledDelayMillis: 5000,
}),
],
/**
* Custom resource attributes
*/
resourceAttributes: {
'deployment.region': 'eu-west-1',
'k8s.pod.name': process.env.POD_NAME,
},
})
```
See the [OpenTelemetry JS documentation](https://opentelemetry.io/docs/languages/js/) for all available options.
## Performance considerations
OpenTelemetry adds some overhead to your application. The SDK needs to create span objects, record timing information, and export data to your collector. In most applications, this overhead is negligible, but you should be aware of it.
For high-traffic production environments, consider these recommendations:
- **Use sampling** to reduce the volume of traces. A `samplingRatio` of `0.1` (10%) is often sufficient to identify issues while dramatically reducing overhead and storage costs.
- **Use batch processing** (the default) rather than sending spans immediately. The `BatchSpanProcessor` queues spans and sends them in batches, reducing network overhead.
- **Be selective with custom spans**. Automatic instrumentation covers most needs. Only add custom spans for business-critical operations where you need additional visibility. Don't over-instrument by using a `@spanAll` decorator on every single class.
See also: [OpenTelemetry Sampling documentation](https://opentelemetry.io/docs/concepts/sampling/)
## Helpers reference
All helpers are available from `@adonisjs/otel/helpers`.
| Helper | Description |
| -------------------------------- | --------------------------------------------------------- |
| `getCurrentSpan()` | Returns the currently active span, or `undefined` if none |
| `setAttributes(attributes)` | Sets attributes on the current span |
| `record(name, fn)` | Creates a span around a function |
| `recordEvent(name, attributes?)` | Records an event on the current span |
| `setUser(user)` | Sets user attributes on the current span |
| `injectTraceContext(carrier)` | Injects trace context into a carrier object (headers) |
| `extractTraceContext(carrier)` | Extracts trace context from a carrier object |
| `otelLoggingPreset(options?)` | Returns pino-pretty options that hide OTEL fields |
---
# Ace Command line
This guide introduces you to the Ace command line. You will learn about the following topics:
- Running Ace commands
- Viewing help documentation
- Creating command aliases
- Running commands programmatically
## Overview
Ace is AdonisJS's command line framework that powers all console commands in your application. Whether you're running database migrations, creating controllers, or building custom CLI tools, Ace provides the foundation for all command line interactions.
The framework handles command parsing, argument validation, interactive prompts, and terminal output formatting, allowing you to focus on building command logic rather than dealing with CLI boilerplate. Every AdonisJS application includes Ace by default, accessible through the `ace.js` entry point file in your project root.
Understanding how to use Ace effectively is essential for AdonisJS development, as you'll interact with it constantly during development and deployment.
## Running Ace commands
You can execute Ace commands using the `ace.js` file located in your project root. This file serves as the entry point for all command line operations.
:::warning
Do not modify the `ace.js` file directly. If you need to add custom code that runs before Ace starts, put it in the `bin/console.ts` file instead.
:::
```sh
node ace
node ace make:controller
node ace migration:run
```
## Viewing available commands
To see a list of all available commands in your application, run the ace entry point without any arguments or use the `list` command explicitly.
```sh
node ace
# Same as above
node ace list
```
Both commands display the same help screen, showing all registered commands organized by category.
:::media

:::
:::note
The help output follows the [docopt](http://docopt.org/) standard, a specification for command line interfaces that ensures consistent documentation formatting across different tools.
:::
## Getting help for specific commands
Every Ace command includes built-in help documentation. To view detailed information about a specific command, including its arguments, flags, and usage examples, append the `--help` flag to any command.
```sh
node ace make:controller --help
```
The help screen shows the command's description, required and optional arguments, available flags with their descriptions, and usage examples.
## Controlling color output
Ace automatically detects your terminal environment and disables colorful output when the terminal doesn't support ANSI colors. However, you can manually control color output using the `--ansi` flag.
```sh
# Disable colors
node ace list --no-ansi
# Force enable colors
node ace list --ansi
```
Disabling colors is useful when redirecting command output to files or when running commands in CI/CD environments that don't support colored terminal output.
## Creating command aliases
Command aliases provide shortcuts for frequently used commands with specific flag combinations. This is particularly useful when you find yourself repeatedly typing the same command with the same flags.
You can define aliases in the `adonisrc.ts` file using the `commandsAliases` object. Each alias maps a short name to a complete command with its flags.
```ts title="adonisrc.ts"
export default defineConfig({
commandsAliases: {
/**
* Create a singular resourceful controller
*/
resource: 'make:controller --resource --singular'
}
})
```
Once defined, you can use the alias name instead of typing the full command. Any additional arguments you provide are appended to the expanded command.
```sh
# Using the alias
node ace resource users
# Expands to
node ace make:controller --resource --singular users
```
### How alias expansion works
When you run a command, Ace follows this expansion process:
1. Ace checks if the command name matches any alias in the `commandsAliases` object
2. If a match is found, Ace extracts the first word from the alias value (before any spaces) and looks up the corresponding command
3. If a command exists with that name, Ace appends all remaining segments from the alias value to form the complete command
4. Finally, Ace appends any arguments or flags you provided when running the alias
For example, if you run:
```sh
node ace resource admin --help
```
Ace expands this to:
```sh
node ace make:controller --resource --singular admin --help
```
The expansion preserves argument order and allows you to add additional flags beyond those defined in the alias.
## Running commands programmatically
You can execute Ace commands from within your application code using the `ace` service. This is useful for building workflows that need to trigger commands programmatically, such as running migrations during application setup or generating files based on user actions.
The `ace` service is available after your application has been booted, ensuring all necessary services and providers are loaded before command execution.
```ts
import ace from '@adonisjs/core/services/ace'
/**
* Execute a command and get its result
*/
const command = await ace.exec('make:controller', [
'users',
'--resource',
])
/**
* The command object contains execution details
*/
console.log(command.exitCode) // 0 for success, 1 for failure
console.log(command.result) // Command return value
console.log(command.error) // Error object if command failed
```
Before executing commands, you should verify that the command exists to avoid runtime errors. Use the `ace.hasCommand` method to check command availability.
```ts
import ace from '@adonisjs/core/services/ace'
/**
* Boot Ace to load all registered commands
* (if not already loaded)
*/
await ace.boot()
if (ace.hasCommand('make:controller')) {
await ace.exec('make:controller', [
'users',
'--resource',
])
} else {
console.log('Controller command not available')
}
```
The `ace.boot()` method loads all commands if they haven't been loaded already. This ensures the `hasCommand` check works correctly by verifying against the complete command registry.
---
# Creating commands
This guide covers creating commands using the Ace command line. You will learn about the following topics:
- Creating custom commands
- Configuring command metadata
- Using lifecycle methods
- Injecting dependencies
- Handling errors
- Managing long-running processes
## Creating your first command
You can generate a new command using the `make:command` Ace command. This creates a basic command (within the `commands` directory) scaffolded with all the necessary boilerplate.
See also: [Make command](../../reference/commands.md)
```sh
node ace make:command greet
# CREATE: commands/greet.ts
```
The generated file contains a command class that extends `BaseCommand`. At minimum, a command must define a `commandName` and implement the `run` method.
```ts title="commands/greet.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class GreetCommand extends BaseCommand {
static commandName = 'greet'
static description = 'Greet a user by name'
async run() {
this.logger.info('Hello world!')
}
}
```
You can now execute your command using the command name you defined.
```sh
node ace greet
```
## Configuring command metadata
Command metadata controls how your command appears in help screens and how it behaves during execution. The metadata includes the command name, description, help text, aliases, and execution options.
### Setting the command name
The `commandName` property defines the name users will type to execute your command. Command names should not contain spaces and should avoid unfamiliar special characters like `*`, `&`, or slashes.
```ts title="commands/greet.ts"
export default class GreetCommand extends BaseCommand {
static commandName = 'greet'
}
```
Command names can include namespaces by using a colon separator. This helps organize related commands together in the help output.
```ts title="commands/make/controller.ts"
export default class MakeControllerCommand extends BaseCommand {
/**
* The command appears under the "make" namespace
*/
static commandName = 'make:controller'
}
```
### Writing command descriptions
The command description appears in the commands list and on the help screen for your command. Keep descriptions concise and use the help text for longer explanations.
```ts title="commands/greet.ts"
export default class GreetCommand extends BaseCommand {
static commandName = 'greet'
static description = 'Greet a user by name'
}
```
### Adding detailed help text
Help text allows you to provide longer descriptions, usage examples, or additional context that doesn't fit in the brief description. Define help text as an array of strings, where each string represents a line of output.
```ts title="commands/greet.ts"
export default class GreetCommand extends BaseCommand {
static commandName = 'greet'
static description = 'Greet a user by name'
static help = [
'The greet command is used to greet a user by name',
'',
'You can also send flowers to a user, if they have an updated address',
'{{ binaryName }} greet --send-flowers',
]
}
```
The `{{ binaryName }}` variable substitution references the binary used to execute ace commands (typically `node ace`), ensuring your help text displays the correct command syntax regardless of how the user runs Ace.
### Defining command aliases
Aliases provide alternative names for your command. This is useful when you want to offer shorter or more intuitive names for frequently used commands.
```ts title="commands/greet.ts"
export default class GreetCommand extends BaseCommand {
static commandName = 'greet'
static aliases = ['welcome', 'sayhi']
}
```
Users can now run your command using any of the defined names.
```sh
node ace greet
node ace welcome
node ace sayhi
```
## Configuring command options
Command options control the execution behavior of your command. These options are defined using the static `options` property and affect how Ace boots the application, handles flags, and manages the command's lifecycle.
```ts title="commands/greet.ts"
import { BaseCommand } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
export default class GreetCommand extends BaseCommand {
static commandName = 'greet'
static options: CommandOptions = {
startApp: false,
allowUnknownFlags: false,
staysAlive: false,
}
}
```
### Starting the application
By default, Ace does not boot your AdonisJS application when running commands. This keeps commands fast and prevents unnecessary application initialization for simple tasks that don't need application state.
However, if your command needs access to models, services, or other application resources, you must tell Ace to start the app before executing the command.
```ts title="commands/send_email.ts"
import { BaseCommand } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
export default class SendEmailCommand extends BaseCommand {
static options: CommandOptions = {
/**
* Start the app to access models and services
*/
startApp: true
}
async run() {
/**
* Can now use application resources like models
*/
const users = await User.all()
}
}
```
### Allowing unknown flags
By default, Ace will display an error if you pass a flag that the command doesn't define. This strict parsing helps catch typos and incorrect flag usage.
However, some commands need to accept arbitrary flags and pass them to other tools. You can disable strict flag parsing using the `allowUnknownFlags` option.
```ts title="commands/proxy.ts"
import { BaseCommand } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
export default class ProxyCommand extends BaseCommand {
static options: CommandOptions = {
/**
* Accept any flags and pass them to external tools
*/
allowUnknownFlags: true
}
}
```
### Creating long-running commands
Ace automatically terminates the application after your command's `run` method completes. This is the desired behavior for most commands that perform a task and exit.
However, if your command needs to run indefinitely (like a queue worker or development server), you must tell Ace not to terminate the application using the `staysAlive` option.
```ts title="commands/queue_worker.ts"
import { BaseCommand } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
export default class QueueWorkerCommand extends BaseCommand {
static options: CommandOptions = {
startApp: true,
/**
* Keep the process alive
*/
staysAlive: true
}
async run() {
/**
* Start processing jobs indefinitely
*/
await this.startJobProcessor()
}
}
```
See also: [Terminating the application](#terminating-the-application) and [Cleaning up before termination](#cleaning-up-before-the-app-terminates)
## Understanding command lifecycle
Ace executes command lifecycle methods in a predefined order, allowing you to organize your command logic into distinct phases. Each lifecycle method serves a specific purpose in the command execution flow.
```ts title="commands/greet.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class GreetCommand extends BaseCommand {
/**
* Runs first - set up initial state
*/
async prepare() {
console.log('preparing')
}
/**
* Runs second - interact with the user
*/
async interact() {
console.log('interacting')
}
/**
* Runs third - execute main command logic
*/
async run() {
console.log('running')
}
/**
* Runs last - cleanup and error handling
*/
async completed() {
console.log('completed')
}
}
```
The following table describes each lifecycle method and its intended use:
| Method | Description |
|--------|-------------|
| `prepare` | The first method Ace executes. Use this to initialize state or data needed by subsequent methods. |
| `interact` | Executed after `prepare`. Use this to display prompts and collect user input. |
| `run` | The command's main logic. This method is called after `interact`. |
| `completed` | Called after all other lifecycle methods. Use this to perform cleanup or handle errors from previous methods. |
You don't need to implement all lifecycle methods. Only define the methods your command actually needs. For simple commands, implementing just the `run` method is sufficient.
## Using dependency injection
Ace constructs and executes commands using the [IoC container](../concepts/dependency_injection.md), enabling you to inject dependencies into any lifecycle method. This is particularly useful for accessing services, repositories, or other resources your command needs.
To inject dependencies, type-hint them as method parameters and decorate the method with the `@inject` decorator.
```ts title="commands/greet.ts"
import { inject } from '@adonisjs/core'
import { BaseCommand } from '@adonisjs/core/ace'
import UserService from '#services/user_service'
export default class GreetCommand extends BaseCommand {
/**
* Inject UserService into the prepare method
*/
@inject()
async prepare(userService: UserService) {
// Use the injected service
}
/**
* Dependencies can be injected into any lifecycle method
*/
@inject()
async interact(userService: UserService) {
}
@inject()
async run(userService: UserService) {
}
@inject()
async completed(userService: UserService) {
}
}
```
The container automatically resolves dependencies, including nested dependencies, making it easy to access your application's services without manual instantiation.
## Handling errors and exit codes
When an exception is thrown from your command, Ace displays the error using the CLI logger and sets the command's exit code to `1`, indicating failure. A non-zero exit code signals to the shell or CI/CD system that the command failed.
However, you can also handle errors explicitly using try/catch blocks or the `completed` lifecycle method. When handling errors yourself, you must update the command's `exitCode` and `error` properties to ensure the command reports its status correctly.
### Handling errors with try/catch
Use try/catch blocks to handle errors directly in the method where they might occur. This gives you fine-grained control over error handling.
```ts title="commands/greet.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class GreetCommand extends BaseCommand {
async run() {
try {
await runSomeOperation()
} catch (error) {
/**
* Log the error message
*/
this.logger.error(error.message)
/**
* Update command state to indicate failure
*/
this.error = error
this.exitCode = 1
}
}
}
```
### Handling errors in the completed method
The `completed` lifecycle method provides a centralized place to handle errors from any previous lifecycle method. This is useful when you want consistent error handling across all command phases.
```ts title="commands/greet.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class GreetCommand extends BaseCommand {
async run() {
/**
* If this throws, the error will be available in completed()
*/
await runSomeOperation()
}
async completed() {
if (this.error) {
/**
* Handle the error from any lifecycle method
*/
this.logger.error(this.error.message)
/**
* Return true to notify Ace that you've handled the error
* This prevents Ace from logging the error again
*/
return true
}
}
}
```
## Terminating the application
Ace automatically terminates the application after executing your command. However, when you enable the `staysAlive` option for long-running commands, you must explicitly terminate the application when your command is done or when an error occurs.
Use the `this.terminate` method to shut down the application gracefully. This is commonly used in long-running processes that need to exit based on specific conditions.
```ts title="commands/monitor_redis.ts"
import { BaseCommand } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
export default class MonitorRedisCommand extends BaseCommand {
static options: CommandOptions = {
startApp: true,
staysAlive: true
}
async run() {
const redis = createRedisConnection()
/**
* Terminate the application when the connection fails
*/
redis.on('error', (error) => {
this.logger.error(error)
this.terminate()
})
/**
* Start monitoring Redis
*/
redis.monitor()
}
}
```
## Cleaning up before the app terminates
Multiple events can trigger application termination, including the [`SIGTERM` signal](https://www.howtogeek.com/devops/linux-signals-hacks-definition-and-more/) sent by process managers or when the user presses Ctrl+C. To ensure your command performs necessary cleanup before shutdown, listen for the `terminating` hook.
The `terminating` hook should be registered in the `prepare` lifecycle method, which runs before your command's main logic. This ensures the cleanup handler is in place before any work begins.
```ts title="commands/queue_worker.ts"
import { BaseCommand } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
export default class QueueWorkerCommand extends BaseCommand {
static options: CommandOptions = {
startApp: true,
staysAlive: true
}
prepare() {
/**
* Register cleanup logic that runs before termination
*/
this.app.terminating(() => {
/**
* Close database connections, flush logs, etc.
*/
this.logger.info('Shutting down gracefully...')
})
}
async run() {
/**
* Start long-running work
*/
await this.processJobs()
}
}
```
---
# Command arguments
This guide covers defining command arguments within custom commands. You will learn about the following topics:
- Defining positional arguments
- Making arguments optional
- Accepting multiple values
- Transforming argument values
## Overview
Arguments are positional values that users provide after the command name when executing a command. Unlike flags, which can be specified in any order, arguments must be provided in the exact order they are defined in your command class.
For example, in the command `node ace make:controller users --resource`, the word `users` is an argument, while `--resource` is a flag. Arguments are ideal for required input values that have a natural order, such as filenames, resource names, or entity identifiers.
## Defining your first argument
You define command arguments as class properties decorated with the `@args` decorator. Ace will accept arguments in the same order as they appear in your class, making the property order significant.
The most common argument type is a string argument, which accepts any text value. Use the `@args.string` decorator to define string arguments.
```ts title="commands/greet.ts"
import { BaseCommand, args } from '@adonisjs/core/ace'
export default class GreetCommand extends BaseCommand {
static commandName = 'greet'
static description = 'Greet a user by name'
/**
* Define a required string argument
*/
@args.string()
declare name: string
async run() {
this.logger.info(`Hello, ${this.name}!`)
}
}
```
Users can now run your command by providing a value for the name argument.
```sh
node ace greet John
# Output: Hello, John!
```
If the user forgets to provide the required argument, Ace will display an error message indicating which argument is missing.
## Accepting multiple values
Some commands need to accept multiple values for a single argument. For example, a command that processes multiple files might accept any number of filenames.
Use the `@args.spread` decorator to accept multiple values. The spread argument must be the last argument in your command, as it captures all remaining values.
```ts title="commands/greet.ts"
import { BaseCommand, args } from '@adonisjs/core/ace'
export default class GreetCommand extends BaseCommand {
static commandName = 'greet'
static description = 'Greet multiple users by name'
/**
* Accept multiple names as an array
*/
@args.spread()
declare names: string[]
async run() {
this.names.forEach((name) => {
this.logger.info(`Hello, ${name}!`)
})
}
}
```
Users can now provide any number of values when running the command.
```sh
node ace greet John Jane Bob
# Output:
# Hello, John!
# Hello, Jane!
# Hello, Bob!
```
## Customizing argument name and description
The argument name appears in help screens and error messages. By default, Ace uses the dashed-case version of your property name as the argument name. For example, a property named `userName` becomes `user-name` in the help output.
You can customize the argument name using the `argumentName` option.
```ts title="commands/greet.ts"
@args.string({
argumentName: 'user-name'
})
declare name: string
```
Adding a description helps users understand what value they should provide. The description appears in the help screen when users run `node ace greet --help`.
```ts title="commands/greet.ts"
@args.string({
argumentName: 'user-name',
description: 'Name of the user to greet'
})
declare name: string
```
## Making arguments optional
All arguments are required by default, ensuring users provide necessary input before your command executes. However, you can make an argument optional by setting the `required` option to `false`.
Optional arguments must come after all required arguments. This ordering requirement prevents ambiguity in argument parsing.
```ts title="commands/greet.ts"
import { BaseCommand, args } from '@adonisjs/core/ace'
export default class GreetCommand extends BaseCommand {
static commandName = 'greet'
@args.string({
description: 'Name of the user to greet'
})
declare name: string
/**
* Optional greeting message with custom wording
*/
@args.string({
description: 'Custom greeting message',
required: false,
})
declare message?: string
async run() {
const greeting = this.message || 'Hello'
this.logger.info(`${greeting}, ${this.name}!`)
}
}
```
Now users can run the command with or without the second argument.
```sh
node ace greet John
# Output: Hello, John!
node ace greet John "Good morning"
# Output: Good morning, John!
```
### Providing default values
You can specify a default value for optional arguments using the `default` property. When users don't provide a value, Ace uses the default instead.
```ts title="commands/greet.ts"
@args.string({
description: 'Name of the user to greet',
required: false,
default: 'guest'
})
declare name: string
```
With a default value, the argument becomes optional but your code can always expect a string value rather than handling undefined.
```sh
node ace greet
# Uses the default value "guest"
# Output: Hello, guest!
```
## Transforming argument values
The `parse` method allows you to transform or validate the argument value before it's assigned to the class property. This is useful for normalizing input, converting types, or performing validation.
The parse method receives the raw string value from the command line and must return the transformed value.
```ts title="commands/greet.ts"
@args.string({
argumentName: 'user-name',
description: 'Name of the user to greet',
parse(value) {
/**
* Convert the name to uppercase
*/
return value ? value.toUpperCase() : value
}
})
declare name: string
```
Now when users provide a name, it will automatically be converted to uppercase before your command's `run` method executes.
```sh
node ace greet john
# The name is transformed to "JOHN"
# Output: Hello, JOHN!
```
You can also use the parse method for validation by throwing an error when the value is invalid.
```ts title="commands/create_user.ts"
@args.string({
description: 'Email address of the user',
parse(value) {
if (!value.includes('@')) {
throw new Error('Please provide a valid email address')
}
return value.toLowerCase()
}
})
declare email: string
```
## Accessing all arguments
You can access all arguments provided by the user, including their raw values, using the `this.parsed.args` property. This is useful for debugging or when you need to inspect the complete argument list.
```ts title="commands/greet.ts"
import { BaseCommand, args } from '@adonisjs/core/ace'
export default class GreetCommand extends BaseCommand {
static commandName = 'greet'
static description = 'Greet a user by name'
@args.string()
declare name: string
async run() {
/**
* Access all arguments as a key-value object
*/
console.log(this.parsed.args)
// Output: { name: 'John' }
this.logger.info(`Hello, ${this.name}!`)
}
}
```
---
# Command flags
This guide covers defining command flags within custom commands. You will learn about the following topics:
- Defining boolean, string, number, and array flags
- Customizing flag names and descriptions
- Creating flag aliases for shorthand usage
- Setting default values for flags
- Transforming and validating flag values
- Accessing all provided flags
## Overview
Flags provide a way to accept optional or named parameters without requiring a specific order. They are specified with either two hyphens (`--`) for full names or a single hyphen (`-`) for aliases.
In the following command, both `--resource` and `--singular` are flags.
```sh
node ace make:controller users --resource --singular
```
Unlike positional arguments, flags can appear anywhere in the command and can be omitted entirely if they're optional. This makes flags ideal for options that customize command behavior, such as enabling features, specifying output formats, or providing configuration values.
Ace supports multiple flag types including boolean flags for on/off options, string flags for text values, number flags for numeric input, and array flags for multiple values.
## Defining boolean flags
Boolean flags represent on/off or yes/no options. They are the simplest flag type and don't require a value - simply mentioning the flag sets it to `true`.
Use the `@flags.boolean` decorator to define a boolean flag.
```ts title="commands/make_controller.ts"
import { BaseCommand, flags } from '@adonisjs/core/ace'
export default class MakeControllerCommand extends BaseCommand {
static commandName = 'make:controller'
/**
* Enable resource controller generation
*/
@flags.boolean()
declare resource: boolean
/**
* Create a singular resource controller
*/
@flags.boolean()
declare singular: boolean
async run() {
if (this.resource) {
this.logger.info('Creating a resource controller')
}
}
}
```
When users mention the flag, its value becomes `true`. If they omit the flag, its value is `undefined`.
```sh
node ace make:controller users --resource
# this.resource === true
node ace make:controller users
# this.resource === undefined
```
### Negating boolean flags
Boolean flags support negation using the `--no-` prefix, allowing users to explicitly set a flag to `false`. This is useful when a flag has a default value of `true` and users need to disable it.
```sh
node ace make:controller users --no-resource
# this.resource === false
```
By default, the negated variant is not shown in help screens to keep output concise. You can display it using the `showNegatedVariantInHelp` option.
```ts title="commands/make_controller.ts"
@flags.boolean({
showNegatedVariantInHelp: true,
})
declare resource: boolean
```
## Defining string flags
String flags accept text values that users provide after the flag name. Use the `@flags.string` decorator to define string flags.
```ts title="commands/make_controller.ts"
import { BaseCommand, flags } from '@adonisjs/core/ace'
export default class MakeControllerCommand extends BaseCommand {
static commandName = 'make:controller'
/**
* The model name to associate with the controller
*/
@flags.string()
declare model: string
async run() {
if (this.model) {
this.logger.info(`Creating controller for ${this.model} model`)
}
}
}
```
Users provide the value after the flag name, separated by a space or equals sign.
```sh
node ace make:controller users --model user
# this.model = 'user'
node ace make:controller users --model=user
# this.model = 'user'
```
If the flag value contains spaces or special characters, users must wrap it in quotes.
```sh
node ace make:controller posts --model blog user
# this.model = 'blog'
# (only takes the first word)
node ace make:controller posts --model "blog user"
# this.model = 'blog user'
# (captures the full phrase)
```
Ace will display an error if users mention the flag but don't provide a value, even when the flag is optional.
```sh
node ace make:controller users
# Works - optional flag is not mentioned
node ace make:controller users --model
# Error: Missing value for flag --model
```
## Defining number flags
Number flags are similar to string flags but Ace validates that the provided value is a valid number. This ensures your command receives numeric input rather than arbitrary text.
Use the `@flags.number` decorator to define number flags.
```ts title="commands/create_user.ts"
import { BaseCommand, flags } from '@adonisjs/core/ace'
export default class CreateUserCommand extends BaseCommand {
static commandName = 'create:user'
/**
* Initial score for the new user
*/
@flags.number()
declare score: number
async run() {
this.logger.info(`Creating user with score: ${this.score}`)
}
}
```
Users must provide a valid numeric value.
```sh
node ace create:user --score 100
# this.score = 100
node ace create:user --score abc
# Error: Flag --score must be a valid number
```
## Defining array flags
Array flags allow users to specify the same flag multiple times, collecting all values into an array. This is useful when a command needs to accept multiple items of the same type, such as file paths, tags, or permission groups.
Use the `@flags.array` decorator to define array flags.
```ts title="commands/create_user.ts"
import { BaseCommand, flags } from '@adonisjs/core/ace'
export default class CreateUserCommand extends BaseCommand {
static commandName = 'create:user'
/**
* Groups to assign to the user
*/
@flags.array()
declare groups: string[]
async run() {
this.logger.info(`Assigning user to groups: ${this.groups.join(', ')}`)
}
}
```
Users can specify the flag multiple times to build up the array.
```sh
node ace create:user --groups=admin --groups=moderators --groups=creators
# this.groups = ['admin', 'moderators', 'creators']
```
## Customizing flag names and descriptions
By default, Ace converts your property name to dashed-case for the flag name. For example, a property named `startServer` becomes `--start-server`. You can customize this using the `flagName` option.
```ts title="commands/serve.ts"
@flags.boolean({
flagName: 'server'
})
declare startServer: boolean
```
Adding a description helps users understand the flag's purpose. The description appears in help screens when users run your command with the `--help` flag.
```ts title="commands/serve.ts"
@flags.boolean({
flagName: 'server',
description: 'Start the application server after the build'
})
declare startServer: boolean
```
## Creating flag aliases
Flag aliases provide shorthand names for flags, making commands faster to type for frequently used options. Aliases use a single hyphen (`-`) and must be a single character.
```ts title="commands/make_controller.ts"
@flags.boolean({
alias: 'r',
description: 'Generate a resource controller'
})
declare resource: boolean
@flags.boolean({
alias: 's',
description: 'Create a singular resource controller'
})
declare singular: boolean
```
Users can use either the full flag name or the alias.
```sh
node ace make:controller users --resource --singular
# Same as
node ace make:controller users -r -s
```
Multiple single-character aliases can be combined after a single hyphen.
```sh
node ace make:controller users -rs
# Equivalent to --resource --singular
```
## Setting default values
You can specify default values for flags using the `default` option. When users don't provide the flag, Ace uses the default value instead of `undefined`.
```ts title="commands/build.ts"
@flags.boolean({
default: true,
description: 'Start the application server after build'
})
declare startServer: boolean
@flags.string({
default: 'sqlite',
description: 'Database connection to use'
})
declare connection: string
```
Default values ensure your command always has a value to work with, even when users don't specify the flag.
```sh
node ace build
# this.startServer = true (default)
# this.connection = 'sqlite' (default)
node ace build --no-start-server --connection=mysql
# this.startServer = false (explicitly set)
# this.connection = 'mysql' (explicitly set)
```
## Transforming flag values
The `parse` method allows you to transform or validate flag values before they're assigned to your class property. This is useful for normalizing input, looking up configuration values, or performing validation.
The parse method receives the raw string value and must return the transformed value.
```ts title="commands/migrate.ts"
@flags.string({
description: 'Database connection to use',
parse(value) {
/**
* Map short names to full connection strings
*/
const connections = {
pg: 'postgresql://localhost/myapp',
mysql: 'mysql://localhost/myapp',
sqlite: 'sqlite://./database.sqlite'
}
return value ? connections[value] || value : value
}
})
declare connection: string
```
Now users can provide short connection names that get expanded to full connection strings.
```sh
node ace migrate --connection=pg
# this.connection = 'postgresql://localhost/myapp'
```
You can also use the parse method to validate input and throw errors for invalid values.
```ts title="commands/deploy.ts"
@flags.string({
description: 'Deployment environment',
parse(value) {
const validEnvironments = ['development', 'staging', 'production']
if (value && !validEnvironments.includes(value)) {
throw new Error(`Environment must be one of: ${validEnvironments.join(', ')}`)
}
return value
}
})
declare environment: string
```
## Accessing all flags
You can access all flags provided by the user using the `this.parsed.flags` property. This returns an object containing all flag values as key-value pairs.
```ts title="commands/make_controller.ts"
import { BaseCommand, flags } from '@adonisjs/core/ace'
export default class MakeControllerCommand extends BaseCommand {
static commandName = 'make:controller'
@flags.boolean()
declare resource: boolean
@flags.boolean()
declare singular: boolean
async run() {
/**
* Access all defined flags
*/
console.log(this.parsed.flags)
// Output: { resource: true, singular: false }
/**
* Access flags that were mentioned but not defined
* (only available when allowUnknownFlags is true)
*/
console.log(this.parsed.unknownFlags)
// Output: ['--some-unknown-flag']
}
}
```
The `unknownFlags` property is particularly useful when you've enabled `allowUnknownFlags` in your command options and need to process or pass through flags that your command doesn't explicitly define.
---
# Prompts
This guide covers using prompts within custom commands. You will learn about the following topics:
- Displaying text and password input prompts
- Creating single and multi-select choice lists
- Using confirmation and toggle prompts
- Validating and transforming user input
- Using autocomplete for searchable lists
- Testing commands with prompts
## Overview
Prompts enable interactive command line experiences by allowing users to provide input through intuitive terminal widgets rather than command line arguments or flags. This is particularly useful for commands that need to guide users through multi-step processes, collect sensitive information like passwords, or allow selection from a list of options.
Ace prompts are powered by the [@poppinss/prompts](https://github.com/poppinss/prompts) package, which supports multiple prompt types including text input, password fields, confirmations, single and multi-select lists, and autocomplete searches. All prompts support validation, default values, and transformation of user input before it's returned to your command.
A key feature of Ace prompts is their [testing support](../testing/console_tests.md). When writing tests, you can trap prompts and respond to them programmatically, making it easy to test interactive commands without manual input.
## Displaying text input
The text input prompt accepts free-form text from users. Use the `this.prompt.ask` method to display a text input prompt, providing the prompt message as the first parameter.
```ts title="commands/make_model.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class MakeModelCommand extends BaseCommand {
static commandName = 'make:model'
async run() {
/**
* Ask for the model name
*/
const modelName = await this.prompt.ask('Enter the model name')
this.logger.info(`Creating model: ${modelName}`)
}
}
```
### Adding validation
You can validate user input by providing a `validate` function in the options object. The function receives the user's input and should return `true` to accept the value, or an error message string to reject it.
```ts title="commands/make_model.ts"
const modelName = await this.prompt.ask('Enter the model name', {
validate(value) {
return value.length > 0
? true
: 'Model name is required'
}
})
```
If validation fails, the prompt displays the error message and asks for input again until the user provides a valid value.
### Providing default values
Default values appear as suggestions that users can accept by pressing Enter. This is useful for providing common values or sensible defaults.
```ts title="commands/make_model.ts"
const modelName = await this.prompt.ask('Enter the model name', {
default: 'User'
})
```
## Collecting passwords
The password prompt masks user input in the terminal, replacing each character with an asterisk or bullet point. This is essential for collecting sensitive information like passwords, API keys, or tokens.
Use the `this.prompt.secure` method to display a password prompt.
```ts title="commands/setup.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class SetupCommand extends BaseCommand {
static commandName = 'setup'
async run() {
/**
* Collect the database password securely
*/
const password = await this.prompt.secure('Enter database password')
this.logger.info('Password collected securely')
}
}
```
You can add validation to password prompts just like text inputs.
```ts title="commands/setup.ts"
const password = await this.prompt.secure('Enter account password', {
validate(value) {
return value.length >= 8
? true
: 'Password must be at least 8 characters long'
}
})
```
## Creating choice lists
The choice prompt displays a list of options that users can navigate with arrow keys and select with Enter. This is ideal when you need users to pick from predefined options.
Use the `this.prompt.choice` method to display a single-select list. The method accepts the prompt message as the first parameter and an array of choices as the second parameter.
```ts title="commands/configure.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class ConfigureCommand extends BaseCommand {
static commandName = 'configure'
async run() {
/**
* Let the user select their package manager
*/
const packageManager = await this.prompt.choice('Select package manager', [
'npm',
'yarn',
'pnpm'
])
this.logger.info(`Using ${packageManager}`)
}
}
```
### Customizing choice display
When you want the displayed text to differ from the returned value, define choices as objects with `name` and `message` properties. The `name` is what your command receives, while the `message` is what users see.
```ts title="commands/configure.ts"
const driver = await this.prompt.choice('Select database driver', [
{
name: 'sqlite',
message: 'SQLite'
},
{
name: 'mysql',
message: 'MySQL'
},
{
name: 'pg',
message: 'PostgreSQL'
}
])
this.logger.info(`Selected driver: ${driver}`)
// If user selected "PostgreSQL", driver will be "pg"
```
## Allowing multiple selections
The multi-select prompt lets users select multiple options from a list using the spacebar to toggle selections. This is useful when users need to choose multiple features, packages, or configurations.
Use the `this.prompt.multiple` method to display a multi-select list. The parameters are the same as the choice prompt.
```ts title="commands/install.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class InstallCommand extends BaseCommand {
static commandName = 'install:packages'
async run() {
/**
* Let users select multiple database drivers
*/
const drivers = await this.prompt.multiple('Select database drivers', [
{
name: 'sqlite',
message: 'SQLite'
},
{
name: 'mysql',
message: 'MySQL'
},
{
name: 'pg',
message: 'PostgreSQL'
}
])
this.logger.info(`Installing drivers: ${drivers.join(', ')}`)
}
}
```
The method returns an array of selected values. Users can select all, some, or none of the options.
## Confirming actions
Confirmation prompts ask users to answer yes or no questions. They're essential for destructive operations or actions that need explicit user consent.
Use the `this.prompt.confirm` method to display a yes/no confirmation. The method returns a boolean value.
```ts title="commands/reset.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class ResetCommand extends BaseCommand {
static commandName = 'db:reset'
async run() {
/**
* Confirm before deleting data
*/
const shouldDelete = await this.prompt.confirm(
'Want to delete all files?'
)
if (shouldDelete) {
this.logger.warning('Deleting all files...')
// Perform deletion
} else {
this.logger.info('Operation cancelled')
}
}
}
```
### Customizing yes/no labels
If you want to customize the yes/no labels to something more contextual, use the `this.prompt.toggle` method. This method accepts an array of two strings for the yes and no labels.
```ts title="commands/reset.ts"
const shouldDelete = await this.prompt.toggle(
'Want to delete all files?',
['Yup', 'Nope']
)
if (shouldDelete) {
this.logger.warning('Deleting all files...')
}
```
## Using autocomplete
The autocomplete prompt combines selection with search functionality, allowing users to fuzzy search through a large list of options. This is particularly useful when dealing with many choices that would be unwieldy in a standard selection list.
Use the `this.prompt.autocomplete` method to display a searchable list.
```ts title="commands/select_city.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class SelectCityCommand extends BaseCommand {
static commandName = 'select:city'
async run() {
/**
* Let users search and select from a large list
*/
const selectedCity = await this.prompt.autocomplete(
'Select your city',
await this.getCitiesList()
)
this.logger.info(`You selected: ${selectedCity}`)
}
private async getCitiesList() {
/**
* Return a large array of city names
*/
return [
'New York',
'Los Angeles',
'Chicago',
'Houston',
// ... hundreds more cities
]
}
}
```
Users can type to filter the list, and the prompt will show matching options based on fuzzy search.
## Understanding prompt options
All prompt types accept a common set of options through the second parameter. These options allow you to customize prompt behavior, validate input, and transform return values.
| Option | Accepted by | Description |
|--------|-------------|-------------|
| `default` | All prompts | The default value to use when no value is entered. For select, multiselect, and autocomplete prompts, the value must be the choices array index. |
| `name` | All prompts | The unique name for the prompt, useful for identifying prompts in tests. |
| `hint` | All prompts | Hint text to display next to the prompt, providing additional context to users. |
| `result` | All prompts | Transform the prompt return value. The input value depends on the prompt type (e.g., multiselect returns an array of selected choices). |
| `format` | All prompts | Live format the input value as the user types. The formatting is only applied to the CLI output, not the return value. |
| `validate` | All prompts | Validate user input. Return `true` to accept the value, or a string error message to reject it. |
| `limit` | `autocomplete` | Limit the number of options to display. Users will need to scroll to see additional options. |
### Transforming return values
The `result` function transforms the value returned by the prompt. This is useful for converting user input to a different format or type.
```ts title="commands/make_model.ts"
const modelName = await this.prompt.ask('Enter the model name', {
result(value) {
/**
* Convert to PascalCase for class names
*/
return value.charAt(0).toUpperCase() + value.slice(1)
}
})
```
### Formatting display values
The `format` function changes how the input appears in the terminal as users type, without affecting the actual return value.
```ts title="commands/configure.ts"
const email = await this.prompt.ask('Enter your email', {
format(value) {
/**
* Display email in lowercase as user types
*/
return value.toLowerCase()
}
})
```
### Adding hints
Hints provide additional context or instructions that appear next to the prompt.
```ts title="commands/make_migration.ts"
const tableName = await this.prompt.ask('Enter table name', {
hint: 'Use plural form (e.g., users, posts)'
})
```
---
# Terminal UI
This guide covers different aspects of Terminal UIs. You will learn about the following topics:
- Displaying log messages with different severity levels
- Adding loading animations and action indicators
- Formatting text with colors
- Rendering tables with custom alignment
- Creating boxed content with stickers
- Building animated task runners with progress updates
## Overview
The Ace terminal UI is powered by the [@poppinss/cliui](https://github.com/poppinss/cliui) package, which provides helpers for displaying logs, rendering tables, showing animated tasks, and more.
All terminal UI primitives are built with testing in mind. When writing tests, you can enable "raw" mode to disable colors and formatting, making it easy to collect logs in memory and write assertions against them. This design ensures your commands remain testable while delivering rich visual experiences to users.
## Displaying log messages
The CLI logger provides methods for displaying messages at different severity levels. Each log level uses distinct colors and icons to help users quickly identify message importance.
```ts title="commands/deploy.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class DeployCommand extends BaseCommand {
static commandName = 'deploy'
async run() {
/**
* Debug message - helpful for troubleshooting
*/
this.logger.debug('Loading deployment configuration')
/**
* Info message - general information
*/
this.logger.info('Deploying application to production')
/**
* Success message - operation completed successfully
*/
this.logger.success('Deployment completed successfully')
/**
* Warning message - potential issues
*/
this.logger.warning('SSL certificate expires in 30 days')
/**
* Error and fatal messages - written to stderr
*/
this.logger.error(new Error('Failed to upload assets'))
this.logger.fatal(new Error('Deployment failed completely'))
}
}
```
The `error` and `fatal` methods write to stderr rather than stdout, making it easier for users to redirect error output separately from normal output.
### Adding prefix and suffix
You can add prefix and suffix text to log messages for additional context. Both prefix and suffix are displayed with reduced opacity to distinguish them from the main message.
```ts title="commands/install.ts"
/**
* Add a suffix showing the command being run
*/
this.logger.info('Installing packages', {
suffix: 'npm i --production'
})
/**
* Add a prefix showing the process ID
*/
this.logger.info('Starting worker', {
prefix: process.pid
})
```
### Creating loading animations
Loading animations display animated dots after a message, providing visual feedback during long-running operations. You can update the message text and stop the animation when the operation completes.
```ts title="commands/build.ts"
/**
* Create a loading animation
*/
const animation = this.logger.await('Installing packages', {
suffix: 'npm i'
})
/**
* Start the animation
*/
animation.start()
/**
* Update the message as progress continues
*/
setTimeout(() => {
animation.update('Unpacking packages', {
suffix: undefined
})
}, 2000)
/**
* Stop the animation when complete
*/
setTimeout(() => {
animation.stop()
this.logger.success('Installation complete')
}, 4000)
```
### Displaying action status
Logger actions provide a consistent way to display the status of operations with automatic styling and color coding. This is particularly useful when performing multiple sequential tasks.
```ts title="commands/setup.ts"
/**
* Create an action indicator
*/
const createFile = this.logger.action('creating config/auth.ts')
try {
await this.createConfigFile()
/**
* Mark the action as succeeded
* Optional: display how long it took
*/
createFile.displayDuration().succeeded()
} catch (error) {
/**
* Mark the action as failed with the error
*/
createFile.failed(error)
}
```
Actions can be marked with three different states:
```ts title="commands/setup.ts"
/**
* Operation completed successfully
*/
action.succeeded()
/**
* Operation was skipped with a reason
*/
action.skipped('File already exists')
/**
* Operation failed with an error
*/
action.failed(new Error('Permission denied'))
```
## Formatting text with colors
Ace uses [kleur](https://www.npmjs.com/package/kleur) for applying ANSI color codes to text. Access kleur's chained API through the `this.colors` property to format text with foreground colors, background colors, and text styles.
```ts title="commands/status.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class StatusCommand extends BaseCommand {
static commandName = 'status'
async run() {
/**
* Apply foreground colors
*/
this.logger.info(this.colors.red('[ERROR]'))
this.logger.info(this.colors.green('[SUCCESS]'))
this.logger.info(this.colors.yellow('[WARNING]'))
/**
* Combine background and foreground colors
*/
this.logger.info(this.colors.bgGreen().white(' CREATED '))
this.logger.info(this.colors.bgRed().white(' FAILED '))
/**
* Apply text styles
*/
this.logger.info(this.colors.bold('Important message'))
this.logger.info(this.colors.dim('Less important details'))
}
}
```
## Rendering tables
Tables organize data into rows and columns, making it easy for users to scan and compare information. Create a table using the `this.ui.table` method, which returns a `Table` instance for defining headers and rows.
```ts title="commands/list_migrations.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class ListMigrationsCommand extends BaseCommand {
static commandName = 'migration:list'
async run() {
/**
* Create a new table
*/
const table = this.ui.table()
/**
* Define table headers
*/
table.head([
'Migration',
'Duration',
'Status',
])
/**
* Add table rows
*/
table.row([
'1590591892626_tenants.ts',
'2ms',
'DONE'
])
table.row([
'1590595949171_entities.ts',
'2ms',
'DONE'
])
/**
* Render the table to the terminal
*/
table.render()
}
}
```
You can apply color formatting to any table cell by wrapping values with color methods.
```ts title="commands/list_migrations.ts"
table.row([
'1590595949171_entities.ts',
'2ms',
this.colors.green('DONE')
])
table.row([
'1590595949172_users.ts',
'5ms',
this.colors.red('FAILED')
])
```
### Right-aligning columns
By default, all columns are left-aligned. You can right-align columns by defining them as objects with an `hAlign` property. When right-aligning a column, make sure to also right-align the corresponding header.
```ts title="commands/list_migrations.ts"
/**
* Right-align the status column header
*/
table.head([
'Migration',
'Batch',
{
content: 'Status',
hAlign: 'right'
},
])
/**
* Right-align the status column data
*/
table.row([
'1590595949171_entities.ts',
'2',
{
content: this.colors.green('DONE'),
hAlign: 'right'
}
])
```
### Rendering full-width tables
By default, tables automatically size columns to fit their content. However, you can render tables at full terminal width using the `fullWidth` method.
In full-width mode, all columns except one use their content width, while the designated "fluid" column expands to fill remaining space. By default, the first column is fluid.
```ts title="commands/list_files.ts"
/**
* Render table at full terminal width
*/
table.fullWidth().render()
```
You can change which column expands to fill available space using the `fluidColumnIndex` method.
```ts title="commands/list_files.ts"
/**
* Make the second column (index 1) fluid instead
*/
table
.fullWidth()
.fluidColumnIndex(1)
.render()
```
## Creating boxed content with stickers
Stickers render content inside a bordered box, drawing user attention to important information like server addresses, configuration instructions, or key next steps.
```ts title="commands/serve.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class ServeCommand extends BaseCommand {
static commandName = 'serve'
async run() {
/**
* Create a sticker for displaying server info
*/
const sticker = this.ui.sticker()
sticker
.add('Started HTTP server')
.add('')
.add(`Local address: ${this.colors.cyan('http://localhost:3333')}`)
.add(`Network address: ${this.colors.cyan('http://192.168.1.2:3333')}`)
.render()
}
}
```
For displaying step-by-step instructions, use the `this.ui.instructions` method instead. This prefixes each line with an arrow symbol (`>`), making it clear these are action items.
```ts title="commands/init.ts"
/**
* Display post-installation instructions
*/
const instructions = this.ui.instructions()
instructions
.add('Run npm install to install dependencies')
.add('Copy .env.example to .env and configure your environment')
.add('Run node ace migrate to set up the database')
.render()
```
## Building animated task runners
The tasks widget provides a polished UI for executing and displaying progress of multiple time-consuming operations. It supports two rendering modes: minimal (for production use) and verbose (for debugging).
In minimal mode, only the currently running task is expanded to show progress updates. In verbose mode, every progress message is logged on its own line, making it easier to debug issues.
### Creating basic tasks
Create a tasks widget using the `this.ui.tasks` method, then add individual tasks with the `add` method.
```ts title="commands/setup.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class SetupCommand extends BaseCommand {
static commandName = 'setup'
async run() {
/**
* Create a tasks widget
*/
const tasks = this.ui.tasks()
/**
* Add tasks and execute them
*/
await tasks
.add('clone repo', async (task) => {
await this.cloneRepository()
return 'Completed'
})
.add('update package file', async (task) => {
try {
await this.updatePackageFile()
return 'Updated'
} catch (error) {
return task.error('Unable to update package file')
}
})
.add('install dependencies', async (task) => {
await this.installDependencies()
return 'Installed'
})
.run()
}
}
```
Each task callback must return a status message. Returning a normal string indicates success, while wrapping the return value in `task.error()` indicates failure. You can also throw an exception to mark a task as failed.
### Reporting task progress
Instead of using `console.log` or `this.logger` inside task callbacks, use the `task.update` method to report progress. This ensures progress updates are displayed correctly in both minimal and verbose modes.
```ts title="commands/build.ts"
/**
* Helper to simulate async work
*/
const sleep = () => new Promise((resolve) => setTimeout(resolve, 50))
const tasks = this.ui.tasks()
await tasks
.add('clone repo', async (task) => {
/**
* Report progress as the task executes
*/
for (let i = 0; i <= 100; i = i + 2) {
await sleep()
task.update(`Downloaded ${i}%`)
}
return 'Completed'
})
.run()
```
In minimal mode, only the latest progress message is visible. In verbose mode, all messages are logged as they occur.
### Enabling verbose mode
You may want to allow users to enable verbose output for debugging. This is commonly done by accepting a `--verbose` flag.
```ts title="commands/deploy.ts"
import { BaseCommand, flags } from '@adonisjs/core/ace'
export default class DeployCommand extends BaseCommand {
static commandName = 'deploy'
/**
* Accept a verbose flag
*/
@flags.boolean({
description: 'Enable verbose output'
})
declare verbose: boolean
async run() {
/**
* Enable verbose mode based on the flag
*/
const tasks = this.ui.tasks({
verbose: this.verbose
})
await tasks
.add('build assets', async (task) => {
// Task implementation
})
.run()
}
}
```
Users can now run your command with `--verbose` to see detailed progress logs for debugging.
---
# REPL
In this guide, you will learn about the following topics:
- Starting and navigating the REPL session
- Importing modules and accessing services
- Using built-in helper methods
- Accessing command history and results
- Adding custom REPL methods
- Working with the editor mode
## Overview
The AdonisJS REPL extends the standard [Node.js REPL](https://nodejs.org/api/repl.html) with application-aware features that make it easy to interact with your codebase. Unlike the basic Node.js REPL, the AdonisJS REPL boots your application, loads its services, and provides convenient shortcuts for common tasks.
The REPL is particularly useful during development for quick experimentation, debugging, and data exploration. You can import TypeScript files directly, access container services without manual imports, create class instances through the IoC container, and extend the REPL with custom methods specific to your application.
## Starting the REPL session
You can start the REPL session using the `node ace repl` command. This boots your AdonisJS application and opens an interactive prompt where you can execute code.
```sh
node ace repl
```
:::media

:::
Once started, you'll see a prompt where you can type JavaScript code and press Enter to execute it. The output appears immediately on the following line, creating a fast feedback loop for testing and exploration.
## Using editor mode
While the REPL is great for single-line expressions, you sometimes need to write multi-line code blocks. The editor mode allows you to write multiple lines of code before executing them.
Enter editor mode by typing the `.editor` command at the REPL prompt.
```sh
> (js) .editor
# // Entering editor mode (Ctrl+D to finish, Ctrl+C to cancel)
```
In editor mode, you can write multiple lines of code. Press `Ctrl+D` to execute the entire code block, or press `Ctrl+C` to cancel and exit editor mode without executing anything.
```sh
> (js) .editor
# // Entering editor mode (Ctrl+D to finish, Ctrl+C to cancel)
const users = await User.query()
.where('isActive', true)
.orderBy('createdAt', 'desc')
.limit(10)
console.log(`Found ${users.length} active users`)
# // Press Ctrl+D to execute
```
## Accessing previous results
The REPL provides special variables for accessing results and errors from previously executed commands, eliminating the need to re-run code when you forget to store a value.
### Accessing the last result
If you execute a statement but forget to assign its result to a variable, you can access it using the `_` (underscore) variable.
```sh
> (js) helpers.string.random(32)
# 'Z3y8QQ4HFpYSc39O2UiazwPeKYdydZ6M'
> (js) _
# 'Z3y8QQ4HFpYSc39O2UiazwPeKYdydZ6M'
> (js) _.length
# 32
```
This is particularly useful when you want to perform additional operations on a result without re-executing the original command.
### Accessing the last error
Similarly, you can access any exception raised by the previous command using the `_error` variable. This is helpful for inspecting error details without cluttering your code with try/catch blocks.
```sh
> (js) helpers.string.random()
# Error: The value of "size" is out of range...
> (js) _error.message
# 'The value of "size" is out of range. It must be >= 0 && <= 2147483647. Received NaN'
> (js) _error.stack
# (full error stack trace)
```
## Navigating command history
The REPL maintains a history of all commands you've executed, saved in the `.adonisjs_v7_repl_history` file in your home directory. This allows you to recall and re-execute previous commands without retyping them.
You can navigate through command history in two ways:
- **Arrow key navigation**: Press the up arrow `↑` key to cycle through previous commands one at a time. Press the down arrow `↓` to move forward through the history.
- **Search mode**: Press `Ctrl+R` to enter reverse search mode, then type characters to search for matching commands in your history. Press `Ctrl+R` again to cycle through multiple matches.
```sh
> (js) [Press Ctrl+R]
(reverse-i-search)`query': const users = await User.query()
```
## Exiting the REPL session
You can exit the REPL session either by typing `.exit` or by pressing `Ctrl+C` twice in quick succession to exit.
```sh
> (js) .exit
# Goodbye!
```
When you exit, AdonisJS performs a graceful shutdown, closing database connections and cleaning up resources before the process terminates.
Note that the REPL session does not automatically reload when you modify your codebase. If you change your application code, you must exit and restart the REPL session for the changes to take effect.
## Importing modules
Node.js does not support `import` statements in REPL sessions, so you must use dynamic `import()` expressions instead. When importing, you need to destructure the module exports or access specific properties.
```sh
> (js) const { default: User } = await import('#models/user')
# undefined
> (js) await User.all()
# [User, User, User, ...]
```
The syntax `const { default: User }` destructures the default export from the module. This can be verbose when you only want the default export.
### Using the importDefault helper
To simplify importing default exports, the REPL provides an `importDefault` helper method that automatically extracts the default export.
```sh
> (js) const User = await importDefault('#models/user')
# undefined
> (js) const Post = await importDefault('#models/post')
# undefined
> (js) await Post.query().where('published', true)
# [Post, Post, Post, ...]
```
This is particularly convenient when working with models, services, or any modules that export a single default value.
## Using helper methods
The REPL includes several built-in helper methods that provide shortcuts for common tasks like importing services, making class instances, and managing the REPL context.
You can view all available helper methods by typing the `.ls` command.
```sh
> (js) .ls
# GLOBAL METHODS:
importDefault Returns the default export for a module
make Make class instance using "container.make" method
loadApp Load "app" service in the REPL context
loadEncryption Load "encryption" service in the REPL context
loadHash Load "hash" service in the REPL context
loadRouter Load "router" service in the REPL context
loadConfig Load "config" service in the REPL context
loadTestUtils Load "testUtils" service in the REPL context
loadHelpers Load "helpers" module in the REPL context
clear Clear a property from the REPL context
p Promisify a function. Similar to Node.js "util.promisify"
```
### Loading services
Instead of manually importing services, you can use the `load*` helper methods to load them into the REPL context.
```sh
> (js) await loadRouter()
# Imported router. You can access it using the "router" property
> (js) router.toJSON()
# { routes: [...], ... }
> (js) await loadHash()
# Imported hash. You can access it using the "hash" property
> (js) await hash.make('secret')
# '$argon2id$v=19$m=65536,t=3,p=4$...'
```
Each `load*` method imports the corresponding service and makes it available as a property in the REPL context, eliminating the need for import statements.
### Making class instances
The `make` method uses the [IoC container](../concepts/dependency_injection.md#constructing-a-tree-of-dependencies) to create class instances with automatic dependency injection.
```sh
> (js) const userService = await make('App/Services/UserService')
# undefined
> (js) await userService.findById(1)
# User { id: 1, email: 'user@example.com', ... }
```
This is useful when you want to test services or classes that have constructor dependencies, as the container automatically resolves and injects them.
### Promisifying functions
The `p` method promisifies callback-based functions, similar to Node.js `util.promisify`.
```sh
> (js) const readFile = p(fs.readFile)
# undefined
> (js) await readFile('package.json', 'utf8')
# '{ "name": "my-app", ... }'
```
## Adding custom REPL methods
You can extend the REPL with custom methods specific to your application. This is useful for creating shortcuts that you use frequently during development, such as loading all models or seeding test data.
Custom methods are typically defined in a [preload file](../../reference/adonisrc_file.md#preloads) that runs only in the REPL environment.
### Creating a REPL preload file
First, generate a preload file configured to run only in the REPL environment.
```sh
node ace make:preload repl -e=repl
# CREATE: start/repl.ts
```
### Defining a custom method
In the preload file, use `repl.addMethod` to define custom methods. For example, let's create a method that imports all models from the `app/models` directory.
```ts title="start/repl.ts"
import app from '@adonisjs/core/services/app'
import repl from '@adonisjs/core/services/repl'
import { fsImportAll } from '@adonisjs/core/helpers'
/**
* Add a method to load all models at once
*/
repl.addMethod(
'loadModels',
async () => {
/**
* Import all files from the models directory
*/
const models = await fsImportAll(app.makePath('app/models'))
/**
* Make models available in the REPL context
*/
repl.server!.context.models = models
/**
* Notify the user that models are loaded
*/
repl.notify(
'Imported models. You can access them using the "models" property'
)
/**
* Display the prompt again
*/
repl.server!.displayPrompt()
},
{
description: 'Load all models from app/models directory',
usage: 'await loadModels()'
}
)
```
The `repl.addMethod` accepts three parameters:
1. **Method name**: The name you'll use to call the method in the REPL
2. **Implementation**: An async function that performs the action
3. **Options** (optional): An object with `description` and `usage` properties that appear in help output
### Using your custom method
After restarting the REPL session, your custom method becomes available.
```sh
node ace repl
> (js) .ls
# GLOBAL METHODS:
# loadModels Load all models from app/models directory
# ...
> (js) await loadModels()
# Imported models. You can access them using the "models" property
> (js) Object.keys(models)
# ['User', 'Post', 'Comment', ...]
> (js) await models.User.all()
# [User, User, User, ...]
```
You can define multiple custom methods in the same preload file to create a comprehensive set of development shortcuts tailored to your application's needs.
---
# Introduction to testing
This guide covers the testing setup in AdonisJS applications. You will learn:
- About Japa, the testing framework used by AdonisJS
- How testing is configured through suites and plugins
- How to create and run your first test
- How to filter tests by file, name, tags, or suite
- How to use watch mode for rapid development
- How to override environment variables for testing
## Overview
AdonisJS has built-in support for testing, and all starter kits come pre-configured with a complete testing setup. You can start writing tests immediately without any additional configuration.
The testing layer is powered by [Japa](https://japa.dev), a testing framework we've built and maintained for over seven years. Unlike general-purpose test runners like Jest or Vitest, Japa is purpose-built for backend applications. It runs natively in Node.js without transpilers and includes plugins specifically designed for backend testing, such as an API client for testing JSON endpoints and a filesystem plugin for managing temporary files during tests.
We chose to build and maintain our own testing framework to avoid the churn that's common in the JavaScript ecosystem. Having seen the community shift from Mocha to Jest to Vitest, we're glad we invested in tooling we control and can evolve alongside AdonisJS.
## Japa and AdonisJS integration
Japa integrates deeply with AdonisJS through the `@japa/plugin-adonisjs` package. This plugin extends Japa with AdonisJS-specific utilities, giving your tests access to the application instance, route helpers for computing URLs, and methods for reading and writing cookies during HTTP and browser tests.
The integration means you write tests that feel native to AdonisJS rather than bolting on a generic test runner that doesn't understand your application's structure.
## Project structure
AdonisJS organizes tests into suites, where each suite represents a category of tests with its own configuration. A typical project structure looks like this.
```sh
tests/
├── bootstrap.ts
├── unit/
│ └── posts_service.spec.ts
└── browser/
└── posts.spec.ts
```
The `tests/bootstrap.ts` file configures Japa plugins and lifecycle hooks. Individual test files live in suite directories, and each suite can have different timeouts, plugins, and setup logic appropriate for that type of testing.
### Understanding suites
A test suite groups related tests that share common characteristics. For example, unit tests run quickly and don't need an HTTP server, while browser tests require a running server and have longer timeouts to account for browser automation.
Hypermedia and Inertia starter kits come with two suites pre-configured:
- **unit** tests isolated pieces of code like services, utilities, and models
- **browser** tests run end-to-end with Playwright, simulating real user interactions
Suites are defined in your `adonisrc.ts` file.
```ts title="adonisrc.ts"
{
tests: {
suites: [
{
files: ['tests/unit/**/*.spec.ts'],
name: 'unit',
timeout: 2000,
},
{
files: ['tests/browser/**/*.spec.ts'],
name: 'browser',
timeout: 300000,
},
],
forceExit: false,
}
}
```
Each suite specifies a glob pattern for locating test files, a name for filtering, and a timeout in milliseconds. Browser tests have a much longer timeout (5 minutes) because browser automation is inherently slower than in-process unit tests.
### Configuring plugins and hooks
The `tests/bootstrap.ts` file is where you configure Japa plugins and define lifecycle hooks that run before and after your test suites.
```ts title="tests/bootstrap.ts"
import { assert } from '@japa/assert'
import app from '@adonisjs/core/services/app'
import type { Config } from '@japa/runner/types'
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
import testUtils from '@adonisjs/core/services/test_utils'
import { browserClient } from '@japa/browser-client'
import { authBrowserClient } from '@adonisjs/auth/plugins/browser_client'
import { sessionBrowserClient } from '@adonisjs/session/plugins/browser_client'
/**
* Configure Japa plugins in the plugins array.
* Learn more - https://japa.dev/docs/runner-config#plugins-optional
*/
export const plugins: Config['plugins'] = [
assert(),
pluginAdonisJS(app),
browserClient({ runInSuites: ['browser'] }),
sessionBrowserClient(app),
authBrowserClient(app),
]
/**
* Configure lifecycle function to run before and after all the
* tests.
*/
export const runnerHooks: Required> = {
setup: [],
teardown: [],
}
/**
* Configure suites by tapping into the test suite instance.
* Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks
*/
export const configureSuite: Config['configureSuite'] = (suite) => {
if (['browser', 'functional', 'e2e'].includes(suite.name)) {
return suite.setup(() => testUtils.httpServer().start())
}
}
```
The `configureSuite` function allows you to add setup logic specific to certain suites. In this example, browser, functional, and e2e suites automatically start the HTTP server before tests run.
## Creating your first test
Generate a new test file using the `make:test` command.
```sh
node ace make:test posts/index --suite=browser
```
This creates a test file at `tests/browser/posts/index.spec.ts` with the following structure.
```ts title="tests/browser/posts/index.spec.ts"
import { test } from '@japa/runner'
test.group('Posts index', () => {
test('display a list of all posts', async ({ assert }) => {})
})
```
Tests are organized into groups using `test.group()`, which helps structure related tests and allows you to apply shared setup and teardown logic. Individual tests are defined with `test()` and receive a context object containing utilities like `assert` for making assertions.
## Running tests
Run your entire test suite with the following command.
```sh
node ace test
```
To run a specific suite, pass the suite name as an argument.
```sh
node ace test unit
node ace test browser
```
### Filtering tests
Japa provides several flags for running a subset of tests.
::::options
:::option{name="--tests"}
Filter by exact test title.
```sh
# Run tests with exact title match
node ace test --tests="can list all posts"
```
:::
:::option{name="--files"}
Filter by test filename (matches against the end of the filename without `.spec.ts`). The `--files` flag supports wildcards for running all tests in a directory.
```sh
# Run a specific test file
node ace test --files="posts/index"
# Run all tests in the posts directory
node ace test --files="posts/*"
```
:::
:::option{name="--groups"}
Filter by exact group name.
```sh
# Run all tests in a specific group
node ace test --groups="Posts index"
```
:::
:::option{name="--tags"}
Filter by tags (prefix with `~` to exclude).
:::
:::option{name="--matchAll"}
Require all specified tags to match instead of any.
:::
::::
### Watch mode
During development, use watch mode to automatically re-run tests when files change.
```sh
node ace test --watch
```
When a test file changes, only that file's tests are re-run. When a source file changes, all tests are executed.
:::tip
If you're iterating on a single test, combine watch mode with the `--files` filter. This ensures any file change runs only the tests you're focused on, providing faster feedback.
:::
### Additional flags
Two flags are particularly useful during development.
```sh
# Stop on first failure
node ace test --bail
# Re-run only tests that failed in the last run
node ace test --failed
```
The `--bail` flag helps when debugging a failing test by preventing subsequent tests from running. The `--failed` flag is useful for quickly verifying that your fix resolved all failures without running the entire suite.
## Environment variables
AdonisJS automatically loads a `.env.test` file when running tests, merging its values with your standard `.env` file. Any variable defined in `.env.test` overrides the corresponding value from `.env`.
Create a `.env.test` file in your project root to configure test-specific settings.
```dotenv title=".env.test"
SESSION_DRIVER=memory
```
The memory session driver is commonly used in tests because it doesn't persist sessions between requests, ensuring test isolation. Other variables you might override include database connection settings, mail drivers, or any service configuration that should behave differently during testing.
## Next steps
Now that you understand how testing is configured in AdonisJS, explore the specific testing guides:
- [Browser tests](./browser_tests.md) - Browser testing with Playwright for Hypermedia and Inertia applications
- [Testing APIs](./api_tests.md) - HTTP testing for JSON APIs
- [CLI tests](./console_tests.md) - Testing Ace commands
---
# API tests
This guide covers testing JSON API endpoints in AdonisJS applications. You will learn how to:
- Configure the API client and related plugins
- Write tests for API endpoints using route names
- Send JSON and form data with requests
- Work with cookies and sessions during tests
- Authenticate users using sessions or access tokens
- Debug requests and responses
- Assert on response status, body, headers, and more
## Overview
API testing in AdonisJS uses [Japa's API client](https://japa.dev/docs/plugins/api-client) to make real HTTP requests against your application. Unlike mocked or simulated requests, the API client boots your AdonisJS server and sends actual network requests from outside in. This approach tests your entire HTTP layer—routes, middleware, controllers, and responses—exactly as they would behave in production.
The API client integrates with AdonisJS features like sessions and authentication through dedicated plugins, making it straightforward to test protected endpoints and stateful interactions.
## Configuration
The `api` starter kit comes pre-configured with three plugins in the `tests/bootstrap.ts` file.
```ts title="tests/bootstrap.ts"
import { apiClient } from '@japa/api-client'
import { authApiClient } from '@adonisjs/auth/plugins/api_client'
import { sessionApiClient } from '@adonisjs/session/plugins/api_client'
import type { Registry } from '../.adonisjs/client/registry/schema.d.ts'
declare module '@japa/api-client/types' {
interface RoutesRegistry extends Registry {}
}
export const plugins: Config['plugins'] = [
assert(),
pluginAdonisJS(app),
// [!code highlight:12]
/**
* Configures Japa's API client for making HTTP requests
*/
apiClient(),
/**
* Adds support for reading/writing session data during requests
*/
sessionApiClient(app),
/**
* Adds support for authenticating users during requests
*/
authApiClient(app),
]
```
When using sessions during tests, the session driver must be set to `memory` in your `.env.test` file. This is configured by default in the starter kit.
```dotenv title=".env.test"
SESSION_DRIVER=memory
```
## Writing your first test
Let's test an account creation endpoint that validates input and creates a new user. We'll write two tests: one for validation errors and one for successful creation.
The route is defined in `start/routes.ts`.
```ts title="start/routes.ts"
router.post('signup', [controllers.NewAccount, 'store'])
```
The first test verifies that validation errors are returned when required fields are missing. The `client.visit()` method accepts a route name and automatically determines the HTTP method and URL pattern from your route definition.
```ts title="tests/functional/auth/signup.spec.ts"
import { test } from '@japa/runner'
test.group('Auth signup', () => {
test('return error when required fields are not provided', async ({ client }) => {
/**
* Make a POST request to the signup route.
* Since no data is sent, validation should fail.
*/
const response = await client.visit('new_account.store')
response.assertStatus(422)
response.assertBodyContains({
errors: [
{
field: 'fullName',
message: 'The fullName field must be defined',
rule: 'required',
},
{
field: 'email',
message: 'The email field must be defined',
rule: 'required',
},
{
field: 'password',
message: 'The password field must be defined',
rule: 'required',
},
{
field: 'passwordConfirmation',
message: 'The passwordConfirmation field must be defined',
rule: 'required',
},
],
})
})
})
```
The second test sends valid data and verifies the user was created. You can query the database directly in your tests to verify side effects.
```ts title="tests/functional/auth/signup.spec.ts"
import { test } from '@japa/runner'
import User from '#models/user'
test.group('Auth signup', () => {
test('create user account', async ({ client, assert }) => {
/**
* Send JSON data using the fluent .json() method
*/
const response = await client.visit('new_account.store').json({
fullName: 'John doe',
email: 'john@example.com',
password: 'secret@123A',
passwordConfirmation: 'secret@123A',
})
response.assertStatus(200)
response.assertBodyContains({
data: {
fullName: 'John doe',
email: 'john@example.com',
},
})
/**
* Verify the user was persisted to the database
*/
const user = await User.findOrFail(response.body().data.id)
assert.equal(user.email, 'john@example.com')
})
})
```
## Cleaning up database state
Tests that create database records need cleanup between runs to ensure isolation. The `testUtils.db().truncate()` hook migrates the database and truncates all tables after each test.
See also: [Database testing utilities](./resetting_state_between_tests.md) for additional methods like migrations and seeders.
```ts title="tests/functional/auth/signup.spec.ts"
import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
test.group('Auth signup', (group) => {
/**
* Truncate tables after each test to ensure
* a clean state for the next test
*/
group.each.setup(() => {
return testUtils.db().truncate()
})
test('create user account', async ({ client, assert }) => {
// ...
})
})
```
## Making requests
The API client provides two approaches for making HTTP requests: using route names or explicit HTTP methods.
### Using route names
The `client.visit()` method accepts a route name and looks up the HTTP method and URL pattern from your router. This keeps your tests in sync with route changes and also provides type-safety within tests.
```ts
const response = await client.visit('posts.store')
```
### Using HTTP methods
When you need to hit a specific URL directly, use the explicit HTTP method functions.
```ts
const response = await client.get('/api/posts')
const response = await client.post('/api/posts')
const response = await client.put('/api/posts/1')
const response = await client.patch('/api/posts/1')
const response = await client.delete('/api/posts/1')
```
## Sending request data
### JSON data
Use the `json()` method to send a JSON payload. The `Content-Type` header is set automatically.
```ts
const response = await client.visit('posts.store').json({
title: 'Hello World',
content: 'This is my first post',
})
```
### Form data
Use the `form()` method to send URL-encoded form data.
```ts
const response = await client.visit('posts.store').form({
title: 'Hello World',
content: 'This is my first post',
})
```
### Multipart data
Use the `field()` method to send multipart form fields.
```ts
const response = await client
.visit('posts.store')
.field('title', 'Hello World')
.field('content', 'This is my first post')
```
## Cookies
You can set cookies on outgoing requests using the `withCookie()` method and its variants.
```ts
/**
* Set a regular cookie
*/
const response = await client
.visit('checkout.store')
.withCookie('affiliateId', '1')
/**
* Set an encrypted cookie (uses AdonisJS encryption)
*/
const response = await client
.visit('checkout.store')
.withEncryptedCookie('affiliateId', '1')
/**
* Set a plain cookie (no signing or encryption)
*/
const response = await client
.visit('checkout.store')
.withPlainCookie('affiliateId', '1')
```
## Sessions
The `withSession()` method populates the session store before making a request. This is useful for testing flows that depend on existing session state.
```ts
const response = await client
.visit('checkout.store')
.withSession({ cartId: 1 })
```
## Authentication
### Session authentication
The `loginAs()` method authenticates a user for the request using your default auth guard. You must create the user before making the authenticated request.
```ts
test('create a post', async ({ client }) => {
const user = await User.create({
fullName: 'John',
email: 'john@example.com',
password: 'secret',
})
const response = await client.visit('posts.store').loginAs(user)
response.assertStatus(200)
})
```
### Token authentication
When using access tokens or a different auth guard, chain the `withGuard()` method before `loginAs()` to specify which guard to use.
```ts
test('create a post via API', async ({ client }) => {
const user = await User.create({
fullName: 'John',
email: 'john@example.com',
password: 'secret',
})
/**
* Use the 'api' guard for token-based authentication
*/
const response = await client
.visit('posts.store')
.withGuard('api')
.loginAs(user)
response.assertStatus(200)
})
```
Make sure your route middleware allows authentication using the specified guard.
```ts title="start/routes.ts"
router
.group(() => {
router.post('posts', [controllers.Posts, 'store'])
})
.use(middleware.auth({ guards: ['web', 'api'] }))
```
## Debugging
### Dumping requests
Chain the `dump()` method when building a request to log the request details before it's sent.
```ts
const response = await client
.visit('posts.store')
.dump()
.json({ title: 'Hello World' })
```
### Dumping responses
The response object provides methods to inspect what was returned.
```ts
const response = await client.visit('posts.index')
/**
* Dump the entire response (status, headers, body)
*/
response.dump()
/**
* Dump only the response body
*/
response.dumpBody()
/**
* Dump only the response headers
*/
response.dumpHeaders()
```
### Checking for server errors
Use `hasFatalError()` to check if the server returned a 500-level error.
```ts
const response = await client.visit('posts.store').json(data)
if (response.hasFatalError()) {
response.dump()
}
```
## Assertions reference
The response object provides assertion methods for validating status codes, body content, headers, cookies, and session data.
### Status and body assertions
| Method | Description |
|--------|-------------|
| `assertStatus(status)` | Assert the response status matches the expected value |
| `assertBody(body)` | Assert the response body exactly matches the expected value |
| `assertBodyContains(subset)` | Assert the response body contains the expected subset |
| `assertBodyNotContains(subset)` | Assert the response body does not contain the subset |
| `assertTextIncludes(text)` | Assert the response text includes the substring |
### Header assertions
| Method | Description |
|--------|-------------|
| `assertHeader(name, value?)` | Assert a header exists, optionally checking its value |
| `assertHeaderMissing(name)` | Assert a header does not exist |
### Cookie assertions
| Method | Description |
|--------|-------------|
| `assertCookie(name, value?)` | Assert a cookie exists, optionally checking its value |
| `assertCookieMissing(name)` | Assert a cookie does not exist |
### Redirect assertions
| Method | Description |
|--------|-------------|
| `assertRedirectsTo(pathname)` | Assert the response redirects to the given pathname |
### Session assertions
| Method | Description |
|--------|-------------|
| `assertSession(key, value?)` | Assert a session key exists, optionally checking its value |
| `assertSessionMissing(key)` | Assert a key is missing from the session store |
| `assertFlashMessage(key, value?)` | Assert a flash message exists, optionally checking its value |
| `assertFlashMissing(key)` | Assert a key is missing from flash messages |
### Validation error assertions
| Method | Description |
|--------|-------------|
| `assertHasValidationError(field)` | Assert flash messages contain validation errors for the field |
| `assertDoesNotHaveValidationError(field)` | Assert flash messages do not contain validation errors for the field |
| `assertValidationError(field, message)` | Assert a specific error message for a field |
| `assertValidationErrors(field, messages)` | Assert all error messages for a field |
### OpenAPI assertions
| Method | Description |
|--------|-------------|
| `assertAgainstApiSpec()` | Assert the response body is valid according to your OpenAPI specification |
See also: [Japa API Client documentation](https://japa.dev/docs/plugins/api-client)
---
# Browser tests
This guide covers end-to-end browser testing for hypermedia and Inertia applications. You will learn how to:
- Configure browser testing plugins in your test suite
- Control test execution via CLI options (browsers, headed mode, traces, slow motion)
- Write basic page visit tests with assertions
- Reset database state between tests
- Fill and submit forms using Playwright selectors
- Use recording mode to generate test code quickly
- Authenticate users before visiting protected pages
## Overview
Browser tests verify your application from the outside-in, navigating it exactly as a real user would. Unlike unit tests that examine isolated pieces of code, browser tests exercise the entire stack: routes, controllers, views, database queries, and client-side interactions all working together.
For hypermedia and Inertia applications, browser tests should form the majority of your test suite. These applications are inherently about user interactions with rendered pages, and browser tests capture this reality directly. When a browser test passes, you have high confidence that the feature actually works for users. When it fails, you've caught a bug that users would have encountered.
This approach may feel different if you're accustomed to the "testing pyramid" where unit tests dominate. For server-rendered applications, inverting this pyramid makes sense: browser tests provide more value per test because they verify complete user flows rather than implementation details.
## Setup
Browser testing requires three plugins configured in your `tests/bootstrap.ts` file. These are already installed and configured with the official Hypermedia and Inertia starter kits.
```ts title="tests/bootstrap.ts"
import { assert } from '@japa/assert'
import app from '@adonisjs/core/services/app'
import type { Config } from '@japa/runner/types'
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
import testUtils from '@adonisjs/core/services/test_utils'
// [!code highlight:3]
import { browserClient } from '@japa/browser-client'
import { authBrowserClient } from '@adonisjs/auth/plugins/browser_client'
import { sessionBrowserClient } from '@adonisjs/session/plugins/browser_client'
export const plugins: Config['plugins'] = [
assert(),
pluginAdonisJS(app),
// [!code highlight:17]
/**
* Configures Playwright and creates a fresh browser
* context before every test.
*/
browserClient({ runInSuites: ['browser'] }),
/**
* Allows reading and writing session data
* via the browser context.
*/
sessionBrowserClient(app),
/**
* Enables the loginAs method for authenticating
* users during tests.
*/
authBrowserClient(app),
]
export const runnerHooks: Required> = {
setup: [],
teardown: [],
}
export const configureSuite: Config['configureSuite'] = (suite) => {
if (['browser', 'functional', 'e2e'].includes(suite.name)) {
return suite.setup(() => testUtils.httpServer().start())
}
}
```
## CLI options
Playwright behavior is controlled through command-line flags when running tests. The following options help with debugging and cross-browser verification.
::::options
:::option{name="--browser"}
Run tests in a specific browser. Supported values are `chromium`, `firefox`, and `webkit`.
```bash
node ace test --browser=firefox
```
:::
:::option{name="--headed"}
Show the browser window during test execution. By default, tests run in headless mode.
```bash
node ace test --headed
```
:::
:::option{name="--devtools"}
Open browser devtools automatically when the browser launches.
```bash
node ace test --devtools
```
:::
:::option{name="--slow"}
Slow down test actions by the specified number of milliseconds. Useful for visually following what the test is doing.
```bash
node ace test --slow=500
```
:::
:::option{name="--trace"}
Record traces for debugging. Use `onError` to record only when tests fail, or `onTest` to record every test.
```bash
node ace test --trace=onError
```
:::
::::
### Recording traces
Traces capture a complete timeline of your test execution, including screenshots, network requests, and DOM snapshots. Generate traces only when tests fail or for every test.
```bash
# Record traces only when a test fails
node ace test --trace=onError
# Record traces for every test
node ace test --trace=onTest
```
Traces are stored in the `browsers` directory. Replay them using Playwright's trace viewer.
```bash
npx playwright show-trace browsers/path-to-trace.zip
```
### Running specific tests
Run all browser tests or target specific files and folders.
```bash
# Run all browser tests
node ace test browser
# Run tests from a specific folder
node ace test --files="posts/*"
```
## Basic page visits
A browser test visits a page and makes assertions about its content. The `visit` helper opens a URL, and the returned page object provides assertion methods.
```ts title="tests/browser/posts/index.spec.ts"
import { test } from '@japa/runner'
test.group('Posts index', () => {
test('display list of posts', async ({ visit, route }) => {
/**
* Visit the posts index page using its named route.
* The visit helper returns a Playwright page instance
* extended with assertion methods.
*/
const page = await visit(route('posts.index'))
/**
* Assert that the body contains specific text.
* This will wait up to 5 seconds for the text to appear.
*/
await page.assertTextContains('body', 'My first post')
})
})
```
This test fails because no posts exist in the database. The failure message indicates the assertion timed out waiting for the expected content.
```sh title="❌ Output of failing test"
ℹ AssertionError: expected 'body' inner text to include 'My first post', timed out after 5000ms
⁃ (AssertionError [ERR_ASSERTION]: expected 'body' inner text to include 'My first post':undefined:undefined)
```
## Database state
Tests should start with a known database state. Use the `testUtils.db().truncate()` hook to clear tables after each test, then create the specific records your test needs.
See also: [Database testing utilities](./resetting_state_between_tests.md) for additional methods like migrations and seeders.
```ts title="tests/browser/posts/index.spec.ts"
import Post from '#models/post'
import User from '#models/user'
import testUtils from '@adonisjs/core/services/test_utils'
import { test } from '@japa/runner'
test.group('Posts index', (group) => {
/**
* Truncate database tables after each test.
* This ensures tests don't affect each other.
*/
group.each.setup(() => testUtils.db().truncate())
test('display list of posts', async ({ visit, route }) => {
/**
* Create the data this test depends on.
* Each test sets up its own state explicitly.
*/
const user = await User.create({
email: 'john@example.com',
password: 'secret',
})
await Post.create({
title: 'My first post',
content: 'This is my first post',
userId: user.id,
})
const page = await visit(route('posts.index'))
await page.assertTextContains('body', 'My first post')
})
})
```
## Form interactions
Forms are filled using Playwright's locator methods. Select inputs by their label text and use `fill` to enter values, then `click` to submit.
```ts title="tests/browser/session/create.spec.ts"
import testUtils from '@adonisjs/core/services/test_utils'
import { test } from '@japa/runner'
test.group('Session create', (group) => {
group.each.setup(() => testUtils.db().truncate())
test('display error when invalid credentials are used', async ({ visit, route }) => {
const page = await visit(route('session.create'))
/**
* Locate inputs by their associated label text.
* This mirrors how users identify form fields.
*/
await page.getByLabel('Email').fill('john@example.com')
await page.getByLabel('Password').fill('secret')
/**
* Click the submit button. getByRole finds elements
* by their ARIA role, making tests resilient to
* markup changes.
*/
await page.getByRole('button').click()
})
})
```
### Recording mode
Writing locators manually requires switching between your browser and test file repeatedly. Recording mode launches a browser where your interactions are converted to test code automatically.
Create a new test file and call the `record` method instead of `visit`. When you run the test, a browser opens where you can interact with your application. Close the browser when finished, and copy the generated code into your test file.
```ts title="tests/browser/posts/create.spec.ts"
import { test } from '@japa/runner'
test.group('Posts create', () => {
test('create a new post', async ({ record, route }) => {
/**
* Opens the browser in recording mode.
* Test timeout is disabled while recording.
* Interact with the page, then close the browser
* to see the generated test code.
*/
await record(route('posts.create'))
})
})
```
After recording, replace the `record` call with `visit` and paste the generated locators and actions.
## Authenticating users
Protected pages require an authenticated user. The `browserContext.loginAs` method authenticates a user for all subsequent page visits within that test.
:::warning
For authentication to work during tests, set `SESSION_DRIVER=memory` in your `.env.test` file. The memory driver allows the test process to manage sessions without file or database overhead.
:::
```ts title="tests/browser/posts/create.spec.ts"
import User from '#models/user'
import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
test.group('Posts create', (group) => {
group.each.setup(() => testUtils.db().truncate())
test('display error when missing post title or content', async ({
visit,
browserContext,
route,
}) => {
const user = await User.create({
email: 'john@example.com',
password: 'secret',
})
/**
* Authenticate the user for this browser context.
* All subsequent page visits will be authenticated.
*/
await browserContext.loginAs(user)
const page = await visit(route('posts.create'))
await page.assertPath('/posts/create')
})
})
```
## Cookies and sessions
The browser context provides methods to read and write cookies, sessions, and flash messages during tests. These are useful when your application behavior depends on stored state.
### Setting cookies
Three methods are available depending on how the cookie should be stored.
```ts title="tests/browser/preferences.spec.ts"
import { test } from '@japa/runner'
test.group('User preferences', () => {
test('apply dark mode from cookie', async ({ visit, browserContext, route }) => {
/**
* Set an encrypted cookie (default cookie behavior in AdonisJS).
*/
await browserContext.setCookie('theme', 'dark')
/**
* Set a plain cookie without encryption.
* Useful for cookies that client-side JavaScript needs to read.
*/
await browserContext.setPlainCookie('locale', 'en-us')
/**
* Set an encrypted cookie explicitly.
* Equivalent to setCookie.
*/
await browserContext.setEncryptedCookie('preferences', { sidebar: 'collapsed' })
const page = await visit(route('dashboard'))
await page.assertVisible('.dark-mode')
})
})
```
### Reading cookies
Retrieve cookie values after page interactions to verify your application sets them correctly.
```ts title="tests/browser/preferences.spec.ts"
import { test } from '@japa/runner'
test.group('User preferences', () => {
test('save theme preference to cookie', async ({ visit, browserContext, route }) => {
const page = await visit(route('settings'))
await page.getByLabel('Theme').selectOption('dark')
await page.getByRole('button', { name: 'Save' }).click()
/**
* Read cookies after the page interaction.
*/
const theme = await browserContext.getCookie('theme')
const locale = await browserContext.getPlainCookie('locale')
const prefs = await browserContext.getEncryptedCookie('preferences')
})
})
```
### Setting session data
Pre-populate session data before visiting a page. This is useful when testing features that depend on session state without going through the UI to establish that state.
```ts title="tests/browser/onboarding.spec.ts"
import { test } from '@japa/runner'
test.group('Onboarding', () => {
test('resume onboarding from step 3', async ({ visit, browserContext, route }) => {
/**
* Set session data before visiting the page.
* The page will read this state and resume accordingly.
*/
await browserContext.setSession({
onboarding: { currentStep: 3, completedSteps: [1, 2] }
})
const page = await visit(route('onboarding'))
await page.assertTextContains('h1', 'Step 3')
})
})
```
### Setting flash messages
Flash messages are session data that persist only for the next request. Set them to test how your UI displays notifications or validation errors.
```ts title="tests/browser/notifications.spec.ts"
import { test } from '@japa/runner'
test.group('Notifications', () => {
test('display success notification', async ({ visit, browserContext, route }) => {
await browserContext.setFlashMessages({
success: 'Your changes have been saved'
})
const page = await visit(route('dashboard'))
await page.assertTextContains('.notification', 'Your changes have been saved')
})
})
```
### Reading session and flash messages
Verify that your application writes the expected data to the session.
```ts title="tests/browser/cart.spec.ts"
import { test } from '@japa/runner'
test.group('Shopping cart', () => {
test('add item to cart stored in session', async ({ visit, browserContext, route }) => {
const page = await visit(route('products.show', { id: 1 }))
await page.getByRole('button', { name: 'Add to cart' }).click()
const session = await browserContext.getSession()
const flashMessages = await browserContext.getFlashMessages()
})
})
```
## See also
- [Japa browser client](https://japa.dev/docs/plugins/browser-client#switching-between-browsers) for the complete assertions API
- [Playwright locators](https://playwright.dev/docs/locators) for advanced element selection strategies
---
# Console tests
This guide covers testing Ace commands in AdonisJS applications. You will learn how to:
- Write tests for custom Ace commands
- Capture and assert logger output using raw mode
- Test table rendering in command output
- Trap and respond to CLI prompts programmatically
- Validate prompt input within tests
- Use built-in assertion methods for command results
## Overview
Console tests allow you to verify that your custom Ace commands behave correctly without manual interaction. Since commands often produce terminal output and prompt users for input, testing them requires special techniques to capture output and simulate user responses.
AdonisJS provides a dedicated testing API through the `ace` service that lets you create command instances, execute them in isolation, and make assertions about their behavior. The API includes tools for capturing log output, intercepting prompts, and verifying exit codes.
Testing commands is particularly valuable when your commands perform critical operations like database migrations, file generation, or deployment tasks. A failing command in production can have serious consequences, so automated tests help catch issues before they reach users.
## Basic example
Let's walk through testing a simple command from start to finish. First, create a new command using the `make:command` generator.
```sh
node ace make:command greet
# DONE: create app/commands/greet.ts
```
The generated command includes a `run` method where you define the command's behavior. Update it to greet the user.
```ts title="app/commands/greet.ts"
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
export default class Greet extends BaseCommand {
static commandName = 'greet'
static description = 'Greet a user by name'
static options: CommandOptions = {}
async run() {
this.logger.info('Hello world from "Greet"')
}
}
```
Next, create a test file for the command. If you haven't already defined a unit test suite, see the [testing introduction](./introduction.md#suites) for setup instructions.
```sh
node ace make:test commands/greet --suite=unit
# DONE: create tests/unit/commands/greet.spec.ts
```
The test uses the `ace` service to create a command instance, execute it, and verify it completed successfully. The `ace.create` method accepts the command class and an array of arguments (empty in this case since the command takes no arguments).
```ts title="tests/unit/commands/greet.spec.ts"
import { test } from '@japa/runner'
import Greet from '#commands/greet'
import ace from '@adonisjs/core/services/ace'
test.group('Commands greet', () => {
test('should greet and exit with code 0', async () => {
/**
* Create an instance of the command. The second argument
* is an array of CLI arguments to pass to the command.
*/
const command = await ace.create(Greet, [])
/**
* Execute the command. This runs the `run` method.
*/
await command.exec()
/**
* Assert the command exited successfully (exit code 0).
*/
command.assertSucceeded()
})
})
```
Run the test using the following command.
```sh
node ace test --files=commands/greet
```
## Testing logger output
The `Greet` command writes a log message to the terminal using `this.logger.info()`. By default, this output goes directly to stdout, which makes it difficult to capture and assert against in tests.
To solve this, you can switch the ace UI library into **raw mode**. In raw mode, ace stores all output in memory instead of writing to the terminal. This allows you to inspect and assert against the exact messages your command produces.
:::tip
Raw mode captures all output from `this.logger`, `this.ui.table()`, and other UI methods. Always switch back to normal mode after your test to avoid affecting other tests.
:::
Use a Japa `group.each.setup` hook to switch modes automatically before and after each test.
```ts title="tests/unit/commands/greet.spec.ts"
import { test } from '@japa/runner'
import Greet from '#commands/greet'
import ace from '@adonisjs/core/services/ace'
test.group('Commands greet', (group) => {
/**
* Switch to raw mode before each test. The returned function
* runs after each test to restore normal mode.
*/
group.each.setup(() => {
ace.ui.switchMode('raw')
return () => ace.ui.switchMode('normal')
})
test('should log greeting message', async () => {
const command = await ace.create(Greet, [])
await command.exec()
command.assertSucceeded()
/**
* Assert the exact log message. In raw mode, colors are
* represented as function names like `blue()`.
*/
command.assertLog('[ blue(info) ] Hello world from "Greet"')
})
})
```
:::warning
Log assertions in raw mode include color function names. The message `this.logger.info('Hello')` becomes `[ blue(info) ] Hello` in raw mode. If your assertion fails, check that you've included the color formatting in your expected string.
:::
## Testing table output
Commands often display tabular data using `this.ui.table()`. You can test table output the same way as log output by switching to raw mode first.
Consider a command that displays a table of team members.
```ts title="app/commands/list_team.ts"
import { BaseCommand } from '@adonisjs/core/ace'
export default class ListTeam extends BaseCommand {
static commandName = 'list:team'
static description = 'List all team members'
async run() {
const table = this.ui.table()
table.head(['Name', 'Email'])
table.row(['Harminder Virk', 'virk@adonisjs.com'])
table.row(['Romain Lanz', 'romain@adonisjs.com'])
table.row(['Julien-R44', 'julien@adonisjs.com'])
table.render()
}
}
```
Use `assertTableRows` to verify the table contents. Pass a two-dimensional array where each inner array represents a row's cells.
```ts title="tests/unit/commands/list_team.spec.ts"
import { test } from '@japa/runner'
import ListTeam from '#commands/list_team'
import ace from '@adonisjs/core/services/ace'
test.group('Commands list:team', (group) => {
group.each.setup(() => {
ace.ui.switchMode('raw')
return () => ace.ui.switchMode('normal')
})
test('should display team members table', async () => {
const command = await ace.create(ListTeam, [])
await command.exec()
/**
* Assert table rows match expected data. Each inner array
* represents one row with its column values.
*/
command.assertTableRows([
['Harminder Virk', 'virk@adonisjs.com'],
['Romain Lanz', 'romain@adonisjs.com'],
['Julien-R44', 'julien@adonisjs.com'],
])
})
})
```
## Trapping prompts
[Prompts](../ace/prompts.md) pause command execution and wait for user input, which blocks automated tests. To handle this, you must **trap** prompts before executing the command. A trap intercepts a specific prompt and provides a programmatic response.
Traps are created using `command.prompt.trap()`, which accepts the prompt title as its argument. The title must match exactly, including case.
:::warning
Prompt titles are case-sensitive. If your prompt asks `"What is your name?"` but you trap `"what is your name?"`, the trap won't match and your test will hang waiting for input. Always copy the exact prompt title from your command.
:::
### Replying to text prompts
Use `replyWith` to provide a text response to prompts created with `this.prompt.ask()`.
```ts title="tests/unit/commands/greet.spec.ts"
import { test } from '@japa/runner'
import Greet from '#commands/greet'
import ace from '@adonisjs/core/services/ace'
test.group('Commands greet', (group) => {
group.each.setup(() => {
ace.ui.switchMode('raw')
return () => ace.ui.switchMode('normal')
})
test('should greet user by name', async () => {
const command = await ace.create(Greet, [])
/**
* Trap the prompt and provide a response. This must be
* set up before calling exec().
*/
command.prompt.trap('What is your name?').replyWith('Virk')
await command.exec()
command.assertSucceeded()
})
})
```
:::tip
Traps are consumed when triggered and automatically removed afterward. If a test completes without triggering a trapped prompt, Japa throws an error to alert you that the expected prompt never appeared.
:::
### Choosing options from select prompts
For prompts created with `this.prompt.choice()` or `this.prompt.multiple()`, use `chooseOption` or `chooseOptions` with zero-based indices.
```ts title="tests/unit/commands/setup.spec.ts"
import { test } from '@japa/runner'
import Setup from '#commands/setup'
import ace from '@adonisjs/core/services/ace'
test.group('Commands setup', (group) => {
group.each.setup(() => {
ace.ui.switchMode('raw')
return () => ace.ui.switchMode('normal')
})
test('should configure with npm', async () => {
const command = await ace.create(Setup, [])
/**
* Choose the first option (index 0) from a select prompt.
*/
command.prompt.trap('Select package manager').chooseOption(0)
await command.exec()
command.assertSucceeded()
})
test('should select multiple databases', async () => {
const command = await ace.create(Setup, [])
/**
* Choose multiple options by passing an array of indices.
*/
command.prompt.trap('Select databases to configure').chooseOptions([0, 2])
await command.exec()
command.assertSucceeded()
})
})
```
### Accepting or rejecting confirmation prompts
For boolean prompts created with `this.prompt.confirm()` or `this.prompt.toggle()`, use `accept` or `reject`.
```ts title="tests/unit/commands/cleanup.spec.ts"
import { test } from '@japa/runner'
import Cleanup from '#commands/cleanup'
import ace from '@adonisjs/core/services/ace'
test.group('Commands cleanup', (group) => {
group.each.setup(() => {
ace.ui.switchMode('raw')
return () => ace.ui.switchMode('normal')
})
test('should delete files when confirmed', async () => {
const command = await ace.create(Cleanup, [])
command.prompt.trap('Want to delete all temporary files?').accept()
await command.exec()
command.assertSucceeded()
})
test('should abort when rejected', async () => {
const command = await ace.create(Cleanup, [])
command.prompt.trap('Want to delete all temporary files?').reject()
await command.exec()
command.assertLog('[ blue(info) ] Cleanup cancelled')
})
})
```
## Intermediate: Testing prompt validation
Prompts can include [validation rules](../ace/prompts.md#prompt-options) that reject invalid input. You can test these validators directly using `assertPasses` and `assertFails` without fully executing the command.
The `assertFails` method accepts the input value and the expected error message. The `assertPasses` method accepts a value that should pass validation.
```ts title="tests/unit/commands/create_user.spec.ts"
import { test } from '@japa/runner'
import CreateUser from '#commands/create_user'
import ace from '@adonisjs/core/services/ace'
test.group('Commands create:user', (group) => {
group.each.setup(() => {
ace.ui.switchMode('raw')
return () => ace.ui.switchMode('normal')
})
test('should validate email format', async () => {
const command = await ace.create(CreateUser, [])
/**
* Test validation without executing the full command.
* Chain multiple assertions to test various inputs.
*/
command.prompt
.trap('Enter your email')
.assertFails('', 'Email is required')
.assertFails('invalid', 'Please enter a valid email')
.assertPasses('user@example.com')
.replyWith('admin@adonisjs.com')
await command.exec()
command.assertSucceeded()
})
})
```
You can chain validation assertions with `replyWith` to both test the validator and provide a final response.
## Available assertions
The command instance provides several assertion methods to verify command behavior.
::::options
:::option{name="assertSucceeded"}
Assert the command exited with `exitCode=0`, indicating success.
```ts title="tests/unit/commands/greet.spec.ts"
await command.exec()
command.assertSucceeded()
```
:::
:::option{name="assertFailed"}
Assert the command exited with a non-zero `exitCode`, indicating failure.
```ts title="tests/unit/commands/greet.spec.ts"
await command.exec()
command.assertFailed()
```
:::
:::option{name="assertExitCode"}
Assert the command exited with a specific exit code. Useful when your command uses different exit codes to signal different error conditions.
```ts title="tests/unit/commands/greet.spec.ts"
await command.exec()
command.assertExitCode(2)
```
:::
:::option{name="assertNotExitCode"}
Assert the command did not exit with a specific code.
```ts title="tests/unit/commands/greet.spec.ts"
await command.exec()
command.assertNotExitCode(1)
```
:::
:::option{name="assertLog"}
Assert the command wrote a specific log message. Optionally specify the output stream as `stdout` or `stderr`. Requires raw mode.
```ts title="tests/unit/commands/greet.spec.ts"
await command.exec()
command.assertLog('[ blue(info) ] Task completed')
command.assertLog('[ red(error) ] Something went wrong', 'stderr')
```
:::
:::option{name="assertLogMatches"}
Assert the command wrote a log message matching a regular expression. Useful when the exact message varies but follows a pattern. Requires raw mode.
```ts title="tests/unit/commands/greet.spec.ts"
await command.exec()
command.assertLogMatches(/Task completed in \d+ms/)
```
:::
:::option{name="assertTableRows"}
Assert the command printed a table with specific rows. Pass a two-dimensional array where each inner array represents a row's column values. Requires raw mode.
```ts title="tests/unit/commands/list_users.spec.ts"
await command.exec()
command.assertTableRows([
['1', 'Alice', 'alice@example.com'],
['2', 'Bob', 'bob@example.com'],
])
```
:::
::::
## See also
- [Ace prompts](../ace/prompts.md) for details on creating interactive prompts
- [Creating commands](../ace/creating_commands.md) for building custom Ace commands
- [Testing introduction](./introduction.md) for configuring test suites and runners
---
# Resetting state between tests
This guide covers managing application state during testing in AdonisJS. You will learn how to:
- Migrate and seed the database before running tests
- Clean up database state between individual tests using transactions or truncation
- Manage filesystem state with automatic cleanup
- Reset Redis data between tests
- Configure separate test databases using environment overrides
## Overview
Tests that modify application state, such as creating database records, uploading files, or caching data in Redis, need a strategy for resetting that state between test runs. Without proper cleanup, tests can interfere with each other, leading to flaky results that pass or fail depending on execution order.
AdonisJS provides utilities through the `testUtils` service that handle common state management patterns. The general approach is to run database migrations once before all tests, then reset data between individual tests using either transactions or truncation. For filesystem and Redis, similar patterns ensure each test starts with a clean slate.
:::warning
Make sure your test environment is configured to use separate databases and storage systems from your development and production environments. Running tests against production data can result in data loss. You can use the `.env.test` file to override environment variables specifically for tests.
:::
## Database state management
### Migrating the database
Register a global setup hook in `tests/bootstrap.ts` to run migrations before any tests execute. The `testUtils.db().migrate()` method applies all pending migrations to prepare the database schema.
```ts title="tests/bootstrap.ts"
import testUtils from '@adonisjs/core/services/test_utils'
export const runnerHooks: Required> = {
setup: [() => testUtils.db().migrate()],
teardown: [],
}
```
If your application uses multiple database connections, pass the connection name to target a specific database.
```ts title="tests/bootstrap.ts"
export const runnerHooks: Required> = {
setup: [
() => testUtils.db().migrate(),
// [!code highlight]
() => testUtils.db('tenant').migrate(),
],
teardown: [],
}
```
### Seeding the database
If your tests require seed data, add the `seed()` hook after migration. This runs your database seeders to populate tables with initial data.
```ts title="tests/bootstrap.ts"
import testUtils from '@adonisjs/core/services/test_utils'
export const runnerHooks: Required> = {
setup: [
() => testUtils.db().migrate(),
// [!code highlight]
() => testUtils.db().seed(),
],
teardown: [],
}
```
### Cleaning Up between tests
While migrations run once globally, you need to clean up data between individual tests to prevent state from leaking. AdonisJS offers two approaches: global transactions and truncation.
**Global transactions** wrap all database operations within a test inside a transaction, then roll back when the test completes. Nothing is actually persisted to the database, which can result in faster test execution.
```ts title="tests/functional/users.spec.ts"
import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
test.group('Users', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('can create a user', async () => {
// Database changes here are automatically rolled back after the test
})
})
```
The `withGlobalTransaction()` method returns a cleanup function that Japa calls automatically after each test to roll back the transaction.
**Truncation** clears all data from tables between tests. This approach actually deletes records rather than rolling back transactions.
```ts title="tests/functional/posts.spec.ts"
import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
test.group('Posts', (group) => {
group.each.setup(() => testUtils.db().truncate())
test('can create a post', async () => {
// Tables are truncated before each test
})
})
```
Global transactions are generally faster, especially when your database has many tables, since rolling back a transaction is less expensive than truncating every table. Choose the approach that best fits your testing needs.
## Filesystem state management
For tests that create files, use the `@japa/file-system` plugin. This plugin provides a simple API for managing files and automatically cleans them up after each test.
Install the plugin as a dev dependency.
```sh
npm i -D @japa/file-system
```
Register the plugin in your test bootstrap file.
```ts title="tests/bootstrap.ts"
import { fileSystem } from '@japa/file-system'
export const plugins: Config['plugins'] = [
assert(),
pluginAdonisJS(app),
// [!code highlight]
fileSystem(),
]
```
Access the `fs` object within your tests to create files. Any files created through this API are automatically deleted when the test completes.
```ts title="tests/functional/uploads.spec.ts"
import { test } from '@japa/runner'
test('can process an uploaded file', async ({ fs }) => {
await fs.create('document.pdf', 'file contents')
// Test your file processing logic
// The file is automatically cleaned up after the test
})
```
Files are created in a temporary directory managed by the plugin. For more configuration options and advanced usage, see the [Japa file-system plugin documentation](https://japa.dev/docs/plugins/file-system).
## Redis state management
For tests that interact with Redis, flush the test database between tests to ensure a clean state. Use a group setup hook to call `flushdb()` before each test.
```ts title="tests/functional/cache.spec.ts"
import { test } from '@japa/runner'
import redis from '@adonisjs/redis/services/main'
test.group('Cache', (group) => {
group.each.teardown(async () => {
await redis.flushdb()
})
test('can cache a value', async () => {
// Redis is empty at the start of each test
})
})
```
The `flushdb()` command clears all keys in the currently selected Redis database without affecting other databases on the same Redis server. Make sure your test environment is configured to use a different Redis database number than development or production.
## Environment configuration
Use the `.env.test` file to override environment variables specifically for your test environment. This file is automatically loaded when running tests.
```dotenv title=".env.test"
DB_DATABASE=my_app_test
REDIS_DB=1
```
This ensures your tests run against isolated databases without risking your development or production data.
---
# Test doubles
This guide covers test doubles in AdonisJS applications. You will learn how to:
- Use built-in fakes for Mail, Hash, Emitter, and Drive services
- Swap container bindings to fake dependencies using `swap` and `useFake`
- Freeze and travel through time when testing time-sensitive code
- Integrate Sinon.js for additional stubbing and mocking needs
## Overview
Test doubles replace real implementations with controlled alternatives during testing. They allow you to isolate code under test, avoid side effects like sending real emails, and verify that your code interacts correctly with its dependencies.
AdonisJS takes a pragmatic approach to test doubles. For internal operations like database queries, we recommend hitting the real database rather than mocking query methods. Real database interactions catch issues that mocks would miss, such as constraint violations, incorrect query syntax, or migration problems. However, for external services like email providers, payment gateways, or third-party APIs, fakes prevent unwanted side effects and make tests faster and more reliable.
The framework provides built-in fakes for common services that interact with external systems, along with container swaps for replacing your own dependencies. For edge cases not covered by these tools, you can integrate libraries like Sinon.js.
## Built-in fakes
AdonisJS provides fake implementations for services that typically interact with external systems. Each fake intercepts calls to the real service and captures them for assertions.
All built-in fakes support [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management) via the `using` keyword. When the variable goes out of scope (at the end of the test function), the fake is automatically restored. If you prefer manual control, you can still call `.restore()` directly or use the `cleanup` hook.
### Emitter fake
The emitter fake prevents event listeners from executing while capturing emitted events for assertions. This is useful when testing code that emits events without triggering side effects like sending notifications or updating external systems.
```ts title="tests/functional/users/register.spec.ts"
import { test } from '@japa/runner'
import emitter from '@adonisjs/core/services/emitter'
import { events } from '#generated/events'
test.group('User registration', () => {
test('emits registration event on signup', async ({ client }) => {
/**
* Fake the emitter to capture events without
* executing listeners. The `using` keyword automatically
* restores the emitter when the test ends.
*/
// [!code highlight]
using fakeEmitter = emitter.fake()
await client.post('/signup').form({
email: 'jane@example.com',
password: 'secret123',
})
/**
* Assert the event was emitted
*/
fakeEmitter.assertEmitted(events.UserRegistered)
})
})
```
You can fake specific events while allowing others to execute normally by passing event names or classes to the `fake` method.
```ts
// Fake only these events, let others execute normally
emitter.fake([events.UserRegistered, events.OrderUpdated])
```
The `EventBuffer` returned by `emitter.fake()` provides several assertion methods.
| Method | Description |
| ---------------------------------- | ------------------------------------------------------ |
| `assertEmitted(event)` | Assert an event was emitted |
| `assertNotEmitted(event)` | Assert an event was not emitted |
| `assertEmittedCount(event, count)` | Assert an event was emitted a specific number of times |
| `assertNoneEmitted()` | Assert no events were emitted |
For conditional assertions, pass a callback to `assertEmitted` that receives the event data and returns `true` if the event matches your criteria.
```ts title="tests/functional/orders/update.spec.ts"
fakeEmitter.assertEmitted(events.OrderUpdated, ({ data }) => {
return data.order.id === orderId
})
```
See also: [Events](../digging_deeper/emitter.md)
### Hash fake
The hash fake replaces the real hashing implementation with a fast alternative that performs no actual hashing. Password hashing algorithms like bcrypt and argon2 are intentionally slow for security, but this can significantly slow down test suites that create many users.
```ts title="tests/functional/users/list.spec.ts"
import { test } from '@japa/runner'
import hash from '@adonisjs/core/services/hash'
import { UserFactory } from '#database/factories/user_factory'
test.group('Users list', () => {
test('paginates users correctly', async ({ client }) => {
/**
* Fake the hash service to make user creation instant.
* Without this, creating 50 users with bcrypt takes ~5 seconds.
* The `using` keyword automatically restores the real
* implementation when the test ends.
*/
// [!code highlight]
using _hash = hash.fake()
await UserFactory.createMany(50)
const response = await client.get('/users')
response.assertStatus(200)
})
})
```
The fake stores plain text and compares strings directly. It should only be used in tests where password hashing is not the focus of what you're testing.
See also: [Hashing](../security/hashing.md)
### Mail fake
The mail fake intercepts all emails and captures them for assertions. This prevents your tests from sending real emails while allowing you to verify that the correct emails would be sent.
```ts title="tests/functional/users/register.spec.ts"
import { test } from '@japa/runner'
import mail from '@adonisjs/mail/services/main'
import VerifyEmailNotification from '#mails/verify_email'
test.group('User registration', () => {
test('sends verification email on signup', async ({ client }) => {
/**
* Fake the mailer. The `using` keyword automatically
* restores the real mailer when the test ends.
*/
// [!code highlight]
using fake = mail.fake()
await client.post('/register').form({ email: 'user@example.com', password: 'secret123' })
/**
* Assert the email was sent with correct recipient and subject
*/
fake.mails.assertSent(VerifyEmailNotification, ({ message }) => {
return message.hasTo('user@example.com').hasSubject('Please verify your email address')
})
})
test('does not send reset email for unknown user', async ({ client }) => {
// [!code highlight]
using fake = mail.fake()
await client.post('/forgot-password').form({ email: 'unknown@example.com' })
fake.mails.assertNotSent(PasswordResetNotification)
})
})
```
The `mails` object provides assertion methods for both sent and queued emails.
| Method | Description |
| -------------------------------- | ------------------------------------------ |
| `assertSent(Mail, finder?)` | Assert an email class was sent |
| `assertNotSent(Mail, finder?)` | Assert an email class was not sent |
| `assertSentCount(count)` | Assert total number of emails sent |
| `assertSentCount(Mail, count)` | Assert count for a specific email class |
| `assertNoneSent()` | Assert no emails were sent |
| `assertQueued(Mail, finder?)` | Assert an email was queued via `sendLater` |
| `assertNotQueued(Mail, finder?)` | Assert an email was not queued |
| `assertQueuedCount(count)` | Assert total number of queued emails |
| `assertNoneQueued()` | Assert no emails were queued |
You can also test mail classes in isolation by building them without sending.
```ts title="tests/unit/mails/verify_email.spec.ts"
import { test } from '@japa/runner'
import { UserFactory } from '#database/factories/user_factory'
import VerifyEmailNotification from '#mails/verify_email'
test.group('VerifyEmailNotification', () => {
test('builds correct message', async () => {
const user = await UserFactory.create()
const email = new VerifyEmailNotification(user)
/**
* Build the message and render templates without sending
*/
await email.buildWithContents()
email.message.assertTo(user.email)
email.message.assertFrom('noreply@example.com')
email.message.assertSubject('Please verify your email address')
email.message.assertHtmlIncludes(`Hello ${user.name}`)
})
})
```
See also: [Mail](../digging_deeper/mail.md)
### Drive fake
The drive fake replaces a disk with a local filesystem implementation. Files are written to `./tmp/drive-fakes` and automatically deleted when you restore the fake.
```ts title="tests/functional/users/update.spec.ts"
import { test } from '@japa/runner'
import drive from '@adonisjs/drive/services/main'
import fileGenerator from '@poppinss/file-generator'
import { UserFactory } from '#database/factories/user_factory'
test.group('User avatar upload', () => {
test('uploads avatar to storage', async ({ client }) => {
/**
* Fake the spaces disk to avoid uploading to real S3.
* The `using` keyword automatically restores the real
* disk when the test ends.
*/
// [!code highlight]
using fakeDisk = drive.fake('spaces')
const user = await UserFactory.create()
/**
* Generate a fake 1mb PNG file
*/
const { contents, mime, name } = await fileGenerator.generatePng('1mb')
await client
.put('/me')
.file('avatar', contents, { filename: name, contentType: mime })
.loginAs(user)
/**
* Assert the file was stored
*/
fakeDisk.assertExists(user.avatar)
})
})
```
See also: [Drive](../digging_deeper/drive.md)
## Container swaps
When using dependency injection, you can swap container bindings to replace services with fake implementations. This is useful for faking your own services, such as a payment gateway or external API client.
The recommended approach is to create a dedicated fake implementation that extends or implements the same interface as the real service.
```ts title="app/services/payment_gateway.ts"
export default class PaymentGateway {
async charge(amount: number, token: string): Promise {
/**
* Real implementation that calls Stripe, Braintree, etc.
*/
}
async refund(chargeId: string): Promise {
/**
* Real implementation
*/
}
}
```
```ts title="app/services/fake_payment_gateway.ts"
import PaymentGateway from './payment_gateway.js'
export default class FakePaymentGateway extends PaymentGateway {
/**
* Store charges for assertions
*/
charges: Array<{ amount: number; token: string }> = []
async charge(amount: number, token: string): Promise {
this.charges.push({ amount, token })
return {
id: 'fake_charge_123',
status: 'succeeded',
}
}
async refund(chargeId: string): Promise {
return {
id: 'fake_refund_123',
status: 'succeeded',
}
}
/**
* Helper method to assert a charge was made
*/
assertCharged(amount: number) {
const charge = this.charges.find((c) => c.amount === amount)
if (!charge) {
throw new Error(`Expected charge of ${amount} but none found`)
}
}
}
```
### Swapping bindings in tests
The test context provides a `swap` method that replaces a container binding with a fake for the duration of the test. The original binding is automatically restored after the test completes, so you don't need to manage cleanup yourself.
```ts title="tests/functional/orders/checkout.spec.ts"
import { test } from '@japa/runner'
import PaymentGateway from '#services/payment_gateway'
import FakePaymentGateway from '#services/fake_payment_gateway'
test.group('Checkout', () => {
test('charges the customer on checkout', async ({ client, swap }) => {
/**
* Swap the real payment gateway with the fake.
* The original binding is restored when the test ends.
*/
const fakePayment = swap(PaymentGateway, new FakePaymentGateway())
await client.post('/checkout').json({ cartId: 'cart_123', paymentToken: 'tok_visa' })
fakePayment.assertCharged(9999)
})
})
```
The `swap` method accepts either a direct instance or a factory function. Use a factory function when you need a fresh instance each time the binding is resolved.
```ts title="tests/functional/orders/checkout.spec.ts"
// Pass a factory function instead of an instance
swap(PaymentGateway, () => new FakePaymentGateway())
```
### The `useFake` helper
The `useFake` helper provides the same functionality as `swap` but as a standalone function. This is useful when you want to extract swap logic into reusable test helpers outside of the test callback.
```ts title="tests/helpers/fakes.ts"
import { useFake } from '@japa/plugin-adonisjs/helpers'
import PaymentGateway from '#services/payment_gateway'
import FakePaymentGateway from '#services/fake_payment_gateway'
/**
* Reusable helper that swaps the payment gateway
* and returns the fake for assertions
*/
export function useFakePaymentGateway() {
return useFake(PaymentGateway, new FakePaymentGateway())
}
```
```ts title="tests/functional/orders/checkout.spec.ts"
import { test } from '@japa/runner'
import { useFakePaymentGateway } from '#tests/helpers/fakes'
test.group('Checkout', () => {
test('charges the customer on checkout', async ({ client }) => {
const fakePayment = useFakePaymentGateway()
await client.post('/checkout').json({ cartId: 'cart_123', paymentToken: 'tok_visa' })
fakePayment.assertCharged(9999)
})
})
```
Like `swap`, `useFake` automatically restores the original binding when the test completes. Both `swap` and `useFake` can only be called within a running Japa test.
### Manual swap with `container.swap`
You can also call `container.swap` and `container.restore` directly on the application container. This gives you full control over when the binding is restored, which can be useful in group-level setup hooks or when you need to restore a binding before the test ends.
```ts title="tests/functional/orders/checkout.spec.ts"
import { test } from '@japa/runner'
import app from '@adonisjs/core/services/app'
import PaymentGateway from '#services/payment_gateway'
import FakePaymentGateway from '#services/fake_payment_gateway'
test.group('Checkout', () => {
test('charges the customer on checkout', async ({ client, cleanup }) => {
const fakePayment = new FakePaymentGateway()
/**
* Swap the binding and register cleanup manually
*/
app.container.swap(PaymentGateway, () => fakePayment)
cleanup(() => app.container.restore(PaymentGateway))
await client.post('/checkout').json({ cartId: 'cart_123', paymentToken: 'tok_visa' })
fakePayment.assertCharged(9999)
})
})
```
See also: [Dependency injection](../concepts/dependency_injection.md)
## Time utilities
Japa provides utilities for controlling time during tests. Both `freezeTime` and `timeTravel` mock `new Date()` and `Date.now()`, and automatically restore the real implementations after the test completes.
### Freezing time
The `freezeTime` function locks time to a specific moment. This is useful when testing code that checks timestamps, such as token expiration.
```ts title="tests/functional/auth/token.spec.ts"
import { test } from '@japa/runner'
import { freezeTime } from '@japa/runner'
import { UserFactory } from '#database/factories/user_factory'
test.group('Token expiration', () => {
test('rejects expired tokens', async ({ client }) => {
const user = await UserFactory.create()
/**
* Create a token at the current time
*/
const token = await user.createToken()
/**
* Freeze time to 2 hours in the future, past the token's
* 1-hour expiration window
*/
const futureDate = new Date(Date.now() + 2 * 60 * 60 * 1000)
freezeTime(futureDate)
const response = await client.get('/protected').header('Authorization', `Bearer ${token.value}`)
response.assertStatus(401)
})
})
```
### Traveling through time
The `timeTravel` function moves time forward by a duration. You can pass a human-readable string expression or a `Date` object.
```ts title="tests/functional/subscriptions/expiry.spec.ts"
import { test } from '@japa/runner'
import { timeTravel } from '@japa/runner'
import { UserFactory } from '#database/factories/user_factory'
test.group('Subscription expiry', () => {
test('marks subscription as expired after 30 days', async ({ client }) => {
const user = await UserFactory.with('subscription', 1, (s) =>
s.merge({ startsAt: new Date() })
).create()
/**
* Travel 31 days into the future
*/
timeTravel('31 days')
const response = await client.get('/subscription').loginAs(user)
response.assertStatus(200)
response.assertBodyContains({ status: 'expired' })
})
})
```
Both utilities only mock the `Date` object. They do not affect timers like `setTimeout` or `setInterval`.
## Sinon.js
For stubbing and mocking needs not covered by the built-in fakes, you can use [Sinon.js](https://sinonjs.org/). Install it as a development dependency.
```sh
npm install -D sinon @types/sinon
```
Sinon provides stubs, spies, and mocks for fine-grained control over function behavior. Always call `sinon.restore()` after tests to clean up.
```ts title="tests/functional/reports/generate.spec.ts"
import { test } from '@japa/runner'
import sinon from 'sinon'
import ReportService from '#services/report_service'
test.group('Report generation', (group) => {
group.each.teardown(() => {
sinon.restore()
})
test('retries on temporary failure', async ({ client }) => {
const stub = sinon.stub(ReportService.prototype, 'generate')
stub.onFirstCall().rejects(new Error('Temporary failure'))
stub.onSecondCall().resolves({ id: 'report_123' })
const response = await client.post('/reports')
response.assertStatus(200)
sinon.assert.calledTwice(stub)
})
})
```
For comprehensive documentation on stubs, spies, mocks, and fake timers, see the [Sinon.js documentation](https://sinonjs.org/releases/latest/).
---
# Application
This guide covers the Application class in AdonisJS. You will learn how to:
- Access the runtime environment (web, console, repl, test)
- Check the Node.js environment and application state
- Listen for process signals and notify parent processes
- Generate absolute paths to project directories and files
- Use generators for consistent naming conventions
## Overview
The [Application](https://github.com/adonisjs/application/blob/9.x/src/application.ts) class handles the heavy lifting of wiring together an AdonisJS application. It manages the application lifecycle, provides access to environment information, tracks the current state, and offers helper methods for generating paths to various project directories.
You access the Application instance through the `app` service, which is available throughout your application.
See also: [Application lifecycle](../guides/concepts/application_lifecycle.md)
```ts title="app/services/some_service.ts"
import app from '@adonisjs/core/services/app'
```
## Environment
The environment refers to the runtime context in which your application is running. AdonisJS recognizes four distinct environments:
| Environment | Description |
|-------------|-------------|
| `web` | The process started for the HTTP server |
| `console` | Ace commands (except the REPL command) |
| `repl` | The process started using `node ace repl` |
| `test` | The process started using `node ace test` |
You can access the current environment using the `getEnvironment` method.
```ts title="app/services/some_service.ts"
import app from '@adonisjs/core/services/app'
console.log(app.getEnvironment())
```
### Switching the environment
You can switch the application environment before it has been booted. This is useful when a command needs to run in a different context than it was started in. For example, the `node ace repl` command starts in the `console` environment but switches to `repl` before presenting the prompt.
```ts title="commands/my_command.ts"
import app from '@adonisjs/core/services/app'
if (!app.isBooted) {
app.setEnvironment('repl')
}
```
## Node environment
The `nodeEnvironment` property provides access to the Node.js environment, derived from the `NODE_ENV` environment variable. AdonisJS normalizes common variations to ensure consistency across different deployment configurations.
```ts title="app/services/some_service.ts"
import app from '@adonisjs/core/services/app'
console.log(app.nodeEnvironment)
```
| NODE_ENV | Normalized to |
|----------|---------------|
| dev | development |
| develop | development |
| stage | staging |
| prod | production |
| testing | test |
### Shorthand properties
Instead of comparing strings, you can use these boolean properties to check the current environment.
```ts title="app/services/some_service.ts"
import app from '@adonisjs/core/services/app'
/**
* Check if running in production
*/
app.inProduction
app.nodeEnvironment === 'production'
/**
* Check if running in development
*/
app.inDev
app.nodeEnvironment === 'development'
/**
* Check if running tests
*/
app.inTest
app.nodeEnvironment === 'test'
```
## State
The state represents where the application is in its lifecycle. The features you can access depend on the current state—for example, you cannot access [container bindings](../guides/concepts/dependency_injection.md#bindings) or [container services](../guides/concepts/container_services.md) until the app reaches the `booted` state.
| State | Description |
|-------|-------------|
| `created` | Default state when Application instance is created |
| `initiated` | Environment variables parsed and `adonisrc.ts` processed |
| `booted` | Service providers registered and booted |
| `ready` | Application ready to handle requests (meaning varies by environment) |
| `terminated` | Application terminated and process will exit shortly |
```ts title="app/services/some_service.ts"
import app from '@adonisjs/core/services/app'
console.log(app.getState())
```
### Shorthand properties
```ts title="app/services/some_service.ts"
import app from '@adonisjs/core/services/app'
/**
* True when state is past 'initiated'
*/
app.isBooted
/**
* True when state is 'ready'
*/
app.isReady
/**
* True when gracefully attempting to terminate
*/
app.isTerminating
/**
* True when state is 'terminated'
*/
app.isTerminated
```
## Listening for process signals
You can listen for [POSIX signals](https://man7.org/linux/man-pages/man7/signal.7.html) using the `app.listen` or `app.listenOnce` methods. These register listeners with the Node.js `process` object.
```ts title="start/events.ts"
import app from '@adonisjs/core/services/app'
app.listen('SIGTERM', () => {
// Handle SIGTERM
})
app.listenOnce('SIGTERM', () => {
// Handle SIGTERM once
})
```
### Conditional listeners
Use `listenIf` or `listenOnceIf` to register listeners only when a condition is met. The listener is registered only when the first argument is truthy.
```ts title="start/events.ts"
import app from '@adonisjs/core/services/app'
/**
* Only listen for SIGINT when running under pm2
*/
app.listenIf(app.managedByPm2, 'SIGINT', () => {
// Handle SIGINT in pm2
})
app.listenOnceIf(app.managedByPm2, 'SIGINT', () => {
// Handle SIGINT once in pm2
})
```
## Notifying parent process
When your application runs as a child process, you can send messages to the parent using the `app.notify` method. This wraps the `process.send` method.
```ts title="start/events.ts"
import app from '@adonisjs/core/services/app'
app.notify('ready')
app.notify({
isReady: true,
port: 3333,
host: 'localhost'
})
```
## Making paths to project files
The Application class provides helper methods that generate absolute paths to files and directories within your project. These helpers respect the directory structure configured in your `adonisrc.ts` file, ensuring paths remain correct even if you customize directory locations.
### makePath
Returns an absolute path to a file or directory within the project root.
```ts title="app/services/some_service.ts"
import app from '@adonisjs/core/services/app'
app.makePath('app/middleware/auth.ts')
// /project_root/app/middleware/auth.ts
```
### makeURL
Returns a file URL to a file or directory within the project root. This is useful when dynamically importing files.
```ts title="app/services/test_runner.ts"
import app from '@adonisjs/core/services/app'
const files = [
'./tests/welcome.spec.ts',
'./tests/maths.spec.ts'
]
await Promise.all(files.map((file) => {
return import(app.makeURL(file).href)
}))
```
### tmpPath
Returns a path to a file inside the `tmp` directory within the project root.
```ts title="app/services/some_service.ts"
app.tmpPath('logs/mail.txt')
// /project_root/tmp/logs/mail.txt
app.tmpPath()
// /project_root/tmp
```
### configPath
```ts title="app/services/some_service.ts"
app.configPath('shield.ts')
// /project_root/config/shield.ts
app.configPath()
// /project_root/config
```
### publicPath
```ts title="app/services/some_service.ts"
app.publicPath('style.css')
// /project_root/public/style.css
app.publicPath()
// /project_root/public
```
### viewsPath
```ts title="app/services/some_service.ts"
app.viewsPath('welcome.edge')
// /project_root/resources/views/welcome.edge
app.viewsPath()
// /project_root/resources/views
```
### languageFilesPath
```ts title="app/services/some_service.ts"
app.languageFilesPath('en/messages.json')
// /project_root/resources/lang/en/messages.json
app.languageFilesPath()
// /project_root/resources/lang
```
### httpControllersPath
```ts title="app/services/some_service.ts"
app.httpControllersPath('users_controller.ts')
// /project_root/app/controllers/users_controller.ts
app.httpControllersPath()
// /project_root/app/controllers
```
### modelsPath
```ts title="app/services/some_service.ts"
app.modelsPath('user.ts')
// /project_root/app/models/user.ts
app.modelsPath()
// /project_root/app/models
```
### servicesPath
```ts title="app/services/some_service.ts"
app.servicesPath('user_service.ts')
// /project_root/app/services/user_service.ts
app.servicesPath()
// /project_root/app/services
```
### middlewarePath
```ts title="app/services/some_service.ts"
app.middlewarePath('auth.ts')
// /project_root/app/middleware/auth.ts
app.middlewarePath()
// /project_root/app/middleware
```
### validatorsPath
```ts title="app/services/some_service.ts"
app.validatorsPath('create_user.ts')
// /project_root/app/validators/create_user.ts
app.validatorsPath()
// /project_root/app/validators
```
### policiesPath
```ts title="app/services/some_service.ts"
app.policiesPath('post_policy.ts')
// /project_root/app/policies/post_policy.ts
app.policiesPath()
// /project_root/app/policies
```
### exceptionsPath
```ts title="app/services/some_service.ts"
app.exceptionsPath('handler.ts')
// /project_root/app/exceptions/handler.ts
app.exceptionsPath()
// /project_root/app/exceptions
```
### transformersPath
```ts title="app/services/some_service.ts"
app.transformersPath('user.ts')
// /project_root/app/transformers/user.ts
app.transformersPath()
// /project_root/app/transformers
```
### eventsPath
```ts title="app/services/some_service.ts"
app.eventsPath('user_created.ts')
// /project_root/app/events/user_created.ts
app.eventsPath()
// /project_root/app/events
```
### listenersPath
```ts title="app/services/some_service.ts"
app.listenersPath('send_invoice.ts')
// /project_root/app/listeners/send_invoice.ts
app.listenersPath()
// /project_root/app/listeners
```
### mailsPath
```ts title="app/services/some_service.ts"
app.mailsPath('verify_email.ts')
// /project_root/app/mails/verify_email.ts
app.mailsPath()
// /project_root/app/mails
```
### migrationsPath
```ts title="app/services/some_service.ts"
app.migrationsPath('create_users_table.ts')
// /project_root/database/migrations/create_users_table.ts
app.migrationsPath()
// /project_root/database/migrations
```
### seedersPath
```ts title="app/services/some_service.ts"
app.seedersPath('user_seeder.ts')
// /project_root/database/seeders/user_seeder.ts
app.seedersPath()
// /project_root/database/seeders
```
### factoriesPath
```ts title="app/services/some_service.ts"
app.factoriesPath('user_factory.ts')
// /project_root/database/factories/user_factory.ts
app.factoriesPath()
// /project_root/database/factories
```
### generatedServerPath
Returns a path to a file inside the `.adonisjs/server` directory, which contains server-side barrel files like controller imports.
```ts title="app/services/some_service.ts"
app.generatedServerPath('controllers.ts')
// /project_root/.adonisjs/server/controllers.ts
app.generatedServerPath()
// /project_root/.adonisjs/server
```
### generatedClientPath
Returns a path to a file inside the `.adonisjs/client` directory, which contains client-side generated files.
```ts title="app/services/some_service.ts"
app.generatedClientPath('manifest.json')
// /project_root/.adonisjs/client/manifest.json
app.generatedClientPath()
// /project_root/.adonisjs/client
```
### startPath
```ts title="app/services/some_service.ts"
app.startPath('routes.ts')
// /project_root/start/routes.ts
app.startPath()
// /project_root/start
```
### providersPath
```ts title="app/services/some_service.ts"
app.providersPath('app_provider.ts')
// /project_root/providers/app_provider.ts
app.providersPath()
// /project_root/providers
```
### commandsPath
```ts title="app/services/some_service.ts"
app.commandsPath('greet.ts')
// /project_root/commands/greet.ts
app.commandsPath()
// /project_root/commands
```
## Generators
Generators create consistent class names and file names for different entities in your application. They ensure naming conventions are followed when creating new files programmatically.
```ts title="commands/make_resource.ts"
import app from '@adonisjs/core/services/app'
app.generators.controllerFileName('user')
// users_controller.ts
app.generators.controllerName('user')
// UsersController
```
See the [`generators.ts` source code](https://github.com/adonisjs/application/blob/9.x/src/generators.ts) for the complete list of available generators.
---
# AdonisRC file
This guide covers the `adonisrc.ts` configuration file. You will learn how to:
- Register service providers and preload files
- Configure directory paths for scaffolding commands
- Define command aliases for frequently used Ace commands
- Set up assembler hooks for build-time code generation
- Specify meta files to include in production builds
- Configure test suites and runner options
## Overview
The `adonisrc.ts` file serves as the central configuration for your AdonisJS workspace. It controls how the framework boots, where scaffolding commands place generated files, which providers to load, and how the build process behaves.
This file is processed by multiple tools beyond your main application, including the Ace CLI, the Assembler (which handles the dev server and production builds), and various code generators. Because of this broad usage, the file must remain environment-agnostic and free of application-specific logic.
The file contains the minimum required configuration to run your application. You can view the complete expanded configuration, including all defaults, by running the `node ace inspect:rcfile` command.
```sh
node ace inspect:rcfile
```
You can access the parsed RCFile contents programmatically using the `app` service.
```ts title="app/services/some_service.ts"
import app from '@adonisjs/core/services/app'
console.log(app.rcFile)
```
## directories
The `directories` object maps logical directory names to their filesystem paths. Scaffolding commands use these mappings to determine where to place generated files.
If you rename directories in your project structure, update the corresponding paths here so that commands like `node ace make:controller` continue to work correctly.
```ts title="adonisrc.ts"
{
directories: {
config: 'config',
commands: 'commands',
contracts: 'contracts',
public: 'public',
providers: 'providers',
languageFiles: 'resources/lang',
migrations: 'database/migrations',
seeders: 'database/seeders',
factories: 'database/factories',
views: 'resources/views',
start: 'start',
tmp: 'tmp',
tests: 'tests',
httpControllers: 'app/controllers',
models: 'app/models',
services: 'app/services',
exceptions: 'app/exceptions',
mailers: 'app/mailers',
mails: 'app/mails',
middleware: 'app/middleware',
policies: 'app/policies',
validators: 'app/validators',
events: 'app/events',
listeners: 'app/listeners',
transformers: 'app/transformers',
stubs: 'stubs',
generatedClient: '.adonisjs/client',
generatedServer: '.adonisjs/server',
}
}
```
## preloads
The `preloads` array specifies files to import during application boot. These files are imported immediately after service providers have been registered and booted, making them ideal for setup code that needs access to the container but should run before the application starts handling requests.
You can register a preload file to run in all environments or restrict it to specific ones.
| Environment | Description |
|-------------|-------------|
| `web` | The HTTP server process |
| `console` | Ace commands (except `repl`) |
| `repl` | The interactive REPL session |
| `test` | The test runner process |
The simplest form registers a file to run in all environments.
```ts title="adonisrc.ts"
{
preloads: [
() => import('./start/view.js')
]
}
```
To restrict a preload file to specific environments, use the object form with an `environment` array.
```ts title="adonisrc.ts"
{
preloads: [
{
file: () => import('./start/view.js'),
environment: ['web', 'console', 'test']
},
]
}
```
:::note
You can create and register a preload file using the `node ace make:preload` command.
:::
## providers
The `providers` array lists [service providers](../guides/concepts/service_providers.md) to load during application boot. Providers are loaded in the order they appear in the array, which matters when providers depend on each other.
Like preload files, providers can be registered for all environments or restricted to specific ones using the same environment values: `web`, `console`, `repl`, and `test`.
```ts title="adonisrc.ts"
{
providers: [
() => import('@adonisjs/core/providers/app_provider'),
() => import('@adonisjs/core/providers/http_provider'),
() => import('@adonisjs/core/providers/hash_provider'),
() => import('./providers/app_provider.js'),
]
}
```
To load a provider only in specific environments, use the object form.
```ts title="adonisrc.ts"
{
providers: [
() => import('@adonisjs/core/providers/app_provider'),
() => import('@adonisjs/core/providers/hash_provider'),
{
file: () => import('@adonisjs/core/providers/http_provider'),
environment: ['web']
},
{
file: () => import('./providers/app_provider.js'),
environment: ['web', 'console', 'test']
},
]
}
```
See also: [Service providers](../guides/concepts/service_providers.md)
## commands
The `commands` array registers Ace commands from installed packages. Your application's own commands (in the `commands` directory) are discovered automatically and do not need to be registered here.
```ts title="adonisrc.ts"
{
commands: [
() => import('@adonisjs/core/commands'),
() => import('@adonisjs/lucid/commands')
]
}
```
See also: [Creating Ace commands](../guides/ace/creating_commands.md)
## commandsAliases
The `commandsAliases` object creates shortcuts for frequently used commands. This is useful for commands with long names or commands you run often.
```ts title="adonisrc.ts"
{
commandsAliases: {
migrate: 'migration:run'
}
}
```
You can define multiple aliases pointing to the same command.
```ts title="adonisrc.ts"
{
commandsAliases: {
migrate: 'migration:run',
up: 'migration:run'
}
}
```
See also: [Creating command aliases](../guides/ace/introduction.md#creating-command-aliases)
## hooks
The `hooks` object registers callbacks that run at specific points during the Assembler lifecycle. The Assembler is the tool responsible for running the dev server, creating production builds, running tests, and performing code generation.
Hooks can be defined inline or as lazily-imported modules. They run in a separate process from your AdonisJS application and do not have access to the IoC container or framework services.
```ts title="adonisrc.ts"
import { defineConfig } from '@adonisjs/core/app'
import { indexEntities } from '@adonisjs/core/app'
export default defineConfig({
hooks: {
init: [indexEntities()],
buildStarting: [() => import('@adonisjs/vite/build_hook')],
},
})
```
The `indexEntities()` hook generates barrel files for controllers, events, and listeners, enabling lazy-loading and type-safe references. Package hooks like `@adonisjs/vite/build_hook` handle build-time asset compilation.
See also: [Assembler hooks](../guides/concepts/assembler_hooks.md) for a complete reference of available hooks and how to create custom hooks for code generation.
## metaFiles
The `metaFiles` array specifies non-TypeScript files to copy into the `build` folder when creating a production build. This includes templates, language files, and other assets your application needs at runtime.
Each entry accepts a glob pattern and an optional `reloadServer` flag that triggers a dev server restart when matching files change.
| Property | Description |
|----------|-------------|
| `pattern` | A [glob pattern](https://nodejs.org/api/fs.html#fspromisesglobpattern-options) to match files |
| `reloadServer` | Whether to restart the dev server when these files change |
```ts title="adonisrc.ts"
{
metaFiles: [
{
pattern: 'public/**',
reloadServer: false
},
{
pattern: 'resources/views/**/*.edge',
reloadServer: false
}
]
}
```
## tests
The `tests` object configures the test runner, including global timeout settings and test suite definitions.
```ts title="adonisrc.ts"
{
tests: {
timeout: 2000,
forceExit: false,
suites: [
{
name: 'functional',
files: ['tests/functional/**/*.spec.ts'],
timeout: 30000
}
]
}
}
```
| Property | Description |
|----------|-------------|
| `timeout` | Default timeout in milliseconds for all tests |
| `forceExit` | Whether to force-exit the process after tests complete. Graceful exit is recommended |
| `suites[].name` | A unique identifier for the test suite |
| `suites[].files` | Glob patterns to discover test files |
| `suites[].timeout` | Suite-specific timeout that overrides the global default |
See also: [Introduction to testing](../guides/testing/introduction.md)
---
# Commands reference
In this guide, we cover the usage of all the commands shipped with the framework core and the official packages. You may also view the commands help using the `node ace list` command or the `node ace --help` command.
```sh
node ace list
```
:::media{alt="The output of the help screen is formatted as per the http://docopt.org standard"}

:::
## serve
The `serve` uses the [@adonisjs/assembler](https://github.com/adonisjs/assembler?tab=readme-ov-file#dev-server) package to start the AdonisJS application in development environment. You can optionally watch for file changes and restart the HTTP server on every file change.
```sh
node ace serve --hmr
```
The `serve` command starts the development server (via the `bin/server.ts` file) as a child process. If you want to pass [node arguments](https://nodejs.org/api/cli.html#options) to the child process, you can define them before the command name.
```sh
node ace --no-warnings --inspect serve --hmr
```
Following is the list of available options you can pass to the `serve` command. Alternatively, use the `--help` flag to view the command's help.
::::options
:::option{name="--hmr"}
Watch the filesystem and reload the server in HMR mode.
:::
:::option{name="--watch"}
Watch the filesystem and always restart the process on file change.
:::
:::option{name="--poll"}
Use polling to detect filesystem changes. You might want to use polling when using a Docker container for development.
:::
:::option{name="--clear | --no-clear"}
Clear the terminal after every file change and before displaying the new logs. Use the `--no-clear` flag to retain old logs.
:::
::::
## build
The `build` command uses the [@adonisjs/assembler](https://github.com/adonisjs/assembler?tab=readme-ov-file#bundler) package to create the production build of your AdonisJS application. The following steps are performed to generate the build.
See also: [Creating the production build](../start/deployment.md#creating-the-production-build).
```sh
node ace build
```
Following is the list of available options you can pass to the `build` command. Alternatively, use the `--help` flag to view the command's help.
::::options
:::option{name="--ignore-ts-errors"}
The build command terminates the build process when your project has TypeScript errors. However, you can ignore those errors and finish the build using the `--ignore-ts-errors` flag.
:::
:::option{name="--package-manager"}
The build command copies the `package.json` file alongside the lock file of the package manager your application is using.
We detect the package manager using the `@antfu/install-pkg` package. However, you can turn off detection by explicitly providing the package manager's name.
:::
::::
## add
The `add` command combines the `npm install ` and `node ace configure` commands. So, instead of running two separate commands, you can install and configure the package in one go using the `add` command.
The `add` command will automatically detect the package manager used by your application and use that to install the package. However, you can always opt for a specific package manager using the `--package-manager` CLI flag.
```sh
# Install and configure the @adonisjs/lucid package
node ace add @adonisjs/lucid
# Install the package as a development dependency and configure it
node ace add my-dev-package --dev
```
If the package can be configured using flags, you can pass them directly to the `add` command. Every unknown flag will be passed down to the `configure` command.
```sh
node ace add @adonisjs/lucid --db=sqlite
```
--verbose
Enable verbose mode to display the package installation and configuration logs.
--force
Passed down to the `configure` command. Force overwrite files when configuring the package. See the `configure` command for more information.
--package-manager
Define the package manager to use for installing the package. The value must be `npm`, `pnpm`, `bun` or `yarn`.
--dev
Install the package as a development dependency.
## configure
Configure a package after it has been installed. The command accepts the package name as the first argument.
```sh
node ace configure @adonisjs/lucid
```
--verbose
Enable verbose mode to display the package installation logs.
--force
The stubs system of AdonisJS does not overwrite existing files. For example, if you configure the `@adonisjs/lucid` package and your application already has a `config/database.ts` file, the configure process will not overwrite the existing config file.
However, you can force overwrite files using the `--force` flag.
## eject
Eject stubs from a given package to your application `stubs` directory. In the following example, we copy the `make/controller` stubs to our application for modification.
See also: [Customizing stubs](../guides/concepts/scaffolding.md#ejecting-stubs)
```sh
# Copy stub from @adonisjs/core package
node ace eject make/controller
# Copy stub from @adonisjs/bouncer package
node ace eject make/policy --pkg=@adonisjs/bouncer
```
## generate\:key
Generate a cryptographically secure random key and write to the `.env` file as the `APP_KEY` environment variable.
See also: [App key](../guides/security/encryption.md)
```sh
node ace generate:key
```
--show
Display the key on the terminal instead of writing it to the `.env` file. By default, the key is written to the env file.
--force
The `generate:key` command does not write the key to the `.env` file when running your application in production. However, you can use the `--force` flag to override this behavior.
## make\:controller
Create a new HTTP controller class. Controllers are created inside the `app/controllers` directory and use the following naming conventions.
- Form: `plural`
- Suffix: `controller`
- Class name example: `UsersController`
- File name example: `users_controller.ts`
```sh
node ace make:controller users
```
You also generate a controller with custom action names, as shown in the following example.
```sh
# Generates controller with "index", "show", and "store" methods
node ace make:controller users index show store
```
--singular
Force the controller name to be in singular form.
--resource
Generate a controller with methods to perform CRUD operations on a resource.
--api
The `--api` flag is similar to the `--resource` flag. However, it does not define the `create` and the `edit` methods since they are used to display forms.
## make\:middleware
Create a new middleware for HTTP requests. Middleware are stored inside the `app/middleware` directory and uses the following naming conventions.
- Form: `singular`
- Suffix: `middleware`
- Class name example: `BodyParserMiddleware`
- File name example: `body_parser_middleware.ts`
```sh
node ace make:middleware bodyparser
```
--stack
Skip the [middleware stack](../guides/basics/middleware.md#middleware-stacks) selection prompt by defining the stack explicitly. The value must be `server`, `named`, or `router`.
```sh
node ace make:middleware bodyparser --stack=router
```
## make\:event
Create a new event class. Events are stored inside the `app/events` directory and use the following naming conventions.
- Form: `NA`
- Suffix: `NA`
- Class name example: `OrderShipped`
- File name example: `order_shipped.ts`
- Recommendation: You must name your events around the lifecycle of an action. For example: `MailSending`, `MailSent`, `RequestCompleted`, and so on.
```sh
node ace make:event orderShipped
```
## make\:validator
Create a new VineJS validator file. The validators are stored inside the `app/validators` directory, and each file may export multiple validators.
- Form: `singular`
- Suffix: `NA`
- File name example: `user.ts`
- Recommendation: You must create validator files around the resources of your application.
```sh
# A validator for managing a user
node ace make:validator user
# A validator for managing a post
node ace make:validator post
```
--resource
Create a validator file with pre-defined validators for `create` and `update` actions.
```sh
node ace make:validator post --resource
```
## make\:listener
Create a new event listener class. The listener classes are stored inside the `app/listeners` directory and use the following naming conventions.
- Form: `NA`
- Suffix: `NA`
- Class name example: `SendShipmentNotification`
- File name example: `send_shipment_notification.ts`
- Recommendation: The event listeners must be named after the action they perform. For example, a listener that sends the shipment notification email should be called `SendShipmentNotification`.
```sh
node ace make:listener sendShipmentNotification
```
--event
Generate an event class alongside the event listener.
```sh
node ace make:listener sendShipmentNotification --event=shipment_received
```
## make\:service
Create a new service class. Service classes are stored inside the `app/services` directory and use the following naming conventions.
:::note
A service has no pre-defined meaning, and you can use it to extract the business logic inside your application. For example, if your application generates a lot of PDFs, you may create a service called `PdfGeneratorService` and reuse it in multiple places.
:::
- Form: `singular`
- Suffix: `service`
- Class name example: `InvoiceService`
- File name example: `invoice_service.ts`
```sh
node ace make:service invoice
```
## make\:exception
Create a new [custom exception class](../guides/basics/exception_handling.md#custom-exceptions). Exceptions are stored inside the `app/exceptions` directory.
- Form: `NA`
- Suffix: `exception`
- Class name example: `CommandValidationException`
- File name example: `command_validation_exception.ts`
```sh
node ace make:exception commandValidation
```
## make\:command
Create a new Ace command. By default, the commands are stored inside the `commands` directory at the root of your application.
Commands from this directory are imported automatically by AdonisJS when you try to execute any Ace command. You may prefix the filename with an `_` to store additional files that are not Ace commands in this directory.
- Form: `NA`
- Suffix: `NA`
- Class name example: `ListRoutes`
- File name example: `list_routes.ts`
- Recommendation: Commands must be named after the action they perform. For example, `ListRoutes`, `MakeController`, and `Build`.
```sh
node ace make:command listRoutes
```
## make\:view
Create a new Edge.js template file. The templates are created inside the `resources/views` directory.
- Form: `NA`
- Suffix: `NA`
- File name example: `posts/view.edge`
- Recommendation: You must group templates for a resource inside a subdirectory. For example: `posts/list.edge`, `posts/create.edge`, and so on.
```sh
node ace make:view posts/create
node ace make:view posts/list
```
## make\:provider
Create a [service provider file](../guides/concepts/service_providers.md). Providers are stored inside the `providers` directory at the root of your application and use the following naming conventions.
- Form: `singular`
- Suffix: `provider`
- Class name example: `AppProvider`
- File name example: `app_provider.ts`
```sh
node ace make:provider app
```
--environments
Define environments in which the provider should get imported. [Learn more about app environments](./application.md#environment)
```sh
node ace make:provider app -e=web -e=console
```
## make\:preload
Create a new [preload file](./adonisrc_file.md#preloads). Preload files are stored inside the `start` directory.
```sh
node ace make:preload view
```
--environments
Define environments in which the preload file should get imported. [Learn more about app environments](./application.md#environment)
```sh
node ace make:preload view app -e=web -e=console
```
## make\:test
Create a new test file inside the `tests/` directory.
- Form: NA
- Suffix: `.spec`
- File name example: `posts/list.spec.ts`, `posts/update.spec.ts`
```sh
node ace make:test --suite=unit
```
--suite
Define the suite for which you want to create the test file. Otherwise, the command will display a prompt for suite selection.
## make\:mail
Create a new mail class inside the `app/mails` directory. The mail classes are suffixed with the `Notification` keyword. However, you may define a custom suffix using the `--intent` CLI flag.
- Form: NA
- Suffix: `Intent`
- Class name example: ShipmentNotification
- File name example: shipment_notification.ts
```sh
node ace make:mail shipment
# ./app/mails/shipment_notification.ts
```
--intent
Define a custom intent for the mail.
```sh
node ace make:mail shipment --intent=confirmation
# ./app/mails/shipment_confirmation.ts
node ace make:mail storage --intent=warning
# ./app/mails/storage_warning.ts
```
## make\:policy
Create a new Bouncer policy class. The policies are stored inside the `app/policies` folder and use the following naming conventions.
- Form: `singular`
- Suffix: `policy`
- Class name example: `PostPolicy`
- File name example: `post_policy.ts`
```sh
node ace make:policy post
```
## inspect\:rcfile
View the contents of the `adonisrc.ts` file after merging the defaults. You may use this command to inspect the available configuration options and override them per your application requirements.
See also: [AdonisRC file](./adonisrc_file.md)
```sh
node ace inspect:rcfile
```
## list\:routes
View list of routes registered by your application. This command will boot your AdonisJS application in the `console` environment.
```sh
node ace list:routes
```
Also, you can see the routes list from the VSCode activity bar if you are using our [official VSCode extension](https://marketplace.visualstudio.com/items?itemName=jripouteau.adonis-vscode-extension).
:::media

:::
--json
View routes as a JSON string. The output will be an array of object.
--table
View routes inside a CLI table. By default, we display routes inside a compact, pretty list.
--middleware
Filter routes list and include the ones using the mentioned middleware. You may use the `*` keyword to include routes using one or more middleware.
--ignore-middleware
Filter routes list and include the ones NOT using the mentioned middleware. You may use the `*` keyword to include routes that do not use any middleware.
## env\:add
The `env:add` command allows you to add a new environment variables to the `.env`, `.env.example` files and will also define the validation rules in the `start/env.ts` file.
You can just run the command and it will prompt you for the variable name, value, and validation rules. Or you can pass them as arguments.
```sh
# Will prompt for the variable name, value, and validation rules
node ace env:add
# Define the variable name, value, and validation rule
node ace env:add MY_VARIABLE value --type=string
```
--type
Define the type of the environment variable. The value must be one of the following: `string`, `boolean`, `number`, `enum`.
--enum-values
Define the allowed values for the environment variable when the type is `enum`.
```sh
node ace env:add MY_VARIABLE foo --type=enum --enum-values=foo --enum-values=bar
```
---
# Edge helpers and tags
In this guide, we will learn about the **helpers and the tags** contributed to Edge by the AdonisJS official packages. The helpers shipped with Edge are not covered in this guide and must reference [Edge](https://edgejs.dev/docs/helpers) documentation for the same.
## request
Reference to the instance of ongoing [HTTP request](../guides/basics/request.md). The property is only available when a template is rendered using the `ctx.view.render` method.
```edge
{{ request.url() }}
{{ request.input('signature') }}
```
## route/signedRoute
Helper functions to create URL for a route using the [URL builder](../guides/basics/routing.md#url-builder). Unlike the URL builder, the view helpers do not have a fluent API and accept the following parameters.
Position
Description
1st
The route identifier or the route pattern
2nd
Route params are defined as an array or an object.
3rd
The options object with the following properties.
qs: Define query string parameters as an object.
domain: Search for routes under a specific domain.
prefixUrl: Prefix a URL to the output.
disableRouteLookup: Enable/disable routes lookup.
```edge
View post
```
```edge
Unsubscribe
```
## app
Reference to the [Application instance](./application.md).
```edge
{{ app.getEnvironment() }}
```
## config
A helper function to reference configuration values inside Edge templates. You may use the `config.has` method to check if the value for a key exists.
```edge
@if(config.has('app.appUrl'))
Home
@else
Home
@end
```
## session
A read-only copy of the [session object](../guides/basics/session.md#reading-and-writing-data). You cannot mutate session data within Edge templates. The `session` property is only available when the template is rendered using the `ctx.view.render` method.
```edge
Post views: {{ session.get(`post.${post.id}.visits`) }}
```
## flashMessages
A read-only copy of [session flash messages](../guides/basics/session.md#flash-messages). The `flashMessages` property is only available when the template is rendered using the `ctx.view.render` method.
```edge
@if(flashMessages.has('inputErrorsBag.title'))
{{ flashMessages.get('inputErrorsBag.title') }}
@end
@if(flashMessages.has('notification'))
{{ flashMessages.get('notification').message }}
@end
```
## old
The `old` method is a shorthand for the `flashMessages.get` method.
```edge
```
## t
The `t` method is contributed by the `@adonisjs/i18n` package to display translations using the [i18n class](../guides/digging_deeper/i18n.md#resolving-translations). The method accepts the translation key identifier, message data and a fallback message as the parameters.
```edge
{{ t('messages.greeting') }}
```
## i18n
Reference to an instance of the I18n class configured using the application's default locale. However, the [`DetectUserLocaleMiddleware`](../guides/digging_deeper/i18n.md#detecting-user-locale-during-an-http-request) overrides this property with an instance created for the current HTTP request locale.
```edge
{{ i18n.formatCurrency(200, { currency: 'USD' }) }}
```
## auth
Reference to the [ctx.auth](../guides/basics/http_context.md#http-context-properties) property shared by the [InitializeAuthMiddleware](https://github.com/adonisjs/auth/blob/10.x/src/middleware/initialize_auth_middleware.ts#L19-L48). You may use this property to access information about the logged-in user.
```edge
@if(auth.isAuthenticated)
{{ auth.user.email }}
@end
```
If you are displaying the logged-in user info on a public page (not protected by the auth middleware), then you may want to first silently check if the user is logged-in or not.
```edge
{{-- Check if user is logged-in --}}
@eval(await auth.use('web').check())
@if(auth.use('web').isAuthenticated)
{{ auth.use('web').user.email }}
@end
```
## asset
Resolve the URL of an asset processed by Vite. Learn more about [referencing assets inside Edge templates](../guides/frontend/vite.md#referencing-assets-inside-edge-templates).
```edge
```
## embedImage / embedImageData
The `embedImage` and the `embedImageData` helpers are added by the [mail](..//guides/digging_deeper/mail.md#embedding-images) package and are only available when rendering a template to send an email.
```edge
```
## @flashMessage
The `@flashMessage` tag provides a better DX for reading flash messages for a given key conditionally.
:::caption{for="error"}
**Instead of writing conditionals**
:::
```edge
@if(flashMessages.has('notification'))
{{ flashMessages.get('notification').message }}
@end
```
:::caption{for="success"}
**You may prefer using the tag**
:::
```edge
@flashMessage('notification')
{{ $message.message }}
@end
```
## @error
The `@error` tag provides a better DX for reading error messages stored inside the `errorsBag` key in `flashMessages`.
:::caption{for="error"}
**Instead of writing conditionals**
:::
```edge
@if(flashMessages.has('errorsBag.E_BAD_CSRF_TOKEN'))
@end
```
:::caption{for="success"}
**You may prefer using the tag**
:::
```edge
@error('E_BAD_CSRF_TOKEN')
{{ $message }}
@end
```
## @inputError
The `@inputError` tag provides a better DX for reading validation error messages stored inside the `inputErrorsBag` key in `flashMessages`.
:::caption{for="error"}
**Instead of writing conditionals**
:::
```edge
@if(flashMessages.has('inputErrorsBag.title'))
@each(message in flashMessages.get('inputErrorsBag.title'))
{{ message }}
@end
@end
```
:::caption{for="success"}
**You may prefer using the tag**
:::
```edge
@inputError('title')
@each(message in $messages)
{{ message }}
@end
@end
```
## @vite
The `@vite` tag accepts an array of entry point paths and returns the `script` and the `link` tags for the same. The path you provide to the `@vite` tag should match exactly the path registered inside the `vite.config.js` file.
```ts
export default defineConfig({
plugins: [
adonisjs({
// highlight-start
entrypoints: ['resources/js/app.js'],
// highlight-end
}),
]
})
```
```edge
@vite(['resources/js/app.js'])
```
You can define the script tag attributes as the 2nd argument. For example:
```edge
@vite(['resources/js/app.js'], {
defer: true,
})
```
## @viteReactRefresh
The `@viteReactRefresh` tag returns a [script tag to enable React HMR](https://vitejs.dev/guide/backend-integration.html#:~:text=you%27ll%20also%20need%20to%20add%20this%20before%20the%20above%20scripts) for project using the [@vitejs/plugin-react](https://www.npmjs.com/package/@vitejs/plugin-react) package.
```edge
@viteReactRefresh()
```
Output HTML
```html
```
## @can/@cannot
The `@can` and `@cannot` tags allows you write authorization checks in Edge templates by referencing the ability name or the policy name as a string.
The first argument is the ability or the policy reference followed by the arguments accepted by the check.
See also: [Pre-registering abilities and policies](../guides/auth/authorization.md#pre-registering-abilities-and-policies)
```edge
@can('editPost', post)
{{-- Can edit post --}}
@end
@can('PostPolicy.edit', post)
{{-- Can edit post --}}
@end
```
```edge
@cannot('editPost', post)
{{-- Cannot edit post --}}
@end
@cannot('editPost', post)
{{-- Cannot edit post --}}
@end
```
---
# Events reference
In this guide, we look at the list of events dispatched by the framework core and the official packages. Check out the [emitter](../guides/digging_deeper/emitter.md) documentation to learn more about its usage.
## http\:request_completed
The [`http:request_completed`](https://github.com/adonisjs/http-server/blob/8.x/src/types/server.ts#L65-L81) event is dispatched after an HTTP request is completed. The event contains an instance of the [HttpContext](../guides/basics/http_context.md) and the request duration. The `duration` value is the output of the `process.hrtime` method.
```ts
import emitter from '@adonisjs/core/services/emitter'
import string from '@adonisjs/core/helpers/string'
emitter.on('http:request_completed', (event) => {
const method = event.ctx.request.method()
const url = event.ctx.request.url(true)
const duration = event.duration
console.log(`${method} ${url}: ${string.prettyHrTime(duration)}`)
})
```
## http\:server_ready
The event is dispatched once the AdonisJS HTTP server is ready to accept incoming requests.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('http:server_ready', (event) => {
console.log(event.host)
console.log(event.port)
/**
* Time it took to boot the app and start
* the HTTP server.
*/
console.log(event.duration)
})
```
## container_binding\:resolved
The event is dispatched after the IoC container resolves a binding or constructs a class instance. The `event.binding` property will be a string (binding name) or a class constructor, and the `event.value` property is the resolved value.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('container_binding:resolved', (event) => {
console.log(event.binding)
console.log(event.value)
})
```
## session\:initiated
The `@adonisjs/session` package emits the event when the session store is initiated during an HTTP request. The `event.session` property is an instance of the [Session class](https://github.com/adonisjs/session/blob/8.x/src/session.ts).
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('session:initiated', (event) => {
console.log(`Initiated store for ${event.session.sessionId}`)
})
```
## session\:committed
The `@adonisjs/session` package emits the event when the session data is written to the session store during an HTTP request.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('session:committed', (event) => {
console.log(`Persisted data for ${event.session.sessionId}`)
})
```
## session\:migrated
The `@adonisjs/session` package emits the event when a new session ID is generated using the `session.regenerate()` method.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('session:migrated', (event) => {
console.log(`Migrating data to ${event.toSessionId}`)
console.log(`Destroying session ${event.fromSessionId}`)
})
```
## i18n\:missing\:translation
The event is dispatched by the `@adonisjs/i18n` package when a translation for a specific key and locale is missing. You may listen to this event to find the missing translations for a given locale.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('i18n:missing:translation', function (event) {
console.log(event.identifier)
console.log(event.hasFallback)
console.log(event.locale)
})
```
## mail\:sending
The `@adonisjs/mail` package emits the event before sending an email. In the case of the `mail.sendLater` method call, the event will be emitted when the mail queue processes the job.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('mail:sending', (event) => {
console.log(event.mailerName)
console.log(event.message)
console.log(event.views)
})
```
## mail\:sent
After sending the email, the event is dispatched by the `@adonisjs/mail` package.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('mail:sent', (event) => {
console.log(event.response)
console.log(event.mailerName)
console.log(event.message)
console.log(event.views)
})
```
## mail\:queueing
The `@adonisjs/mail` package emits the event before queueing the job to send the email.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('mail:queueing', (event) => {
console.log(event.mailerName)
console.log(event.message)
console.log(event.views)
})
```
## mail\:queued
After the email has been queued, the event is dispatched by the `@adonisjs/mail` package.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('mail:queued', (event) => {
console.log(event.mailerName)
console.log(event.message)
console.log(event.views)
})
```
## queued\:mail\:error
The event is dispatched when the [MemoryQueue](https://github.com/adonisjs/mail/blob/10.x/src/messengers/memory_queue.ts) implementation of the `@adonisjs/mail` package is unable to send the email queued using the `mail.sendLater` method.
If you are using a custom queue implementation, you must capture the job errors and emit this event.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('queued:mail:error', (event) => {
console.log(event.error)
console.log(event.mailerName)
})
```
## session_auth\:login_attempted
The event is dispatched by the [SessionGuard](https://github.com/adonisjs/auth/blob/10.x/modules/session_guard/guard.ts) implementation of the `@adonisjs/auth` package when the `auth.login` method is called either directly or internally by the session guard.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('session_auth:login_attempted', (event) => {
console.log(event.guardName)
console.log(event.user)
})
```
## session_auth\:login_succeeded
The event is dispatched by the [SessionGuard](https://github.com/adonisjs/auth/blob/10.x/modules/session_guard/guard.ts) implementation of the `@adonisjs/auth` package after a user has been logged in successfully.
You may use this event to track sessions associated with a given user.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('session_auth:login_succeeded', (event) => {
console.log(event.guardName)
console.log(event.sessionId)
console.log(event.user)
console.log(event.rememberMeToken) // (if created one)
})
```
## session_auth\:authentication_attempted
The event is dispatched by the `@adonisjs/auth` package when an attempt is made to validate the request session and check for a logged-in user.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('session_auth:authentication_attempted', (event) => {
console.log(event.guardName)
console.log(event.sessionId)
})
```
## session_auth\:authentication_succeeded
The event is dispatched by the `@adonisjs/auth` package after the request session has been validated and the user is logged in. You may access the logged-in user using the `event.user` property.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('session_auth:authentication_succeeded', (event) => {
console.log(event.guardName)
console.log(event.sessionId)
console.log(event.user)
console.log(event.rememberMeToken) // if authenticated using token
})
```
## session_auth\:authentication_failed
The event is dispatched by the `@adonisjs/auth` package when the authentication check fails, and the user is not logged in during the current HTTP request.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('session_auth:authentication_failed', (event) => {
console.log(event.guardName)
console.log(event.sessionId)
console.log(event.error)
})
```
## session_auth\:logged_out
The event is dispatched by the `@adonisjs/auth` package after the user has been logged out.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('session_auth:logged_out', (event) => {
console.log(event.guardName)
console.log(event.sessionId)
/**
* The value of the user will be null when logout is called
* during a request where no user was logged in in the first place.
*/
console.log(event.user)
})
```
## access_tokens_auth\:authentication_attempted
The event is dispatched by the `@adonisjs/auth` package when an attempt is made to validate the access token during an HTTP request.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('access_tokens_auth:authentication_attempted', (event) => {
console.log(event.guardName)
})
```
## access_tokens_auth\:authentication_succeeded
The event is dispatched by the `@adonisjs/auth` package after the access token has been verified. You may access the authenticated user using the `event.user` property.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('access_tokens_auth:authentication_succeeded', (event) => {
console.log(event.guardName)
console.log(event.user)
console.log(event.token)
})
```
## access_tokens_auth\:authentication_failed
The event is dispatched by the `@adonisjs/auth` package when the authentication check fails.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('access_tokens_auth:authentication_failed', (event) => {
console.log(event.guardName)
console.log(event.error)
})
```
## authorization\:finished
The event is dispatched by the `@adonisjs/bouncer` package after the authorization check has been performed. The event payload includes the final response you may inspect to know the status of the check.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('authorization:finished', (event) => {
console.log(event.user)
console.log(event.response)
console.log(event.parameters)
console.log(event.action)
})
```
## cache\:cleared
The event is dispatched by the `@adonisjs/cache` package after the cache has been cleared using the `cache.clear` method.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('cache:cleared', (event) => {
console.log(event.store)
})
```
## cache\:deleted
The event is dispatched by the `@adonisjs/cache` package after a cache key has been deleted using the `cache.delete` method.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('cache:deleted', (event) => {
console.log(event.key)
})
```
## cache\:hit
The event is dispatched by the `@adonisjs/cache` package when a cache key is found in the cache store.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('cache:hit', (event) => {
console.log(event.key)
console.log(event.value)
})
```
## cache\:miss
The event is dispatched by the `@adonisjs/cache` package when a cache key is not found in the cache store.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('cache:miss', (event) => {
console.log(event.key)
})
```
## cache\:written
The event is dispatched by the `@adonisjs/cache` package after a cache key has been written to the cache store.
```ts
import emitter from '@adonisjs/core/services/emitter'
emitter.on('cache:written', (event) => {
console.log(event.key)
console.log(event.value)
})
```
---
# Exceptions reference
In this guide we will go through the list of known exceptions raised by the framework core and the official packages. Some of the exceptions are marked as **self-handled**. [Self-handled exceptions](../guides/basics/exception_handling.md#defining-the-handle-method) can convert themselves to an HTTP response.
## E_ROUTE_NOT_FOUND
The exception is raised when the HTTP server receives a request for a non-existing route. By default, the client will get a 404 response, and optionally, you may render an HTML page using [status pages](../guides/basics/exception_handling.md#status-pages).
- **Status code**: 404
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_ROUTE_NOT_FOUND) {
// handle error
}
```
## E_ROW_NOT_FOUND
The exception is raised when the database query for finding one item fails [e.g when using `Model.findOrFail()`]. By default, the client will get a 404 response, and optionally, you may render an HTML page using [status pages](../guides/basics/exception_handling.md#status-pages).
- **Status code**: 404
- **Self handled**: No
```ts
import { errors as lucidErrors } from '@adonisjs/lucid'
if (error instanceof lucidErrors.E_ROW_NOT_FOUND) {
// handle error
console.log(`${error.model?.name || 'Row'} not found`)
}
```
## E_AUTHORIZATION_FAILURE
The exception is raised when a bouncer authorization check fails. The exception is self-handled and [uses content-negotiation](../guides/auth/authorization.md#throwing-authorizationexception) to return an appropriate error response to the client.
- **Status code**: 403
- **Self handled**: Yes
- **Translation identifier**: `errors.E_AUTHORIZATION_FAILURE`
```ts
import { errors as bouncerErrors } from '@adonisjs/bouncer'
if (error instanceof bouncerErrors.E_AUTHORIZATION_FAILURE) {
}
```
## E_TOO_MANY_REQUESTS
The exception is raised by the [@adonisjs/rate-limiter](../guides/security/rate_limiting.md) package when a request exhausts all the requests allowed during a given duration. The exception is self-handled and [uses content-negotiation](../guides/security/rate_limiting.md#handling-throttleexception) to return an appropriate error response to the client.
- **Status code**: 429
- **Self handled**: Yes
- **Translation identifier**: `errors.E_TOO_MANY_REQUESTS`
```ts
import { errors as limiterErrors } from '@adonisjs/limiter'
if (error instanceof limiterErrors.E_TOO_MANY_REQUESTS) {
}
```
## E_BAD_CSRF_TOKEN
The exception is raised when a form using [CSRF protection](../guides/security/securing_ssr_applications.md#csrf-protection) is submitted without the CSRF token, or the CSRF token is invalid.
- **Status code**: 403
- **Self handled**: Yes
- **Translation identifier**: `errors.E_BAD_CSRF_TOKEN`
```ts
import { errors as shieldErrors } from '@adonisjs/shield'
if (error instanceof shieldErrors.E_BAD_CSRF_TOKEN) {
}
```
The `E_BAD_CSRF_TOKEN` exception is [self-handled](https://github.com/adonisjs/shield/blob/9.x/src/errors.ts#L23-L66), and the user will be redirected back to the form, and you can access the error using the flash messages.
```edge
@error('E_BAD_CSRF_TOKEN')
{{ message }}
@end
```
## E_OAUTH_MISSING_CODE
The `@adonisjs/ally` package raises the exception when the OAuth service does not provide the OAuth code during the redirect.
You can avoid this exception if you [handle the errors](../guides/auth/social_authentication.md#handling-callback-response) before calling the `.accessToken` or `.user` methods.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors as allyErrors } from '@adonisjs/ally'
if (error instanceof allyErrors.E_OAUTH_MISSING_CODE) {
}
```
## E_OAUTH_STATE_MISMATCH
The `@adonisjs/ally` package raises the exception when the CSRF state defined during the redirect is missing.
You can avoid this exception if you [handle the errors](../guides/auth/social_authentication.md#handling-callback-response) before calling the `.accessToken` or `.user` methods.
- **Status code**: 400
- **Self handled**: No
```ts
import { errors as allyErrors } from '@adonisjs/ally'
if (error instanceof allyErrors.E_OAUTH_STATE_MISMATCH) {
}
```
## E_UNAUTHORIZED_ACCESS
The exception is raised when one of the authentication guards is not able to authenticate the request. The exception is self-handled and uses [content-negotiation](../guides/auth/session_guard.md#handling-authentication-exception) to return an appropriate error response to the client.
- **Status code**: 401
- **Self handled**: Yes
- **Translation identifier**: `errors.E_UNAUTHORIZED_ACCESS`
```ts
import { errors as authErrors } from '@adonisjs/auth'
if (error instanceof authErrors.E_UNAUTHORIZED_ACCESS) {
}
```
## E_INVALID_CREDENTIALS
The exception is raised when the auth finder is not able to verify the user credentials. The exception is handled and use [content-negotiation](../guides/auth/verifying_user_credentials.md#handling-exceptions) to return an appropriate error response to the client.
- **Status code**: 400
- **Self handled**: Yes
- **Translation identifier**: `errors.E_INVALID_CREDENTIALS`
```ts
import { errors as authErrors } from '@adonisjs/auth'
if (error instanceof authErrors.E_INVALID_CREDENTIALS) {
}
```
## E_CANNOT_LOOKUP_ROUTE
The exception is raised when you attempt to create a URL for a route using the [URL builder](../guides/basics/routing.md#url-builder).
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_CANNOT_LOOKUP_ROUTE) {
// handle error
}
```
## E_HTTP_EXCEPTION
The `E_HTTP_EXCEPTION` is a generic exception for throwing errors during an HTTP request. You can use this exception directly or create a custom exception extending it.
- **Status code**: Defined at the time of raising the exception
- **Self handled**: Yes
```ts
// title: Throw exception
import { errors } from '@adonisjs/core'
throw errors.E_HTTP_EXCEPTION.invoke(
{
errors: ['Cannot process request'],
},
422
)
```
```ts
// title: Handle exception
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_HTTP_EXCEPTION) {
// handle error
}
```
## E_HTTP_REQUEST_ABORTED
The `E_HTTP_REQUEST_ABORTED` is a sub-class of the `E_HTTP_EXCEPTION` exception. This exception is raised by the [response.abort](../guides/basics/response.md#aborting-request-with-an-error) method.
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_HTTP_REQUEST_ABORTED) {
// handle error
}
```
## E_INSECURE_APP_KEY
The exception is raised when the length of `appKey` is smaller than 16 characters. You can use the [generate:key](./commands.md#generatekey) ace command to generate a secure app key.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_INSECURE_APP_KEY) {
// handle error
}
```
## E_MISSING_APP_KEY
The exception is raised when the `appKey` property is not defined inside the `config/app.ts` file. By default, the value of the `appKey` is set using the `APP_KEY` environment variable.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_MISSING_APP_KEY) {
// handle error
}
```
## E_INVALID_ENV_VARIABLES
The exception is raised when one or more environment variables fail the validation. The detailed validation errors can be accessed using the `error.help` property.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_INVALID_ENV_VARIABLES) {
console.log(error.help)
}
```
## E_MISSING_COMMAND_NAME
The exception is raised when a command does not define the `commandName` property or its value is an empty string.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_MISSING_COMMAND_NAME) {
console.log(error.commandName)
}
```
## E_COMMAND_NOT_FOUND
The exception is raised by Ace when unable to find a command.
- **Status code**: 404
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_COMMAND_NOT_FOUND) {
console.log(error.commandName)
}
```
## E_MISSING_FLAG
The exception is raised when executing a command without passing a required CLI flag.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_MISSING_FLAG) {
console.log(error.commandName)
}
```
## E_MISSING_FLAG_VALUE
The exception is raised when trying to execute a command without providing any value to a non-boolean CLI flag.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_MISSING_FLAG_VALUE) {
console.log(error.commandName)
}
```
## E_MISSING_ARG
The exception is raised when executing a command without defining the required arguments.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_MISSING_ARG) {
console.log(error.commandName)
}
```
## E_MISSING_ARG_VALUE
The exception is raised when executing a command without defining the value for a required argument.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_MISSING_ARG_VALUE) {
console.log(error.commandName)
}
```
## E_UNKNOWN_FLAG
The exception is raised when executing a command with an unknown CLI flag.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_UNKNOWN_FLAG) {
console.log(error.commandName)
}
```
## E_INVALID_FLAG
The exception is raised when the value provided for a CLI flag is invalid—for example, passing a string value to a flag that accepts numeric values.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_INVALID_FLAG) {
console.log(error.commandName)
}
```
## E_MULTIPLE_REDIS_SUBSCRIPTIONS
The `@adonisjs/redis` package raises the exception when you attempt to [subscribe to a given pub/sub channel](../guides/database/redis.md#pubsub) multiple times.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors as redisErrors } from '@adonisjs/redis'
if (error instanceof redisErrors.E_MULTIPLE_REDIS_SUBSCRIPTIONS) {
}
```
## E_MULTIPLE_REDIS_PSUBSCRIPTIONS
The `@adonisjs/redis` package raises the exception when you attempt to [subscribe to a given pub/sub pattern](../guides/database/redis.md#pubsub) multiple times.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors as redisErrors } from '@adonisjs/redis'
if (error instanceof redisErrors.E_MULTIPLE_REDIS_PSUBSCRIPTIONS) {
}
```
## E_MAIL_TRANSPORT_ERROR
The exception is raised by the `@adonisjs/mail` package when unable to send the email using a given transport. Usually, this will happen when the HTTP API of the email service returns a non-200 HTTP response.
You may access the network request error using the `error.cause` property. The `cause` property is the [error object](https://github.com/sindresorhus/got/blob/main/documentation/8-errors.md) returned by `got` (npm package).
- **Status code**: 400
- **Self handled**: No
```ts
import { errors as mailErrors } from '@adonisjs/mail'
if (error instanceof mailErrors.E_MAIL_TRANSPORT_ERROR) {
console.log(error.cause)
}
```
## E_SESSION_NOT_MUTABLE
The exception is raised by the `@adonisjs/session` package when the session store is initiated in the read-only mode.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors as sessionErrors } from '@adonisjs/session'
if (error instanceof sessionErrors.E_SESSION_NOT_MUTABLE) {
console.log(error.message)
}
```
## E_SESSION_NOT_READY
The exception is raised by the `@adonisjs/session` package when the session store has not been initiated yet. This will be the case when you are not using the session middleware.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors as sessionErrors } from '@adonisjs/session'
if (error instanceof sessionErrors.E_SESSION_NOT_READY) {
console.log(error.message)
}
```
## E_MISSING_METAFILE_PATTERN
The exception is raised when the `pattern` property is missing in the [metaFile](./adonisrc_file.md#metaFiles).
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_MISSING_METAFILE_PATTERN) {
console.log(error.message)
}
```
## E_MISSING_PRELOAD_FILE
The exception is raised when a preload file registered in the `adonisrc.ts` file cannot be found.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_MISSING_PRELOAD_FILE) {
console.log(error.message)
}
```
## E_INVALID_PRELOAD_FILE
The exception is raised when the preload `file` property is not a function.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_INVALID_PRELOAD_FILE) {
console.log(error.message)
}
```
## E_MISSING_PROVIDER_FILE
The exception is raised when a service provider file registered in the `adonisrc.ts` file cannot be found.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_MISSING_PROVIDER_FILE) {
console.log(error.message)
}
```
## E_INVALID_PROVIDER
The exception is raised when the provider `file` property is not a function.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_INVALID_PROVIDER) {
console.log(error.message)
}
```
## E_MISSING_SUITE_NAME
The exception is raised when a test suite configuration in the `adonisrc.ts` file is missing the required `name` property.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_MISSING_SUITE_NAME) {
console.log(error.message)
}
```
## E_MISSING_SUITE_FILES
The exception is raised when a test suite configuration in the `adonisrc.ts` file is missing the required `files` property.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_MISSING_SUITE_FILES) {
console.log(error.message)
}
```
## E_UNKNOWN_ASSEMBLER_HOOK
The exception is raised when an unknown assembler hook is defined in the `adonisrc.ts` file.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_UNKNOWN_ASSEMBLER_HOOK) {
console.log(error.message)
}
```
## E_INVALID_HOOKS_VALUE
The exception is raised when the `hooks` configuration in the `adonisrc.ts` file is not an array of lazy imports.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_INVALID_HOOKS_VALUE) {
console.log(error.message)
}
```
## E_INVALID_PRESETS_VALUE
The exception is raised when the `presets` configuration is not an array of preset functions.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_INVALID_PRESETS_VALUE) {
console.log(error.message)
}
```
## E_INVALID_PRESET_FUNCTION
The exception is raised when a preset is not defined as a function.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_INVALID_PRESET_FUNCTION) {
console.log(error.message)
}
```
## E_PRESET_EXECUTION_ERROR
The exception is raised when an error occurs during preset execution.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_PRESET_EXECUTION_ERROR) {
console.log(error.message)
console.log(error.cause)
}
```
## E_INVALID_ENV_VARIABLES
The exception is raised when one or more environment variables are invalid as per the rules defined within the `start/env.ts` file.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_INVALID_ENV_VARIABLES) {
console.log(error.message)
console.log(error.help)
}
```
## E_IDENTIFIER_ALREADY_DEFINED
The exception is raised when trying to override the implementation of an Env identifier. Consider using `Env.defineIdentifierIfMissing` method instead.
- **Status code**: 500
- **Self handled**: No
```ts
import { errors } from '@adonisjs/core'
if (error instanceof errors.E_IDENTIFIER_ALREADY_DEFINED) {
console.log(error.message)
}
```
---
# Helpers reference
AdonisJS bundles its utilities into the `helpers` module and makes them available to your application code. Since these utilities are already installed and used by the framework, the `helpers` module does not add any additional bloat to your `node_modules`.
The helper methods are exported from the following modules.
```ts
import is from '@adonisjs/core/helpers/is'
import * as helpers from '@adonisjs/core/helpers'
import string from '@adonisjs/core/helpers/string'
```
## escapeHTML
Escape HTML entities in a string value. Under the hood, we use the [he](https://www.npmjs.com/package/he#heescapetext) package.
```ts
import string from '@adonisjs/core/helpers/string'
string.escapeHTML('
', 12, {
completeWords: true,
})
// Output: This is a very...
```
## slug
Generate slug for a string value. The method is exported from the [slugify package](https://www.npmjs.com/package/slugify); therefore, consult its documentation for available options.
```ts
import string from '@adonisjs/core/helpers/string'
console.log(string.slug('hello ♥ world'))
// hello-love-world
```
You can add custom replacements for Unicode values as follows.
```ts
string.slug.extend({ '☢': 'radioactive' })
console.log(string.slug('unicode ♥ is ☢'))
// unicode-love-is-radioactive
```
## interpolate
Interpolate variables inside a string. The variables must be inside double curly braces.
```ts
import string from '@adonisjs/core/helpers/string'
string.interpolate('hello {{ user.username }}', {
user: {
username: 'virk'
}
})
// hello virk
```
Curly braces can be escaped using the `\\` prefix.
```ts
string.interpolate('hello \\{{ users.0 }}', {})
// hello {{ users.0 }}
```
## plural
Convert a word to its plural form. The method is exported from the [pluralize package](https://www.npmjs.com/package/pluralize).
```ts
import string from '@adonisjs/core/helpers/string'
string.plural('test')
// tests
```
## isPlural
Find if a word already is in plural form.
```ts
import string from '@adonisjs/core/helpers/string'
string.isPlural('tests') // true
```
## pluralize
This method combines the `singular` and the `plural` methods and uses one or the other based on the count. For example:
```ts
import string from '@adonisjs/core/helpers/string'
string.pluralize('box', 1) // box
string.pluralize('box', 2) // boxes
string.pluralize('box', 0) // boxes
string.pluralize('boxes', 1) // box
string.pluralize('boxes', 2) // boxes
string.pluralize('boxes', 0) // boxes
```
The `pluralize` property exports [additional methods](https://www.npmjs.com/package/pluralize) to register custom uncountable, irregular, plural, and singular rules.
```ts
import string from '@adonisjs/core/helpers/string'
string.pluralize.addUncountableRule('paper')
string.pluralize.addSingularRule(/singles$/i, 'singular')
```
## singular
Convert a word to its singular form. The method is exported from the [pluralize package](https://www.npmjs.com/package/pluralize).
```ts
import string from '@adonisjs/core/helpers/string'
string.singular('tests')
// test
```
## isSingular
Find if a word is already in a singular form.
```ts
import string from '@adonisjs/core/helpers/string'
string.isSingular('test') // true
```
## camelCase
Convert a string value to camelcase.
```ts
import string from '@adonisjs/core/helpers/string'
string.camelCase('user_name') // userName
```
Following are some of the conversion examples.
| Input | Output |
| ---------------- | ------------- |
| 'test' | 'test' |
| 'test string' | 'testString' |
| 'Test String' | 'testString' |
| 'TestV2' | 'testV2' |
| '_foo_bar_' | 'fooBar' |
| 'version 1.2.10' | 'version1210' |
| 'version 1.21.0' | 'version1210' |
## capitalCase
Convert a string value to a capital case.
```ts
import string from '@adonisjs/core/helpers/string'
string.capitalCase('helloWorld') // Hello World
```
Following are some of the conversion examples.
| Input | Output |
| ---------------- | ---------------- |
| 'test' | 'Test' |
| 'test string' | 'Test String' |
| 'Test String' | 'Test String' |
| 'TestV2' | 'Test V 2' |
| 'version 1.2.10' | 'Version 1.2.10' |
| 'version 1.21.0' | 'Version 1.21.0' |
## dashCase
Convert a string value to a dash case.
```ts
import string from '@adonisjs/core/helpers/string'
string.dashCase('helloWorld') // hello-world
```
Optionally, you can capitalize the first letter of each word.
```ts
string.dashCase('helloWorld', { capitalize: true }) // Hello-World
```
Following are some of the conversion examples.
| Input | Output |
| ---------------- | -------------- |
| 'test' | 'test' |
| 'test string' | 'test-string' |
| 'Test String' | 'test-string' |
| 'Test V2' | 'test-v2' |
| 'TestV2' | 'test-v-2' |
| 'version 1.2.10' | 'version-1210' |
| 'version 1.21.0' | 'version-1210' |
## dotCase
Convert a string value to a dot case.
```ts
import string from '@adonisjs/core/helpers/string'
string.dotCase('helloWorld') // hello.World
```
Optionally, you can convert the first letter of all the words to lowercase.
```ts
string.dotCase('helloWorld', { lowerCase: true }) // hello.world
```
Following are some of the conversion examples.
| Input | Output |
| ---------------- | -------------- |
| 'test' | 'test' |
| 'test string' | 'test.string' |
| 'Test String' | 'Test.String' |
| 'dot.case' | 'dot.case' |
| 'path/case' | 'path.case' |
| 'TestV2' | 'Test.V.2' |
| 'version 1.2.10' | 'version.1210' |
| 'version 1.21.0' | 'version.1210' |
## noCase
Remove all sorts of casing from a string value.
```ts
import string from '@adonisjs/core/helpers/string'
string.noCase('helloWorld') // hello world
```
Following are some of the conversion examples.
| Input | Output |
| ---------------------- | ---------------------- |
| 'test' | 'test' |
| 'TEST' | 'test' |
| 'testString' | 'test string' |
| 'testString123' | 'test string123' |
| 'testString_1_2_3' | 'test string 1 2 3' |
| 'ID123String' | 'id123 string' |
| 'foo bar123' | 'foo bar123' |
| 'a1bStar' | 'a1b star' |
| 'CONSTANT_CASE ' | 'constant case' |
| 'CONST123_FOO' | 'const123 foo' |
| 'FOO_bar' | 'foo bar' |
| 'XMLHttpRequest' | 'xml http request' |
| 'IQueryAArgs' | 'i query a args' |
| 'dot\.case' | 'dot case' |
| 'path/case' | 'path case' |
| 'snake_case' | 'snake case' |
| 'snake_case123' | 'snake case123' |
| 'snake_case_123' | 'snake case 123' |
| '"quotes"' | 'quotes' |
| 'version 0.45.0' | 'version 0 45 0' |
| 'version 0..78..9' | 'version 0 78 9' |
| 'version 4_99/4' | 'version 4 99 4' |
| ' test ' | 'test' |
| 'something_2014_other' | 'something 2014 other' |
| 'amazon s3 data' | 'amazon s3 data' |
| 'foo_13_bar' | 'foo 13 bar' |
## pascalCase
Convert a string value to a Pascal case. Great for generating JavaScript class names.
```ts
import string from '@adonisjs/core/helpers/string'
string.pascalCase('user team') // UserTeam
```
Following are some of the conversion examples.
| Input | Output |
| ---------------- | ------------- |
| 'test' | 'Test' |
| 'test string' | 'TestString' |
| 'Test String' | 'TestString' |
| 'TestV2' | 'TestV2' |
| 'version 1.2.10' | 'Version1210' |
| 'version 1.21.0' | 'Version1210' |
## sentenceCase
Convert a value to a sentence.
```ts
import string from '@adonisjs/core/helpers/string'
string.sentenceCase('getting_started-with-adonisjs')
// Getting started with adonisjs
```
Following are some of the conversion examples.
| Input | Output |
| ---------------- | ---------------- |
| 'test' | 'Test' |
| 'test string' | 'Test string' |
| 'Test String' | 'Test string' |
| 'TestV2' | 'Test v2' |
| 'version 1.2.10' | 'Version 1 2 10' |
| 'version 1.21.0' | 'Version 1 21 0' |
## snakeCase
Convert value to snake case.
```ts
import string from '@adonisjs/core/helpers/string'
string.snakeCase('user team') // user_team
```
Following are some of the conversion examples.
| Input | Output |
| ---------------- | -------------- |
| '\_id' | 'id' |
| 'test' | 'test' |
| 'test string' | 'test_string' |
| 'Test String' | 'test_string' |
| 'Test V2' | 'test_v2' |
| 'TestV2' | 'test_v_2' |
| 'version 1.2.10' | 'version_1210' |
| 'version 1.21.0' | 'version_1210' |
## titleCase
Convert a string value to the title case.
```ts
import string from '@adonisjs/core/helpers/string'
string.titleCase('small word ends on')
// Small Word Ends On
```
Following are some of the conversion examples.
| Input | Output |
| ---------------------------------- | ---------------------------------- |
| 'one. two.' | 'One. Two.' |
| 'a small word starts' | 'A Small Word Starts' |
| 'small word ends on' | 'Small Word Ends On' |
| 'we keep NASA capitalized' | 'We Keep NASA Capitalized' |
| 'pass camelCase through' | 'Pass camelCase Through' |
| 'follow step-by-step instructions' | 'Follow Step-by-Step Instructions' |
| 'this vs. that' | 'This vs. That' |
| 'this vs that' | 'This vs That' |
| 'newcastle upon tyne' | 'Newcastle upon Tyne' |
| 'newcastle \*upon\* tyne' | 'Newcastle \*upon\* Tyne' |
## random
Generate a cryptographically secure random string of a given length. The output value is a URL-safe base64 encoded string.
```ts
import string from '@adonisjs/core/helpers/string'
string.random(32)
// 8mejfWWbXbry8Rh7u8MW3o-6dxd80Thk
```
## sentence
Convert an array of words to a comma-separated sentence.
```ts
import string from '@adonisjs/core/helpers/string'
string.sentence(['routes', 'controllers', 'middleware'])
// routes, controllers, and middleware
```
You can replace the `and` with an `or` by specifying the `options.lastSeparator` property.
```ts
string.sentence(['routes', 'controllers', 'middleware'], {
lastSeparator: ', or ',
})
```
In the following example, the two words are combined using the `and` separator, not the comma (usually advocated in English). However, you can use a custom separator for a pair of words.
```ts
string.sentence(['routes', 'controllers'])
// routes and controllers
string.sentence(['routes', 'controllers'], {
pairSeparator: ', and ',
})
// routes, and controllers
```
## condenseWhitespace
Remove multiple whitespaces from a string to a single whitespace.
```ts
import string from '@adonisjs/core/helpers/string'
string.condenseWhitespace('hello world')
// hello world
string.condenseWhitespace(' hello world ')
// hello world
```
## seconds
Parse a string-based time expression to seconds.
```ts
import string from '@adonisjs/core/helpers/string'
string.seconds.parse('10h') // 36000
string.seconds.parse('1 day') // 86400
```
Passing a numeric value to the `parse` method is returned as it is, assuming the value is already in seconds.
```ts
string.seconds.parse(180) // 180
```
You can format seconds to a pretty string using the `format` method.
```ts
string.seconds.format(36000) // 10h
string.seconds.format(36000, true) // 10 hours
```
## milliseconds
Parse a string-based time expression to milliseconds.
```ts
import string from '@adonisjs/core/helpers/string'
string.milliseconds.parse('1 h') // 3.6e6
string.milliseconds.parse('1 day') // 8.64e7
```
Passing a numeric value to the `parse` method is returned as it is, assuming the value is already in milliseconds.
```ts
string.milliseconds.parse(180) // 180
```
Using the `format` method, you can format milliseconds to a pretty string.
```ts
string.milliseconds.format(3.6e6) // 1h
string.milliseconds.format(3.6e6, true) // 1 hour
```
## bytes
Parse a string-based unit expression to bytes.
```ts
import string from '@adonisjs/core/helpers/string'
string.bytes.parse('1KB') // 1024
string.bytes.parse('1MB') // 1048576
```
Passing a numeric value to the `parse` method is returned as it is, assuming the value is already in bytes.
```ts
string.bytes.parse(1024) // 1024
```
Using the `format` method, you can format bytes to a pretty string. The method is exported directly from the [bytes](https://www.npmjs.com/package/bytes) package. Please reference the package README for available options.
```ts
string.bytes.format(1048576) // 1MB
string.bytes.format(1024 * 1024 * 1000) // 1000MB
string.bytes.format(1024 * 1024 * 1000, { thousandsSeparator: ',' }) // 1,000MB
```
## ordinal
Get the ordinal letter for a given number.
```ts
import string from '@adonisjs/core/helpers/string'
string.ordinal(1) // 1st
string.ordinal(2) // '2nd'
string.ordinal(3) // '3rd'
string.ordinal(4) // '4th'
string.ordinal(23) // '23rd'
string.ordinal(24) // '24th'
```
## safeEqual
Check if two buffer or string values are the same. This method does not leak any timing information and prevents [timing attack](https://javascript.plainenglish.io/what-are-timing-attacks-and-how-to-prevent-them-using-nodejs-158cc7e2d70c).
Under the hood, this method uses Node.js [crypto.timeSafeEqual](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b) method, with support for comparing string values. _(crypto.timeSafeEqual does not support string comparison)_
```ts
import { safeEqual } from '@adonisjs/core/helpers'
/**
* The trusted value, it might be saved inside the db
*/
const trustedValue = 'hello world'
/**
* Untrusted user input
*/
const userInput = 'hello'
if (safeEqual(trustedValue, userInput)) {
// both are the same
} else {
// value mismatch
}
```
## safeTiming
Ensure a callback takes at least a minimum amount of time to execute. This prevents [timing attacks](https://en.wikipedia.org/wiki/Timing_attack) where an attacker measures response times to infer sensitive information (for example, user enumeration via password reset).
The first argument is the minimum execution time in milliseconds. If the callback finishes early, `safeTiming` waits for the remaining duration. If it throws, the error is re-thrown after the delay.
```ts
import { safeTiming } from '@adonisjs/core/helpers'
// Both paths (user exists or not) take the same minimum time
return safeTiming(200, async () => {
const user = await User.findBy('email', email)
if (user) await sendResetEmail(user)
return { message: 'If this email exists, you will receive a reset link.' }
})
```
The callback receives a `timing` object with a `returnEarly` method to skip the delay. This is useful when you want constant time on failure, but fast responses on success.
```ts
import { safeTiming } from '@adonisjs/core/helpers'
return safeTiming(200, async (timing) => {
const token = await Token.findBy('value', request.header('x-api-key'))
if (token) {
timing.returnEarly()
return token.owner
}
throw new UnauthorizedException()
})
```
## compose
The `compose` helper allows you to use TypeScript class mixins with a cleaner API. Following is an example of mixin usage without the `compose` helper.
```ts
class User extends UserWithAttributes(UserWithAge(UserWithPassword(UserWithEmail(BaseModel)))) {}
```
Following is an example with the `compose` helper.
- There is no nesting.
- The order of mixins is from (left to right/top to bottom). Whereas earlier, it was inside out.
```ts
import { compose } from '@adonisjs/core/helpers'
class User extends compose(
BaseModel,
UserWithEmail,
UserWithPassword,
UserWithAge,
UserWithAttributes
) {}
```
## base64
Utility methods to base64 encode and decode values.
```ts
import { base64 } from '@adonisjs/core/helpers'
base64.encode('hello world')
// aGVsbG8gd29ybGQ=
```
Like the `encode` method, you can use the `urlEncode` to generate a base64 string safe to pass in a URL.
The `urlEncode` method performs the following replacements.
- Replace `+` with `-`.
- Replace `/` with `_`.
- And remove the `=` sign from the end of the string.
```ts
base64.urlEncode('hello world')
// aGVsbG8gd29ybGQ
```
You can use the `decode` and the `urlDecode` methods to decode a previously encoded base64 string.
```ts
base64.decode(base64.encode('hello world'))
// hello world
base64.urlDecode(base64.urlEncode('hello world'))
// hello world
```
The `decode` and the `urlDecode` methods return `null` when the input value is an invalid base64 string. You can turn on the `strict` mode to raise an exception instead.
```ts
base64.decode('hello world') // null
base64.decode('hello world', 'utf-8', true) // raises exception
```
## fsReadAll
Get a list of all the files from a directory. The method recursively fetches files from the main and the sub-folders. The dotfiles are ignored implicitly.
```ts
import { fsReadAll } from '@adonisjs/core/helpers'
const files = await fsReadAll(new URL('./config', import.meta.url), { pathType: 'url' })
await Promise.all(files.map((file) => import(file)))
```
You can also pass the options along with the directory path as the second argument.
```ts
type Options = {
ignoreMissingRoot?: boolean
filter?: (filePath: string, index: number) => boolean
sort?: (current: string, next: string) => number
pathType?: 'relative' | 'unixRelative' | 'absolute' | 'unixAbsolute' | 'url'
}
const options: Partial = {}
await fsReadAll(location, options)
```
| Argument | Description |
|------------|------------|
| `ignoreMissingRoot` | By default, an exception is raised when the root directory is missing. Setting `ignoreMissingRoot` to true will not result in an error, and an empty array is returned. |
| `filter` | Define a filter to ignore certain paths. The method is called on the final list of files. |
| `sort` | Define a custom method to sort file paths. By default, the files are sorted using natural sort. |
| `pathType` | Define how to return the collected paths. By default, OS-specific relative paths are returned. If you want to import the collected files, you must set the`pathType = 'url'` |
## fsImportAll
The `fsImportAll` method imports all the files recursively from a given directory and sets the exported value from each module on an object.
```ts
import { fsImportAll } from '@adonisjs/core/helpers'
const collection = await fsImportAll(new URL('./config', import.meta.url))
console.log(collection)
```
- Collection is an object with a tree of key-value pairs.
- The key is the nested object created from the file path.
- Value is the exported values from the module. Only the default export is used if a module has both `default` and `named` exports.
The second param is the option to customize the import behavior.
```ts
type Options = {
ignoreMissingRoot?: boolean
filter?: (filePath: string, index: number) => boolean
sort?: (current: string, next: string) => number
transformKeys? (keys: string[]) => string[]
}
const options: Partial = {}
await fsImportAll(location, options)
```
| Argument | Description |
|------------|------------|
| `ignoreMissingRoot` | By default, an exception is raised when the root directory is missing. Setting `ignoreMissingRoot` to true will not result in an error, and an empty object will be returned. |
| `filter` | Define a filter to ignore certain paths. By default, only files ending with `.js`, `.ts`, `.json`, `.cjs`, and `.mjs` are imported. |
| `sort` | Define a custom method to sort file paths. By default, the files are sorted using natural sort. |
| `transformKeys` | Define a callback method to transform the keys for the final object. The method receives an array of nested keys and must return an array. |
## String builder
The `StringBuilder` class offers a fluent API to perform transformations on a string value. You may get an instance of string builder using the `string.create` method.
```ts
import string from '@adonisjs/core/helpers/string'
const value = string
.create('userController')
.removeSuffix('controller') // user
.plural() // users
.snakeCase() // users
.suffix('_controller') // users_controller
.ext('ts') // users_controller.ts
.toString()
```
## Message builder
The `MessageBuilder` class offers an API to serialize JavaScript data types with an expiry and purpose. You can either store the serialized output in safe storage like your application database or encrypt it (to avoid tampering) and share it publicly.
In the following example, we serialize an object with the `token` property and set its expiry to be `1 hour`.
```ts
import { MessageBuilder } from '@adonisjs/core/helpers'
const builder = new MessageBuilder()
const encoded = builder.build(
{
token: string.random(32),
},
'1 hour',
'email_verification'
)
/**
* {
* "message": {
* "token":"GZhbeG5TvgA-7JCg5y4wOBB1qHIRtX6q"
* },
* "purpose":"email_verification",
* "expiryDate":"2022-10-03T04:07:13.860Z"
* }
*/
```
Once you have the JSON string with the expiry and the purpose, you can encrypt it (to prevent tampering) and share it with the client.
During the token verification, you can decrypt the previously encrypted value and use the `MessageBuilder` to verify the payload and convert it to a JavaScript object.
```ts
import { MessageBuilder } from '@adonisjs/core/helpers'
const builder = new MessageBuilder()
const decoded = builder.verify(value, 'email_verification')
if (!decoded) {
return 'Invalid payload'
}
console.log(decoded.token)
```
## Secret
The `Secret` class lets you hold sensitive values within your application without accidentally leaking them inside logs and console statements.
For example, the `appKey` value defined inside the `config/app.ts` file is an instance of the `Secret` class. If you try to log this value to the console, you will see `[redacted]` and not the original value.
For demonstration, let's fire up a REPL session and try it.
```sh
node ace repl
```
```sh
> (js) config = await import('./config/app.js')
# [Module: null prototype] {
// highlight-start
# appKey: [redacted],
// highlight-end
# http: {
# }
# }
```
```sh
> (js) console.log(config.appKey)
# [redacted]
```
You can call the `config.appKey.release` method to read the original value. The purpose of the Secret class is not to prevent your code from accessing the original value. Instead, it provides a safety net from exposing sensitive data inside logs.
### Using the Secret class
You can wrap custom values inside the Secret class as follows.
```ts
import { Secret } from '@adonisjs/core/helpers'
const value = new Secret('some-secret-value')
console.log(value) // [redacted]
console.log(value.release()) // some-secret-value
```
## Data-types detection
We export the [@sindresorhus/is](https://github.com/sindresorhus/is) module from the `helpers/is` import path, and you may use it to perform the type detection in your apps.
```ts
import is from '@adonisjs/core/helpers/is'
is.object({}) // true
is.object(null) // false
```
---
# Types helpers
## InferRouteParams
Infer params of a route pattern. The params must be defined as per the AdonisJS routing syntax.
```ts
import type { InferRouteParams } from '@adonisjs/core/helpers/types'
InferRouteParams<'/users'> // {}
InferRouteParams<'/users/:id'> // { id: string }
InferRouteParams<'/users/:id?'> // { id?: string }
InferRouteParams<'/users/:id/:slug?'> // { id: string; slug?: string }
InferRouteParams<'/users/:id.json'> // { id: string }
InferRouteParams<'/users/*'> // { '*': string[] }
InferRouteParams<'/posts/:category/*'> // { 'category': string; '*': string[] }
```
## Prettify
Prettifies the complex TypeScript types to a simplified type for a better viewing experience. For example:
```ts
import type { Prettify } from '@adonisjs/core/helpers/types'
import type { ExtractDefined, ExtractUndefined } from '@adonisjs/core/helpers/types'
type Values = {
username: string | undefined
email: string
fullName: string | undefined
age: number | undefined
}
// When not using prettify helper
type WithUndefinedOptional = {
[K in ExtractDefined]: Values[K]
} & {
[K in ExtractUndefined]: Values[K]
}
// When using prettify helper
type WithUndefinedOptionalPrettified = Prettify<
{
[K in ExtractDefined]: Values[K]
} & {
[K in ExtractUndefined]: Values[K]
}
>
```
## Primitive
Union of primitive types. It includes `null | undefined | string | number | boolean | symbol | bigint`
```ts
import type { Primitive } from '@adonisjs/core/helpers/types'
function serialize(
values:
| Primitive
| Record
| Primitive[]
| Record[]
) {}
```
## OneOrMore
Specify a union that accepts either `T` or `T[]`.
```ts
import type { OneOrMore } from '@adonisjs/core/helpers/types'
import type { Primitive } from '@adonisjs/core/helpers/types'
function serialize(
values: OneOrMore | OneOrMore>
) {}
```
## Constructor
Represent a class constructor. The `T` refers to the class instance properties, and `Arguments` refers to the constructor arguments.
```ts
import type { Constructor } from '@adonisjs/core/helpers/types'
function make(Klass: Constructor, ...args: Args) {
return new Klass(...args)
}
```
## AbstractConstructor
Represent a class constructor that could also be abstract. The `T` refers to the class instance properties, and `Arguments` refers to the constructor arguments.
```ts
import type { AbstractConstructor } from '@adonisjs/core/helpers/types'
function log(Klass: AbstractConstructor, ...args: Args) {}
```
## LazyImport
Represent a function that lazily imports a module with `export default`.
```ts
import type { LazyImport, Constructor } from '@adonisjs/core/helpers/types'
function middleware(list: LazyImport>[]) {}
```
## UnWrapLazyImport
Unwrap the default export of a `LazyImport` function.
```ts
import type { LazyImport, UnWrapLazyImport } from '@adonisjs/core/helpers/types'
type Middleware = LazyImport>
type MiddlewareClass = UnWrapLazyImport
```
## NormalizeConstructor
Normalizes the constructor arguments of a class for use with mixins. The helper is created to work around [TypeScript issue#37142](https://github.com/microsoft/TypeScript/issues/37142).
```ts
// title: Usage without NormalizeConstructor
class Base {}
function DatesMixin(superclass: TBase) {
// A mixin class must have a constructor with a single rest parameter of type 'any[]'. ts(2545)
return class HasDates extends superclass {
// ❌ ^^
declare createdAt: Date
declare updatedAt: Date
}
}
// Base constructors must all have the same return type.ts(2510)
class User extends DatesMixin(Base) {}
// ❌ ^^
```
```ts
// title: Using NormalizeConstructor
import type { NormalizeConstructor } from '@adonisjs/core/helpers/types'
class Base {}
function DatesMixin>(superclass: TBase) {
return class HasDates extends superclass {
declare createdAt: Date
declare updatedAt: Date
}
}
class User extends DatesMixin(Base) {}
```
## Opaque
Define an opaque type to distinguish between similar properties.
```ts
import type { Opaque } from '@adonisjs/core/helpers/types'
type Username = Opaque
type Password = Opaque
function checkUser(_: Username) {}
// ❌ Argument of type 'string' is not assignable to parameter of type 'Opaque'.
checkUser('hello')
// ❌ Argument of type 'Opaque' is not assignable to parameter of type 'Opaque'.
checkUser('hello' as Password)
checkUser('hello' as Username)
```
## UnwrapOpaque
Unwrap the value from an opaque type.
```ts
import type { Opaque, UnwrapOpaque } from '@adonisjs/core/helpers/types'
type Username = Opaque
type Password = Opaque
type UsernameValue = UnwrapOpaque // string
type PasswordValue = UnwrapOpaque // string
```
## ExtractFunctions
Extract all the functions from an object. Optionally specify a list of methods to ignore.
```ts
import type { ExtractFunctions } from '@adonisjs/core/helpers/types'
class User {
declare id: number
declare username: string
create() {}
update(_id: number, __attributes: any) {}
}
type UserMethods = ExtractFunctions // 'create' | 'update'
```
You may use the `IgnoreList` to ignore methods from a known parent class
```ts
import type { ExtractFunctions } from '@adonisjs/core/helpers/types'
class Base {
save() {}
}
class User extends Base {
declare id: number
declare username: string
create() {}
update(_id: number, __attributes: any) {}
}
type UserMethods = ExtractFunctions // 'create' | 'update'
type UserMethodsWithParent = ExtractFunctions> // 'create' | 'update'
```
## AreAllOptional
Check if all the top-level properties of an object are optional.
```ts
import type { AreAllOptional } from '@adonisjs/core/helpers/types'
AreAllOptional<{ id: string; name?: string }> // false
AreAllOptional<{ id?: string; name?: string }> // true
```
## ExtractUndefined
Extract properties that are `undefined` or are a union with `undefined` values.
```ts
import type { ExtractUndefined } from '@adonisjs/core/helpers/types'
type UndefinedProperties = ExtractUndefined<{ id: string; name: string | undefined }>
```
## ExtractDefined
Extract properties that are not `undefined` nor is a union with `undefined` values.
```ts
import type { ExtractDefined } from '@adonisjs/core/helpers/types'
type UndefinedProperties = ExtractDefined<{ id: string; name: string | undefined }>
```
## AsyncOrSync
Define a union with the value or a `PromiseLike` of the value.
```ts
import type { AsyncOrSync } from '@adonisjs/core/helpers/types'
function log(fetcher: () => AsyncOrSync<{ id: number }>) {
const { id } = await fetcher()
}
```
---
Comments
@each(comment in post.comments){{ comment.content }}
No comments yet.
@end