Ekemini Samuel
Posted on July 1, 2024
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
Navigate into the project with:
cd secure-blog
Then, install these dependencies.
npm install @arcjet/next
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],
});
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";
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,
}),
],
});
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();
}
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
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;
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>
);
}
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 }
);
}
}
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,
}),
],
});
💡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 });
}
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 });
}
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);
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
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;
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())
}
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=
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:
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.
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"
Ensure you have the latest version of Powershell installed
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:
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.
Then the deployment to Fly.io continues like so:
Copy the Fly.io URL for the app and open it in your browser, the blog loads like so:
Click on Signin, which redirects to the Sign in with Google page, using the Next-auth.
When you sign in with Google Auth, you will see your name and email at the top right:
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.
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
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
Posted on July 1, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.