Secure Your Next.js App with Arcjet: Rate Limiting, Bot Protection, and Shield WAF in 2026

Shipping a Next.js app in 2026 without a security layer is asking for trouble. Scraper bots, credential stuffing, API abuse, and AI prompt-injection attacks are cheap, automated, and relentless. Writing bespoke middleware for each of these is slow and fragile.
Arcjet is a developer-first security SDK that runs inside your Next.js app. One package, one protect() call, and you get rate limiting, bot detection, a WAF (Shield), email validation, PII filtering, and prompt-injection detection — all as code, all testable, all with granular per-route rules.
In this tutorial you will add all six protections to a Next.js 15 App Router project, test them locally, and deploy safely to production. No DNS reconfiguration, no reverse proxy, no additional infrastructure.
What You'll Build
A Next.js 15 app with:
- A public
/api/helloendpoint protected by Shield WAF, bot detection, and fixed-window rate limiting. - A
/api/signupendpoint that blocks disposable emails and limits signups per IP. - An
/api/chatAI endpoint that enforces a token budget, blocks prompt-injection, and masks sensitive info. - A dashboard of real-time security decisions in the Arcjet console.
Prerequisites
Before starting, ensure you have:
- Node.js 20 or newer installed
- A Next.js 15 project using the App Router (or follow Step 1 to create one)
- An Arcjet account — the free tier covers this tutorial
- Familiarity with TypeScript and Next.js route handlers
- Basic understanding of HTTP status codes and middleware
Step 1: Create a Next.js 15 Project
Spin up a fresh project (skip this if you already have one).
npx create-next-app@latest nextjs-arcjet-demo \
--typescript --app --tailwind --eslint \
--src-dir --import-alias "@/*"
cd nextjs-arcjet-demoStart the dev server once to confirm it boots.
npm run devOpen http://localhost:3000 — you should see the default Next.js landing page.
Step 2: Install Arcjet
Install the Next.js SDK.
npm install @arcjet/nextThen install the optional @arcjet/inspect helper (useful for verifying spoofed bots).
npm install @arcjet/inspectStep 3: Get Your Arcjet Site Key
Sign in at app.arcjet.com, create a site, and copy the site key (starts with ajkey_).
Add it to .env.local.
# .env.local
ARCJET_KEY=ajkey_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxArcjet is edge-safe: the key stays on the server and never ships to the browser.
Step 4: Protect a Public API Route
Create src/app/api/hello/route.ts.
// src/app/api/hello/route.ts
import arcjet, { detectBot, shield, fixedWindow } from "@arcjet/next";
import { NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
shield({ mode: "LIVE" }),
detectBot({
mode: "LIVE",
allow: [
"CATEGORY:SEARCH_ENGINE",
"CATEGORY:MONITOR",
],
}),
fixedWindow({
mode: "LIVE",
window: "1m",
max: 20,
}),
],
});
export async function GET(req: Request) {
const decision = await aj.protect(req);
if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
return NextResponse.json(
{ error: "Too many requests" },
{ status: 429 },
);
}
if (decision.reason.isBot()) {
return NextResponse.json(
{ error: "Automated access is not allowed" },
{ status: 403 },
);
}
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return NextResponse.json({ message: "Hello, human." });
}Three rules fire on every request:
- Shield blocks OWASP Top 10 style attacks (SQL injection, path traversal, XSS payloads in query strings).
- detectBot allows verified search engines and monitoring services but blocks everything else, including curl, Puppeteer, and headless Chromium.
- fixedWindow caps each client at 20 requests per minute.
Test it from two terminals.
# Terminal 1 — follow decisions in your Arcjet dashboard
# Terminal 2 — flood the endpoint
for i in {1..25}; do curl -s -o /dev/null -w "%{http_code}\n" \
http://localhost:3000/api/hello; doneYou should see roughly 20 responses with status 200, then 429 for the rest.
Step 5: Block Bots but Allow Real Browsers
Arcjet ships an opinionated bot taxonomy. A partial list:
| Category | Example clients |
|---|---|
CATEGORY:SEARCH_ENGINE | Googlebot, Bingbot, Yandex |
CATEGORY:MONITOR | Uptime Robot, Pingdom, BetterStack |
CATEGORY:PREVIEW | Slackbot, Discordbot, Twitterbot |
CATEGORY:AI | GPTBot, ClaudeBot, PerplexityBot |
To allow link previews on social platforms but block AI crawlers, adjust the rule.
detectBot({
mode: "LIVE",
allow: [
"CATEGORY:SEARCH_ENGINE",
"CATEGORY:MONITOR",
"CATEGORY:PREVIEW",
],
// deny: ["CATEGORY:AI"], // explicit AI block (optional)
}),Some bots lie about their User-Agent. Use isSpoofedBot to cross-check the request IP against the claimed bot's known IP ranges.
import { isSpoofedBot } from "@arcjet/inspect";
const decision = await aj.protect(req);
if (decision.results.some(isSpoofedBot)) {
return NextResponse.json(
{ error: "Spoofed bot detected" },
{ status: 403 },
);
}Step 6: Protect a Signup Form with protectSignup
Signup endpoints are a prime target: fake accounts, credential stuffing, and spam lists. Arcjet bundles three rules into one helper.
Create src/app/api/signup/route.ts.
// src/app/api/signup/route.ts
import arcjet, { protectSignup } from "@arcjet/next";
import { NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
protectSignup({
rateLimit: {
mode: "LIVE",
interval: "10m",
max: 5,
},
bots: {
mode: "LIVE",
allow: [],
},
email: {
mode: "LIVE",
deny: ["DISPOSABLE", "INVALID", "NO_MX_RECORDS"],
},
}),
],
});
export async function POST(req: Request) {
const body = await req.json();
const email = String(body.email ?? "");
const decision = await aj.protect(req, { email });
if (decision.isDenied()) {
if (decision.reason.isEmail()) {
return NextResponse.json(
{ error: "Invalid or disposable email" },
{ status: 400 },
);
}
if (decision.reason.isRateLimit()) {
return NextResponse.json(
{ error: "Too many signup attempts" },
{ status: 429 },
);
}
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return NextResponse.json({ ok: true });
}Test with a disposable address.
curl -X POST http://localhost:3000/api/signup \
-H "Content-Type: application/json" \
-d '{"email":"throwaway@mailinator.com"}'
# → 400 Invalid or disposable email
curl -X POST http://localhost:3000/api/signup \
-H "Content-Type: application/json" \
-d '{"email":"real@noqta.tn"}'
# → 200 okThe email rule checks three layers: syntactic validity, MX record presence, and a disposable-domain list maintained by Arcjet.
Step 7: Protect an AI Chat Endpoint
AI endpoints are expensive and uniquely vulnerable. A single malicious request can burn through your token budget or leak PII from your system prompt. Combine token-bucket rate limiting, prompt-injection detection, and sensitive-info filtering.
Create src/app/api/chat/route.ts.
// src/app/api/chat/route.ts
import arcjet, {
detectBot,
detectPromptInjection,
sensitiveInfo,
shield,
tokenBucket,
} from "@arcjet/next";
import { NextResponse } from "next/server";
const aj = arcjet({
key: process.env.ARCJET_KEY!,
characteristics: ["userId"],
rules: [
shield({ mode: "LIVE" }),
detectBot({ mode: "LIVE", allow: [] }),
tokenBucket({
mode: "LIVE",
refillRate: 2_000,
interval: "1h",
capacity: 5_000,
}),
sensitiveInfo({
mode: "LIVE",
deny: ["CREDIT_CARD_NUMBER", "EMAIL", "PHONE_NUMBER"],
}),
detectPromptInjection({ mode: "LIVE", threshold: 0.5 }),
],
});
export async function POST(req: Request) {
const { userId, message } = (await req.json()) as {
userId: string;
message: string;
};
const estimate = Math.ceil(message.length / 4);
const decision = await aj.protect(req, {
userId,
requested: estimate,
sensitiveInfoValue: message,
detectPromptInjectionMessage: message,
});
if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
return NextResponse.json(
{ error: "AI usage limit exceeded" },
{ status: 429 },
);
}
if (decision.reason.isSensitiveInfo()) {
return NextResponse.json(
{ error: "Please remove personal info" },
{ status: 400 },
);
}
if (decision.reason.isPromptInjection()) {
return NextResponse.json(
{ error: "Prompt injection detected" },
{ status: 400 },
);
}
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Forward to your LLM provider of choice here.
return NextResponse.json({ reply: "pong" });
}The characteristics: ["userId"] line is critical. Without it, all users share one token bucket keyed by IP — a single power user depletes the quota for everyone. With a user-scoped characteristic, each account gets its own budget.
Step 8: Use the Arcjet Middleware for App-Wide Protection
Route-level protection is explicit but repetitive. For blanket coverage, put Arcjet in middleware.ts.
// middleware.ts
import arcjet, { createMiddleware, detectBot, shield } from "@arcjet/next";
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
shield({ mode: "LIVE" }),
detectBot({
mode: "LIVE",
allow: ["CATEGORY:SEARCH_ENGINE", "CATEGORY:PREVIEW"],
}),
],
});
export default createMiddleware(aj);Keep the middleware rules minimal. Cost-sensitive features like token buckets belong on the expensive routes, not on every static asset fetch.
Step 9: Use DRY_RUN Mode When Tuning Rules
Never deploy a new rule in LIVE mode directly. Start in DRY_RUN so Arcjet logs the decision without blocking traffic.
fixedWindow({
mode: "DRY_RUN",
window: "1m",
max: 20,
}),Watch the Arcjet dashboard for a few days. If the decision stream shows only legitimate traffic hitting the limit, tighten the rule. Once the data looks right, flip to LIVE.
Step 10: Testing Your Implementation
Arcjet ships a testing helper so you do not need to hit real IPs during unit tests.
npm install -D @arcjet/test-utilsWrite a spec that verifies the signup rule rejects disposable emails.
// __tests__/signup.test.ts
import { POST } from "@/app/api/signup/route";
test("blocks disposable email", async () => {
const req = new Request("http://localhost/api/signup", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: "junk@mailinator.com" }),
});
const res = await POST(req);
expect(res.status).toBe(400);
});For local end-to-end testing of rate limits, run the for-loop from Step 4 with a short window (set window: "10s" temporarily) so you do not wait a full minute to see throttling kick in.
Step 11: Deploy to Production
Arcjet has no region lock-in. Deploy to Vercel, Netlify, Cloudflare Pages, Fly.io, or a self-hosted Node server — the SDK works the same everywhere.
On Vercel, add ARCJET_KEY as an environment variable under Project Settings and redeploy.
vercel env add ARCJET_KEY production
vercel --prodIn the Arcjet dashboard, open the Decisions view. You should see real traffic flowing and each rule's verdict counted per minute.
Step 12: Monitoring and Alerts
The dashboard surfaces three signals you want to watch:
- Request volume by rule — a sudden spike in
detectBothits usually means a new scraper found your site. - Block rate — if more than 1% of real users are being blocked, your rules are too tight.
- Spoofed bot ratio — a rising share means bad actors are rotating User-Agents to pose as Googlebot. Enable
isSpoofedBotverification.
Wire the webhook integration to Slack or PagerDuty so you are alerted when the block rate crosses a threshold.
Troubleshooting
All requests are returning 403. Double-check that your bot allow list includes CATEGORY:SEARCH_ENGINE if you want Googlebot to reach you. During local development, your own browser should never be flagged — if it is, confirm you set mode: "DRY_RUN" on the detectBot rule until the allow list is right.
Rate limits do not reset. The fixed-window and token-bucket algorithms are keyed by IP by default. If you are testing from behind a corporate NAT, every developer shares the same key. Add characteristics: ["userId"] and pass a user ID to the protect() call to scope limits per account instead.
Shield flags legitimate search queries. Shield's OWASP detection can trip on form inputs that contain SQL-like words. Move Shield to DRY_RUN for that specific route, inspect the decisions tab, and add exemptions by moving the rule out of the global middleware.
Prompt injection detector misses jailbreaks. The threshold defaults to 0.5 on a 0–1 score. Lower it to 0.3 for stricter filtering, then monitor false-positive rates. For adversarial chat use cases, pair the detector with a system-prompt hardening layer — no single defense stops every attack.
Next Steps
- Explore the Arcjet bot list and build a custom allow list for your audience.
- Combine Arcjet with Upstash Redis for rate limiting and caching when you need distributed counters across multiple workloads.
- Add observability with OpenTelemetry tracing and monitoring so you can correlate Arcjet decisions with downstream latency.
- Layer Better Auth for strong authentication, then use Arcjet's
characteristicsto scope rate limits per user. - Extend to AI chat with the Claude Agent SDK tutorial so every tool call routes through Arcjet first.
Conclusion
In under an hour you added six layers of production-grade protection to a Next.js 15 app: a WAF, bot detection, fixed-window and token-bucket rate limits, email validation, PII filtering, and prompt-injection defense. All six live inside your codebase, all six are testable, and none of them require a reverse proxy or DNS change.
Security is not a one-time setup. Ship the rules in DRY_RUN, watch the dashboard, tune the thresholds, and promote to LIVE once the signal is clean. Then iterate — add a new rule every time you ship a new surface.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Better Auth with Next.js 15: The Complete Authentication Guide for 2026
Learn how to implement full-featured authentication in Next.js 15 using Better Auth. This tutorial covers email/password, OAuth, sessions, middleware protection, and role-based access control.

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

Passkeys and WebAuthn with Next.js 15: Build Passwordless Authentication in 2026
Learn how to implement passwordless authentication using Passkeys and the WebAuthn API in a Next.js 15 application. This tutorial covers credential registration, biometric login, server-side verification, and production deployment.