DEV Community

Cover image for Crafting Session-Enabled Apps with Deno: A Step-by-Step Guide
Francisco Mendes
Francisco Mendes

Posted on • Edited on

Crafting Session-Enabled Apps with Deno: A Step-by-Step Guide

At some point in the development of web applications it will be necessary to add a layer of data persistence, which can be integration with a database, even the persistence of user sessions.

This article will teach you how to use Deno to create a full stack application, which will include the integration of a MongoDB database and sessions, with protected routes included.

final app

Introduction

In this article we will cover some topics, such as the creation of API endpoints, the creation of views using a template engine, user sessions and the creation of flash messages. Finally, we will have an application to which we can add many more features in the future.

To give you a little more context, in this article we are going to use the following technologies:

Before starting this article, I recommend that you have Deno installed and that you have a brief experience using Node.

Generate Deno Project

The first thing that needs to be done is to generate the project's base files with the following command:

deno init .
Enter fullscreen mode Exit fullscreen mode

Inside deno.jsonc we will add the following tasks:

{
  "tasks": {
    "dev": "deno run --watch ./app/server.ts",
    "build": "deno compile ./app/server.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

Still in deno.jsonc, we will define the imports of our project and which dependencies we will use:

{
  // ...
  "imports": {
    "oak": "https://deno.land/x/oak@v12.5.0/mod.ts",
    "edge.js": "npm:edge.js@^5.5.1",
    "path": "https://deno.land/std@0.194.0/path/posix.ts",
    "mongodb": "https://deno.land/x/mongo@v0.31.2/mod.ts",
    "zod": "https://deno.land/x/zod@v3.21.4/mod.ts",
    "bcrypt": "https://deno.land/x/bcrypt@v0.4.1/mod.ts",
    "sessions": "https://deno.land/x/oak_sessions@v4.1.9/mod.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

Once these changes have been made, we can run the command deno task dev and it will watch the changes we make to the project and hot reload it. With this we can move on to the next point of this article.

Create a MongoDB instance

As mentioned previously, in this article we will use MongoDB to persist application data. In the following steps I will show how we can create an instance locally using MongoDB, but you can use a third-party service if you prefer.

First, create a docker-compose.yml file in the root of your project:

touch docker-compose.yml
Enter fullscreen mode Exit fullscreen mode

In this docker-compose.yml that we have just created, we will add the configuration of the development environment that will contain two containers, one with the database instance and the other with an application that is a UI interface. Add the following configuration within the file:

version: '3.8'
services:
  mongodb:
    container_name: mongodb
    image: mongo:latest
    restart: always
    ports:
      - 27017:27017
    environment:
      - MONGO_INITDB_ROOT_USERNAME=root
      - MONGO_INITDB_ROOT_PASSWORD=root
    volumes:
      - mongo-data:/data/db

  mongo-express:
    container_name: mongo-express
    image: mongo-express:latest
    restart: always
    ports:
      - 8081:8081
    environment:
      - ME_CONFIG_MONGODB_ADMINUSERNAME=root
      - ME_CONFIG_MONGODB_ADMINPASSWORD=root
      - ME_CONFIG_MONGODB_SERVER=mongodb
volumes:
  mongo-data:
    driver: local
Enter fullscreen mode Exit fullscreen mode

Taking into account the configuration above, we used the following images:

Before executing the command we will use below, check that ports 27017 and 8081 are not occupied.

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Once the images specified in the configuration have been downloaded and the containers and volumes have been created, you should have the database instance ready.

Create App modules

In this part of the article we will create the modules that will be reused by the application. Starting with the database client connector:

// @/app/modules/db.ts
import { MongoClient } from "mongodb";

export const client = new MongoClient();

const DB_NAME = "DEV";

await client.connect(
  `mongodb://root:root@localhost:27017/${DB_NAME}?authSource=admin`,
);

export const db = client.database(DB_NAME);
Enter fullscreen mode Exit fullscreen mode

And then we will create an instance of our template engine, in which we will define the path where the app views will be available:

// @/app/modules/template.ts
import { Edge } from "edge.js";
import { join } from "path";

const cwd = Deno.cwd();

const edge = new Edge({ cache: false });
edge.mount(join(cwd, "app/views"));

export default edge;
Enter fullscreen mode Exit fullscreen mode

With these two modules defined, we can move on to the next step, which is defining the collection of documents in the database.

Database Collections

In this article we will have a single collection, which will be related to user data, which will contain information such as email, password, among others.

// @/app/models/User.ts
import type { ObjectId } from "mongodb";

import { db } from "../modules/db.ts";

export interface UserCollection {
  _id: ObjectId;
  username: string;
  email: string;
  password: string;
}

export default db.collection<UserCollection>("users");
Enter fullscreen mode Exit fullscreen mode

In the code snippet above we define the model schema and export the users collection.

Define the app's Views

In this step, the first thing we will do is define the main layout of the application, where we will define the base content of the page and some sections on the page, such as the body.

<!-- @/app/views/layout/main.edge -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura-dark.css" type="text/css" />
    <title>dminx | {{ title }}</title>
  </head>
  <body>
    <main>
      @!section('body')
    </main>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Next, we will create the main page of our application, which is where the user can create a new account. To do this, we will define that we will use the main layout and that we will populate the content of the body section with the following:

<!-- @/app/views/index.edge -->
@layout('layouts/main')
@set('title', 'Create Account')

@section('body')
<section>
  @if(message)
    <blockquote>{{ message }}</blockquote>
  @end

  <form action="/signup" method="POST">
    <div>
      <label for="username">Username:</label>
      <input id="username" name="username" type="text" value="" minlength="3" required />
    </div>

    <div>
        <label for="email">Email:</label>
        <input id="email" name="email" type="email" value="" required />
      </div>

    <div>
      <label for="password">Password:</label>
      <input id="password" name="password" type="password" value="" minlength="8" required />
    </div>

    <div>
      <button type="submit">Create Account</button>
      <a href="/login">
        <small>Go to Login</small>
      </a>
    </div>
  </form>
</section>
@end
Enter fullscreen mode Exit fullscreen mode

In the code snippet above, we have a conditional that is related to the message, this message that we are going to print from the page is related to error messages that we want to send from the controller to the view (error messages, etc).

The next page we are going to create is very similar to the main page, we are just going to remove some fields that are not relevant for logging into the application.

<!-- @/app/views/login.edge -->
@layout('layouts/main')
@set('title', 'Login')

@section('body')
<section>
  @if(message)
    <blockquote>{{ message }}</blockquote>
  @end

  <form action="/signin" method="POST">
    <div>
        <label for="email">Email:</label>
        <input id="email" name="email" type="email" value="" required />
      </div>

    <div>
      <label for="password">Password:</label>
      <input id="password" name="password" type="password" value="" minlength="8" required />
    </div>

    <div>
      <button type="submit">Login</button>
      <a href="/">
        <small>Go to Register</small>
      </a>
    </div>
  </form>
</section>
@end
Enter fullscreen mode Exit fullscreen mode

The last page we will create is for the protected route, on this page we will have some information about the user's session and we will have an anchor that the user can interact with to end the session.

<!-- @/app/views/protected.edge -->
@layout('layouts/main')
@set('title', 'Protected')

@section('body')
<section>
  <h2>Hi {{ username }}!</h2>
  <p>This route is secured and requires you to be logged in.</p>
  <br>
  <a href="/signout">Logout</a>
</section>
@end
Enter fullscreen mode Exit fullscreen mode

Now that we have all the pages created, we can move on to the next step of this article, which will be related to defining the app's routes.

Set up Router

First of all, let's import the dependencies and modules necessary to define the app's routes:

// @/app/routes/user.ts
import { Context, Next, Router } from "oak";
import { z } from "zod";
import { compare, hash } from "bcrypt";

import User from "../models/User.ts";
import type { AppState } from "../server.ts";
import template from "../modules/template.ts";

export const userRouter = new Router<AppState>();

// ...
Enter fullscreen mode Exit fullscreen mode

Next, we will define some validation schemas to ensure that the data coming from the HTTP request has the necessary fields and the correct data types.

// @/app/routes/user.ts
// ...

/**
 * Controllers
 */

const signInSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

const signupSchema = signInSchema.extend({
  username: z.string().min(3),
});

// ...
Enter fullscreen mode Exit fullscreen mode

The first route we are going to register is related to the creation of a new account in the app, first we will obtain the data from the HTTP request and we will validate it with the schema we have just created. If the data is valid, we will run a query on the database to check if the account can be created with the credentials provided. If the account already exists, we send a message to the view, otherwise we hash the password, insert a new document into the database, create the user session and redirect them to the protected page. This way:

// @/app/routes/user.ts
// ...
userRouter.post("/signup", async (ctx) => {
  const body = ctx.request.body({ type: "form" });
  const value = await body.value;

  try {
    const data = await signupSchema.parseAsync(Object.fromEntries(value));

    const found = await User.findOne({
      $or: [{ username: data.username }, { email: data.email }],
    });

    if (found) {
      ctx.state.session.flash(
        "message",
        "The username or email are no longer available.",
      );
      ctx.response.redirect("/");
      return;
    }

    const hashedPassword = await hash(data.password);

    const documentId = await User.insertOne({
      ...data,
      password: hashedPassword,
    });

    ctx.state.session.set("username", data.username);
    ctx.state.session.set("_id", documentId.toString());
    ctx.response.redirect("/protected");
  } catch (cause) {
    console.error(cause);
    ctx.state.session.flash(
      "message",
      "An error occurred during the account creation process.",
    );
    ctx.response.redirect("/");
  }
});
// ...
Enter fullscreen mode Exit fullscreen mode

Moving now to the next route, which will be responsible for initiating a new session, we start by obtaining the HTTP request data and validating it. We then check whether the user exists in the database and compare the passwords to check whether the credentials are valid. If all this is successful, we create a new session for the user and redirect him to the protected route.

// @/app/routes/user.ts
// ...
userRouter.post("/signin", async (ctx) => {
  const body = ctx.request.body({ type: "form" });
  const value = await body.value;

  try {
    const data = await signInSchema.parseAsync(Object.fromEntries(value));

    const found = await User.findOne({ email: data.email });

    if (!found) {
      ctx.state.session.flash(
        "message",
        "Account credentials do not exist, please try again.",
      );
      ctx.response.redirect("/login");
      return;
    }

    const isValid = await compare(data.password, found.password);
    if (!isValid) {
      ctx.state.session.flash(
        "message",
        "Double check that your credentials are correct.",
      );
      ctx.response.redirect("/login");
      return;
    }

    ctx.state.session.set("username", found.username);
    ctx.state.session.set("_id", found._id.toString());
    ctx.response.redirect("/protected");
  } catch (cause) {
    console.error(cause);
    ctx.state.session.flash(
      "message",
      "An error occurred during the login process.",
    );
    ctx.response.redirect("/login");
  }
});
// ...
Enter fullscreen mode Exit fullscreen mode

Now in the signout route, what we need to do is delete the user's current session and redirect them to the login page. This way:

// @/app/routes/user.ts
// ...
userRouter.get("/signout", async (ctx) => {
  await ctx.state.session.deleteSession();

  ctx.response.redirect("/login");
});
// ...
Enter fullscreen mode Exit fullscreen mode

Now that we have the routes on the application's API defined, we can move on to defining the routes that will render the app's pages. But first we need to create some middleware to redirect the user to the indicated pages taking into account the user's session state.

// @/app/routes/user.ts
// ...

/**
 * Middlewares
 */

const isLoggedIn = async (ctx: Context<AppState>, next: Next) => {
  const userId = await ctx.state.session.get("_id");
  if (userId) await next();
  else ctx.response.redirect("/login");
};

const isLoggedOut = async (ctx: Context<AppState>, next: Next) => {
  const userId = await ctx.state.session.get("_id");
  if (!userId) await next();
  else ctx.response.redirect("/protected");
};

// ...
Enter fullscreen mode Exit fullscreen mode

Taking into account the code snippet above, the isLoggedIn middleware should be used for routes that are protected, this is because we check if the user has a session and if they don't, they are redirected to the login page.

While the isLoggedOut middleware should be used for routes that do not require any session, however, if the user visits such a route with a session, he is redirected to the protected page.

Last but not least is the definition of the routes that render the app's views:

// @/app/routes/user.ts
// ...

/**
 * Views
 */

userRouter.get("/", isLoggedOut, async (ctx) => {
  const message = await ctx.state.session.get("message");

  const html = await template.render("index", { message });

  ctx.response.type = "text/html";
  ctx.response.body = html;
});

userRouter.get("/login", isLoggedOut, async (ctx) => {
  const message = await ctx.state.session.get("message");

  const html = await template.render("login", { message });

  ctx.response.type = "text/html";
  ctx.response.body = html;
});

userRouter.get("/protected", isLoggedIn, async (ctx) => {
  const username = await ctx.state.session.get("username");
  const message = await ctx.state.session.get("message");

  const html = await template.render("protected", { username, message });

  ctx.response.type = "text/html";
  ctx.response.body = html;
});
Enter fullscreen mode Exit fullscreen mode

Now we can say that we have each of the routes registered, which allows us to go to the next and last step.

Set up App instance

In this step, we are going to import the necessary middlewares for the application, not forgetting the router that was created just now and serve the app. Like this:

// @/app/server.ts
import { Application } from "oak";
import { Session } from "sessions";

import { userRouter } from "./routes/user.ts";

export type AppState = {
  session: Session;
};

const app = new Application<AppState>({ logErrors: true });

app.use(Session.initMiddleware());

app.use(userRouter.routes());
app.use(userRouter.allowedMethods());

app.listen({ port: 3333 });
Enter fullscreen mode Exit fullscreen mode

To build the project just run this command:

deno task build
Enter fullscreen mode Exit fullscreen mode

Conclusion

I hope you found this article helpful, whether you're using the information in an existing project or just giving it a try for fun.

Please let me know if you notice any mistakes in the article by leaving a comment. And, if you'd like to see the source code for this article, you can find it on the github repository linked below.

Github Repo

Top comments (0)