├── rate-limit ├── verify-email.ts ├── utils.ts ├── login.ts └── signup.ts ├── .env.example ├── .eslintrc.json ├── src └── app │ ├── lib │ ├── constants.ts │ ├── email-verification-code.ts │ ├── rate-limit.ts │ └── zod.schema.ts │ ├── db │ ├── migrations │ │ ├── 0002_talented_rumiko_fujikawa.sql │ │ ├── meta │ │ │ ├── _journal.json │ │ │ ├── 0002_snapshot.json │ │ │ ├── 0001_snapshot.json │ │ │ └── 0000_snapshot.json │ │ ├── 0001_bitter_pandemic.sql │ │ └── 0000_blushing_alex_wilder.sql │ ├── index.ts │ └── drizzle.schema.ts │ ├── styles │ ├── index.css │ └── base.css │ ├── favicon.ico │ ├── signup │ └── page.tsx │ ├── resend-otp │ └── page.tsx │ ├── components │ ├── toast.tsx │ ├── signup.tsx │ ├── resend-otp.tsx │ ├── login.tsx │ └── verify-email.tsx │ ├── page.tsx │ ├── login │ └── page.tsx │ ├── verify-email │ └── page.tsx │ ├── layout.tsx │ ├── dashboard │ └── page.tsx │ ├── actions │ ├── login.ts │ ├── signup.ts │ └── verify-email.ts │ ├── auth │ └── lucia.ts │ └── middleware.ts ├── postcss.config.js ├── sqlite.db ├── env.d.ts ├── next.config.js ├── drizzle.config.ts ├── .gitignore ├── tailwind.config.js ├── public ├── vercel.svg └── next.svg ├── seed ├── delete.ts └── insert.ts ├── tsconfig.json ├── package.json └── README.md /rate-limit/verify-email.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_PATH=xxx 2 | 3 | REDIS_URL=xxx 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /src/app/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const VERIFIED_EMAIL_ALERT = 'VERIFIED_EMAIL_ALERT' 2 | -------------------------------------------------------------------------------- /src/app/db/migrations/0002_talented_rumiko_fujikawa.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS `session_userId_idx`; -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /sqlite.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deadcoder0904/next-14-lucia-v3-sqlite-drizzle-conform-zod-email-verification-otp-server-actions/HEAD/sqlite.db -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | DATABASE_PATH: string 5 | } 6 | } 7 | } 8 | export {} 9 | -------------------------------------------------------------------------------- /src/app/styles/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | 3 | @import './base.css'; 4 | 5 | @import 'tailwindcss/components'; 6 | @import 'tailwindcss/utilities'; 7 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deadcoder0904/next-14-lucia-v3-sqlite-drizzle-conform-zod-email-verification-otp-server-actions/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | serverActions: { 5 | allowedOrigins: ['hoppscotch.io', 'localhost:3000'], 6 | allowedForwardedHosts: ['hoppscotch.io', 'localhost:3000'], 7 | }, 8 | }, 9 | } 10 | 11 | module.exports = nextConfig 12 | -------------------------------------------------------------------------------- /src/app/db/index.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/better-sqlite3' 2 | import sqlite from 'better-sqlite3' 3 | 4 | const client = sqlite(process.env.DATABASE_PATH) 5 | client.pragma('journal_mode = WAL') // see https://github.com/WiseLibs/better-sqlite3/blob/master/docs/performance.md 6 | 7 | export const db = drizzle(client) 8 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'drizzle-kit' 2 | import 'dotenv/config' 3 | 4 | export default { 5 | schema: './src/app/db/drizzle.schema.ts', 6 | out: './src/app/db/migrations', 7 | driver: 'better-sqlite', 8 | dbCredentials: { 9 | url: process.env.DATABASE_PATH, 10 | }, 11 | verbose: true, 12 | } satisfies Config 13 | -------------------------------------------------------------------------------- /src/app/styles/base.css: -------------------------------------------------------------------------------- 1 | html { 2 | scroll-behavior: smooth; 3 | } 4 | 5 | form { 6 | @apply mt-2; 7 | } 8 | 9 | input[type='email'] { 10 | @apply text-black px-2 w-64; 11 | } 12 | 13 | .anchor, 14 | button[type='submit'] { 15 | @apply text-sky-300 underline; 16 | } 17 | 18 | .resend-otp { 19 | @apply text-red-400 underline; 20 | } 21 | 22 | .anchor-dark { 23 | @apply text-gray-300 underline; 24 | } 25 | -------------------------------------------------------------------------------- /src/app/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation' 2 | 3 | import { SignupForm } from '@/app/components/signup' 4 | import { validateRequest } from '@/app/auth/lucia' 5 | 6 | export default async function SignupPage() { 7 | const { user } = await validateRequest() 8 | const userExists = user && user.emailVerified 9 | if (userExists) return redirect('/dashboard') 10 | 11 | return ( 12 | <> 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/app/resend-otp/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation' 2 | 3 | import { ResendOTPForm } from '@/app/components/resend-otp' 4 | import { validateRequest } from '@/app/auth/lucia' 5 | 6 | export default async function ResendOTPPage() { 7 | const { user } = await validateRequest() 8 | const userExists = user && user.emailVerified 9 | if (userExists) return redirect('/dashboard') 10 | 11 | return ( 12 | <> 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /src/app/components/toast.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React from 'react' 4 | import { useCookies } from 'next-client-cookies' 5 | 6 | import { VERIFIED_EMAIL_ALERT } from '@/app/lib/constants' 7 | 8 | export function Toast({ message }: { message: string }) { 9 | const cookies = useCookies() 10 | 11 | React.useEffect(() => { 12 | const toast = cookies.get(VERIFIED_EMAIL_ALERT) 13 | if (toast) { 14 | alert(message) 15 | cookies.remove(VERIFIED_EMAIL_ALERT) 16 | } 17 | }, [cookies, message]) 18 | 19 | return null 20 | } 21 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 5 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 7 | ], 8 | theme: { 9 | extend: { 10 | backgroundImage: { 11 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 12 | 'gradient-conic': 13 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } 19 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { redirect } from 'next/navigation' 3 | 4 | import { validateRequest } from '@/app/auth/lucia' 5 | 6 | export default async function Home() { 7 | const { user } = await validateRequest() 8 | const userExists = user && user.emailVerified 9 | if (userExists) return redirect('/dashboard') 10 | 11 | return ( 12 |
13 | 14 | signup 15 | 16 | 17 | login 18 | 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation' 2 | import { cookies } from 'next/headers' 3 | 4 | import { LoginForm } from '@/app/components/login' 5 | import { validateRequest } from '@/app/auth/lucia' 6 | import { VERIFIED_EMAIL_ALERT } from '@/app/lib/constants' 7 | 8 | export default async function Login() { 9 | // const cookieAlert = cookies().get(VERIFIED_EMAIL_ALERT) 10 | const { user } = await validateRequest() 11 | const userExists = user && user.emailVerified 12 | if (userExists) return redirect('/dashboard') 13 | 14 | return ( 15 | <> 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/app/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1706449547422, 9 | "tag": "0000_blushing_alex_wilder", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "5", 15 | "when": 1706518448804, 16 | "tag": "0001_bitter_pandemic", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "5", 22 | "when": 1706531812894, 23 | "tag": "0002_talented_rumiko_fujikawa", 24 | "breakpoints": true 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /seed/delete.ts: -------------------------------------------------------------------------------- 1 | import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' 2 | 3 | import { db } from '@/app/db/index' 4 | import { sessionTable, userTable } from '@/app/db/drizzle.schema' 5 | 6 | const cleanupDatabase = (db: BetterSQLite3Database>) => { 7 | try { 8 | const users = db.delete(userTable).run() 9 | const sessions = db.delete(sessionTable).run() 10 | console.log({ users, sessions }) 11 | } catch (err) { 12 | console.error('Something went wrong...') 13 | console.error(err) 14 | } 15 | } 16 | 17 | const main = () => { 18 | console.log('🧨 Started deleting the database...\n') 19 | cleanupDatabase(db) 20 | console.log('\n🧨 Done deleting the database successfully...\n') 21 | } 22 | 23 | main() 24 | -------------------------------------------------------------------------------- /rate-limit/utils.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'node-html-parser' 2 | 3 | export const LOCALHOST_URL = 'http://localhost:3000' 4 | 5 | export async function getActionParams(): Promise<{ 6 | actionNo: string 7 | actionKey: string 8 | }> { 9 | const res = await fetch(`${LOCALHOST_URL}/signup`) 10 | const html = await res.text() 11 | const root = parse(html) 12 | 13 | const actionNo = root 14 | .querySelector('input[name="$ACTION_1:0"]') 15 | ?.getAttribute('value') 16 | ?.toString() as string 17 | const actionKey = root 18 | .querySelector('input[name="$ACTION_KEY"]') 19 | ?.getAttribute('value') 20 | ?.toString() as string 21 | 22 | const no = JSON.parse(actionNo) 23 | 24 | return { 25 | actionNo: no.id, 26 | actionKey, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/verify-email/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation' 2 | 3 | import { VerifyEmailForm } from '@/app/components/verify-email' 4 | import { validateRequest } from '@/app/auth/lucia' 5 | 6 | export default async function VerifyEmailPage() { 7 | const { user } = await validateRequest() 8 | const userExists = user && user.emailVerified 9 | if (userExists) return redirect('/dashboard') 10 | 11 | // if (await downloadLimiter.hasExceededLimit(userKey, fallbackKey)) { 12 | // return errorResponse( 13 | // 429, 14 | // `We've noticed an unusual amount of downloading from your account. Contact support@civitai.com or come back later.` 15 | // ); 16 | // } 17 | 18 | return ( 19 | <> 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/app/lib/email-verification-code.ts: -------------------------------------------------------------------------------- 1 | import { eq } from 'drizzle-orm' 2 | import { TimeSpan, createDate } from 'oslo' 3 | import { generateRandomString, alphabet } from 'oslo/crypto' 4 | 5 | import { db } from '@/app/db/index' 6 | import { emailVerificationCodeTable } from '@/app/db/drizzle.schema' 7 | 8 | export async function generateEmailVerificationCode( 9 | userId: string 10 | ): Promise { 11 | await db 12 | .delete(emailVerificationCodeTable) 13 | .where(eq(emailVerificationCodeTable.userId, userId)) 14 | 15 | const code = generateRandomString(6, alphabet('0-9', 'A-Z')) 16 | 17 | await db.insert(emailVerificationCodeTable).values({ 18 | code, 19 | expiresAt: createDate(new TimeSpan(1, 'm')).getTime(), // 5 minutes 20 | userId, 21 | }) 22 | 23 | return code 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"], 24 | "rate-limit/*": ["./rate-limit/*"] 25 | } 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from 'next/font/google' 2 | import clsx from 'clsx' 3 | import { CookiesProvider } from 'next-client-cookies/server' 4 | 5 | import '@/app/styles/index.css' 6 | 7 | const inter = Inter({ subsets: ['latin'] }) 8 | 9 | export const metadata = { 10 | title: 'magic link using lucia auth', 11 | description: 12 | 'a sample demo of magic link using lucia auth with drizzle & sqlite', 13 | } 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: { 18 | children: React.ReactNode 19 | }) { 20 | return ( 21 | 22 | 28 | {children} 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/app/db/migrations/0001_bitter_pandemic.sql: -------------------------------------------------------------------------------- 1 | /* 2 | SQLite does not support "Dropping foreign key" out of the box, we do not generate automatic migration for that, so it has to be done manually 3 | Please refer to: https://www.techonthenet.com/sqlite/tables/alter_table.php 4 | https://www.sqlite.org/lang_altertable.html 5 | 6 | Due to that we don't generate migration automatically and it has to be done manually 7 | */--> statement-breakpoint 8 | /* 9 | SQLite does not support "Creating foreign key on existing column" out of the box, we do not generate automatic migration for that, so it has to be done manually 10 | Please refer to: https://www.techonthenet.com/sqlite/tables/alter_table.php 11 | https://www.sqlite.org/lang_altertable.html 12 | 13 | Due to that we don't generate migration automatically and it has to be done manually 14 | */ -------------------------------------------------------------------------------- /src/app/db/migrations/0000_blushing_alex_wilder.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `email_verification_code` ( 2 | `id` text PRIMARY KEY NOT NULL, 3 | `code` text, 4 | `user_id` text NOT NULL, 5 | `expires_at` integer, 6 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action 7 | ); 8 | --> statement-breakpoint 9 | CREATE TABLE `session` ( 10 | `id` text PRIMARY KEY NOT NULL, 11 | `user_id` text NOT NULL, 12 | `expires_at` integer NOT NULL, 13 | FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE cascade ON DELETE cascade 14 | ); 15 | --> statement-breakpoint 16 | CREATE TABLE `user` ( 17 | `id` text PRIMARY KEY NOT NULL, 18 | `email` text NOT NULL, 19 | `email_verified` integer NOT NULL 20 | ); 21 | --> statement-breakpoint 22 | CREATE UNIQUE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint 23 | CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`); -------------------------------------------------------------------------------- /seed/insert.ts: -------------------------------------------------------------------------------- 1 | import { generateId } from 'lucia' 2 | import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' 3 | 4 | import { db } from '@/app/db/index' 5 | import { userTable } from '@/app/db/drizzle.schema' 6 | 7 | const seedUsers = (db: BetterSQLite3Database>) => { 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 |
32 | 37 |
{fields.email.errors}
38 | 39 |
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 |
32 | 37 |
{fields.email.errors}
38 | 41 |
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 |
20 | 21 |
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 |
33 | 38 |
{fields.email.errors}
39 | 42 |
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 |
30 | 37 |
{fields.code.errors}
38 | 39 |
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 | --------------------------------------------------------------------------------