Creating a Login System with Koa Typescript, Objection.js, JWT, React, and MySQL

In this tutorial, we are going to build a simple login using React for the frontend application and Koa(Typescript) for the backend application
Aside from React and Koa, we will be using Objection.js (ORM) and MySQL (database).
Installation
First, we need to make sure we already have Node.js installed.
Setting Up The Koa Backend App
Let’s first set up the backend application, then we will continue with the frontend, React.
We will be using Koa for our backend framework. Originally, Koa comes in Javascript but since Typescript has been getting a lot of attention, we will be using Typescript to set up Koa.
Run the following command.
mkdir rasyue
cd rasyue
mkdir backend
cd backendnpm init -ynpm i koa koa-router mysql koa-bodyparser @koa/corsnpm i -D typescript ts-node nodemonnpm i -D @types/koa @types/koa-router @types/node @types/koa-bodyparser
The above install all the necessary node modules that we will need in our project for now.
Using Typescript for Koa
Since we will be using Typescript for Koa, we will need to create a tsconfig.json
file.
Go ahead and create a tsconfig.json
in your root folder, then paste the following in the file.
{"compilerOptions": {"target": "ES2016","module": "commonjs","lib": ["es2016"],"outDir": "dist","rootDir": "src","noImplicitAny": true,"experimentalDecorators": true,"emitDecoratorMetadata": true,}}
The above are pretty basics configurations for Typescript. You can read more about it here.
Nodemon.json
Now, to make things easier for us during development, we will need to use nodemon
. There is a more neat way to use nodemon
which is through using nodemon.json
.
Create nodemon.json
in your root folder and paste the following codes.
{"watch": ["src"],"ext": "ts,json","ignore": ["src/**/*.spec.ts"],"exec": "ts-node ./src/index.ts"}
Now, go into your package.json
and paste the followings replacing your "scripts"
.
"scripts": {"test": "echo \"Error: no test specified\" && exit 1","dev": "nodemon"},
Server File
After we have set up the tsconfig.json
and nodemon.json
, it is time to write codes for the Koa server.
Create a folder src
and inside the folder create a new file and name it index.ts
. Paste the following inside it.
import * as Koa from 'koa';
import bodyParser from 'koa-bodyparser';const app:Koa = new Koa();app.use(async (ctx: Koa.Context, next: () => Promise<any>) => {try {await next();} catch (error) {ctx.status = error.statusCode || error.status;error.status = ctx.status;ctx.body = { error };ctx.app.emit('error', error, ctx);}});
app.use(bodyParser());app.use(async (ctx:Koa.Context) => {ctx.body = 'Hello world';});app.on('error', console.error);app.listen(3500);export default app;
Go ahead and run npm run dev
and go to localhost:3500
.
The above codes only run the basic Koa server. From here we will need to create controllers, models, routes, and DB config file.
Configuring Database
Let’s start with creating the config file to connect to our database (MySQL).
To make things easier for us, we will be using a type of ORM which is known as Objection.js
First, make sure you have installed it.
npm install objection
npm install knex
Objection.js is a Node.js ORM that is built on an SQL query builder known as knex.
With Objection.js, we can make our work easier. We can save a lot of time during development.
To use Objection.js, first create an knexfile.ts
in the root folder. Make sure it is not in your src
folder.
Paste the followings in your knexfile.ts
.
const knexConfig = {development: {client: 'mysql',connection: {user : 'root',password : '',database : 'rasyuekoa'}},migrations: {tableName: 'knex_migrations',directory: 'migrations'},seeds: {directory: './seeds'}};export default knexConfig;
Make sure you change the DB credentials accordingly.
Now that the knexfile.ts
has been created, we need to import it in our index.ts
to make the connection.
Open the index.ts
and paste the following.
import Koa from 'koa';import Knex from 'knex'import { Model } from 'objection'import knexConfig from '../knexfile'
import bodyParser from 'koa-bodyparser';const knex = Knex(knexConfig.development)Model.knex(knex);const app:Koa = new Koa();app.use(async (ctx: Koa.Context, next: () => Promise<any>) => {try {await next();} catch (error) {ctx.status = error.statusCode || error.status;error.status = ctx.status;ctx.body = { error };ctx.app.emit('error', error, ctx);}});app.use(bodyParser());app.use(async (ctx:Koa.Context) => {ctx.body = 'Hello world';});app.on('error', console.error);app.listen(3500);export default app;
Now let’s move on to defining our Models.
Defining Models
Before we can proceed with the migrations and seeds, we need to define our models first.
Go ahead and a new folder in the src
folder and name it models
.
In the src/models/
, create two new file name as User.ts
and Book.ts
.
In the User.ts
, paste the following.
import { Model } from 'objection'export default class User extends Model {id!: numberusername!: stringpassword!: stringemail!: stringstatic tableName = 'users'static jsonSchema = {type: 'object',required: ['username', 'password', 'email'],properties: {id: { type: 'integer' },username: { type: 'string', minLength: 1, maxLength: 255 },password: { type: 'string', minLength: 1, maxLength: 255 },email: { type: 'string', minLength: 1, maxLength: 255 },},}}
In the Book.ts
, paste the following.
import { Model } from 'objection'export default class Book extends Model {id!: numberauthor!: stringtitle!: stringdescription!: stringstatic tableName = 'books'static jsonSchema = {type: 'object',required: ['author', 'title', 'description'],properties: {id: { type: 'integer' },author: { type: 'string', minLength: 1, maxLength: 255 },title: { type: 'string', minLength: 1, maxLength: 255 },description: { type: 'string', minLength: 1, maxLength: 255 },},}}
And that’s it for our models. We’ve created one Model for the User and another one for Book.
Migrations and Seeds
With Objection.js, we can run migrations and seeds easily. This helps us to version control our database and allow us to modify the database schema and stay up to date with the current schema.
Seeding allows us to populate our database with fake or dummy data which will allow us better insight during development.
To start, open package.json
and paste the following.
"scripts": {"test": "echo \"Error: no test specified\" && exit 1","dev": "nodemon","make:migration" : "knex migrate:make migration_name","migrate" : "knex migrate:latest","make:seed" : "knex seed:make seed_name","seeding" : "knex seed:run"},
We have added four new commands to the "scripts"
which are make:migration
, migrate
, make:seed
and seeding
.
First, we need to have a migration file created and since we want to eliminate manual as much as possible, running make:migration
will help us to create a new migration file.
Run make:migration
and open the newly created file. Paste the following inside it.
import * as Knex from "knex";export async function up(knex: Knex): Promise<any> {await knex.schema.createTable('users', (table: Knex.TableBuilder) => {table.increments('id').primary()table.string('username')table.string('password')table.integer('email')}).createTable('books', (table: Knex.TableBuilder) => {table.increments('id').primary()table.string('author')table.string('title')table.string('description')})}export async function down(knex: Knex): Promise<any> {await knex.schema.dropTableIfExists('users').dropTableIfExists('books')}
As you can see, there are only two functions here up
and down
.
With up
, you can specify the type of tables and the table’s columns that you want to create based on your models.
With down
you can drop any existing table, this helps us to delete the old existing table and create a new one if there are any changes to the database schema.
Next up is seeding.
Again, to make things easier, run make:seed
to help us automatically create a seed file.
Open the newly created seed file and paste the following.
import * as Knex from "knex";export async function seed(knex: Knex): Promise<void> {// Deletes ALL existing entriesawait knex("books").del();// Inserts seed entriesawait knex("books").insert([{ id: 1, author: "Moby Dick Author", title: "Moby Dick", description: "Some Description" },]);};
The above I believed is fairly easy to understand. Just write any dummy data you want to dump into the table of your choice.
Run the above code would not actually create tables and populate them with fake data without YOU actually telling knex to do it.
Run migrate
and seeding
so that knex
will do the manual work of creating tables and making rows for you.
Let’s move on with Controllers and Routes now.
Controllers and Routes
It is a good practice to group up all the controllers in one controller's folder.
Create a new folder at src/controllers
and then create two new files src/controllers/book.controlller.ts
and src/controllers/user.controller.ts
.
Inside the book.controller.ts
paste the following.
import * as Koa from 'koa';import Router from 'koa-router';import Book from './../models/book'const routerOpts: Router.IRouterOptions = {prefix: '/book',};const router: Router = new Router(routerOpts);router.get('/', async (ctx:Koa.Context) => {const book = Book.query()ctx.body = await book;});router.post('/', async (ctx:Koa.Context) => {const book = await Book.query().insert(ctx.request.body);ctx.body = book});export default router;
To explain the above code, we simply defined our routes in the controller file. One route for get
and another one for post
. You can define other method like patch
or delete
or even put
depending on your preferences.
The above are just simple routes defined that are required in this project
Moving on to user.controller.ts
. Inside the file, paste the following.
import * as Koa from 'koa';import Router from 'koa-router';import User from './../models/user'import jsonwebtoken from 'jsonwebtoken'export interface LoginDetails {username: string,password: string}const routerOpts: Router.IRouterOptions = {prefix: '/public',};const router: Router = new Router(routerOpts);router.post('/login', async (ctx:Koa.Context) => {let b: LoginDetails = ctx.request.bodyif(b.username && b.password){console.log(b)ctx.body = {token: jsonwebtoken.sign({data: ctx.request.body,exp: Math.floor(Date.now() / 1000) - (60 * 60) // 60 seconds * 60 minutes = 1 hour}, "secret")}}});export default router;
Inside the user.controller.ts
, we defined only one route which is a post route that is used for login.
When the user logs into his/her account, we will send an HTTP request to the endpoint.
The endpoint in turn should validate the inputs and cross-check in the database before returning a token if the user exists in the database.
Return null if-else.
One last thing to do is to update the index.ts
. Replace with the following.
import Koa from 'koa';import Knex from 'knex'import bodyParser from 'koa-bodyparser';import { Model } from 'objection'import knexConfig from '../knexfile'import bookController from './controllers/book.controller';import userController from './controllers/user.controller';import jwt from 'koa-jwt';import cors from '@koa/cors';const knex = Knex(knexConfig.development)Model.knex(knex);const app:Koa = new Koa();app.use(bodyParser());app.use(cors());app.use(async (ctx: Koa.Context, next: () => Promise<any>) => {try {await next();} catch (error) {ctx.status = error.statusCode || error.status;error.status = ctx.status;ctx.body = { error };ctx.app.emit('error', error, ctx);}});app.use(jwt({secret: "secret"}).unless({path: [/^\/public/, "/"]}));app.use(bookController.routes());app.use(bookController.allowedMethods());app.use(userController.routes());app.use(userController.allowedMethods());app.use(async (ctx:Koa.Context) => {ctx.body = 'Hello world';});app.on('error', console.error);app.listen(3500);export default app;
And that’s it for our Koa backend application. If you try to send a request to the /book
endpoint, you will get an error since you do not have the JSON web token or not authenticated.
Creating React Frontend
For a quick way to create a React app, run the following.
npx create-react-app rasyue-frontend
cd rasyue-frontend
npm start
But since we used Typescript for the Koa backend, let’s use Typescript for React as well.
Run the following to create a React app Typescript.
npx create-react-app rasyue-frontend --template typescript
cd rasyue-frontend
npm i react-router-dom
npm start
Now, we are all set to start creating the necessary things for your Frontend application.
Creating Login and Profile Pages
We will be creating two pages namely Login
and Profile
.
The Login
page should be public and the Profile
page should be private or only accessible when you are authenticated.
Go ahead and create a new folder in the src
folder and name it main
.
Create two new files src/main/login.tsx
and src/main/profile.tsx
.
In the login.tsx
, paste the following.
import React, { useState } from 'react';import PropTypes from 'prop-types';async function login(credentials: any) {return fetch('http://localhost:3500/public/login', {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify(credentials)}).then(data => data.json())}export default function Login({ setToken }: any) {const [username, setUserName] = useState("");const [password, setPassword] = useState("");const handleSubmit = async (e: any) => {e.preventDefault();const token = await login({username,password});setToken(token.token);}return(<form onSubmit={handleSubmit}><label><p>Username</p><input type="text" onChange={e => setUserName(e.target.value)}/></label><label><p>Password</p><input type="password" onChange={e => setPassword(e.target.value)}/></label><div><button type="submit">Submit</button></div></form>)}Login.propTypes = {setToken: PropTypes.func.isRequired}
To explain what we coded in the login.tsx
, the page contains a basic form which we will allow User to input their username and password.
Upon form submit, we will take both the username and password and usefetch
method to make a POST request to our backend server at /login
endpoint.
Once our backend server complete the process, it will return the token which our frontend will obtain and save into the localStorage
through the setToken
method.
But where does the setToken
method comes from? We will talk about it after we finish the profile
page.
In the profile.tsx
, paste the following.
import React from 'react';import useToken from '../services/useToken'import Login from './login'async function getBooks() {return fetch('http://localhost:3500/book/', {method: 'GET',headers: {'Content-Type': 'application/json'},}).then(data => data.json())}export default function Profile() {const { token, setToken } = useToken();const books = getBooks()console.log(books)if(!token) {return <Login setToken={setToken} />}return(<h2>Profile Page</h2>);}
In profile.tsx
, we will use fetch
to send a get HTTP request to a protected route in our backend server. Simple stuff.
We are almost done with our frontend app, with only two things left to do. To create a service and modify our app.tsx
Creating A Service file for localStorage
Go ahead and create a file src/services/useToken.tsx
, in the file paste the following.
import { useState } from 'react';export default function useToken() {const getToken = () => {const tokenString: any = localStorage.getItem('token');const userToken = JSON.parse(tokenString);return userToken};const [token, setToken] = useState(getToken());const saveToken = (userToken: any) => {localStorage.setItem('token', JSON.stringify(userToken));setToken(userToken.token);};return {setToken: saveToken,token}}
This is a service that we will use later to set and get our item in the localStorage
.
And now finally, open the app.tsx
and paste the following.
import React from 'react';import './App.css';import { BrowserRouter, Link, Route, Switch } from 'react-router-dom';import Profile from './main/profile';import Login from './main/login'import useToken from './services/useToken'function App() {const { token, setToken } = useToken();if(!token) {return <Login setToken={setToken} />}return (<BrowserRouter ><div><nav><ul><li><Link to="/">Home</Link></li><li><Link to="/profile">Profile</Link></li></ul></nav><Switch><Route path="/profile"><Profile /></Route></Switch></div></BrowserRouter >);}export default App;
On a side note, there are many ways that you can build the same process. What I’ve shown above is one way that I did and if you think you can improve it to be better, then let’s have a knowledge sharing to improve our knowledge.
And with that, authentication should now work between the React frontend and the Koa backend.