Skip to content

Commit

Permalink
chore(examples): Added new Next.js + React Hook Form example (#559)
Browse files Browse the repository at this point in the history
Added new Next.js + React Hook Form example. Also adds the Vercel Deploy button ref arcjet/arcjet#1397 and closes #576.
  • Loading branch information
davidmytton committed Apr 16, 2024
1 parent a8ac938 commit b0a13a4
Show file tree
Hide file tree
Showing 40 changed files with 6,295 additions and 6 deletions.
25 changes: 25 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,31 @@ updates:
- dependency-name: eslint
versions: [">=9"]

- package-ecosystem: npm
directory: /examples/nextjs-14-react-hook-form
schedule:
# Our dependencies should be checked daily
interval: daily
assignees:
- blaine-arcjet
reviewers:
- blaine-arcjet
commit-message:
prefix: deps(example)
prefix-development: deps(example)
groups:
dependencies:
patterns:
- "*"
ignore:
# Ignore updates to the @types/node package due to conflict between
# Headers in DOM.
- dependency-name: "@types/node"
versions: [">18.18"]
# TODO(#539): Upgrade to eslint 9
- dependency-name: eslint
versions: [">=9"]

- package-ecosystem: npm
directory: /examples/nextjs-example
schedule:
Expand Down
12 changes: 6 additions & 6 deletions .trunk/trunk.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml
version: 0.1
cli:
version: 1.20.1
version: 1.21.0
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
plugins:
sources:
- id: trunk
ref: v1.4.4
ref: v1.4.5
uri: https://github.com/trunk-io/plugins
# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes)
runtimes:
Expand All @@ -20,17 +20,17 @@ lint:
enabled:
- shellcheck@0.10.0
- shfmt@3.6.0
- trivy@0.49.1
- trivy@0.50.1
- yamllint@1.35.1
- semgrep@1.65.0
- semgrep@1.69.0
- gitleaks@8.18.2
- actionlint@1.6.27
- git-diff-check
- markdownlint@0.39.0
- osv-scanner@1.6.2
- osv-scanner@1.7.0
- prettier@3.2.5
- svgo@3.2.0
- trufflehog@3.69.0
- trufflehog@3.71.0
disabled:
# tfsec and checkov are replaced by Trivy
- tfsec
Expand Down
1 change: 1 addition & 0 deletions examples/nextjs-14-react-hook-form/.env.local.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ARCJET_KEY=
5 changes: 5 additions & 0 deletions examples/nextjs-14-react-hook-form/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
dist/*
.cache
public
node_modules
*.esm.js
30 changes: 30 additions & 0 deletions examples/nextjs-14-react-hook-form/.eslintrc.json
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"
}
]
}
36 changes: 36 additions & 0 deletions examples/nextjs-14-react-hook-form/.gitignore
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
12 changes: 12 additions & 0 deletions examples/nextjs-14-react-hook-form/.prettierignore
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
56 changes: 56 additions & 0 deletions examples/nextjs-14-react-hook-form/README.md
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 examples/nextjs-14-react-hook-form/app/api/submit/route.ts
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,
});
}
56 changes: 56 additions & 0 deletions examples/nextjs-14-react-hook-form/app/layout.tsx
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>
</>
);
}
Loading

0 comments on commit b0a13a4

Please sign in to comment.