Tutorial

Create Dynamic Routes in Next.js with Route Guards and User Authentication

Published on April 1, 2024
Create Dynamic Routes in Next.js with Route Guards and User Authentication

Introduction

When creating scalable websites, a developer’s most important task is handling dynamic data. For a website that sells thousands of products, it will be nearly impossible for a developer to create traditional/static routes for all those products. That’s where dynamic routing comes in handy. Dynamic routes use a standard webpage layout to display content while the content itself is retrieved from the server based on dynamic data requested from the client.

Through this tutorial, you will learn how to implement dynamic routes in a Next.js app. Next.js is an excellent React-based framework that handles both the client-side and server-side actions. You will learn how you can use the file-based routing of Next.js to implement dynamic routes and how you can put route guards in place to secure your server.

Note: For users who have used Next.js versions <13.2, this will be a new syntax for them because Next.js has shifted to a new architecture.

Prerequisites

To follow this tutorial, you will need:

In this tutorial, you will learn how to implement dynamic routes in a Next.js application. You will first learn to create a Next.js project, then how to implement dynamic routing from scratch. Following that, you will handle routes that you have not set up (handling 404 error). In the later sections, you will learn how you can set up authentication to create route guards to prevent users from gaining access to routes they are not authorized to.

Step 2 - Creating Dynamic Routes in Next.js

Open a terminal and type in the following command to create a Next.js project using the create-next-app.

npx create-next-app@latest

Once you start the process, you will be asked some inputs and preferences:

Output
Need to install the following packages: create-next-app@14.1.3 Ok to proceed? (y) y √ What is your project named? ... sample_app √ Would you like to use TypeScript? ... No / Yes √ Would you like to use ESLint? ... No / Yes √ Would you like to use Tailwind CSS? ... No / Yes √ Would you like to use `src/` directory? ... No / Yes √ Would you like to use App Router? (recommended) ... No / Yes √ Would you like to customize the default import alias (@/*)? ... No / Yes

Now, you can select whether you want TypeScript, ESLint, or Tailwind, in your project. However, to continue with this tutorial you need to select Yes to App Router. Also, it is your choice to keep your source files in src or change the default import alias (which is @/).

Now that you have set up the project, you must understand the Next.js architecture. The latest version of Next.js (i.e., version 14.x) uses a different project structure than older versions.

You get an app directory which contains all the source files. Then, inside the app directory, there is page.js which is the Next.js equivalent of App.js in a React application. page.js is the entry point for every route and sub-route.

How Routing Works in Next.js?

Next.js uses file-based routing. In the current Next.js version, the app directory corresponds to the / route. The page.js in the app directory controls the behavior of the / page. To create sub-routes, you need to create subdirectories to the app directory and add page.js to each endpoint.

For example, you need to create two routes: your_domain/user and your_domain/blog/post.

To create the first route, you need to create a sub-directory in the app directory named user and initialize a page.js in that sub-directory.

--app
|--user
|--|--page.js

In this page.js, you will create the logic for your user endpoint. Similarly, to create the second route, create nested directories like the following.

--app
|--blog
|--|--page.js
|--|--post
|--|--|--page.js

Note: You can skip the page.js in the app/blog directory if you do not need that endpoint. In this case, Next.js will render a 404 page.

In this manner, you can create infinitely nested static routes in Next.js. But, creating dynamic routes requires a different syntax, which you will learn in the next section.

Creating the Directory to Handle Dynamic Routes

Now, let us assume that we need to create a simple dynamic route, in the home route of the application. Then, the syntax of creating dynamic routes such as your_domain/:params is:

--app
|--page.js
|--[slug]
|--|--page.js

Here, you created a directory named slug enclosed in []. This tells Next.js that this route will be dynamic. Using the name slug is just a convention, you can replace it with any name; slug essentially acts as a placeholder for the dynamic data that will be passed using a dynamic route.

Configuring File-Based Routing for Dynamic Routes

To test the directory set up in the previous section, open the page.js in your [slug] directory and type in the following code:

  export default function Page({params}){
  return (
    <div>My slug is: {params.slug}</div>
  )
}

Here, the {params} argument allows the endpoint to access URL parameters. Then, we simply display the slug value using params.slug (replace slug with the placeholder you used in the directory name). Now, run your application using the following command:

npm run dev

Once it has compiled, navigate to http:localhost:3000/sammy Here, you can replace sammy with any alphanumeric data and you will get the same data inside your page as follows:

Image 1

You can nest dynamic routes similar to static routes, as explained in the previous section. Also, you can create a nested route containing any number of static and dynamic routes in it. In the next section, you will learn how you can set up user authentication for your dynamic routes.

Step 3 - Creating User Authentication

The previous section explained how you can create dynamic routes using the dynamic data passed using URL parameters. However, this creates a problem for you as a developer. This approach allows infinite possible pages to be rendered using different parameters in the URL to your application, which is not a good idea. Therefore, you must create a logic preventing unnecessary routes from being created and preventing unauthorized access to these dynamic routes.

In this section, you will set up a user authentication system, which in turn will allow you to handle the security of your routes.

Installing Additional Dependencies

Now, you will create a user signup-fetch system using the server-side rendering in Next.js. To start with the following sections, you must install Mongoose and JSON web token dependencies in your Next.js project. You can install these packages with the following command:

cd <project directory>

npm i mongoose jsonwebtoken

Once you have installed these, move forward with the tutorial.

Setting Up API Routes, Mongoose Connection and Models

Now, you will create an API in your Next.js application. To do so, create a directory inside your /app directory named api. This directory will contain all the routes for our backend API, which we will use to add users and fetch a user’s details from the MongoDB database.

Creating sub-routes in the API route is similar to creating a client-side route. You will create a directory with the name of your route, inside that directory, create a route.js file, which will handle the logic for your API route.

For this tutorial, we need to create the following routes: /api/adduser and /api/getuser. You can define these routes by creating the following directories and files.

app/
└── api/
    ├── adduser/
    │   └── route.js
    └── getuser/
        └── route.js

It would look something like the following:

Image 2

After creating the directories for API routes, you need to set up your Mongoose connection to MongoDB. If you are unfamiliar with Mongoose, refer to this DigitalOcean tutorial on How To Perform CRUD Operations with Mongoose and MongoDB Atlas | DigitalOcean.

Now, we will set up our connection and schema files at the same level as the app directory, which makes it easier to import to any other file. Create a directory named db in your root project directory. Inside this db directory, create two files:

  1. connect.js – To handle connections to MongoDB.

  2. UserModel.js – To define the schema for your user profile.

The file structure would look like the following:

Image 3

Now, open the connect.js file and add the following code to it:

sample_app/db/connect.js
import mongoose from "mongoose";

const connectDB = async () => {
  try {
    if (mongoose.connection.readyState === 0) {      // checking currently open connections
      await mongoose.connect("mongodb://127.0.0.1:27017/sammy");
      console.log("MongoDB Connected...");
    } else {
      console.log("Using existing MongoDB connection...");
    }
  } catch (error) {
    console.error("Error connecting to MongoDB:", error);
    process.exit(1);
  }
};

export default connectDB;

Info: In this tutorial, the community version of MongoDB is used with a database named sammy.

Here, you are importing the Mongoose module and exporting a connectDB function, which creates a new MongoDB connection if there is no existing connection. If another connection exists, this will use that same connection instead of creating a new connection for every request.

Warning: Do not use localhost instead of the IP address 127.0.0.1. MongoDB does not support it.

Now, you will create a user schema to handle all the requests made to your database. Open the UserModel.js file and type in the following code.

sample_app/db/UserModel.js
import mongoose from "mongoose";

const userSchema = new mongoose.Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true },
  password: { type: String, required: true },
});

const User = mongoose.model("User", userSchema);

mongoose.models = {}; // prevents rewriting of mongoose model
module.exports = User;

Here, you create a schema named userSchema. This schema defines the structure of your documents inside the User collection. A Mongoose model translates to a collection in the MongoDB database.

Note: In Next.js, you must add the line mongoose.models = {} as this prevents the overwriting of the model after each request. If you do not add this line, your app will throw an error on all requests except the first.

In the next section, you will create the logic for your routes.

Creating API Routes

Now, we will create logic for all the API routes, starting with the adduser route.

/api/adduser Route

Open the route.js inside the adduser directory and add the following code to it:

sample_app/app/api/adduser/route.js
import connectDB from "@/db/connect";
import User from "@/db/UserModel";
const jwt = require("jsonwebtoken");
const secret = "YOUR_JWT_SECRET_KEY";

export async function POST(Request) {
  await connectDB();

  try {
    const data = await Request.json();

    let user = new User({
      name: data.name,
      email: data.email,
      password: data.password,
    });

    await user.save();

    const token = jwt.sign(user.id, secret);

return new Response("", {
        status: 200,
        headers: { Authorization: token },
      });
  } catch (error) {
    console.log(error);
  }
}

Here, we import the connectDB function and User model from our db directory. Then, we import jsonwebtoken module to handle our authentication logic. If you are new to using the jsonwebtoken package, refer to this guide on How To Use JSON Web Tokens (JWTs) in Express.js to help you understand its usage.

Now, you will create a POST request in the Route handler of NestJS using the async/await function. Keep in mind that Route handling in the Next.js 13.2+ version is different than the previous versions and in any other JavaScript frameworks.

First, you call the connectDB function to establish a connection to MongoDB. Then, you fetch data from the request body using Request.json(). After that, you will create a new User object to create a user based on credentials retrieved from the request body. Then, save the object in MongoDB using the save() function of Mongoose.

Once the user account is created, create a JWT (jsonwebtoken) token using the sign function. This function takes a payload (user.id in this case) and a secret_key< as a required parameter and, returns a JWT hashed token. You can change the secret_key to any string.

Info:: It is strongly recommended, for security reasons, to store your secret_key and any other credentials in a process environment file rather than hard-coding it in your files.

Finally, you need to send a JSON response containing the token as a header named Authentication for authentication on various routes.

Warning: You must never handle passwords as strings. In this tutorial, passwords as strings are used in an attempt to simplify the steps. However, in a production environment, you should handle passwords as hashes.

You can use BcryptJS to handle password hashes in JavaScript frameworks. Refer to this guide on How to Handle Passwords Safely with BcryptsJS in JavaScript for a better understanding of using BcryptJS in Node.js based frameworks.

/api/getuser Route

Now, you will create the second route needed in your API. The getuser route will fetch user details from our database after successful authentication. Open the route.js file in the getuser directory and add the following code:

sample_app/app/api/getuser/route.js
import connectDB from "@/db/connect";
import User from "@/db/UserModel";
import { headers } from "next/headers";
const jwt = require("jsonwebtoken");
const secret = "YOUR_JWT_SECRET_KEY";

export async function POST() {
  await connectDB();

  const headerList = headers();
  const token = headerList.get("Authorization");

  if (!token) {
    return Response.json({ success: false, msg: "Unauthorized!" });
  }

  try {
    const data = await jwt.verify(token, secret);
    let user = await User.findById(data).select("-password");

    if (!user) {
      return Response.json({ success: false, msg: "Unauthorized!" });
    }

    return Response.json({ success: true, user });

  } catch (error) {
    return Response.json({ success: false, msg: "Invalid token!" });
  }
}

Here, you make similar imports as the other two routes except for the headers module from next/headers. This provides the interface to access headers in a given route.

Again, you create a POST request and connect to MongoDB. Then, you will fetch all the headers from the HTTP request using the headers() function. You retrieve the Authentication header from this list of headers and create a response for its absence.

If the Authentication header exists, we take the token from its value and verify it using the verify() function of the jsonwebtoken package (make sure that you provide the same secret_key you used when creating the token).

On successful verification of the token, you will get the user.id payload as the return value of the verify() function. Then, you will use this user.id to perform a search query using the findById() function of Mongoose. The select('-password') ensures that you get all data in the user document except the password.

If a user with the id retrieved from the token payload exists in your database, you will get its details as a JSON response with success: true. Otherwise, you get a success: false response. This ensures that only existing users with authenticated tokens can access routes in your application.

Now that you have completed the API to implement user authentication, you will use this authentication system to apply route guards on dynamic routes created on your website.

Step 4 - Adding Route Guards

In the previous section, you learned how to create a basic authentication system in the Next.js backend using JWT and Mongoose. Now, you will use that backend authentication system to prevent unauthorized access to dynamic routes in a Next.js app. You will start with creating a dynamic route for your user profile page. Create the following directory structure in your app directory.

app/
└── user/
    ├── [slug]/
    │   └── page.js

This structure will create the following dynamic route to display user profiles of different users.

your_domain/user/:slug

In this command, slug will be the user’s name. Now, you want to prevent unnecessary URLs from being allowed on your client-side application. You will create the logic for this in the next section.

Route Guards Against Non-Existing Pages

To prevent access to illegal routes, you can send a ‘404 Page Not Found’ response and redirect any route to the 404 page. Next.js has its own custom 404 page which you can access with the notFound() function from the next/navigation library.

Open the page.js inside /user/[slug] and add the following code to the file:

sample_app/app/user/[slug]/page.js
"use client";
import { notFound } from "next/navigation";

const Page = ({ params })=> {

  // logic to handle allowed routes

  notFound();
}

export default  Page;

This will be a client component therefore you need to inform the Next.js compiler that it is a client component. This can be done by adding a string at the top of the file “use client”. This syntax informs the compiler to treat this file as a client component, not a server one.

After this, you import the notFound() page from next/navigation and create a Page function. Here, you have called the notFound() function as the only response so by default every request in the /user/:slug route will redirect to the 404 Page Not found route.

You will add the logic to handle allowed dynamic routes in the next section.

Info: Next.js allows you to design your custom 404 page. You can achieve this by creating a not-found.js file in the app directory and adding your custom code to it. If the not-found.js file is present in the app directory, Next.js will redirect all notFound() requests to that page rather than the default page.

Route Guards Using User Authentication for Dynamic Route Access

So far, you have set up a Next.js application that creates users and after their successful signup, shows user details on the profile page. Also, since you are using dynamic data to display user profiles, you set up a virtual barrier that sends all unauthorized requests to the profile page route and gets redirected to the 404 page.

The notFound() method you added to your user profile page redirects all requests to the dynamic route /user/:slug to Page Not Found. Now, you will add the logic for an authenticated user to get their profile details shown instead of a ‘404 Page Not Found’.

First, you will create a web route for user signup. You can do this by adding two directories in the app directory.

app/
   ├── signup/
    │   └── page.js

Now, Open the page.js in the app/signup directory and enter the following code:

<details> <summary>This is a large piece of code. Click to view</summary>

sample_app/app/signup/page.js
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";

const Page = () => {
  const [email, setEmail] = useState("");
  const [name, setName] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState(null);
  const router = useRouter();

  const handleSubmit = async (event) => {
    event.preventDefault();

    // Basic validation
    if (!email || !password || !name) {
      setError("Please fill in all fields...");
      return;
    }

    try {
      const response = await fetch("/api/adduser", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ email, name, password }),
      });

      if (!response.ok) {
        throw new Error("Sign Up failed");
      }

      const responseAuth = response.headers.get("Authorization");

      const userDetails = await fetch("/api/getuser", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: responseAuth,
        },
      });

      if (!userDetails.ok) {
        throw new Error("Sign Up failed");
      }
      const userData = await userDetails.json();

      localStorage.setItem(`userData`, JSON.stringify(userData.user));
      router.push(`/user/${userData.user.name}`);
    } catch (error) {
      console.log(error);
    }
  };

  return (
    <div>
      <h1>Sign Up</h1>
      {error && <p style={{ color: "red" }}>{error}</p>}
      <form onSubmit={handleSubmit}>
        <div>
          <label>Name:</label>
          <input
            type="text"
            value={name}
            onChange={(e) => setName(e.target.value)}
          />
          <label>Email:</label>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
        </div>
        <div>
          <label>Password:</label>
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          />
        </div>
        <button type="submit">Sign Up</button>
      </form>
    </div>
  );
};

export default Page;

</details>

On this page, you have created a form that takes input for the user’s name, email, and password. After that, you create an onSubmit handler for the form which calls the handleSubmit function to handle the API requests with payload from the body.

In the handleSubmit() function, perform a simple check for input values. Then, you make a fetch API call to /api/adduser, which you defined in earlier sections (since both client and server run on the same port in the Next.js application, you do not need to provide the absolute path). In the request body, you send the name, email, and password from the form body.

The /api/adduser route returns an Authorization header. You store that header in a variable and make a second fetch API call to the /api/getuser by sending the saved header from the first API call as an Authorization header.

This will return the details of the user (name and email). You store these details in localStorage as strings with the key userData and reroute the page to the dynamic route /user/:<user’s name>.

Now, open the page.js in the dynamic route /user/[slug] and change it to the following logic:

sample_app/app/user/[slug]/page.js
"use client";
import { notFound } from "next/navigation";

const Page = ({ params })=> {

  const userData = JSON.parse(localStorage.getItem("userData"));

  if (userData.name === params.slug) {
    return (
      <div>
        <p>User : {userData.name}</p>
        <p>Email: {userData.email}</p>
      </div>
    );
  }

  notFound();
}

export default  Page;

In this updated logic, you retrieve the userData from localStorage and check if the slug matches the user.name from userData. If the check returns true, you will get the user details. If not, you will get the 404 Page Not Found.

Info: Adding this extra security ensures that even if you are an authorized user, you can only access your details and not somebody else’s.

The signup page would look like this:

Image 4

Go ahead and fill up the fields with any values. After clicking on the Sign Up button, the page will get redirected as follows:

Image 5

The results are as expected. You can change the sammy parameter in the URL to sammy2 and see that you will get redirected to the following page.

Image 6

This tests the route guards you have set up in your application.

Conclusion

In this tutorial, you learned how to create a Next.js application. You learned the architecture of a Next.js application and how it uses file-based routing to create application routes. Then, you learned to create dynamic routes in your application.

After that, you moved on to set up a user authentication system to apply route guards to your dynamic routes. You created a REST API to handle user sign-up and fetching user details, with authentication. You learned to access this API and create dynamic routes to display user information on the dynamic route.

The author selected OWASP Foundation to receive a donation as part of the Write for DOnations program.

Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

Learn more about us


About the authors
Default avatar

Technical Content Engineer


Default avatar

Technical Writer


Still looking for an answer?

Ask a questionSearch for more help

Was this helpful?
 
Leave a comment


This textbox defaults to using Markdown to format your answer.

You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!

Try DigitalOcean for free

Click below to sign up and get $200 of credit to try our products over 60 days!

Sign up

Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

Featured on Community

Get our biweekly newsletter

Sign up for Infrastructure as a Newsletter.

Hollie's Hub for Good

Working on improving health and education, reducing inequality, and spurring economic growth? We'd like to help.

Become a contributor

Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.

Welcome to the developer cloud

DigitalOcean makes it simple to launch in the cloud and scale up as you grow — whether you're running one virtual machine or ten thousand.

Learn more
DigitalOcean Cloud Control Panel