How to Build a Secure Next.js Blog with Fly.io and Arcjet
Tags: webdev, nextjs, flyio, arcjet
Published: 2024-07-01 15:22:33

In this tutorial, we will build a secure Next.js blog leveraging the security features of Arcjet and the global deployment capabilities of Fly.io. The blog will include functionalities such as markdown content management, newsletter signups, user authentication, and a basic comment system.

### Prerequisites
Before we begin, ensure you have the following:

- Basic understanding of JavaScript and Next.js
- Node.js and npm installed on your computer.
- A code editor like Visual Studio Code.
- [Arcjet](https://arcjet.com/) and [Fly.io](https://fly.io/) accounts.

## Overview of Arcjet and Fly.io
[Arcjet](https://arcjet.com/) is a security platform offering rate limiting, bot detection, spam protection, and attack detection features. 

[Fly.io](https://fly.io/) is a platform to deploy applications globally, reducing latency and simplifying app management for developers.

Let's get started!

### Setting Up the Project

Open your terminal, navigate to your workspace folder, and run the command below to create a new Next.js project:

```shell
npx create-next-app@latest arcfly-blog
```


![terminal](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/14ljart5e1fqqm2a68ib.png)



Navigate into the project with:

```shell
cd secure-blog
```

Then, install these dependencies.

```shell
npm install @arcjet/next
```

This blog uses Google for the sign-in/login authentication with [Next-auth](https://next-auth.js.org/).

Create an `auth.jsx` file in the root directory and enter this code:

```jsx
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [Google],
});
```


### Implementing Middleware for Arcjet in Next.js

We use Arcjet, a developer-security platform, to protect our blog from threats and abuse. This middleware is crucial for ensuring the blog's security and stability.

[Learn more with Arcjet's documentation](https://docs.arcjet.com/integrations/authjs)

- Import Arcjet:

```jsx
import arcjet, { detectBot, tokenBucket } from "@arcjet/next";
```

Here, we import Arcjet's Next.js package and two specific rules: `detectBot` and `tokenBucket`.

- Configuring Arcjet:

```jsx
const aj = arcjet({
  key: process.env.ARCJET_KEY,
  rules: [
    detectBot({
      mode: "LIVE",
      block: ["AUTOMATED"],
    }),
    tokenBucket({
      mode: "LIVE",
      capacity: 10,
      interval: 60,
      refillRate: 10,
    }),
  ],
});
```

In the code above, we set up Arcjet with two main rules:

Bot Detection: Configured to detect and block automated bots.
Rate Limiting: To allow 10 requests per minute, helping prevent abuse.


- Middleware Function:

```jsx
export default async function arcjetAuth(req, res, next) {
  const decision = await aj.protect(req, { requested: 1 });

  if (decision.isDenied()) {
    if (decision.reason.isRateLimit()) {
      return res.status(429).json({
        error: "Rate limited. Try again later.",
      });
    } else if (decision.reason.isBot()) {
      return res.status(403).json({
        error: "Bot detected. Access denied.",
      });
    }
  }

  next();
}
```

`arcjetAuth` is an asynchronous middleware function that uses Arcjet to protect incoming requests.
`await aj.protect(req, { requested: 1 })` evaluates the request based on the configured rules.

**Note that if:**
- A request is rate-limited, it returns a 429 status (Too Many Requests).
- Bot is detected, it returns a 403 status (Forbidden).
- The request passes all checks, it calls next() to proceed with the request.

### Creating a Login Component with NextAuth in Next.js

In this step, we create up a login component for our Next.js blog using `next-auth` to handle authentication. The component allows users to log in using their email and password and provides feedback on the success or failure of the login attempt using `react-hot-toast`

Ensure you have next-auth installed in your project by running:
```shell
npm install next-auth
```

Create an `index.jsx` file inside in the project folder like so: `/app/login/_components` for the login component, and enter the code:

```jsx
"use client";
import { signIn, signOut, useSession } from "next-auth/react";
import { useState } from "react";
import toast from "react-hot-toast";
const MainContent = () => {
  // const { data: session } = useSession();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleSubmit = async (e) => {
    e.preventDefault();
    const result = await signIn("credentials", {
      redirect: false,
      email,
      password,
    });
    if (!result.error) {
      // Handle successful login
      toast.success("Login process succesfully");
    } else {
      toast.error(result.error);
      // Handle error
    }
  };

  return (
    <div className="flex flex-col relative w-full gap-4">
      {/* {loading && <Loader />} */}
      <div className="w-full flex flex-col gap-8">
        {/* single posts */}
        <div className="w-full flex items-center justify-center bg-[#fff] py-12">
          <div className="w-[90%] md:w-[500px] max-w-custom_1 flex flex-col items-start gap-4 justify-center mx-auto">
            <div className="flex w-full flex-col gap-8">
              <h4 className="text-2xl md:text-4xl font-bold">
                Sign in Here
                <span className="block font-normal text-base pt-4 text-grey">
                  Login in to your account to have access to exclusive rights
                  and contents
                </span>
              </h4>
              <form className="w-full flex flex-col gap-4">
                <label
                  htmlFor="name"
                  className="text-base flex flex-col gap-4 font-semibold"
                >
                  Email
                  <input
                    value={email}
                    onChange={(e) => setEmail(e.target.value)}
                    name="email"
                    type="email"
                    className="input"
                  />
                </label>
                <label
                  htmlFor="Password"
                  value={password}
                  onChange={(e) => setPassword(e.target.value)}
                  name="password"
                  className="text-base flex flex-col gap-4 font-semibold"
                >
                  Password
                  <input type="password" className="input" />
                </label>
                <div className="flex pt-4">
                  <button
                    type="submit"
                    onClick={handleSubmit}
                    className="btn py-3 px-8 rounded-xl text-white text-lg"
                  >
                    Submit
                  </button>
                </div>
              </form>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

export default MainContent;
```

`signIn`, `signOut`, and `useSession` are hooks provided by `next-auth` to handle authentication actions.

Then `useState` manages the state of `email` and `password` inputs. To handle form submission, `handleSubmit` is used as an asynchronous function that prevents the default form submission action and then calls the `signIn` function with the provided email and password.

`signIn` is configured to not redirect (`redirect: false`) and to handle the result directly in the component. If the login is successful, a success message is shown using `react-hot-toast`. And if the login fails, an error message is displayed.

The `"use client";` directive ensures this component is rendered on the client-side, which is necessary for the `useState` and authentication hooks to work properly.

To integrate the login component (MainContent) into the login page, we'll create a `page.js` file in the `app/login` directory and add the following code:

```jsx
import Head from "next/head";
import HomeIndex from "./_components";
export default async function Root() {
  return (
    <div className="relative">
      <HomeIndex />
    </div>
  );
}
```
`import HomeIndex from "./_components";` imports the `HomeIndex` component (the `MainContent` component we created earlier) from the `_components` directory.

###  Creating the Comment System
 
Next, we develop a comment system using Arcjet and the [Arcjet/Auth.js integration.](https://docs.arcjet.com/integrations/authjs)
To configure this, we create the API route for handling comments on blog posts, including creating new comments and fetching existing comments.  We'll use [Prisma](https://www.prisma.io/) for database interactions and a custom authentication function to verify user sessions. 

Create a `route.jsx` file in this directory: `app/api/comment` and enter the code:

```jsx
import { NextResponse } from "next/server";
import prisma from "@/prisma";
import { auth } from "@/auth";
import arcjet, { tokenBucket } from "@arcjet/next";

const aj = arcjet({
  key: process.env.ARCJET_KEY,
  rules: [
    // Create a token bucket rate limit. Other algorithms are supported.
    tokenBucket({
      mode: "LIVE", 
      characteristics: ["userId"], 
      refillRate: 1, // refill 1 tokens per interval
      interval: 60, // refill every 60 seconds
      capacity: 1, // bucket maximum capacity of 1 tokens
    }),
  ],
});
export async function POST(req) {
  const { body, postId } = await req.json();
  const session = await auth();
     try {
       if (!session) {
         return NextResponse.json(
           { message: "You are not allowed to perform this action" },
           { status: 401 }
         );
       }

       if (session) {
         // console.log("User:", session.user);

         // If there is a user ID then use it, otherwise use the email
         let userId;
         if (session.user?.id) {
           userId = session.user.id;
         } else if (session.user?.email) {
           
           const email = session.user?.email;
           const emailHash = require("crypto")
             .createHash("sha256")
             .update(email)
             .digest("hex");

           userId = emailHash;
         } else {
           return Response.json({ message: "Unauthorized" }, { status: 401 });
         }

         // Deduct 5 tokens from the token bucket
         const decision = await aj.protect(req, { userId, requested: 1 });
         // console.log("Arcjet Decision:", decision);

         if (decision.isDenied()) {
           return Response.json(
             {
               message: "Too Many Requests",
               reason: decision.reason,
             },
             {
               status: 429,
             }
           );
         }
         // message creation handler

         const comment = await prisma.comment.create({
           data: {
             body,
             postId,
             username: session?.user?.name,
             userimage: session?.user?.image,
           },
         });

         return NextResponse.json(comment);
       }
     } catch (error) {
       return NextResponse.json(
         {
           message: error.response?.data?.message || error.message,
         },
         { status: error.response?.status || 500 }
       );
     }

}

export async function GET(req, { params }) {
  const searchParams = req.nextUrl.searchParams;
  const query = searchParams.get("query");
  // console.log(query);
  try {
    const comment = await prisma.comment.findMany({
      where: {
        postId: query,
      },
    });

    return NextResponse.json(comment);
  } catch (error) {
    return NextResponse.json(
      {
        message: error.response?.data?.message || error.message,
      },
      { status: error.response?.status || 500 }
    );
  }
}
```

Let's break down how the code above works:

We configure [Arcjet](https://arcjet.com/) to implement rate limiting using a token bucket algorithm. The rules specify a live mode that will block requests if limits are exceeded.

```jsx
const aj = arcjet({
  key: process.env.ARCJET_KEY,
  rules: [
    tokenBucket({
      mode: "LIVE",
      characteristics: ["userId"],
      refillRate: 1,
      interval: 60,
      capacity: 1,
    }),
  ],
});

```

>💡_A demo of how this works in the blog is shown later in the tutorial_

The `auth` function to check if the user is authenticated. If not, it returns a 401 Unauthorized response.

```jsx
const session = await auth();
if (!session) {
  return NextResponse.json({ message: "You are not allowed to perform this action" }, { status: 401 });
}

```

Next, we have a function that deducts tokens from the token bucket to enforce rate limiting. If the request is denied due to rate limits, it returns a 429 Too Many Requests response.

```jsx
const decision = await aj.protect(req, { userId, requested: 1 });
if (decision.isDenied()) {
  return Response.json({ message: "Too Many Requests", reason: decision.reason }, { status: 429 });
}

```

If the user is authenticated and the **rate limit** is not exceeded, a new comment is created in the database using Prisma.

```jsx
const comment = await prisma.comment.create({
  data: {
    body,
    postId,
    username: session?.user?.name,
    userimage: session?.user?.image,
  },
});
return NextResponse.json(comment);

```


### Using Contentlayer MDX for the blog content

We will use [Contentlayer](https://contentlayer.dev/) in the blog through the markdown (MDX) local files.

Run this command to install Content layer dependencies:

```shell
npm install contentlayer next-contentlayer date-fns
```

Then, create an `index.js` file in this directory - `app/blog/[id]/_components` and enter the code below:

```jsx
"use client";
import React, { useState, useEffect } from "react";
import axios from "axios";
import toast from "react-hot-toast";
import { allPosts } from "@/.contentlayer/generated/index.mjs";

const MainContent = ({ blogid }) => {
  const blog = allPosts?.find(
    (blog) => blog._raw.flattenedPath === decodeURIComponent(blogid)
  );
  const [body, setBody] = useState("");
  const [comment, setComment] = useState([]);
  const [loading, setLoading] = useState(false);

  // Function to create a new comment
  const handleCreateComment = async () => {
    try {
      setLoading(true);
      const { data } = await axios.post("/api/comment", {
        body: body,
        postId: decodeURIComponent(blogid),
      });
      setBody("");
      setLoading(false);
      toast.success("Comment successfully created");
    } catch (error) {
      toast.error(
        error.response && error.response.data.message
          ? error.response.data.message
          : error.message
      );
    }
  };

  // Fetch all comments for the current blog post
  useEffect(() => {
    const path = `/api/comment?query=${decodeURIComponent(blogid)}`;
    const getAllComments = async () => {
      try {
        setLoading(true);
        const { data } = await axios.get(path);
        setLoading(false);
        setComment(data);
      } catch (error) {
        toast.error(
          error.response && error.response.data.message
            ? error.response.data.message
            : error.message
        );
      }
    };
    getAllComments();
  }, [blogid]);

  return (
    <div className="relative">
      {/* Your blog content display */}
      <h1>{blog?.title}</h1>
      <p>{blog?.excerpt}</p>

      {/* Form to add new comments */}
      <form onSubmit={handleCreateComment}>
        <textarea
          value={body}
          onChange={(e) => setBody(e.target.value)}
          placeholder="Enter your comment..."
        />
        <button type="submit">Submit Comment</button>
      </form>

      {/* Displaying existing comments */}
      <div>
        {comment.map((c) => (
          <div key={c.id}>
            <p>{c.body}</p>
            <p>Posted by {c.username} on {c.createdAt}</p>
          </div>
        ))}
      </div>
    </div>
  );
};

export default MainContent;

```

In the `MainContent` component, the `blogid` prop is utilized to locate a specific blog post from `allPosts` by referencing its `flattenedPath`. 

This component manages state with `body` handling new comment text, `comment` storing fetched comments, and `loading` signaling data fetching or submission. 

The `handleCreateComment` function facilitates comment submission by sending a POST request to `/api/comment` with the comment body and postId, clearing the `body` field on successful submission, and managing error messages. 

Using the `useEffect` hook, comments associated with the current `blogid` are fetched, updating the `comment` state with fetched data while handling potential errors. 

The component also renders existing comments by mapping through the `comment` array, displaying each comment's body, username, and `createdAt` date.

### Storing the Newsletter Signups

The newsletter signups in the blog are stored using a [Next.js database with Prisma](https://www.prisma.io/nextjs) 

Fly.io will provide a `DATABASE_URL` to connect to the database at runtime. This is done through the `prisma/schema.prisma` file in the project's root directory.

```prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mongodb"
  url      = env("DATABASE_URL")
}

model Comment {
  id        String   @id @default(auto()) @map("_id") @db.ObjectId
  body      String?
  username  String?
  userimage String?
  postId  String?
  createdAt DateTime @default(now())
}
```
### Environment Variables

In the root directory of your Next.js project, create a new file named `.env.local` and enter this to define the environment variables:

```shell
DATABASE_URL=your_db_url_here
NEXT_AUTH_SECRET=
NEXTAUTH_URL=
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_SECRET=
```
> Replace the placeholders with your actual keys, and add `.env.local` to your `.gitignore` file

### Deploying the Nextjs app to Fly.io

Now that our application is ready, let's deploy it to Fly.io.

Log in to your Fly account, add a payment method, and choose the framework you want to deploy in:

![fly](https://hackmd.io/_uploads/Byeu238K8C.png)

First, we must install the Fly CLI to interact with Fly.io and deploy the Next.js blog application. Follow along with the Fly/Nextjs docs:
https://fly.io/docs/js/frameworks/nextjs/

Fly.io also supports other frameworks and programming languages.

![Fly](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hflcknijbl05hdjaw1tj.png)

As noted on the documentation:

> _`flyctl` is a command-line utility that lets you work with Fly.io, from creating your account to deploying your applications. It runs on your local device, so install the appropriate version for your operating system._

Using Windows, run the PowerShell install script:. [Check the commands for other Operating Systems](https://fly.io/docs/flyctl/install/).

```shell
pwsh -Command "iwr https://fly.io/install.ps1 -useb | iex"
```

>_Ensure you have the [latest version of Powershell](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows?view=powershell-7.4) installed_

![Fly CLI](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5qf4zolsst88i09shc9l.png)

After installing `flyctl`, open the Next.js app in your terminal from the root directory. Log in with `fly auth login` or Create an account with `fly auth signup`.

It will redirect to your browser for you to confirm it:


![login](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gdi3k34kf4k5nioef20x.png)


Then [deploy the application to Fly.io](https://fly.io/docs/getting-started/launch/) by running this command in the project root directory: `fly launch`

It will also redirect to the browser for you to confirm the;Launch settings.

![fly launch](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u6kt8x3w3gipxsmi4ojm.png)


Then the deployment to Fly.io continues like so:


![fly](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rrgorwvxysowvopwlk8f.png)


Copy the Fly.io URL for the app and open it in your browser, the blog loads like so:

![the Nextjs Blog](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/soj5wtmgknw0eqb7o8q6.png)

Click on **Signin**, which redirects to the **Sign in with Google page**, using the Next-auth.


![Sign in](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nnet2jx1u5tnboqyboox.png)


When you sign in with Google Auth, you will see your name and email at the top right:

![Auth](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/04c8h6c16u2uw5t7vkz0.png)

Here is a demo of the comment system, which has a limit feature using Arcjet. It prevents spamming the comment section by limiting the number of comments a user of the blog can enter at a time.


![demo of blog](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jjizz7bpjo7mkjqv6j9u.gif)

And that's it! Your secure Next.js blog is now deployed on Fly.io with Arcjet protection!

To clone the project and run it locally, open your terminal and run this command:

```shell
git clone https://github.com/Tabintel/arcfly-blog
```

Then run `npm install` to install all the project's dependencies and `npm run dev` to run the web app.

Get the full source code on [GitHub](https://github.com/Tabintel/arcfly-blog).

## Conclusion

With Arcjet and Fly.io, you can build applications that are secure and scale globally.

Check out the documentation of Fly and Arcjet:
- https://fly.io/docs/getting-started/
- https://docs.arcjet.com/get-started

Also, see [example applications of Arcjet](https://github.com/arcjet/arcjet-js/tree/main/examples) in the SDK GitHub.


>**Disclaimer:** _Arcjet contacted me to ask if I would like to try their beta and then write about the experience. They paid me for my time but did not influence this writeup_