-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore(examples): Added new Next.js + React Hook Form example (#559)
Added new Next.js + React Hook Form example. Also adds the Vercel Deploy button ref arcjet/arcjet#1397 and closes #576.
- Loading branch information
1 parent
a8ac938
commit b0a13a4
Showing
40 changed files
with
6,295 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
ARCJET_KEY= |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
dist/* | ||
.cache | ||
public | ||
node_modules | ||
*.esm.js |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
{ | ||
"$schema": "https://json.schemastore.org/eslintrc", | ||
"root": true, | ||
"extends": [ | ||
"next/core-web-vitals", | ||
"prettier", | ||
"plugin:tailwindcss/recommended" | ||
], | ||
"plugins": ["tailwindcss"], | ||
"rules": { | ||
"@next/next/no-html-link-for-pages": "off", | ||
"react/jsx-key": "off", | ||
"tailwindcss/no-custom-classname": "off" | ||
}, | ||
"settings": { | ||
"tailwindcss": { | ||
"callees": ["cn"], | ||
"config": "tailwind.config.js" | ||
}, | ||
"next": { | ||
"rootDir": ["./"] | ||
} | ||
}, | ||
"overrides": [ | ||
{ | ||
"files": ["*.ts", "*.tsx"], | ||
"parser": "@typescript-eslint/parser" | ||
} | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | ||
|
||
# dependencies | ||
node_modules | ||
.pnp | ||
.pnp.js | ||
|
||
# testing | ||
coverage | ||
|
||
# next.js | ||
.next/ | ||
out/ | ||
build | ||
|
||
# misc | ||
.DS_Store | ||
*.pem | ||
|
||
# debug | ||
npm-debug.log* | ||
yarn-debug.log* | ||
yarn-error.log* | ||
.pnpm-debug.log* | ||
|
||
# local env files | ||
.env.local | ||
.env.development.local | ||
.env.test.local | ||
.env.production.local | ||
|
||
# turbo | ||
.turbo | ||
|
||
.contentlayer | ||
.env |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
cache | ||
.cache | ||
package.json | ||
package-lock.json | ||
public | ||
CHANGELOG.md | ||
.yarn | ||
dist | ||
node_modules | ||
.next | ||
build | ||
.contentlayer |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
<a href="https://arcjet.com" target="_arcjet-home"> | ||
<picture> | ||
<source media="(prefers-color-scheme: dark)" srcset="https://arcjet.com/arcjet-logo-minimal-dark-mark-all.svg"> | ||
<img src="https://arcjet.com/arcjet-logo-minimal-light-mark-all.svg" alt="Arcjet Logo" height="128" width="auto"> | ||
</picture> | ||
</a> | ||
|
||
# Protecting a Next.js React Hook Form with Arcjet | ||
|
||
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Farcjet%2Farcjet-js%2Ftree%2Fmain%2Fexamples%2Fnextjs-14-react-hook-form&project-name=aj-react-hook-form&repository-name=aj-react-hook-form&redirect-url=https%3A%2F%2Fapp.arcjet.com%2Fintegrations%2Fvercel&developer-id=oac_1GEcKBuKBilVnjToj1QUwdb8&integration-ids=oac_1GEcKBuKBilVnjToj1QUwdb8) | ||
|
||
This example shows how to protect a Next.js React Hook Form with [Arcjet signup | ||
form protection](https://docs.arcjet.com/signup-protection/concepts). It uses | ||
[shadcn/ui](https://ui.shadcn.com/) form components to build the [React Hook | ||
Form](https://react-hook-form.com/) with both client and server side validation. | ||
|
||
This includes: | ||
|
||
- Form handling with [React Hook Form](https://react-hook-form.com/). | ||
- Client-side validation with [Zod](https://zod.dev/). | ||
- Server-side validation with Zod and [Arcjet email | ||
validation](https://docs.arcjet.com/email-validation/concepts). | ||
- Server-side email verification with Arcjet to check if the email is from a | ||
disposable provider and that the domain has a valid MX record. | ||
- [Rate limiting with | ||
Arcjet](https://docs.arcjet.com/rate-limiting/quick-start/nextjs) set to 5 | ||
requests over a 10 minute sliding window - a reasonable limit for a signup | ||
form, but easily configurable. | ||
- [Bot protection with | ||
Arcjet](https://docs.arcjet.com/bot-protection/quick-start/nextjs) to stop | ||
automated clients from submitting the form. | ||
|
||
These are all combined using the Arcjet `protectSignup` rule | ||
([docs](https://docs.arcjet.com/signup-protection/concepts)), but they can also | ||
be used separately on different routes. | ||
|
||
## How to use | ||
|
||
1. Enter this directory and install the example's dependencies. | ||
|
||
```bash | ||
cd examples/nextjs-14-react-hook-form | ||
npm ci | ||
``` | ||
|
||
2. Rename `.env.local.example` to `.env.local` and add your Arcjet key. | ||
|
||
3. Start the dev server. | ||
|
||
```bash | ||
npm run dev | ||
``` | ||
|
||
4. Visit `http://localhost:3000`. | ||
5. Submit the form with the example non-existent email to show the errors. | ||
Submit it more than 5 times to trigger the rate limit. |
122 changes: 122 additions & 0 deletions
122
examples/nextjs-14-react-hook-form/app/api/submit/route.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
import { formSchema } from "@/lib/formSchema"; | ||
import arcjet, { protectSignup } from "@arcjet/next"; | ||
import { NextResponse } from "next/server"; | ||
|
||
const aj = arcjet({ | ||
// Get your site key from https://app.arcjet.com | ||
// and set it as an environment variable rather than hard coding. | ||
// See: https://nextjs.org/docs/app/building-your-application/configuring/environment-variables | ||
key: process.env.ARCJET_KEY, | ||
rules: [ | ||
// Arcjet's protectSignup rule is a combination of email validation, bot | ||
// protection and rate limiting. Each of these can also be used separately | ||
// on other routes e.g. rate limiting on a login route. See | ||
// https://docs.arcjet.com/get-started | ||
protectSignup({ | ||
email: { | ||
mode: "LIVE", // will block requests. Use "DRY_RUN" to log only | ||
// Block emails that are disposable, invalid, or have no MX records | ||
block: ["DISPOSABLE", "INVALID", "NO_MX_RECORDS"], | ||
}, | ||
bots: { | ||
mode: "LIVE", | ||
// Block clients that we are sure are automated | ||
block: ["AUTOMATED"], | ||
}, | ||
// It would be unusual for a form to be submitted more than 5 times in 10 | ||
// minutes from the same IP address | ||
rateLimit: { | ||
// uses a sliding window rate limit | ||
mode: "LIVE", | ||
interval: "10m", // counts requests over a 10 minute sliding window | ||
max: 5, // allows 5 submissions within the window | ||
}, | ||
}), | ||
], | ||
}); | ||
|
||
export async function POST(req: Request) { | ||
const json = await req.json(); | ||
const data = formSchema.safeParse(json); | ||
|
||
if (!data.success) { | ||
const { error } = data; | ||
|
||
return NextResponse.json( | ||
{ message: "Invalid request", error }, | ||
{ status: 400 } | ||
); | ||
} | ||
|
||
const { email } = data.data; | ||
|
||
const decision = await aj.protect(req, { email }); | ||
|
||
console.log("Arcjet decision: ", decision); | ||
|
||
if (decision.isDenied()) { | ||
if (decision.reason.isEmail()) { | ||
let message: string; | ||
|
||
// These are specific errors to help the user, but will also reveal the | ||
// validation to a spammer. | ||
if (decision.reason.emailTypes.includes("INVALID")) { | ||
message = "email address format is invalid. Is there a typo?"; | ||
} else if (decision.reason.emailTypes.includes("DISPOSABLE")) { | ||
message = "we do not allow disposable email addresses."; | ||
} else if (decision.reason.emailTypes.includes("NO_MX_RECORDS")) { | ||
message = | ||
"your email domain does not have an MX record. Is there a typo?"; | ||
} else { | ||
// This is a catch all, but the above should be exhaustive based on the | ||
// configured rules. | ||
message = "invalid email."; | ||
} | ||
|
||
return NextResponse.json( | ||
{ message, reason: decision.reason }, | ||
{ status: 400 } | ||
); | ||
} else if (decision.reason.isRateLimit()) { | ||
const reset = decision.reason.resetTime; | ||
|
||
if (reset === undefined) { | ||
return NextResponse.json( | ||
{ | ||
message: "Too many requests. Please try again later.", | ||
reason: decision.reason, | ||
}, | ||
{ status: 429 } | ||
); | ||
} | ||
|
||
// Calculate number of seconds between reset Date and now | ||
const seconds = Math.floor((reset.getTime() - Date.now()) / 1000); | ||
const minutes = Math.ceil(seconds / 60); | ||
|
||
if (minutes > 1) { | ||
return NextResponse.json( | ||
{ | ||
message: `Too many requests. Please try again in ${minutes} minutes.`, | ||
reason: decision.reason, | ||
}, | ||
{ status: 429 } | ||
); | ||
} else { | ||
return NextResponse.json( | ||
{ | ||
message: `Too many requests. Please try again in ${reset} seconds.`, | ||
reason: decision.reason, | ||
}, | ||
{ status: 429 } | ||
); | ||
} | ||
} else { | ||
return NextResponse.json({ message: "Forbidden" }, { status: 403 }); | ||
} | ||
} | ||
|
||
return NextResponse.json({ | ||
ok: true, | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import { SiteHeader } from "@/components/site-header"; | ||
import { ThemeProvider } from "@/components/theme-provider"; | ||
import { siteConfig } from "@/config/site"; | ||
import { fontSans } from "@/lib/fonts"; | ||
import { cn } from "@/lib/utils"; | ||
import "@/styles/globals.css"; | ||
import type { Viewport } from "next"; | ||
import { Metadata } from "next"; | ||
|
||
export const viewport: Viewport = { | ||
themeColor: [ | ||
{ media: "(prefers-color-scheme: light)", color: "white" }, | ||
{ media: "(prefers-color-scheme: dark)", color: "black" }, | ||
], | ||
}; | ||
|
||
export const metadata: Metadata = { | ||
title: { | ||
default: siteConfig.name, | ||
template: `%s - ${siteConfig.name}`, | ||
}, | ||
description: siteConfig.description, | ||
|
||
icons: { | ||
icon: "/favicon.ico", | ||
shortcut: "/favicon-16x16.png", | ||
apple: "/apple-touch-icon.png", | ||
}, | ||
}; | ||
|
||
interface RootLayoutProps { | ||
children: React.ReactNode; | ||
} | ||
|
||
export default function RootLayout({ children }: RootLayoutProps) { | ||
return ( | ||
<> | ||
<html lang="en" suppressHydrationWarning> | ||
<head /> | ||
<body | ||
className={cn( | ||
"min-h-screen bg-background font-sans antialiased", | ||
fontSans.variable | ||
)} | ||
> | ||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> | ||
<div className="relative flex min-h-screen flex-col"> | ||
<SiteHeader /> | ||
<div className="flex-1">{children}</div> | ||
</div> | ||
</ThemeProvider> | ||
</body> | ||
</html> | ||
</> | ||
); | ||
} |
Oops, something went wrong.