>) => {
8 | const userData: (typeof userTable.$inferInsert)[] = [
9 | {
10 | email: 'a@a.com',
11 | emailVerified: 1,
12 | },
13 | {
14 | email: 'b@b.com',
15 | emailVerified: 1,
16 | },
17 | {
18 | email: 'c@c.com',
19 | emailVerified: 1,
20 | },
21 | ]
22 |
23 | try {
24 | db.insert(userTable).values(userData).run()
25 |
26 | const users = db.select().from(userTable).all()
27 |
28 | console.log({ users })
29 | } catch (err) {
30 | console.error('Something went wrong...')
31 | console.error(err)
32 | }
33 | }
34 |
35 | const main = () => {
36 | console.log('🧨 Started seeding the database...\n')
37 | seedUsers(db)
38 | console.log('\n🧨 Done seeding the database successfully...\n')
39 | }
40 |
41 | main()
42 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app/components/signup.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import { useFormState } from 'react-dom'
5 | import Link from 'next/link'
6 | import { getFormProps, getInputProps, useForm } from '@conform-to/react'
7 | import { parseWithZod } from '@conform-to/zod'
8 |
9 | import { signup } from '@/app/actions/signup'
10 | import { createSignupSchema } from '@/app/lib/zod.schema'
11 |
12 | export function SignupForm() {
13 | const [lastResult, action] = useFormState(signup, undefined)
14 |
15 | const [form, fields] = useForm({
16 | id: 'signup-form',
17 | lastResult,
18 | onValidate({ formData }) {
19 | return parseWithZod(formData, {
20 | schema: (control) => createSignupSchema(control),
21 | })
22 | },
23 | shouldValidate: 'onBlur',
24 | })
25 |
26 | return (
27 | <>
28 |
29 | go home
30 |
31 |
40 | >
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/components/resend-otp.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import { useFormState } from 'react-dom'
5 | import Link from 'next/link'
6 | import { getFormProps, getInputProps, useForm } from '@conform-to/react'
7 | import { parseWithZod } from '@conform-to/zod'
8 |
9 | import { login } from '@/app/actions/login'
10 | import { loginSchema } from '@/app/lib/zod.schema'
11 |
12 | export function ResendOTPForm() {
13 | const [lastResult, action] = useFormState(login, undefined)
14 |
15 | const [form, fields] = useForm({
16 | id: 'resend-otp-form',
17 | lastResult,
18 | onValidate({ formData }) {
19 | return parseWithZod(formData, {
20 | schema: loginSchema,
21 | })
22 | },
23 | shouldValidate: 'onBlur',
24 | })
25 |
26 | return (
27 | <>
28 |
29 | go home
30 |
31 |
42 | >
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/src/app/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import { redirect } from 'next/navigation'
3 | import { cookies } from 'next/headers'
4 |
5 | import { lucia, validateRequest } from '@/app/auth/lucia'
6 | import { VERIFIED_EMAIL_ALERT } from '@/app/lib/constants'
7 | import { Toast } from '@/app/components/toast'
8 |
9 | const Dashboard = async () => {
10 | const { user } = await validateRequest()
11 | const userExists = user && user.emailVerified
12 | if (!userExists) return redirect('/login')
13 |
14 | return (
15 | <>
16 |
17 | go home
18 |
19 |
22 |
23 | >
24 | )
25 | }
26 |
27 | async function logout() {
28 | 'use server'
29 | const { session } = await validateRequest()
30 |
31 | if (!session) {
32 | return {
33 | error: 'Unauthorized',
34 | }
35 | }
36 |
37 | await lucia.invalidateSession(session.id)
38 |
39 | const sessionCookie = lucia.createBlankSessionCookie()
40 | cookies().set(
41 | sessionCookie.name,
42 | sessionCookie.value,
43 | sessionCookie.attributes
44 | )
45 | await lucia.deleteExpiredSessions()
46 |
47 | cookies().delete(VERIFIED_EMAIL_ALERT)
48 |
49 | return redirect('/login')
50 | }
51 |
52 | export default Dashboard
53 |
--------------------------------------------------------------------------------
/src/app/components/login.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import { useFormState } from 'react-dom'
5 | import Link from 'next/link'
6 | import { getFormProps, getInputProps, useForm } from '@conform-to/react'
7 | import { parseWithZod } from '@conform-to/zod'
8 |
9 | import { login } from '@/app/actions/login'
10 | import { loginSchema } from '@/app/lib/zod.schema'
11 | import { Toast } from '@/app/components/toast'
12 |
13 | export function LoginForm() {
14 | const [lastResult, action] = useFormState(login, undefined)
15 |
16 | const [form, fields] = useForm({
17 | id: 'login-form',
18 | lastResult,
19 | onValidate({ formData }) {
20 | return parseWithZod(formData, {
21 | schema: loginSchema,
22 | })
23 | },
24 | shouldValidate: 'onBlur',
25 | })
26 |
27 | return (
28 | <>
29 |
30 | go home
31 |
32 |
43 |
44 | >
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/src/app/lib/rate-limit.ts:
--------------------------------------------------------------------------------
1 | import Redis from 'ioredis'
2 | import { RateLimiterRedis } from 'rate-limiter-flexible'
3 |
4 | const redisClient = new Redis({ enableOfflineQueue: false })
5 |
6 | const maxWrongAttemptsByIPperDay = 100
7 | const maxConsecutiveFailsByEmailAndIP = 10
8 |
9 | export const REDIS_KEYS = {
10 | USER: {
11 | SIGNUP: 'user:signup',
12 | LOGIN: 'user:login',
13 | OTP: 'user:otp',
14 | },
15 | } as const
16 |
17 | export const signupLimiter = new RateLimiterRedis({
18 | storeClient: redisClient,
19 | useRedisPackage: true,
20 | keyPrefix: REDIS_KEYS.USER.SIGNUP,
21 | points: maxWrongAttemptsByIPperDay,
22 | duration: 60 * 60 * 24,
23 | blockDuration: 60 * 60 * 3, // Block for 3 hours, if 100 wrong attempts per day
24 | })
25 |
26 | export function rateLimit() {
27 | const check = () => {}
28 |
29 | return {
30 | check,
31 | }
32 | }
33 |
34 | const limiterSlowBruteByIP = new RateLimiterRedis({
35 | storeClient: redisClient,
36 | useRedisPackage: true,
37 | keyPrefix: 'login_fail_ip_per_day',
38 | points: maxWrongAttemptsByIPperDay,
39 | duration: 60 * 60 * 24,
40 | blockDuration: 60 * 60 * 12, // Block for 12 hours, if 100 wrong attempts per day
41 | })
42 |
43 | const limiterConsecutiveFailsByEmailAndIP = new RateLimiterRedis({
44 | storeClient: redisClient,
45 | keyPrefix: 'login_fail_consecutive_email_and_ip',
46 | points: maxConsecutiveFailsByEmailAndIP,
47 | duration: 60 * 60 * 24 * 90, // Store number for 90 days since first fail
48 | blockDuration: 60 * 60, // Block for 1 hour
49 | })
50 |
--------------------------------------------------------------------------------
/rate-limit/login.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 |
3 | import { LOCALHOST_URL, getActionParams } from 'rate-limit/utils'
4 |
5 | async function main() {
6 | const { actionNo, actionKey } = await getActionParams()
7 | const stateTree = ''
8 | const formData = new FormData()
9 | formData.append('email', 'a@a.com')
10 | /*
11 | try {
12 | const response = await axios.post(`${LOCALHOST_URL}/signup`, {
13 | headers: {
14 | accept: 'text/x-component',
15 | 'Next-Action': actionNo,
16 | Host: LOCALHOST_URL,
17 | },
18 | body: formData,
19 | })
20 |
21 | console.log({ response })
22 | } catch (err) {
23 | console.log('error:->')
24 | console.error(err)
25 | }
26 | */
27 | // const data = JSON.parse(response.data.split('\n')[1].replace('1:', ''))
28 | // if (data !== null) {
29 | // console.log(data)
30 | // }
31 |
32 | const res = await fetch(`${LOCALHOST_URL}/signup`, {
33 | method: 'POST',
34 | headers: {
35 | Accept: 'text/x-component',
36 | 'Next-Action': actionNo,
37 | 'Next-Router-State-Tree': stateTree,
38 | // Host: LOCALHOST_URL,
39 | },
40 | body: formData,
41 | })
42 |
43 | const body = res.body
44 | const headers = res.headers
45 | const redirected = res.redirected
46 | const contentType = res.headers.get('content-type') || ''
47 |
48 | const response1 = await res.text()
49 | // const data1 = JSON.parse(response1?.data.split('\n')[1].replace('1:', ''))
50 |
51 | console.log({ res, body, headers, redirected, contentType })
52 | }
53 |
54 | main()
55 |
--------------------------------------------------------------------------------
/src/app/components/verify-email.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import React from 'react'
4 | import { useFormState } from 'react-dom'
5 | import { getFormProps, getInputProps, useForm } from '@conform-to/react'
6 | import { parseWithZod } from '@conform-to/zod'
7 | import Link from 'next/link'
8 |
9 | import { verifyEmail } from '@/app/actions/verify-email'
10 | import { verifyEmailSchema } from '@/app/lib/zod.schema'
11 | import { Toast } from '@/app/components/toast'
12 |
13 | export function VerifyEmailForm() {
14 | const [lastResult, action] = useFormState(verifyEmail, undefined)
15 |
16 | const [form, fields] = useForm({
17 | id: 'verify-email-form',
18 | lastResult,
19 | onValidate({ formData }) {
20 | return parseWithZod(formData, {
21 | schema: verifyEmailSchema,
22 | })
23 | },
24 | shouldValidate: 'onBlur',
25 | })
26 |
27 | return (
28 | <>
29 |
40 |
41 | {' '}
42 | didn't get an otp?{' '}
43 |
44 | resend otp
45 |
46 |
47 |
48 | >
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/src/app/actions/login.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { cookies } from 'next/headers'
4 | import { redirect } from 'next/navigation'
5 | import { eq } from 'drizzle-orm'
6 | import { parseWithZod } from '@conform-to/zod'
7 | import { z } from 'zod'
8 | import { TimeSpan } from 'oslo'
9 |
10 | import { db } from '@/app/db/index'
11 | import { userTable } from '@/app/db/drizzle.schema'
12 | import { loginSchema } from '@/app/lib/zod.schema'
13 |
14 | import { sendEmailVerificationCode } from '@/app/actions/signup'
15 | import { VERIFIED_EMAIL_ALERT } from '@/app/lib/constants'
16 |
17 | export async function login(prevState: unknown, formData: FormData) {
18 | const submission = await parseWithZod(formData, {
19 | schema: loginSchema.transform(async (data, ctx) => {
20 | const existingEmail = await db
21 | .select()
22 | .from(userTable)
23 | .where(eq(userTable.email, data.email))
24 | .execute()
25 | .then((s) => s[0])
26 | if (!(existingEmail && existingEmail.id)) {
27 | ctx.addIssue({
28 | path: ['email'],
29 | code: z.ZodIssueCode.custom,
30 | message: 'Invalid email',
31 | })
32 | return z.NEVER
33 | }
34 |
35 | return { ...data, ...existingEmail }
36 | }),
37 | async: true,
38 | })
39 |
40 | if (submission.status !== 'success') {
41 | return submission.reply()
42 | }
43 |
44 | try {
45 | sendEmailVerificationCode(submission.value.id, submission.value.email)
46 | cookies().set(VERIFIED_EMAIL_ALERT, 'true', {
47 | maxAge: new TimeSpan(1, 'm').seconds(), // 10 minutes = 60 * 60 * 1
48 | })
49 | } catch (err) {
50 | console.error(`Login error while creating Lucia session:`)
51 | console.error(err)
52 | }
53 |
54 | return redirect('/verify-email')
55 | }
56 |
--------------------------------------------------------------------------------
/src/app/db/drizzle.schema.ts:
--------------------------------------------------------------------------------
1 | import {
2 | sqliteTable,
3 | text,
4 | integer,
5 | uniqueIndex,
6 | } from 'drizzle-orm/sqlite-core'
7 | import { relations } from 'drizzle-orm'
8 | import { ulid } from 'ulidx'
9 |
10 | export const userTable = sqliteTable('user', {
11 | id: text('id')
12 | .primaryKey()
13 | .$defaultFn(() => ulid()),
14 | email: text('email').unique().notNull(),
15 | emailVerified: integer('email_verified').notNull(),
16 | })
17 |
18 | export const userTableRelations = relations(userTable, ({ many }) => ({
19 | session: many(sessionTable),
20 | emailVerificationCode: many(emailVerificationCodeTable),
21 | }))
22 |
23 | export const sessionTable = sqliteTable('session', {
24 | id: text('id').primaryKey(),
25 | userId: text('user_id')
26 | .notNull()
27 | .references(() => userTable.id, {
28 | onUpdate: 'cascade',
29 | onDelete: 'cascade',
30 | }),
31 | expiresAt: integer('expires_at').notNull(),
32 | })
33 |
34 | export const sessionTableRelations = relations(sessionTable, ({ one }) => ({
35 | user: one(userTable, {
36 | fields: [sessionTable.userId],
37 | references: [userTable.id],
38 | }),
39 | }))
40 |
41 | export const emailVerificationCodeTable = sqliteTable(
42 | 'email_verification_code',
43 | {
44 | id: text('id')
45 | .primaryKey()
46 | .$defaultFn(() => ulid()),
47 | code: text('code'),
48 | userId: text('user_id')
49 | .notNull()
50 | .references(() => userTable.id, {
51 | onUpdate: 'cascade',
52 | onDelete: 'cascade',
53 | }),
54 | expiresAt: integer('expires_at'),
55 | }
56 | )
57 |
58 | export const emailVerificationCodeRelations = relations(
59 | emailVerificationCodeTable,
60 | ({ one }) => ({
61 | user: one(userTable, {
62 | fields: [emailVerificationCodeTable.userId],
63 | references: [userTable.id],
64 | }),
65 | })
66 | )
67 |
--------------------------------------------------------------------------------
/src/app/lib/zod.schema.ts:
--------------------------------------------------------------------------------
1 | import type { Intent } from '@conform-to/react'
2 | import { conformZodMessage } from '@conform-to/zod'
3 | import { z } from 'zod'
4 |
5 | export const verifyEmailSchema = z.object({
6 | code: z
7 | .string({ required_error: 'Code is required' })
8 | .length(6, { message: 'Must be exactly 6-digits long' }),
9 | })
10 |
11 | export const loginSchema = z.object({
12 | email: z
13 | .string({ required_error: 'Email is required' })
14 | .email('Invalid email address'),
15 | })
16 |
17 | export function createSignupSchema(
18 | intent: Intent | null,
19 | options?: {
20 | // isEmailUnique is only defined on the server
21 | isEmailUnique: (email: string) => boolean
22 | }
23 | ) {
24 | return z.object({
25 | email: z
26 | .string({ required_error: 'Email is required' })
27 | .email('Invalid Email address')
28 | // Pipe the schema so it runs only if the email is valid
29 | .pipe(
30 | z.string().superRefine((email, ctx) => {
31 | const isValidatingEmail =
32 | intent === null ||
33 | (intent.type === 'validate' && intent.payload.name === 'email')
34 |
35 | if (!isValidatingEmail) {
36 | ctx.addIssue({
37 | code: 'custom',
38 | message: conformZodMessage.VALIDATION_SKIPPED,
39 | })
40 | return
41 | }
42 |
43 | if (typeof options?.isEmailUnique !== 'function') {
44 | ctx.addIssue({
45 | code: 'custom',
46 | message: conformZodMessage.VALIDATION_UNDEFINED,
47 | fatal: true,
48 | })
49 | return
50 | }
51 |
52 | const isUnique = options.isEmailUnique(email)
53 | if (!isUnique) {
54 | ctx.addIssue({
55 | code: 'custom',
56 | message: 'Email is already used',
57 | })
58 | return
59 | }
60 | })
61 | ),
62 | })
63 | }
64 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-13-lucia-auth-drizzle-turso-sqlite-magic-link",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "turbo": "next dev --turbo",
8 | "build": "next build",
9 | "start": "next start",
10 | "lint": "next lint",
11 | "db:push": "drizzle-kit push:sqlite --config drizzle.config.ts",
12 | "db:generate": "drizzle-kit generate:sqlite --config drizzle.config.ts",
13 | "db:studio": "drizzle-kit studio --host localhost --port 3002 --verbose --config drizzle.config.ts",
14 | "db:seed": "node --import tsx --env-file .env ./seed/insert.ts",
15 | "db:delete": "node --import tsx --env-file .env ./seed/delete.ts",
16 | "ratelimit:signup": "tsx ./rate-limit/signup.ts",
17 | "ratelimit:login": "tsx ./rate-limit/login.ts",
18 | "clean": "rimraf .next",
19 | "knip": "knip"
20 | },
21 | "dependencies": {
22 | "@conform-to/react": "1.0.0",
23 | "@conform-to/zod": "1.0.0",
24 | "@lucia-auth/adapter-drizzle": "1.0.0",
25 | "@types/node": "20.11.16",
26 | "@types/react": "18.2.52",
27 | "@types/react-dom": "18.2.18",
28 | "autoprefixer": "10.4.17",
29 | "better-sqlite3": "^9.4.0",
30 | "clsx": "^2.1.0",
31 | "dotenv": "^16.4.1",
32 | "drizzle-orm": "^0.29.3",
33 | "eslint": "8.56.0",
34 | "eslint-config-next": "14.1.0",
35 | "ioredis": "^5.3.2",
36 | "lucia": "3.0.1",
37 | "next": "^14.1.0",
38 | "next-client-cookies": "^1.1.0",
39 | "oslo": "^1.0.4",
40 | "postcss": "8.4.33",
41 | "rate-limiter-flexible": "^4.0.1",
42 | "react": "^18.2.0",
43 | "react-dom": "18.2.0",
44 | "tailwindcss": "3.4.1",
45 | "thumbmarkjs": "^0.12.1",
46 | "typescript": "5.3.3",
47 | "ulidx": "^2.2.1",
48 | "zod": "^3.22.4"
49 | },
50 | "devDependencies": {
51 | "@types/better-sqlite3": "^7.6.9",
52 | "axios": "^1.6.7",
53 | "drizzle-kit": "^0.20.14",
54 | "knip": "^4.3.0",
55 | "node-html-parser": "^6.1.12",
56 | "playwright": "^1.41.2",
57 | "puppeteer": "^21.11.0",
58 | "rimraf": "^5.0.5",
59 | "tsx": "^4.7.0"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/rate-limit/signup.ts:
--------------------------------------------------------------------------------
1 | import puppeteer from 'puppeteer'
2 |
3 | async function main() {
4 | const browser = await puppeteer.launch({
5 | headless: false,
6 | devtools: true,
7 | })
8 | const page = await browser.newPage()
9 |
10 | for (let i = 0; i < 20; i++) {
11 | await page.goto('http://localhost:3000/signup', {
12 | waitUntil: 'networkidle0',
13 | })
14 |
15 | await page.type('input[name="email"]', `test${i}@example.com`)
16 | await page.waitForSelector('button[type=submit]')
17 |
18 | await page.click('button[type="submit"]') // Clicking the link will indirectly cause a navigation
19 | // await page.waitForNavigation({ waitUntil: 'networkidle0' })
20 | await page.waitForSelector('#verify-email-form', {
21 | visible: true,
22 | timeout: 0,
23 | })
24 |
25 | console.log(i)
26 | }
27 |
28 | await page.close()
29 | await browser.close()
30 | }
31 |
32 | /*
33 | import { chromium } from 'playwright'
34 |
35 | async function main() {
36 | const browser = await chromium.launch({ headless: false })
37 |
38 | const page = await browser.newPage()
39 | for (let i = 0; i < 20; i++) {
40 | await page.goto('http://localhost:3000/signup')
41 | await page.waitForLoadState('domcontentloaded')
42 |
43 | await page.fill('input[name="email"]', `test${i}@example.com`)
44 | await page.click('button[type="submit"]')
45 |
46 | await page.waitForURL('http://localhost:3000/verify-email')
47 | // await page.waitForSelector('#verify-email-form', {
48 | // state: 'visible',
49 | // timeout: 0,
50 | // })
51 |
52 | console.log(i)
53 | }
54 |
55 | await page.close()
56 | await browser.close()
57 | }
58 | */
59 |
60 | main()
61 |
62 | /*
63 | async function main() {
64 | const formData = new FormData()
65 | formData.append('email', 'a@a.com')
66 |
67 | const res = await fetch('/signup', {
68 | method: 'POST',
69 | body: formData,
70 | headers: {
71 | 'Next-Action': '948fdf27b221db98253b47aa8f8d1c589c93e063',
72 | },
73 | })
74 |
75 | const data = await res.text()
76 |
77 | console.log({ res, data })
78 | }
79 | */
80 |
--------------------------------------------------------------------------------
/src/app/auth/lucia.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Lucia, TimeSpan, type User, type Session } from 'lucia'
3 | import { cookies } from 'next/headers'
4 |
5 | import { db } from '@/app/db/index'
6 | import { userTable, sessionTable } from '@/app/db/drizzle.schema'
7 | import { DrizzleSQLiteAdapter } from '@lucia-auth/adapter-drizzle'
8 |
9 | const IS_DEV = process.env.NODE_ENV === 'development' ? 'DEV' : 'PROD'
10 |
11 | const adapter = new DrizzleSQLiteAdapter(db, sessionTable, userTable)
12 |
13 | export const lucia = new Lucia(adapter, {
14 | sessionCookie: {
15 | name: 'user_session',
16 | expires: false,
17 | attributes: {
18 | secure: !IS_DEV,
19 | },
20 | },
21 | sessionExpiresIn: new TimeSpan(1, 'm'), // 1 month
22 | getUserAttributes: (attributes) => {
23 | return {
24 | emailVerified: attributes.emailVerified,
25 | email: attributes.email,
26 | }
27 | },
28 | })
29 |
30 | const uncachedValidateRequest = async (): Promise<
31 | { user: User; session: Session } | { user: null; session: null }
32 | > => {
33 | const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null
34 | if (!sessionId) {
35 | return {
36 | user: null,
37 | session: null,
38 | }
39 | }
40 |
41 | const result = await lucia.validateSession(sessionId)
42 | // next.js throws when you attempt to set cookie when rendering page
43 | try {
44 | if (result.session && result.session.fresh) {
45 | const sessionCookie = lucia.createSessionCookie(result.session.id)
46 | cookies().set(
47 | sessionCookie.name,
48 | sessionCookie.value,
49 | sessionCookie.attributes
50 | )
51 | }
52 | if (!result.session) {
53 | const sessionCookie = lucia.createBlankSessionCookie()
54 | cookies().set(
55 | sessionCookie.name,
56 | sessionCookie.value,
57 | sessionCookie.attributes
58 | )
59 | }
60 | } catch {}
61 | return result
62 | }
63 |
64 | export const validateRequest = React.cache(uncachedValidateRequest)
65 |
66 | declare module 'lucia' {
67 | interface Register {
68 | Lucia: typeof lucia
69 | DatabaseUserAttributes: {
70 | email: string
71 | emailVerified: number
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/app/actions/signup.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { cookies } from 'next/headers'
4 | import { redirect } from 'next/navigation'
5 | import { eq } from 'drizzle-orm'
6 | import { parseWithZod } from '@conform-to/zod'
7 | import { TimeSpan } from 'oslo'
8 | import { ulid } from 'ulidx'
9 |
10 | import { lucia } from '@/app/auth/lucia'
11 | import { db } from '@/app/db/index'
12 | import { userTable } from '@/app/db/drizzle.schema'
13 | import { createSignupSchema } from '@/app/lib/zod.schema'
14 | import { generateEmailVerificationCode } from '@/app/lib/email-verification-code'
15 | import { VERIFIED_EMAIL_ALERT } from '@/app/lib/constants'
16 | import { signupLimiter } from '@/app/lib/rate-limit'
17 |
18 | export async function sendEmailVerificationCode(userId: string, email: string) {
19 | const code = await generateEmailVerificationCode(userId)
20 | console.log(`\n🤫 OTP for ${email} is ${code}\n`) // send an email to user with this OTP
21 | }
22 |
23 | export async function signup(prevState: unknown, formData: FormData) {
24 | // const {} = await signupLimiter.consume()
25 |
26 | const userId = ulid()
27 | const submission = await parseWithZod(formData, {
28 | schema: (control) =>
29 | // create a zod schema base on the control
30 | createSignupSchema(control, {
31 | isEmailUnique(email) {
32 | const user = db
33 | .select()
34 | .from(userTable)
35 | .where(eq(userTable.email, email))
36 | .all()
37 | return !user.length
38 | },
39 | }).transform(async (data, ctx) => {
40 | const user = await db
41 | .insert(userTable)
42 | .values({ id: userId, emailVerified: 0, ...data })
43 | .returning()
44 | .then((s) => s[0])
45 |
46 | return { ...data, ...user }
47 | }),
48 | async: true,
49 | })
50 |
51 | if (submission.status !== 'success') {
52 | return submission.reply()
53 | }
54 |
55 | try {
56 | sendEmailVerificationCode(userId, submission.value.email)
57 |
58 | cookies().set(VERIFIED_EMAIL_ALERT, 'true', {
59 | maxAge: new TimeSpan(1, 'm').seconds(), // 10 minutes = 60 * 60 * 1
60 | })
61 | } catch (err) {
62 | console.error(`Signup error while creating Lucia session:`)
63 | console.error(err)
64 | }
65 |
66 | return redirect('/verify-email')
67 | }
68 |
--------------------------------------------------------------------------------
/src/app/actions/verify-email.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { cookies } from 'next/headers'
4 | import { redirect } from 'next/navigation'
5 | import { eq } from 'drizzle-orm'
6 | import { parseWithZod } from '@conform-to/zod'
7 | import { z } from 'zod'
8 | import { isWithinExpirationDate, TimeSpan } from 'oslo'
9 |
10 | import { lucia } from '@/app/auth/lucia'
11 | import { db } from '@/app/db/index'
12 | import { emailVerificationCodeTable, userTable } from '@/app/db/drizzle.schema'
13 | import { verifyEmailSchema } from '@/app/lib/zod.schema'
14 | import { VERIFIED_EMAIL_ALERT } from '@/app/lib/constants'
15 |
16 | export async function verifyEmail(prevState: unknown, formData: FormData) {
17 | const submission = await parseWithZod(formData, {
18 | schema: verifyEmailSchema.transform(async (data, ctx) => {
19 | const { code } = data
20 |
21 | const databaseCode = await db
22 | .select()
23 | .from(emailVerificationCodeTable)
24 | .where(eq(emailVerificationCodeTable.code, code))
25 | .execute()
26 | .then((s) => s[0])
27 |
28 | if (!databaseCode) {
29 | ctx.addIssue({
30 | path: ['code'],
31 | code: z.ZodIssueCode.custom,
32 | message: 'Invalid OTP. Try again!',
33 | })
34 | return z.NEVER
35 | }
36 |
37 | if (
38 | databaseCode.expiresAt &&
39 | !isWithinExpirationDate(new Date(databaseCode.expiresAt))
40 | ) {
41 | ctx.addIssue({
42 | path: ['code'],
43 | code: z.ZodIssueCode.custom,
44 | message: 'Verification code expired',
45 | })
46 | return z.NEVER
47 | }
48 |
49 | await db
50 | .delete(emailVerificationCodeTable)
51 | .where(eq(emailVerificationCodeTable.id, databaseCode.id))
52 |
53 | return { ...data, ...databaseCode }
54 | }),
55 | async: true,
56 | })
57 |
58 | if (submission.status !== 'success') {
59 | return submission.reply()
60 | }
61 |
62 | const user = await db
63 | .select()
64 | .from(userTable)
65 | .where(eq(userTable.id, submission.value.userId))
66 | .execute()
67 | .then((s) => s[0])
68 |
69 | await lucia.invalidateUserSessions(user.id)
70 | await db
71 | .update(userTable)
72 | .set({ emailVerified: 1 })
73 | .where(eq(userTable.id, user.id))
74 |
75 | console.log(`\n😊 ${user.email} has been verified.\n`)
76 |
77 | const session = await lucia.createSession(user.id, {})
78 | const sessionCookie = lucia.createSessionCookie(session.id)
79 | cookies().set(sessionCookie)
80 |
81 | cookies().set(VERIFIED_EMAIL_ALERT, 'true', {
82 | maxAge: new TimeSpan(1, 'm').seconds(), // 10 minutes = 60 * 60 * 1
83 | })
84 |
85 | return redirect('/dashboard')
86 | }
87 |
--------------------------------------------------------------------------------
/src/app/middleware.ts:
--------------------------------------------------------------------------------
1 | /*
2 | import { NextRequest, NextResponse } from 'next/server'
3 |
4 | const getEmailIPkey = (email: string, ip: string) => `${email}_${ip}`
5 |
6 | export const config = {
7 | matcher: '/verify-email',
8 | }
9 |
10 | export default async function middleware(request: NextRequest) {
11 | if (request.method === 'POST') {
12 | // You could alternatively limit based on user ID or similar
13 | const ip = request.ip ?? '127.0.0.1'
14 | const emailIPkey = getEmailIPkey(request.body.email, ip)
15 |
16 | const [resUsernameAndIP, resSlowByIP] = await Promise.all([
17 | limiterConsecutiveFailsByEmailAndIP.get(emailIPkey),
18 | limiterSlowBruteByIP.get(ip),
19 | ])
20 |
21 | let retrySecs = 0
22 |
23 | // Check if IP or Username + IP is already blocked
24 | if (
25 | resSlowByIP !== null &&
26 | resSlowByIP.consumedPoints > maxWrongAttemptsByIPperDay
27 | ) {
28 | retrySecs = Math.round(resSlowByIP.msBeforeNext / 1000) || 1
29 | } else if (
30 | resUsernameAndIP !== null &&
31 | resUsernameAndIP.consumedPoints > maxConsecutiveFailsByEmailAndIP
32 | ) {
33 | retrySecs = Math.round(resUsernameAndIP.msBeforeNext / 1000) || 1
34 | }
35 |
36 | if (retrySecs > 0) {
37 | return NextResponse.json(
38 | { error: 'Too Many Requests' },
39 | { status: 429, headers: { 'Retry-After': String(retrySecs) } }
40 | )
41 | } else {
42 | const user = authorise(req.body.email, req.body.password) // should be implemented in your project
43 | if (!user.isLoggedIn) {
44 | // Consume 1 point from limiters on wrong attempt and block if limits reached
45 | try {
46 | const promises = [limiterSlowBruteByIP.consume(ip)]
47 | if (user.exists) {
48 | // Count failed attempts by Username + IP only for registered users
49 | promises.push(
50 | limiterConsecutiveFailsByEmailAndIP.consume(emailIPkey)
51 | )
52 | }
53 |
54 | await Promise.all(promises)
55 |
56 | return NextResponse.json({ error: 'Invalid email' }, { status: 400 })
57 | } catch (rlRejected) {
58 | if (rlRejected instanceof Error) {
59 | throw rlRejected
60 | } else {
61 | return NextResponse.json(
62 | { error: 'Too Many Requests' },
63 | {
64 | status: 429,
65 | headers: {
66 | 'Retry-After':
67 | String(Math.round(rlRejected.msBeforeNext / 1000)) || 1,
68 | },
69 | }
70 | )
71 | }
72 | }
73 | }
74 |
75 | if (user.isLoggedIn) {
76 | if (resUsernameAndIP !== null && resUsernameAndIP.consumedPoints > 0) {
77 | // Reset on successful authorisation
78 | await limiterConsecutiveFailsByEmailAndIP.delete(emailIPkey)
79 | }
80 | }
81 | }
82 | // Authorized
83 | return NextResponse.next()
84 | }
85 | }
86 | */
87 |
88 | import { NextRequest, NextResponse } from 'next/server'
89 | import { RateLimiterMemory } from 'rate-limiter-flexible'
90 |
91 | const opts = {
92 | points: 10,
93 | duration: 1, // Per second
94 | }
95 |
96 | const rateLimiter = new RateLimiterMemory(opts)
97 |
98 | export default async function middleware(request: NextRequest) {
99 | console.log(request.method)
100 | if (request.method === 'POST') {
101 | console.log(request.method)
102 | let res: any
103 | try {
104 | res = await rateLimiter.consume(2)
105 | } catch (error) {
106 | res = error
107 | }
108 |
109 | if (res._remainingPoints > 0) {
110 | return NextResponse.next()
111 | } else {
112 | return NextResponse.json(
113 | {
114 | error: 'Rate limit exceeded. Please try again later.',
115 | },
116 | {
117 | status: 429,
118 | }
119 | )
120 | }
121 | }
122 | }
123 |
124 | export const config = {
125 | matcher: ['/signup'],
126 | }
127 |
--------------------------------------------------------------------------------
/src/app/db/migrations/meta/0002_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "5",
3 | "dialect": "sqlite",
4 | "id": "7d501736-93b6-421b-ba10-ab7ea3afee10",
5 | "prevId": "0666f03e-2b03-4c74-bd75-a0bb7c293bf5",
6 | "tables": {
7 | "email_verification_code": {
8 | "name": "email_verification_code",
9 | "columns": {
10 | "id": {
11 | "name": "id",
12 | "type": "text",
13 | "primaryKey": true,
14 | "notNull": true,
15 | "autoincrement": false
16 | },
17 | "code": {
18 | "name": "code",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": false,
22 | "autoincrement": false
23 | },
24 | "user_id": {
25 | "name": "user_id",
26 | "type": "text",
27 | "primaryKey": false,
28 | "notNull": true,
29 | "autoincrement": false
30 | },
31 | "expires_at": {
32 | "name": "expires_at",
33 | "type": "integer",
34 | "primaryKey": false,
35 | "notNull": false,
36 | "autoincrement": false
37 | }
38 | },
39 | "indexes": {},
40 | "foreignKeys": {
41 | "email_verification_code_user_id_user_id_fk": {
42 | "name": "email_verification_code_user_id_user_id_fk",
43 | "tableFrom": "email_verification_code",
44 | "tableTo": "user",
45 | "columnsFrom": [
46 | "user_id"
47 | ],
48 | "columnsTo": [
49 | "id"
50 | ],
51 | "onDelete": "cascade",
52 | "onUpdate": "cascade"
53 | }
54 | },
55 | "compositePrimaryKeys": {},
56 | "uniqueConstraints": {}
57 | },
58 | "session": {
59 | "name": "session",
60 | "columns": {
61 | "id": {
62 | "name": "id",
63 | "type": "text",
64 | "primaryKey": true,
65 | "notNull": true,
66 | "autoincrement": false
67 | },
68 | "user_id": {
69 | "name": "user_id",
70 | "type": "text",
71 | "primaryKey": false,
72 | "notNull": true,
73 | "autoincrement": false
74 | },
75 | "expires_at": {
76 | "name": "expires_at",
77 | "type": "integer",
78 | "primaryKey": false,
79 | "notNull": true,
80 | "autoincrement": false
81 | }
82 | },
83 | "indexes": {},
84 | "foreignKeys": {
85 | "session_user_id_user_id_fk": {
86 | "name": "session_user_id_user_id_fk",
87 | "tableFrom": "session",
88 | "tableTo": "user",
89 | "columnsFrom": [
90 | "user_id"
91 | ],
92 | "columnsTo": [
93 | "id"
94 | ],
95 | "onDelete": "cascade",
96 | "onUpdate": "cascade"
97 | }
98 | },
99 | "compositePrimaryKeys": {},
100 | "uniqueConstraints": {}
101 | },
102 | "user": {
103 | "name": "user",
104 | "columns": {
105 | "id": {
106 | "name": "id",
107 | "type": "text",
108 | "primaryKey": true,
109 | "notNull": true,
110 | "autoincrement": false
111 | },
112 | "email": {
113 | "name": "email",
114 | "type": "text",
115 | "primaryKey": false,
116 | "notNull": true,
117 | "autoincrement": false
118 | },
119 | "email_verified": {
120 | "name": "email_verified",
121 | "type": "integer",
122 | "primaryKey": false,
123 | "notNull": true,
124 | "autoincrement": false
125 | }
126 | },
127 | "indexes": {
128 | "user_email_unique": {
129 | "name": "user_email_unique",
130 | "columns": [
131 | "email"
132 | ],
133 | "isUnique": true
134 | }
135 | },
136 | "foreignKeys": {},
137 | "compositePrimaryKeys": {},
138 | "uniqueConstraints": {}
139 | }
140 | },
141 | "enums": {},
142 | "_meta": {
143 | "schemas": {},
144 | "tables": {},
145 | "columns": {}
146 | }
147 | }
--------------------------------------------------------------------------------
/src/app/db/migrations/meta/0001_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "5",
3 | "dialect": "sqlite",
4 | "id": "0666f03e-2b03-4c74-bd75-a0bb7c293bf5",
5 | "prevId": "10f4fa4d-e0a6-48d7-a869-2b1bd7618732",
6 | "tables": {
7 | "email_verification_code": {
8 | "name": "email_verification_code",
9 | "columns": {
10 | "id": {
11 | "name": "id",
12 | "type": "text",
13 | "primaryKey": true,
14 | "notNull": true,
15 | "autoincrement": false
16 | },
17 | "code": {
18 | "name": "code",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": false,
22 | "autoincrement": false
23 | },
24 | "user_id": {
25 | "name": "user_id",
26 | "type": "text",
27 | "primaryKey": false,
28 | "notNull": true,
29 | "autoincrement": false
30 | },
31 | "expires_at": {
32 | "name": "expires_at",
33 | "type": "integer",
34 | "primaryKey": false,
35 | "notNull": false,
36 | "autoincrement": false
37 | }
38 | },
39 | "indexes": {},
40 | "foreignKeys": {
41 | "email_verification_code_user_id_user_id_fk": {
42 | "name": "email_verification_code_user_id_user_id_fk",
43 | "tableFrom": "email_verification_code",
44 | "tableTo": "user",
45 | "columnsFrom": [
46 | "user_id"
47 | ],
48 | "columnsTo": [
49 | "id"
50 | ],
51 | "onDelete": "cascade",
52 | "onUpdate": "cascade"
53 | }
54 | },
55 | "compositePrimaryKeys": {},
56 | "uniqueConstraints": {}
57 | },
58 | "session": {
59 | "name": "session",
60 | "columns": {
61 | "id": {
62 | "name": "id",
63 | "type": "text",
64 | "primaryKey": true,
65 | "notNull": true,
66 | "autoincrement": false
67 | },
68 | "user_id": {
69 | "name": "user_id",
70 | "type": "text",
71 | "primaryKey": false,
72 | "notNull": true,
73 | "autoincrement": false
74 | },
75 | "expires_at": {
76 | "name": "expires_at",
77 | "type": "integer",
78 | "primaryKey": false,
79 | "notNull": true,
80 | "autoincrement": false
81 | }
82 | },
83 | "indexes": {
84 | "session_userId_idx": {
85 | "name": "session_userId_idx",
86 | "columns": [
87 | "user_id"
88 | ],
89 | "isUnique": true
90 | }
91 | },
92 | "foreignKeys": {
93 | "session_user_id_user_id_fk": {
94 | "name": "session_user_id_user_id_fk",
95 | "tableFrom": "session",
96 | "tableTo": "user",
97 | "columnsFrom": [
98 | "user_id"
99 | ],
100 | "columnsTo": [
101 | "id"
102 | ],
103 | "onDelete": "cascade",
104 | "onUpdate": "cascade"
105 | }
106 | },
107 | "compositePrimaryKeys": {},
108 | "uniqueConstraints": {}
109 | },
110 | "user": {
111 | "name": "user",
112 | "columns": {
113 | "id": {
114 | "name": "id",
115 | "type": "text",
116 | "primaryKey": true,
117 | "notNull": true,
118 | "autoincrement": false
119 | },
120 | "email": {
121 | "name": "email",
122 | "type": "text",
123 | "primaryKey": false,
124 | "notNull": true,
125 | "autoincrement": false
126 | },
127 | "email_verified": {
128 | "name": "email_verified",
129 | "type": "integer",
130 | "primaryKey": false,
131 | "notNull": true,
132 | "autoincrement": false
133 | }
134 | },
135 | "indexes": {
136 | "user_email_unique": {
137 | "name": "user_email_unique",
138 | "columns": [
139 | "email"
140 | ],
141 | "isUnique": true
142 | }
143 | },
144 | "foreignKeys": {},
145 | "compositePrimaryKeys": {},
146 | "uniqueConstraints": {}
147 | }
148 | },
149 | "enums": {},
150 | "_meta": {
151 | "schemas": {},
152 | "tables": {},
153 | "columns": {}
154 | }
155 | }
--------------------------------------------------------------------------------
/src/app/db/migrations/meta/0000_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "5",
3 | "dialect": "sqlite",
4 | "id": "10f4fa4d-e0a6-48d7-a869-2b1bd7618732",
5 | "prevId": "00000000-0000-0000-0000-000000000000",
6 | "tables": {
7 | "email_verification_code": {
8 | "name": "email_verification_code",
9 | "columns": {
10 | "id": {
11 | "name": "id",
12 | "type": "text",
13 | "primaryKey": true,
14 | "notNull": true,
15 | "autoincrement": false
16 | },
17 | "code": {
18 | "name": "code",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": false,
22 | "autoincrement": false
23 | },
24 | "user_id": {
25 | "name": "user_id",
26 | "type": "text",
27 | "primaryKey": false,
28 | "notNull": true,
29 | "autoincrement": false
30 | },
31 | "expires_at": {
32 | "name": "expires_at",
33 | "type": "integer",
34 | "primaryKey": false,
35 | "notNull": false,
36 | "autoincrement": false
37 | }
38 | },
39 | "indexes": {},
40 | "foreignKeys": {
41 | "email_verification_code_user_id_user_id_fk": {
42 | "name": "email_verification_code_user_id_user_id_fk",
43 | "tableFrom": "email_verification_code",
44 | "tableTo": "user",
45 | "columnsFrom": [
46 | "user_id"
47 | ],
48 | "columnsTo": [
49 | "id"
50 | ],
51 | "onDelete": "no action",
52 | "onUpdate": "no action"
53 | }
54 | },
55 | "compositePrimaryKeys": {},
56 | "uniqueConstraints": {}
57 | },
58 | "session": {
59 | "name": "session",
60 | "columns": {
61 | "id": {
62 | "name": "id",
63 | "type": "text",
64 | "primaryKey": true,
65 | "notNull": true,
66 | "autoincrement": false
67 | },
68 | "user_id": {
69 | "name": "user_id",
70 | "type": "text",
71 | "primaryKey": false,
72 | "notNull": true,
73 | "autoincrement": false
74 | },
75 | "expires_at": {
76 | "name": "expires_at",
77 | "type": "integer",
78 | "primaryKey": false,
79 | "notNull": true,
80 | "autoincrement": false
81 | }
82 | },
83 | "indexes": {
84 | "session_userId_idx": {
85 | "name": "session_userId_idx",
86 | "columns": [
87 | "user_id"
88 | ],
89 | "isUnique": true
90 | }
91 | },
92 | "foreignKeys": {
93 | "session_user_id_user_id_fk": {
94 | "name": "session_user_id_user_id_fk",
95 | "tableFrom": "session",
96 | "tableTo": "user",
97 | "columnsFrom": [
98 | "user_id"
99 | ],
100 | "columnsTo": [
101 | "id"
102 | ],
103 | "onDelete": "cascade",
104 | "onUpdate": "cascade"
105 | }
106 | },
107 | "compositePrimaryKeys": {},
108 | "uniqueConstraints": {}
109 | },
110 | "user": {
111 | "name": "user",
112 | "columns": {
113 | "id": {
114 | "name": "id",
115 | "type": "text",
116 | "primaryKey": true,
117 | "notNull": true,
118 | "autoincrement": false
119 | },
120 | "email": {
121 | "name": "email",
122 | "type": "text",
123 | "primaryKey": false,
124 | "notNull": true,
125 | "autoincrement": false
126 | },
127 | "email_verified": {
128 | "name": "email_verified",
129 | "type": "integer",
130 | "primaryKey": false,
131 | "notNull": true,
132 | "autoincrement": false
133 | }
134 | },
135 | "indexes": {
136 | "user_email_unique": {
137 | "name": "user_email_unique",
138 | "columns": [
139 | "email"
140 | ],
141 | "isUnique": true
142 | }
143 | },
144 | "foreignKeys": {},
145 | "compositePrimaryKeys": {},
146 | "uniqueConstraints": {}
147 | }
148 | },
149 | "enums": {},
150 | "_meta": {
151 | "schemas": {},
152 | "tables": {},
153 | "columns": {}
154 | }
155 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # next-14-lucia-v3-sqlite-drizzle-conform-zod-email-verification-otp-server-actions
2 |
3 | # Store Environment Variables
4 |
5 | 1. Copy `.env.example` file to `.env` file
6 |
7 | ```bash
8 | cp .env.example .env # duplicate .env.example & name it .env
9 | ```
10 |
11 | 2. Change `DATABASE_PATH` to the filename of your choice
12 |
13 | ```bash
14 | DATABASE_PATH=sqlite.db
15 | ```
16 |
17 | ## Install the dependencies
18 |
19 | ```bash
20 | pnpm install
21 | ```
22 |
23 | ## Start the server
24 |
25 | ```bash
26 | pnpm start
27 | ```
28 |
29 | ## Push schema changes to database
30 |
31 | ```bash
32 | pnpm db:push
33 | ```
34 |
35 | ## Generate Migrations
36 |
37 | ```bash
38 | pnpm db:generate
39 | ```
40 |
41 | ## Install Redis for the rate-limiting on OTP function
42 |
43 | https://redis.io/docs/install/install-redis/
44 |
45 | > Note: You can disable it by removing Redis Specific Code & `middleware.ts`
46 |
47 | ## Must-watch videos on Redis & Rate Limiting to understand the basic concepts
48 |
49 | 1. https://www.youtube.com/watch?v=MR_BN1Ricjw
50 | 2. https://www.youtube.com/watch?v=qUydEBZmGvU
51 | 3. https://www.youtube.com/watch?v=a4yX7RUgTxI
52 |
53 | ## When to rate-limit API routes?
54 |
55 | 1. Sign Up - What if someone brute-forces 100s of requests to fill our database with UGC spam?
56 | 2. Login - What if someone tries every possible combination of email to get the right one?
57 | 3. OTP - What if someone tries to gain access by trying various different OTP combinations to find the best one?
58 |
59 | Since it is a simple example, I will go with IP-based blocking for 1 day using browser finderprinting library.
60 |
61 | ## How to test rate-limit API using Postman/Hoppscotch?
62 |
63 | Go to https://hoppscotch.io/ (or https://www.postman.com/)
64 |
65 | Select `POST` & enter `http://localhost:3000/signup` in URL.
66 |
67 | Go to `Headers` & add `Next-Action` to `948fdf27b221db98253b47aa8f8d1c589c93e063` as well as `Origin` to `localhost:3000` & click on `Send` 10 times to see the error.
68 |
69 | ## Tried Rate Limits on Next.js 14 Server Actions using 2 methods:
70 |
71 | 1. Puppeteer/Playwright
72 |
73 | The scripts are in `rate-limit` folder. Both for some reason send 2 requests & it always gives `Email is not unique` error since 1st request adds it to the database. Its a nasty bug in either Puppeteer/Playwright or my scripts. But definitely not in my email signup process. It works well manually.
74 |
75 | 2. Fetch request using Web API
76 |
77 | Tried it with `Next-Action` as a header but that doesn't work either.
78 |
79 | Most of the issues are with Next.js 14 Server Actions that I stumbled upon only while automating rate limits.
80 |
81 | I don't think its ready for prime time yet so I'm gonna use API routes on my main project.
82 |
83 | If you want to use this repo, check out 2 commits behind this as that works without any errors.
84 |
85 | ### Basic Rate Limit Example that works
86 |
87 | #### src/middleware.ts
88 |
89 | ```ts
90 | import { NextRequest, NextResponse } from 'next/server'
91 | import { RateLimiterMemory } from 'rate-limiter-flexible'
92 |
93 | const opts = {
94 | points: 10,
95 | duration: 5, // Per second
96 | }
97 |
98 | const rateLimiter = new RateLimiterMemory(opts)
99 |
100 | export default async function middleware(request: NextRequest) {
101 | if (request.method === 'POST') {
102 | let res: any
103 | try {
104 | res = await rateLimiter.consume(2)
105 | console.log({ res })
106 | } catch (error) {
107 | res = error
108 | }
109 |
110 | if (res._remainingPoints > 0) {
111 | return NextResponse.next()
112 | } else {
113 | return NextResponse.json(
114 | {
115 | error: 'Rate limit exceeded. Please try again later.',
116 | },
117 | {
118 | status: 429,
119 | },
120 | )
121 | }
122 | }
123 | }
124 |
125 | export const config = {
126 | matcher: ['/api/login'],
127 | }
128 | ```
129 |
130 | #### rate-limit/login.ts
131 |
132 | ```ts
133 | const LOCALHOST_URL = 'http://localhost:3000'
134 |
135 | async function main() {
136 | for (let i = 0; i < 20; i++) {
137 | const url = `${LOCALHOST_URL}/api/login`
138 | const email = `test${i}@example.com`
139 | const body = JSON.stringify({ email })
140 |
141 | const response = await fetch(url, { method: 'POST', body })
142 | const data = await response.json()
143 |
144 | console.log({ data })
145 | }
146 | }
147 |
148 | main()
149 | ```
150 |
151 | Run Next.js dev server using `pnpm dev` in one terminal & brute-force the login api in another using `pnpm ratelimit:login` & it'll show this output in ratelimit terminal:
152 |
153 | ```json
154 | { data: { success: false } }
155 | { data: { success: false } }
156 | { data: { success: false } }
157 | { data: { success: false } }
158 | { data: { success: false } }
159 | { data: { success: false } }
160 | { data: { success: false } }
161 | { data: { success: false } }
162 | { data: { success: false } }
163 | { data: { error: 'Rate limit exceeded. Please try again later.' } }
164 | { data: { error: 'Rate limit exceeded. Please try again later.' } }
165 | { data: { error: 'Rate limit exceeded. Please try again later.' } }
166 | { data: { error: 'Rate limit exceeded. Please try again later.' } }
167 | { data: { error: 'Rate limit exceeded. Please try again later.' } }
168 | { data: { error: 'Rate limit exceeded. Please try again later.' } }
169 | { data: { error: 'Rate limit exceeded. Please try again later.' } }
170 | { data: { error: 'Rate limit exceeded. Please try again later.' } }
171 | { data: { error: 'Rate limit exceeded. Please try again later.' } }
172 | { data: { error: 'Rate limit exceeded. Please try again later.' } }
173 | { data: { error: 'Rate limit exceeded. Please try again later.' } }
174 | ```
175 |
176 | And this output in dev server terminal:
177 |
178 | ```json
179 | {
180 | res: RateLimiterRes {
181 | _remainingPoints: 9,
182 | _msBeforeNext: 5000,
183 | _consumedPoints: 1,
184 | _isFirstInDuration: true
185 | }
186 | }
187 | [19:49:26.652] INFO (9732): 🏁 POST /api/login/route
188 | {
189 | res: RateLimiterRes {
190 | _remainingPoints: 8,
191 | _msBeforeNext: 4940,
192 | _consumedPoints: 2,
193 | _isFirstInDuration: false
194 | }
195 | }
196 | [19:49:26.706] INFO (9732): 🏁 POST /api/login/route
197 | {
198 | res: RateLimiterRes {
199 | _remainingPoints: 7,
200 | _msBeforeNext: 4898,
201 | _consumedPoints: 3,
202 | _isFirstInDuration: false
203 | }
204 | }
205 | [19:49:26.750] INFO (9732): 🏁 POST /api/login/route
206 | {
207 | res: RateLimiterRes {
208 | _remainingPoints: 6,
209 | _msBeforeNext: 4855,
210 | _consumedPoints: 4,
211 | _isFirstInDuration: false
212 | }
213 | }
214 | [19:49:26.793] INFO (9732): 🏁 POST /api/login/route
215 | {
216 | res: RateLimiterRes {
217 | _remainingPoints: 5,
218 | _msBeforeNext: 4810,
219 | _consumedPoints: 5,
220 | _isFirstInDuration: false
221 | }
222 | }
223 | [19:49:26.839] INFO (9732): 🏁 POST /api/login/route
224 | {
225 | res: RateLimiterRes {
226 | _remainingPoints: 4,
227 | _msBeforeNext: 4765,
228 | _consumedPoints: 6,
229 | _isFirstInDuration: false
230 | }
231 | }
232 | [19:49:26.882] INFO (9732): 🏁 POST /api/login/route
233 | {
234 | res: RateLimiterRes {
235 | _remainingPoints: 3,
236 | _msBeforeNext: 4721,
237 | _consumedPoints: 7,
238 | _isFirstInDuration: false
239 | }
240 | }
241 | [19:49:26.931] INFO (9732): 🏁 POST /api/login/route
242 | {
243 | res: RateLimiterRes {
244 | _remainingPoints: 2,
245 | _msBeforeNext: 4672,
246 | _consumedPoints: 8,
247 | _isFirstInDuration: false
248 | }
249 | }
250 | [19:49:26.977] INFO (9732): 🏁 POST /api/login/route
251 | {
252 | res: RateLimiterRes {
253 | _remainingPoints: 1,
254 | _msBeforeNext: 4625,
255 | _consumedPoints: 9,
256 | _isFirstInDuration: false
257 | }
258 | }
259 | [19:49:27.022] INFO (9732): 🏁 POST /api/login/route
260 | {
261 | res: RateLimiterRes {
262 | _remainingPoints: 0,
263 | _msBeforeNext: 4583,
264 | _consumedPoints: 10,
265 | _isFirstInDuration: false
266 | }
267 | }
268 | ```
269 |
270 | It only works with API routes. Idk how to make it work for Server Actions. I tried Puppeteer/Playwright but for some reason, it calls login api twice & halts the process because of not unique email.
271 |
--------------------------------------------------------------------------------