AdonisJs. Beginner’s Guide

This tutorial will guide you through creating Adonis fullstack-app, deploying it to Heroku, working with database, user authentication and validating data, step by step.
What will be covered in this article:
- 1. Creating new AdonisJs project
- 2. Configuring Heroku app
- 3. Adding new Routes
- 4. Adding data to the Database
- 5. Migrations
- 6. Controllers
- 7. Lucid Models
- 8. CSRF
- 9. Security
- 10. Data validation
- 11. Testing
1. Creating new AdonisJs project
Install adonis/CLI
npm i -g @adonisjs/cli
Create a new project with any name you like
adonis new projectname
This will create adonis-fullstack-app
DOCS:
…, it comes pre-configured with:
Bodyparser, Session, Authentication,Web security middleware, CORS, Edge, template engine, Lucid ORM, Migrations and seeds.
You can use
api-only
flag to create only api server. But this is for another story.
// Scaffold project for api server
adonis new projectname --api-only
adonis new projectname --api
To start a project locally and check that everything works as expected you can use this script (will run app using .env
production file)
adonis serve --dev
--dev
flag means that it will restart every time if code been changed, without that flag you should restart server manually
To run adonis locally with another env
file (.env.dev
, for example), you can run such script
ENV_PATH=.env.dev adonis serve --dev
Before deploying to Heroku we will make a few changes. In the future you will understand “why”:
Add mysql
and url-parse
npm packages to package.json
npm i mysql url-parse
Add new lines in /config/database.js
file:
//insert those line after Helpresconst Url = use('url-parse')
const CLEARDB_DATABASE_URL = new Url(Env.get('CLEARDB_DATABASE_URL'))// and before module.exports {
Change MySQL configuration in /config/database.js
file:
mysql: {
client: 'mysql',
connection: {
host: Env.get('DB_HOST', CLEARDB_DATABASE_URL.host),
port: Env.get('DB_PORT', ''),
user: Env.get('DB_USER', CLEARDB_DATABASE_URL.username),
password: Env.get('DB_PASSWORD', CLEARDB_DATABASE_URL.password),
database: Env.get('DB_DATABASE', CLEARDB_DATABASE_URL.pathname.substr(1))
}
},
I hope you know how to deploy your project to git, so I will skip this part and will assume that you already done it.
2. Configuring Heroku app
Create a new app

I prefer to choose auto-deploy from git master
branch, but you could choose any way you like.

After connecting your repository with AdonisJs application, add ClearDB MySQL add-on to you Heroku app


Just in case -> add-on documentation
After adding ClearDB add-on you will see a new added env variable CLEARDB_DATABASE_URL
in your Heroku app. As you remember I added lines of code using this env variable.
Then add DB_CONNECTION
and APP_KEY
environment variables in the Heroku app settings. Value of the APP_KEY
you can find in the .env file of your created AdonisJs project.

If you will not add APP_KEY you could get an error with such message:
RuntimeException: E_MISSING_ENV_KEY: Make sure to define environment variable APP_KEY.App key is a randomly generated 16 or 32 characters long string required to encrypted cookies, sessions and other sensitive data.
It is recommended to add two more ClearDB databases to your Heroku add-ons, so you would have three environments:
- Production with a configuration in
.env
file; - Staging with a configuration in
.env.dev
file; - Testing with a configuration in
.env.testing
file;
3. Adding new Routes
Add in new lines in /start/routes.js
file.
Returning simple text message
Route.get('/simple-text', () => 'Hello Adonis')
You will get a text message ”Hello Adonis” as a response in {heroku-host}/simple-text
url. Simple isn’t it?
Returning html page
Route.on('/html-page').render('custom')
custom
is a name of a/resources/views/custom.edge
file (which is just a simple html with dynamic inclusions like inwelcom.edge
file. More about *.edge files you can read here
Dynamic routes
Required params
Route.get('dynamic/:id', ({ params }) => {
return `Post ${params.id}`
})
Optional params
Route.get('dynamic/:drink?', ({ params }) => {
// use Coffee as fallback when drink is not defined
const drink = params.drink || 'Coffee'return `One ${drink}, coming right up!`
})
To know more -> routes documentation
4. Adding data to the Database
Add new lines of code to /routes.js
// Database
const Database = use('Database')Route.get('users/:username', async ({ params }) => {
const userId = await Database
.table('users')
.insert({ username: params.username })return userId
}).formats(['json'])
If you will try to get /users/custom-name
you will get an error
Error: insert into `users` (`username`) values ('custom-name') - ER_NO_DEFAULT_FOR_FIELD: Field 'email' doesn't have a default value
Do you know why?
Because we forgot to make changes in the schema file, that was created after adonis new projectname
script.
class UserSchema extends Schema {
up () {
this.create('users', table => {
table.increments()
table.string('username', 80).notNullable().unique()
table.string('email', 254).notNullable().unique()
table.string('password', 60).notNullable()
table.timestamps()
})
}down () {
this.drop('users')
}
}
Because after we got to /users/some-name
our defined schema UserSchema
was created in the Heroku database.
Don’t be angry 😠 . It was done intentionally so that we could discover approaches of solving such issues :)😉

So somehow we need to fix our schema in the database (make changes to required fields email
and password
that are not required for our case)
In this case, we will use migrations.
From the official documentation :
Migrations are mutations to your database as you keep evolving your application. Think of them as step by step screenshot of your database schema, that you can roll back at any given point of time.
Also, migrations make it easier to work as a team, where database changes from one developer are easily spotted and used by other developers in the team.
5. Migrations
Creating schema
To create migrations you can use this script:
adonis make:migration users
> Create table // for creating new table
Select table // for altering old tableadonis migration:run // executes up() function defined in Schema
“Create table” and “Select table” have one slight difference, and nothing more :

You can add
NODE_ENV
variable before the script (orENV_PATH=/user/.env
) for running migration on different databases . If you will setNODE_ENV
variable totesting
AdonisJs attempts to load.env.testing
file from the root of your application.For example:
NODE_ENV=testing adonis migration:run
Before making changes in the “production” database, it would be great to test out future migrations, that they would work as expected.
I do not recommend to use
sqlite
for testing migration purposes, because ,as official KnexJS documentation says about alter() function:
This only works in .alterTable() and is not supported by SQlite or Amazon Redshift.
We will create the same version of our “production” ClearDB Heroku database, but for “developing” purposes.
Don’t forget to copy Heroku environment database variable to .env.testing
, after creating new “development” database. (Env variable name could be different from mine)

But first, let’s check migration testing status, just in case:
NODE_ENV=testing adonis migration:status
We will see something like this:

Let’s create our testing database:
$ NODE_ENV=testing adonis migration:run
If all went well, you will see a message Database migrated successfully
If you want to drop your database, run
NODE_ENV=testing adonis migration:reset
orNODE_ENV=testing adonis migration:rollback
if it was only one action
And we have two choices: remove “email” column from schema or make it not required
.
First variant:
NODE_ENV=testing adonis make:migration user
> Select table
… with updating created /database/migrations/***_user_schema.js file
class UserSchema extends Schema {
up () {
this.table('users', table => {
table.dropColumn('email')
})
}
}
or the Second variant:
NODE_ENV=testing adonis make:migration user
> Select table
and
class UserSchema extends Schema {
up () {
this.table('users', table => {
table.string('email', 254).nullable().alter()
})
}down () {
this.table('users', table => {
table.string('email', 254).notNullable().alter()
})
}
}
To be honest, I like the second one. So let’s do it.
NODE_ENV=testing adonis migration:run
And result… Database migrated successfully
Recommendation. When you are creating migration always add logic for down() function, so your migration would work other way too -> when you execute
migration:run
andmigration:rollback
Production
Let try it out on “production” database
adonis migration:run --force
And result… Database migrated successfully!

If you want to see queries before they will be executed, you can run
adonis migration:reset --force --log
The result would be:
Queries for ***_user_schema.js
alter table `users` modify `email` varchar(254) not nullQueries for ***_token.js
drop table `tokens`Queries for ***_user.js
drop table `users`
Before we deploy changes to Heroku let’s make one change to our Routes.
6. Controllers
It is great that will can easily add callback function with logic to our routes.js
like this:
Route.get('users/:user/:password/:email', async ({ params }) => {
const userId = await Database
.table('users')
.insert({
username: params.username,
password: params.password,
email: params.email,
});const user = await User.find(userId)Database.close(['mysql'])return user
}).formats(['json'])
!Reminder: DONT use get methods for passing user’s personal information. This is just an example.
But with the growth of our application /start/routes.js
file could become huge.
To prevent this situation in future AdodisJs provides useful functionality called Binding Controllers so our Routes could be as simple as they could be, for example:
Route.get('users', 'UserController.index')
index
is just a function name in theUserController
that need to be executed when user tries to reach the end-point (few example names:store
,show
,edit
,update
,destroy
). To know about other controller functions you can read about them in the documentation.
class UserController {
index () {
return 'Some custom response'
}
}
Name of a function could be any you like, there is just a name conventions that are preferable
Let create a controller for Users:
adonis make:controller UsersSelect controller type
❯ For HTTP requests
For Websocket channel
A new app/Controllers/Http/UserController.js
file will be created:

So how would a callback function look like in the UserController
?
class UserController {
customName ({ request }) {
return {
host: request.header('host'),
url: request.url(),
originalUrl: request.originalUrl(),
method: request.method(),
intended: request.intended(),
ip: request.ip(),
subdomains: request.subdomains(),
'user-agent': request.header('user-agent'),
accept: request.header('accept'),
hello: request.header('hello'),
isAjax: request.ajax(),
hostname: request.hostname(),
protocol: request.protocol()
}
}
...
and in our /start/routes.js
file
Route.get('request', 'UserController.customName').formats(['json'])
Now let’s look at the result in Insomnia (or Postman, if you prefer)

7. Lucid Models
After we discovered how it works, let’s add some CRUD functions in our UserController
using Lucid Inserts and Updates.
Lucid is an implementation of Active Record pattern in Javascript. If you are coming from the Laravel or the Rails world, then you may be quite familiar with it.
“official documentation”
Let’s update our UserController
with new lines of code:
const User = use('App/Models/User')class UserController {
async index () {
return await User.all()
}async show ({ params }) {
return await User.find(params.id)
}async store () {}
async update () {}
async destroy () {}
}
/routes.js
:
Route.get('users', 'UserController.index').formats(['json'])
Route.get('users/:id', 'UserController.show').formats(['json'])
Result of getting All Users and User by id:


If you will get an empty object -> don’t worry. It is because you may not added any users to your database. All works if you just get response status 200 “OK”.
Now, let’s add modifying database operations:
class UserController {
...async store ({ request }) {
const { username, password, email } = request.post()
const user = new User()
user.username = username
user.password = password
user.email = emailawait user.save()
}async update ({ request, params }) {
const user = await User.find(params.id)const { username, password, email } = request.post()user.username = username
user.password = password
user.email = emailawait user.save()
}async destroy ({ params }) {
const user = await User.find(params.id)await user.delete()
}
}
and in routes.js
Route.post('users', 'UserController.store').formats(['json'])
Route.patch('users/:id', 'UserController.update').formats(['json'])Route.delete('users/:id', 'UserController.destroy').formats(['json'])
8. CSRF
If you will try to reach one of previously added three endpoints and try to modify the database, you will get an error :
403:Forbidden “HttpException: EBADCSRFTOKEN: Invalid CSRF token”
This is because of a CSRF (Cross-Site Request Forgery) part.
FROM DOC: Allows an attacker to perform actions on behalf of another user without their knowledge or permission.
If you just want to disable this feature, you can go to /config/shield.js
file in your project and change csrf.enable
to false
. (not recommended if you are using not only API endpoints)

How to customize CSRF we will see next time. Now, let’s see how our functions would work after disabling CSRF feature:



Great job!
But before we will talk about CSRF, let’s optimize our Routes a little bit. You can combine all 5 CRUD User Routes in one, with the exact same result:
Route
.resource('users', 'UserController')
.apiOnly()
And even group our API Routes with adding a prefix:
Route.group(() => {
Route.get('request', 'CustomController.someCustom')
.formats(['json'])Route
.resource('users', 'UserController')
.apiOnly()
}).prefix('api/v1/')
And now we can investigate more about CSRF.
FROM DOC: AdonisJs protects your application from CSRF attacks by denying unidentified requests. HTTP requests with POST, PUT and DELETE methods are checked to make sure that the right people from the right place invoke these requests.
Shield middleware relies on sessions, so make sure they are set up correctly.
Good article about this topic: Should I use CSRF protection on Rest API endpoints?
So how to “fix” this? As we will use our application as API for now, we can add filterUrls to the /config/shield.js
configuration
csrf: {
enable: true,
methods: ['POST', 'PUT', 'DELETE'],
filterUris: ['/api/(.*)'],
cookieOptions: {
httpOnly: false,
sameSite: true,
path: '/',
maxAge: 7200
}
}
@adonisjs/shield
library with/config/shield.js
file will be NOT be included if you create your app with—-api-only
flag. So if you are not interested in using *.edge files as responses to url-s you can disable csrf.
9. Security
I think our application has grown enough for adding security part for user authentication.
The AdonisJs authentication provider comes pre-installed with fullstack
and api
boilerplates.
By default AdonisJs uses session
for authorization, let change it to JWT in /config/auth.js
file:
authenticator: 'jwt',
jwt: {
algorithm: 'HS256', // by default
serializer: 'lucid',
model: 'App/Models/User',
scheme: 'jwt',
uid: 'email',
password: 'password',
options: {
secret: Env.get('APP_KEY'),
expiresIn: "1m", // 1 minute
},
},
Next, modify our /start/routes.js
file. Add new Route for /login
path and new chain function middleware(‘auth’)
to our old /users
Route:
// We don't want our logged-in user to access this
Route.
post('login', 'AuthController.login').
middleware('guest');Route.
resource('users', 'UserController').
apiOnly().
middleware('auth');
Create new AuthController
:
adonis make:controller Auth
As you noticed we should add login function to AuthController
as well:
async login ({ auth, request }) {
const { email, password } = request.post();return auth.withRefreshToken().
attempt(email, password);
}
Let’s test it out. First, let’s check that now we would not be able to get all users from our database:

And our login
:

Now, we need to find some user’s email
and password
to login with. In my case, it is a user with email newUser2@gmail.com
and with password 123
.

As you can see from the screenshot, password
was previously stored in the database in hashed
value. It was done by the AdonisJs middleware in /app/models/User.js
file.
/**
* A hook to hash the user password before saving
* it to the database.
*/
this.addHook('beforeSave', async (userInstance) => {
if (userInstance.dirty.password) {
userInstance.password = await Hash.make(userInstance.password);
}
});
So when the user tries to authenticate AdonisJs hashes passed password and comparing it with a database hashed password.
So let’s try to login:

I would prefer not to copy-paste token
and refreshToken
values to every request where authorization is needed every time I log in, so let’s use some Insomnia helpers:

And not we have two options setting the token value to other requests.
First, is to set token
env variable directly to Headers:

Second, is to use Insomnia Auth menu option which will do exactly the same:

If you will add token
env variable to the Login
request you will get an error:

As you remember we added middleware([‘guest’])
to login
Route, which means that route will return an error if user already logged in.
Let’s test our authorization functionality with GET_ALL_USERS
endpoint:

And because we previously set that JWT token expiresIn: 1m
, if we wait for one minute and then will try to get all users, we will get:

Let’s update login function in AuthController
for refreshing token functionality:
async login ({ auth, request }) {
const { refreshToken, email, password } = request.post();if (refreshToken) {
return await auth.
generateForRefreshToken(refreshToken);
}
return auth.withRefreshToken().
attempt(email, password);
}
And add refreshToken
to Insomnia environment variables as we did previously with token
. After that let’s check the results of our work:

Great job!
10. Data validation
At first, we need to add @adonisjs/validator
library to our project:
adonis install @adonisjs/validator
Next, register the validator inside the /start/app.js
file:
const providers = [
...
'@adonisjs/validator/providers/ValidatorProvider'
]
And after that let’s use some validations in our store()
method in UserController
:
class UserController {
async store({ request }) {
const rules = {
email: 'required|email|unique:users,email',
password: 'required',
};const validation = await validate(request.post(), rules);if (validation.fails()) {
return validation.messages();
}...
}
Let’s test it out on creating new User
with wrong email
data:



Validation works as expected, but error messages… could be better. We can adjust error messages by validation types, in our case by: required
, email
and unique:
const messages = {
'email.required': 'Email is not present in request',
'email.email': 'Enter a valid email address.',
'email.unique': 'Email is already present in db',
};
And pass messages
as a third argument to validate()
function:
const validation = await validate(request.post(), rules, messages);
Validate() function stops on the first error and returns it. But if we want to validate all fields we passed, validateAll() here for rescue:
const { validateAll } = use('Validator');
...
const validation = await validateAll(request.post(), rules, messages);

It looks great, but most of the time validations are repeating themselves. To prevent that we can create a new Validator
and use it as Middleware. Let's create one:
adonis make:validator StoreUser
Let’s fill this /app/Validators/StoreUser.js
validator with few functions:
class StoreUser {
// use validateAll function instead of validate
get validateAll () {
return true;
}get rules () {
return {
email: 'required|email|unique:users,email',
password: 'required',
};
}get messages () {
return {
'email.required': 'Email is not present in request',
'email.email': 'Enter a valid email address.',
'email.unique': 'Email is already present in db',
};
}get sanitizationRules () {
return {
email: 'normalize_email',
};
}async fails (errorMessages) {
return this.ctx.response.send(errorMessages);
}
}
Then update User
Route for using StoreUser
validator only on store
function in UserController
:
Route
.resource('users', 'UserController')
.middleware('auth')
.validator(new Map([
[['users.store'], ['StoreUser']],
]))
.apiOnly();
After that, you can remove all previously added validate functions in store
function UserController
:
async store ({ request }) {
const { username, password, email } = request.post();
const user = new User();
user.username = username;
user.password = password;
user.email = email;await user.save();
}
And the result will be the same. Cool, isn’t it 😉?
Few words about sanitizationRules
function in StoreUser
validator. If user will pass email
in “strange” format like this ThIs-My@gMai.COM
, it will be “sanitized” to more “proper” way like: this-my@gmail.com.
As our application grows, as our application becomes more and more complex, more chances to break something. To prevent such events would be great to add some test )
11. Testing
For testing our code AdonisJs provides @adonisjs/vow
library
In the beginning, we need to install it (it is NOT included in the scaffold)
adonis install @adonisjs/vow
If the script will execute as expected vowfile.js
and /test
folder with one test in it will be created in the root of your app:
test('make sure 2 + 2 is 4', async ({ assert }) => {
assert.equal(2 + 2, 4);
});
After that, we need to register '@adonisjs/vow/providers/VowProvider'
in the /start/app.js
file:
const aceProviders = [
...
'@adonisjs/vow/providers/VowProvider'
]
To run our tests we will use the command:
adonis test
If everything was configured properly you will see PASSED
message after running that script.
Now let’s add some tests for our UserController
.
adonis make:test UserController
Unit test
> Functional test
Step by step:
- We are creating a user in our test database:
const user = await User.create({
username: 'some',
email: EMAIL,
password: PASSWORD,
});
2. We are logging in by this created user:
const login = await client
.post('api/v1/login')
.send({
email: EMAIL,
password: PASSWORD,
})
.end();
// You must call "end" to execute HTTP client requests.
3. We are creating a new user, using response data token in header.
const response = await client
.post('api/v1/users')
.header('accept', 'application/json')
.header('Authorization', `Bearer ${loginResponseJson.token}`)
.send({
username: 'someName',
email: 'hello@gmail.com',
password: '123',
})
.end();
4. Our database data will role back after tests.
trait('DatabaseTransactions');
5. We are testing an API call, just like we did in Insomnia.
trait('Test/ApiClient');
That’s all! Good job!
Thank you for your attention!
If you have some questions → I will be pleased to answer 😉