DEV Community

Cover image for How to Build a Secure Next.js Blog with Fly.io and Arcjet
Ekemini Samuel
Ekemini Samuel

Posted on

How to Build a Secure Next.js Blog with Fly.io and Arcjet

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 and Fly.io accounts.

Overview of Arcjet and Fly.io

Arcjet is a security platform offering rate limiting, bot detection, spam protection, and attack detection features.

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:



npx create-next-app@latest arcfly-blog


Enter fullscreen mode Exit fullscreen mode

terminal

Navigate into the project with:



cd secure-blog


Enter fullscreen mode Exit fullscreen mode

Then, install these dependencies.



npm install @arcjet/next


Enter fullscreen mode Exit fullscreen mode

This blog uses Google for the sign-in/login authentication with Next-auth.

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



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],
});


Enter fullscreen mode Exit fullscreen mode

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

  • Import Arcjet:


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


Enter fullscreen mode Exit fullscreen mode

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

  • Configuring Arcjet:


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


Enter fullscreen mode Exit fullscreen mode

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:


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();
}


Enter fullscreen mode Exit fullscreen mode

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:



npm install next-auth


Enter fullscreen mode Exit fullscreen mode

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



"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;


Enter fullscreen mode Exit fullscreen mode

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:



import Head from "next/head";
import HomeIndex from "./_components";
export default async function Root() {
  return (
    <div className="relative">
      <HomeIndex />
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

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.
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 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:



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 }
    );
  }
}


Enter fullscreen mode Exit fullscreen mode

Let's break down how the code above works:

We configure Arcjet to implement rate limiting using a token bucket algorithm. The rules specify a live mode that will block requests if limits are exceeded.



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



Enter fullscreen mode Exit fullscreen mode

đź’ˇ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.



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



Enter fullscreen mode Exit fullscreen mode

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.



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



Enter fullscreen mode Exit fullscreen mode

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



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



Enter fullscreen mode Exit fullscreen mode

Using Contentlayer MDX for the blog content

We will use Contentlayer in the blog through the markdown (MDX) local files.

Run this command to install Content layer dependencies:



npm install contentlayer next-contentlayer date-fns


Enter fullscreen mode Exit fullscreen mode

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



"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;



Enter fullscreen mode Exit fullscreen mode

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

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.



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())
}


Enter fullscreen mode Exit fullscreen mode

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:



DATABASE_URL=your_db_url_here
NEXT_AUTH_SECRET=
NEXTAUTH_URL=
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_SECRET=


Enter fullscreen mode Exit fullscreen mode

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

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

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.



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


Enter fullscreen mode Exit fullscreen mode

Ensure you have the latest version of Powershell installed

Fly CLI

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

Then deploy the application to Fly.io 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

Then the deployment to Fly.io continues like so:

fly

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

the Nextjs Blog

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

Sign in

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

Auth

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

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:



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


Enter fullscreen mode Exit fullscreen mode

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.

Conclusion

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

Check out the documentation of Fly and Arcjet:

Also, see example applications of Arcjet 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

Top comments (1)