What is new and how do we start using adonisjs today
Recently, AdonisJS unveiled its much-anticipated version 6. As someone who has just started a new project, this release presents an exciting opportunity for me to dig into its features and capabilities.
For those unfamiliar, AdonisJS is a comprehensive Node.js framework used for crafting web applications and API servers. Its environment is particularly easy to follow when coming from Laravel, as AdonisJS offers a familiar feel. A notable aspect of AdonisJS is that most of its packages are officially maintained by the core team, ensuring reliability and consistency in development.
AdonisJS version 6 introduces several significant updates, including:
For a comprehensive overview of all the updates, check out the official announcement post.
If you want the quick version click here If you just want to try for yourself, follow the installation guide.
First make sure you are using Node version > 20.6. Then we can go ahead and create the project.
❯ npm init adonisjs@latest hello-world-6
Need to install the following packages:
create-adonisjs@2.1.1
Ok to proceed? (y)
_ _ _ _ ____
/ \ __| | ___ _ __ (_)___ | / ___|
/ _ \ / _` |/ _ \| '_ \| / __|_ | \___ \
/ ___ \ (_| | (_) | | | | \__ \ |_| |___) |
/_/ \_\__,_|\___/|_| |_|_|___/\___/|____/
❯ Which starter kit would you like to use? … Press <ENTER> to select
❯ Slim Starter Kit A lean AdonisJS application with just the framework core
Web Starter Kit Everything you need to build a server render app
API Starter Kit AdonisJS app tailored for creating JSON APIs
Now we get to choose the type of project we're going to build. Let's opt for the Web Starter Kit. When prompted about installing dependencies, we'll agree to proceed. After this, there's a brief waiting period while all the necessary packages are downloaded.
An interesting feature here is the ability to create your own Starter Kits. This allows for a quick and easy setup using a specific starter template you've customized.
❯ Which starter kit would you like to use? · Web Starter Kit
❯ Do you want us to install dependencies using "npm"? (Y/n) · true
❯ Download starter kit (1.06 s)
Downloaded "github:adonisjs/web-starter-kit"
❯ Install packages (1.2 min)
Packages installed using "npm"
❯ Prepare application (444 ms)
Application ready
❯ Configure Lucid (7.06 s)
Lucid configured to use "sqlite" database
❯ Configure Auth (2.38 s)
Auth configured to use "session" guard
╭──────────────────────────────────────────────────────────────────╮
│ Your AdonisJS project has been created successfully! │
│──────────────────────────────────────────────────────────────────│
│ │
│ ❯ cd hello-world-6 │
│ ❯ npm run dev │
│ ❯ Open http://localhost:3333 │
│ ❯ │
│ ❯ Have any questions? │
│ ❯ Join our Discord server - https://discord.gg/vDcEjq6 │
│ │
╰──────────────────────────────────────────────────────────────────╯
Once the setup is complete, we can dive into our project. The Web Starter Kit notably includes Lucid, AdonisJS's own ORM, already configured with SQLite. It sets up models and migrations for session authentication as well. For more insights into what the Web Starter Kit offers, check out the details here.
Exploring the folder structure could be a topic for another post. For now, let's focus on adding a basic protected route.
To ensure everything is up and running, let's start the development server:
npm run dev
Afterwards, open your browser and head to http://localhost:333 to see the welcome message. This confirms that our project is correctly set up and operational.
Let's begin by creating a protected page. This is done by adding a route in the start/routes.ts file.
/*
|--------------------------------------------------------------------------
| Routes file
|--------------------------------------------------------------------------
|
| The routes file is used for defining the HTTP routes.
|
*/
import router from '@adonisjs/core/services/router'
router.on('/').render('pages/home')
router.on('/protected').render('pages/protected')
Why not using router.get(...) you may ask? As observed, we're opting for router.on in this instance. This approach serves as a shortcut when you have a route that solely focuses on rendering a view. You can read more in render view from router.
If we wanted to use router.get we could use the following instead.
router.get('/', async ({ view }) => {
return view.render('login')
})
Also we need to create the protected page resources/views/pages/protected.edge.
<h1>This page is protected</h1>
The Web Starter Kit leverages Edge as its templating engine, which is both developed and maintained by the AdonisJS core team.
If we navigate to http://localhost:3333/protected, we'll notice that the page is accessible, which isn't the intended behavior for a protected route. Ideally, unauthorized access attempts should either result in a 401 Unauthorized response or, even better, redirect the user to the login page.
To address this, we can utilize the session guard, which the Web Starter Kit has already configured for us within config/auth.ts, setting up a default guard named web.
To enforce the guard on our route, we need to make an update:
router.on('/protected').render('pages/protected').use(middleware.auth())
To safeguard our route, we implement the auth middleware, which is already registered within start/kernel.ts. This middleware, by default, authenticates users using the predefined guard.
Attempting to access http://localhost:3333/protected now results in a 404 error, as the web middleware attempts to redirect to a non-existent /login route.
To resolve this, we need to create a basic login page. Now, here's a bit of a challenge: Think you can do it without hitting a 404 error? :)
Done? Great job! I'm curious, how does your approach compare to mine?
Now, when you attempt to visit the protected route, you'll be redirected to the login page, which initially displays as blank. To enhance this, we'll add a login form, enabling users to authenticate themselves.
<h1>Login</h1>
<form action="/login" method="POST">
<input type="text" name="email" placeholder="email">
<input type="password" name="password" placeholder="Password">
<button>Login</button>
</form>
Attempting to log in at this stage will result in an error indicating that posting is not allowed. Can you guess what the issue might be? That's right, we haven't registered a matching route for the POST method for our login.
Let's address that now:
router.post('/login', async ({ response }) => {
response.redirect('/')
})
If you attempt to submit the form now, you'll notice that nothing happens. However, if you check the console where your application is running, you might encounter warnings like WARN (3693): Invalid or expired CSRF token. This message originates from the @adonisjs/shield package, which provides protection against CSRF (Cross-Site Request Forgery) attempts.
To rectify this issue, you need to include the Edge helper {{ csrfField() }} within your form. This helper generates a hidden input field with a valid CSRF token, ensuring that your form submissions are secure and verified.
<h1>Login</h1>
<form action="/login" method="POST">
{{ csrfField() }}
<input type="text" name="email" placeholder="email">
<input type="password" name="password" placeholder="Password">
<button>Login</button>
</form>
Now, after incorporating the CSRF token into your form, attempting to log in should redirect you to the homepage. This indicates that the form submission is proceeding as expected, but we haven't actually handled the authentication logic yet.
Let's set up the functionality required for user login. We'll begin by updating our routes to capture the parameters submitted through the form. Then, we'll attempt to retrieve the user from the database. If the user exist we will login the user.
import User from '#models/user'
router.post('/login', async ({ request, response, auth }) => {
const { email, password } = request.only(['email', 'password'])
const user = await User.verifyCredentials(email, password)
await auth.use('web').login(user)
response.redirect('/protected')
})
If you attempt to submit the login form now, you'll encounter an SQLite error. By examining the console, you might find a message similar to SqliteError: select * from 'users' where 'email' = 'asd' limit 1 - no such table: users. This error occurs because we haven't yet set up our database or the users table within it.
The Web Starter Kit is already equipped with a connection to SQLite, a User model located in models/user.ts, and the necessary migrations within database/migrations for setting up the database.
To initialize our database, let's run our migration with node ace migration:run. This step is crucial for creating the required tables, including the users table. You can find more details about Lucid migrations here.
❯ node ace migration:run
[ info ] Upgrading migrations version from "1" to "2"
❯ migrated database/migrations/1706732662221_create_users_table
Migrated in 28 ms
After running the migration, attempting to log in might result in the page simply flashing. This behavior occurs because the verifyCredentials method throws an E_INVALID_CREDENTIALS exception by default if the credentials don't match, leading to a redirection back to the login page with an error message. This message is passed along using flash messages, which are a method of transferring information between requests.
To display this message, we can use the flashMessage Edge helper in our login page.
<h1>Login</h1>
<form action="/login" method="POST">
{{ csrfField() }}
<input type="text" name="email" value="{{old('email') || ''}}" placeholder="email">
<input type="password" name="password" placeholder="Password">
<button>Login</button>
</form>
@flashMessage('errorsBag.E_INVALID_CREDENTIALS')
<div class="invalid">
{{ $message }}
</div>
@end
Upon attempting to log in without any registered users, you're likely to encounter a message indicating Invalid user credentials. This outcome isn't surprising, given that we haven't registered any users in our database yet. To improve user experience, we've also incorporated value="{{old('email') || ''}}" within our login form. This helpful feature repopulates the email field with the previously entered value, sparing users from having to re-enter their information.
Note: For debugging purposes, adding {{ flashMessages }} to your template can reveal detailed error messages. For instance, you might see something like {"email":"asd","errorsBag":{"E_INVALID_CREDENTIALS":"Invalid user credentials"}}, which provides insight into the login attempt and the nature of the error.
We could also surround the logic with a try/catch to return our own custom flash message instead of the need to check errorsBag.
Now, it's time to tackle user registration. Ill grab som ☕️ while you go and try implement the register route and view :)
Done? Great, let's proceed:
//route.ts
import { registerUserValidator } from '#validators/user'
router.on('/register').render('pages/register')
router.post('/register', async ({ request, response, auth, session }) => {
const payload = await registerUserValidator.validate(request.all())
try {
await User.create(payload)
} catch (error) {
session.flash('invalid', {
type: 'error',
message: 'Could not register user',
})
return response.redirect().back()
}
response.redirect('/login')
})
// register.edge
<h1>Register</h1>
<form action="/register" method="POST">
{{ csrfField() }}
<input type="text" name="email" value="{{old('email') || ''}}" placeholder="email">
<input type="password" name="password" placeholder="Password">
<button>Register</button>
</form>
@inputError('email')
@each(message in $messages)
<p>{{ message }}</p>
@end
@end
@inputError('password')
@each(message in $messages)
<p>{{ message }}</p>
@end
@end
@flashMessage('invalid')
<div class="error">
{{ $message.message }}
</div>
@end
One new aspect we've introduced in this section is the use of a validator. While it was possible to proceed without it, I wanted to demonstrate how straightforward it is to incorporate validation into our forms. AdonisJS have its own validation library called VineJS, which simplifies the process of ensuring that user inputs meet our application's criteria.
To create a new validator, we can utilize the Ace CLI with the make:validator command. This tool generates a template for us to define our validation rules,
❯ node ace make:validator user
DONE: create app/validators/user.ts
Then we set up the validation to require a email and password.
import vine from '@vinejs/vine'
export const registerUserValidator = vine.compile(
vine.object({
email: vine.string().email(),
password: vine.string(),
})
)
Then, we can leverage the flashMessage helper once more to display the default error messages provided by AdonisJS's validation system. The @inputError is a edge helper to make it easier to show the error messages.
If you attempt to register a user without specifying an email and password, the validation will not pass. This is because the default validation rule for these fields is string() which implicitly requires the field to be defined. As a result, you might see an error message like The email field must be defined. The password field must be defined.
Upon successfully registering a user, you'll be redirected to the login page.
Note: The reason we wrap our user creation logic in a try/catch block is to gracefully handle any potential issues. For example, if you try to register a user with an email that's already in use, you'll encounter an error like UNIQUE constraint failed: users.email. This error occurs because our database enforces uniqueness on the email column, preventing duplicate entries.
Once you've successfully registered and logged in with your new user, you'll be redirected to the protected page. To make our application even more user-friendly, let's update our protected page to display some information about the logged-in user."
<h1>This page is protected!</h1>
@if(auth.isAuthenticated)
<p> Hello {{ auth.user.email }} </p>
@end
If we refresh the page now we will see the email of our user!
This guide provides an introduction of starting a new project with AdonisJS and incorporating authentication functionality. While we've covered the essential steps from setting up the project to registering and logging in users, there's a lot more details and features within AdonisJS that we haven't touched on. From routing and validation to database interactions. Exploring these areas further would be the subject of another post.
Here you go, the summary of what we have discussed above :)
npm init adonisjs@latest -- -K=web hello-world-6
node ace migration:run
/*
|--------------------------------------------------------------------------
| Routes file
|--------------------------------------------------------------------------
|
| The routes file is used for defining the HTTP routes.
|
*/
import router from '@adonisjs/core/services/router'
import { middleware } from './kernel.js'
import User from '#models/user'
import { registerUserValidator } from '#validators/user'
router.on('/').render('pages/home')
router.on('/login').render('pages/login')
router.post('/login', async ({ request, response, auth, session }) => {
const { email, password } = request.only(['email', 'password'])
const user = await User.verifyCredentials(email, password)
await auth.use('web').login(user)
response.redirect('/protected')
})
router.on('/register').render('pages/register')
router.post('/register', async ({ request, response, auth, session }) => {
const payload = await registerUserValidator.validate(request.all())
try {
await User.create(payload)
} catch (error) {
console.log(error)
session.flash('invalid', {
type: 'error',
message: 'Could not register user',
})
return response.redirect().back()
}
response.redirect('/login')
})
router.on('/protected').render('pages/protected').use(middleware.auth())
// login.edge
<h1>Login</h1>
<form action="/login" method="POST">
{{ csrfField() }}
<input type="text" name="email" value="{{old('email') || ''}}" placeholder="email">
<input type="password" name="password" placeholder="Password">
<button>Login</button>
</form>
@flashMessage('errorsBag.E_INVALID_CREDENTIALS')
<div class="invalid">
{{ $message }}
</div>
@end
// protected
<h1>This page is protected!</h1>
@if(auth.isAuthenticated)
<p> Hello {{ auth.user.email }} </p>
@end
// register.edge
<h1>Register</h1>
<form action="/register" method="POST">
{{ csrfField() }}
<input type="text" name="email" value="{{old('email') || ''}}" placeholder="email">
<input type="password" name="password" placeholder="Password">
<button>Register</button>
</form>
@inputError('email')
@each(message in $messages)
<p>{{ message }}</p>
@end
@end
@inputError('password')
@each(message in $messages)
<p>{{ message }}</p>
@end
@end
@flashMessage('invalid')
<div class="error">
{{ $message.message }}
</div>
@end
import vine from '@vinejs/vine'
export const registerUserValidator = vine.compile(
vine.object({
email: vine.string().email(),
password: vine.string(),
})
)