├── .prettierrc ├── src ├── app │ ├── favicon.ico │ ├── api │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ ├── loading.tsx │ ├── (main) │ │ ├── layout.tsx │ │ ├── admin │ │ │ ├── loading.tsx │ │ │ ├── actions.ts │ │ │ ├── page.tsx │ │ │ └── delete-application.tsx │ │ ├── email-verified │ │ │ └── page.tsx │ │ ├── profile │ │ │ ├── logout-everywhere-button.tsx │ │ │ ├── page.tsx │ │ │ ├── loading.tsx │ │ │ ├── email-form.tsx │ │ │ ├── password-form.tsx │ │ │ └── profile-details-form.tsx │ │ ├── verify-email │ │ │ ├── page.tsx │ │ │ └── resend-verification-button.tsx │ │ ├── navbar.tsx │ │ └── dashboard │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ ├── (auth) │ │ ├── sign-in │ │ │ ├── page.tsx │ │ │ └── sign-in-form.tsx │ │ ├── sign-up │ │ │ ├── page.tsx │ │ │ └── sign-up-form.tsx │ │ ├── layout.tsx │ │ ├── forgot-password │ │ │ ├── page.tsx │ │ │ └── forgot-password-form.tsx │ │ └── reset-password │ │ │ ├── page.tsx │ │ │ └── reset-password-form.tsx │ ├── forbidden.tsx │ ├── unauthorized.tsx │ ├── layout.tsx │ ├── page.tsx │ └── globals.css ├── assets │ ├── better_auth_logo.png │ └── coding_in_flow_logo.jpg ├── lib │ ├── utils.ts │ ├── get-session.ts │ ├── validation.ts │ ├── auth-client.ts │ ├── prisma.ts │ ├── email.ts │ └── auth.ts └── components │ ├── ui │ ├── skeleton.tsx │ ├── sonner.tsx │ ├── label.tsx │ ├── input.tsx │ ├── avatar.tsx │ ├── checkbox.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── form.tsx │ └── dropdown-menu.tsx │ ├── loading-button.tsx │ ├── user-avatar.tsx │ ├── icons │ ├── GitHubIcon.tsx │ └── GoogleIcon.tsx │ ├── password-input.tsx │ ├── mode-toggle.tsx │ └── user-dropdown.tsx ├── postcss.config.mjs ├── components.json ├── next.config.ts ├── README.md ├── .gitignore ├── tsconfig.json ├── eslint.config.mjs ├── package.json └── prisma └── schema.prisma /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/better-auth-tutorial/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /src/assets/better_auth_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/better-auth-tutorial/HEAD/src/assets/better_auth_logo.png -------------------------------------------------------------------------------- /src/assets/coding_in_flow_logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/better-auth-tutorial/HEAD/src/assets/coding_in_flow_logo.jpg -------------------------------------------------------------------------------- /src/app/api/auth/[...all]/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/lib/auth"; 2 | import { toNextJsHandler } from "better-auth/next-js"; 3 | 4 | export const { POST, GET } = toNextJsHandler(auth); 5 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from "lucide-react"; 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/get-session.ts: -------------------------------------------------------------------------------- 1 | import { headers } from "next/headers"; 2 | import { cache } from "react"; 3 | import { auth } from "./auth"; 4 | 5 | export const getServerSession = cache(async () => { 6 | console.log("getServerSession"); 7 | return await auth.api.getSession({ headers: await headers() }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "./navbar"; 2 | 3 | export default async function MainLayout({ 4 | children, 5 | }: Readonly<{ children: React.ReactNode }>) { 6 | return ( 7 |
8 | 9 | {children} 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/validation.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | 3 | export const passwordSchema = z 4 | .string() 5 | .min(1, { message: "Password is required" }) 6 | .min(8, { message: "Password must be at least 8 characters" }) 7 | .regex(/[^A-Za-z0-9]/, { 8 | message: "Password must contain at least one special character", 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /src/lib/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { inferAdditionalFields } from "better-auth/client/plugins"; 2 | import { nextCookies } from "better-auth/next-js"; 3 | import { createAuthClient } from "better-auth/react"; 4 | import { auth } from "./auth"; 5 | 6 | export const authClient = createAuthClient({ 7 | plugins: [inferAdditionalFields(), nextCookies()], 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { SignInForm } from "./sign-in-form"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Sign in", 6 | }; 7 | 8 | export default function SignIn() { 9 | return ( 10 |
11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-up/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { SignUpForm } from "./sign-up-form"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Sign up", 6 | }; 7 | 8 | export default function SignUp() { 9 | return ( 10 |
11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/(main)/admin/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | export default function AdminLoading() { 4 | return ( 5 |
6 |
7 | 8 | 9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "@/lib/get-session"; 2 | import { redirect } from "next/navigation"; 3 | import { ReactNode } from "react"; 4 | 5 | export default async function AuthLayout({ 6 | children, 7 | }: { 8 | children: ReactNode; 9 | }) { 10 | const session = await getServerSession(); 11 | const user = session?.user; 12 | 13 | if (user) redirect("/dashboard"); 14 | 15 | return children; 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@/generated/prisma"; 2 | import { withAccelerate } from "@prisma/extension-accelerate"; 3 | 4 | const globalForPrisma = global as unknown as { 5 | prisma: PrismaClient; 6 | }; 7 | 8 | const prisma = 9 | globalForPrisma.prisma || new PrismaClient().$extends(withAccelerate()); 10 | 11 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; 12 | 13 | export default prisma; 14 | -------------------------------------------------------------------------------- /src/lib/email.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from "resend"; 2 | 3 | const resend = new Resend(process.env.RESEND_API_KEY); 4 | 5 | interface SendEmailValues { 6 | to: string; 7 | subject: string; 8 | text: string; 9 | } 10 | 11 | export async function sendEmail({ to, subject, text }: SendEmailValues) { 12 | await resend.emails.send({ 13 | from: "verification@codinginflow-sample.com", 14 | to, 15 | subject, 16 | text, 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/(main)/admin/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getServerSession } from "@/lib/get-session"; 4 | import { forbidden, unauthorized } from "next/navigation"; 5 | import { setTimeout } from "node:timers/promises"; 6 | 7 | export async function deleteApplication() { 8 | const session = await getServerSession(); 9 | const user = session?.user; 10 | 11 | if (!user) unauthorized(); 12 | 13 | if (user.role !== "admin") forbidden(); 14 | 15 | // Delete app... 16 | 17 | await setTimeout(800); 18 | } 19 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | experimental: { 5 | authInterrupts: true, 6 | }, 7 | eslint: { 8 | ignoreDuringBuilds: true, 9 | }, 10 | images: { 11 | remotePatterns: [ 12 | { 13 | protocol: "https", 14 | hostname: "lh3.googleusercontent.com", 15 | }, 16 | { 17 | protocol: "https", 18 | hostname: "avatars.githubusercontent.com", 19 | }, 20 | ], 21 | }, 22 | }; 23 | 24 | export default nextConfig; 25 | -------------------------------------------------------------------------------- /src/components/loading-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Loader2 } from "lucide-react"; 5 | 6 | interface LoadingButtonProps extends React.ComponentProps { 7 | loading: boolean; 8 | } 9 | 10 | export function LoadingButton({ 11 | loading, 12 | disabled, 13 | children, 14 | ...props 15 | }: LoadingButtonProps) { 16 | return ( 17 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Better-Auth With Next.js Tutorial 2 | 3 | Learn how to **implement Better-Auth with Next.js 15 and Prisma Postgres** in this YouTube tutorial: https://www.youtube.com/watch?v=w5Emwt3nuV0 4 | 5 | Including: 6 | 7 | - Auth client & database setup 8 | - Email & password login 9 | - Transactional emails with Resend 10 | - OAuth (Google & GitHub) 11 | - Admin role & authorization 12 | - Hooks and custom password validation with Zod 13 | - Updating user profile data 14 | - SSR and caching 15 | - Deployment to Vercel 16 | - and more 17 | 18 | Thumb 1 1 19 | -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner, ToasterProps } from "sonner" 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 22 | ) 23 | } 24 | 25 | export { Toaster } 26 | -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | /src/generated/prisma 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from "@eslint/eslintrc"; 2 | import eslintConfigPrettier from "eslint-config-prettier/flat"; 3 | import { dirname } from "path"; 4 | import { fileURLToPath } from "url"; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = dirname(__filename); 8 | 9 | const compat = new FlatCompat({ 10 | baseDirectory: __dirname, 11 | }); 12 | 13 | const eslintConfig = [ 14 | ...compat.extends("next/core-web-vitals", "next/typescript"), 15 | eslintConfigPrettier, 16 | { 17 | ignores: [ 18 | "node_modules/**", 19 | ".next/**", 20 | "out/**", 21 | "build/**", 22 | "next-env.d.ts", 23 | ], 24 | }, 25 | ]; 26 | 27 | export default eslintConfig; 28 | -------------------------------------------------------------------------------- /src/app/forbidden.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import Link from "next/link"; 3 | 4 | export default function ForbiddenPage() { 5 | return ( 6 |
7 |
8 |
9 |

403 - Forbidden

10 |

11 | You don't have access to this page. 12 |

13 |
14 |
15 | 18 |
19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/app/(auth)/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { ForgotPasswordForm } from "./forgot-password-form"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Forgot password", 6 | }; 7 | 8 | export default function ForgotPasswordPage() { 9 | return ( 10 |
11 |
12 |
13 |

Forgot password

14 |

15 | Enter your email address and we'll send you a link to reset 16 | your password. 17 |

18 |
19 | 20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/app/unauthorized.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import Link from "next/link"; 5 | import { usePathname } from "next/navigation"; 6 | 7 | export default function UnauthorizedPage() { 8 | const pathname = usePathname(); 9 | 10 | return ( 11 |
12 |
13 |
14 |

401 - Unauthorized

15 |

Please sign in to continue.

16 |
17 |
18 | 21 |
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/(main)/email-verified/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import type { Metadata } from "next"; 3 | import Link from "next/link"; 4 | 5 | export const metadata: Metadata = { 6 | title: "Email Verified", 7 | }; 8 | 9 | export default function EmailVerifiedPage() { 10 | return ( 11 |
12 |
13 |
14 |

Email verified

15 |

16 | Your email has been verified successfully. 17 |

18 |
19 | 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/user-avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { ComponentProps } from "react"; 4 | 5 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 6 | import { cn } from "@/lib/utils"; 7 | 8 | export interface UserAvatarProps extends ComponentProps { 9 | name: string; 10 | image: string | null | undefined; 11 | } 12 | 13 | export function UserAvatar({ 14 | name, 15 | image, 16 | className, 17 | ...props 18 | }: UserAvatarProps) { 19 | const initials = name 20 | .split(" ") 21 | .filter(Boolean) 22 | .map((part) => part[0]) 23 | .join(""); 24 | 25 | return ( 26 | 27 | 32 | {initials} 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/icons/GitHubIcon.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from "react"; 2 | 3 | export function GitHubIcon(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/(main)/admin/page.tsx: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "@/lib/get-session"; 2 | import type { Metadata } from "next"; 3 | import { forbidden, unauthorized } from "next/navigation"; 4 | import { DeleteApplication } from "./delete-application"; 5 | 6 | export const metadata: Metadata = { 7 | title: "Admin", 8 | }; 9 | 10 | export default async function AdminPage() { 11 | const session = await getServerSession(); 12 | const user = session?.user; 13 | 14 | if (!user) unauthorized(); 15 | 16 | if (user.role !== "admin") forbidden(); 17 | 18 | return ( 19 |
20 |
21 |
22 |

Admin

23 |

24 | You have administrator access. 25 |

26 |
27 | 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /src/app/(main)/profile/logout-everywhere-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LoadingButton } from "@/components/loading-button"; 4 | import { authClient } from "@/lib/auth-client"; 5 | import { useRouter } from "next/navigation"; 6 | import { useState } from "react"; 7 | import { toast } from "sonner"; 8 | 9 | export function LogoutEverywhereButton() { 10 | const [loading, setLoading] = useState(false); 11 | 12 | const router = useRouter(); 13 | 14 | async function handleLogoutEverywhere() { 15 | setLoading(true); 16 | const { error } = await authClient.revokeSessions(); 17 | setLoading(false); 18 | 19 | if (error) { 20 | toast.error(error.message || "Failed to log out everywhere"); 21 | } else { 22 | toast.success("Logged out from all devices"); 23 | router.push("/sign-in"); 24 | } 25 | } 26 | 27 | return ( 28 | 34 | Log out everywhere 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/app/(main)/verify-email/page.tsx: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "@/lib/get-session"; 2 | import type { Metadata } from "next"; 3 | import { redirect, unauthorized } from "next/navigation"; 4 | import { ResendVerificationButton } from "./resend-verification-button"; 5 | 6 | export const metadata: Metadata = { 7 | title: "Verify Email", 8 | }; 9 | 10 | export default async function VerifyEmailPage() { 11 | const session = await getServerSession(); 12 | const user = session?.user; 13 | 14 | if (!user) unauthorized(); 15 | 16 | if (user.emailVerified) redirect("/dashboard"); 17 | 18 | return ( 19 |
20 |
21 |
22 |

Verify your email

23 |

24 | A verification email was sent to your inbox. 25 |

26 |
27 | 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/password-input.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "@/components/ui/input"; 2 | import { cn } from "@/lib/utils"; 3 | import { EyeIcon, EyeOffIcon } from "lucide-react"; 4 | import { useState } from "react"; 5 | 6 | export function PasswordInput({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | const [showPassword, setShowPassword] = useState(false); 11 | 12 | return ( 13 |
14 | 19 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from "@/components/ui/sonner"; 2 | import type { Metadata } from "next"; 3 | import { ThemeProvider } from "next-themes"; 4 | import { Outfit } from "next/font/google"; 5 | import "./globals.css"; 6 | 7 | const outfit = Outfit({ 8 | variable: "--font-outfit", 9 | subsets: ["latin"], 10 | }); 11 | 12 | export const metadata: Metadata = { 13 | title: { 14 | template: "%s | Better-Auth Tutorial", 15 | absolute: "Better-Auth Tutorial by Coding in Flow", 16 | }, 17 | description: 18 | "Learn how to handle authentication in Next.js using Better-Auth with this tutorial by Coding in Flow", 19 | }; 20 | 21 | export default function RootLayout({ 22 | children, 23 | }: Readonly<{ 24 | children: React.ReactNode; 25 | }>) { 26 | return ( 27 | 28 | 29 | 35 | {children} 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/app/(main)/navbar.tsx: -------------------------------------------------------------------------------- 1 | import codingInFlowLogo from "@/assets/coding_in_flow_logo.jpg"; 2 | import { ModeToggle } from "@/components/mode-toggle"; 3 | import { UserDropdown } from "@/components/user-dropdown"; 4 | import { getServerSession } from "@/lib/get-session"; 5 | import Image from "next/image"; 6 | import Link from "next/link"; 7 | 8 | export async function Navbar() { 9 | const session = await getServerSession(); 10 | const user = session?.user; 11 | 12 | if (!user) return null; 13 | 14 | return ( 15 |
16 |
17 | 21 | Coding in Flow logo 28 | Better-Auth Tutorial 29 | 30 |
31 | 32 | 33 |
34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/app/(auth)/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { ResetPasswordForm } from "./reset-password-form"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Reset password", 6 | }; 7 | 8 | interface ResetPasswordPageProps { 9 | searchParams: Promise<{ token: string }>; 10 | } 11 | 12 | export default async function ResetPasswordPage({ 13 | searchParams, 14 | }: ResetPasswordPageProps) { 15 | const { token } = await searchParams; 16 | 17 | return ( 18 |
19 | {token ? ( 20 | 21 | ) : ( 22 |
23 | Token is missing. 24 |
25 | )} 26 |
27 | ); 28 | } 29 | 30 | interface ResetPasswordUIProps { 31 | token: string; 32 | } 33 | 34 | function ResetPasswordUI({ token }: ResetPasswordUIProps) { 35 | return ( 36 |
37 |
38 |

Reset password

39 |

Enter your new password below.

40 |
41 | 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Avatar({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | function AvatarImage({ 25 | className, 26 | ...props 27 | }: React.ComponentProps) { 28 | return ( 29 | 34 | ) 35 | } 36 | 37 | function AvatarFallback({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ) 51 | } 52 | 53 | export { Avatar, AvatarImage, AvatarFallback } 54 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { CheckIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Checkbox({ 10 | className, 11 | ...props 12 | }: React.ComponentProps) { 13 | return ( 14 | 22 | 26 | 27 | 28 | 29 | ) 30 | } 31 | 32 | export { Checkbox } 33 | -------------------------------------------------------------------------------- /src/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu"; 10 | import { Moon, Sun } from "lucide-react"; 11 | import { useTheme } from "next-themes"; 12 | 13 | export function ModeToggle() { 14 | const { setTheme } = useTheme(); 15 | 16 | return ( 17 | 18 | 19 | 24 | 25 | 26 | setTheme("light")}> 27 | Light 28 | 29 | setTheme("dark")}> 30 | Dark 31 | 32 | setTheme("system")}> 33 | System 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/icons/GoogleIcon.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from "react"; 2 | 3 | export function GoogleIcon(props: SVGProps) { 4 | return ( 5 | 12 | 16 | 20 | 24 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/(main)/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import { getServerSession } from "@/lib/get-session"; 2 | import type { Metadata } from "next"; 3 | import { unauthorized } from "next/navigation"; 4 | import { EmailForm } from "./email-form"; 5 | import { LogoutEverywhereButton } from "./logout-everywhere-button"; 6 | import { PasswordForm } from "./password-form"; 7 | import { ProfileDetailsForm } from "./profile-details-form"; 8 | 9 | export const metadata: Metadata = { 10 | title: "Profile", 11 | }; 12 | 13 | export default async function ProfilePage() { 14 | const session = await getServerSession(); 15 | const user = session?.user; 16 | 17 | if (!user) unauthorized(); 18 | 19 | return ( 20 |
21 |
22 |
23 |

Profile

24 |

25 | Update your account details, email, and password. 26 |

27 |
28 |
29 |
30 | 31 |
32 |
33 | 34 | 35 | 36 |
37 |
38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/app/(main)/admin/delete-application.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LoadingButton } from "@/components/loading-button"; 4 | import { useTransition } from "react"; 5 | import { toast } from "sonner"; 6 | import { deleteApplication } from "./actions"; 7 | 8 | export function DeleteApplication() { 9 | const [isPending, startTransition] = useTransition(); 10 | 11 | async function handleDeleteApplication() { 12 | startTransition(async () => { 13 | try { 14 | await deleteApplication(); 15 | toast.success("Application deletion authorized successfully"); 16 | } catch (error) { 17 | console.error(error); 18 | toast.error("Something went wrong"); 19 | } 20 | }); 21 | } 22 | 23 | return ( 24 |
25 |
26 |
27 |
28 |

Delete Application

29 |

30 | This action will delete the entire application. This cannot be 31 | undone. 32 |

33 |
34 | 40 | Delete Application 41 | 42 |
43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "better-auth-tutorial", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build --turbopack", 8 | "start": "next start", 9 | "lint": "eslint", 10 | "postinstall": "prisma generate" 11 | }, 12 | "dependencies": { 13 | "@hookform/resolvers": "^5.2.1", 14 | "@prisma/client": "^6.14.0", 15 | "@prisma/extension-accelerate": "^2.0.2", 16 | "@radix-ui/react-avatar": "^1.1.10", 17 | "@radix-ui/react-checkbox": "^1.3.3", 18 | "@radix-ui/react-dropdown-menu": "^2.1.16", 19 | "@radix-ui/react-label": "^2.1.7", 20 | "@radix-ui/react-slot": "^1.2.3", 21 | "better-auth": "^1.3.7", 22 | "class-variance-authority": "^0.7.1", 23 | "clsx": "^2.1.1", 24 | "date-fns": "^4.1.0", 25 | "lucide-react": "^0.541.0", 26 | "next": "15.5.0", 27 | "next-themes": "^0.4.6", 28 | "react": "19.1.0", 29 | "react-dom": "19.1.0", 30 | "react-hook-form": "^7.62.0", 31 | "resend": "^6.0.1", 32 | "sonner": "^2.0.7", 33 | "tailwind-merge": "^3.3.1", 34 | "zod": "^4.1.0" 35 | }, 36 | "devDependencies": { 37 | "@eslint/eslintrc": "^3", 38 | "@tailwindcss/postcss": "^4", 39 | "@types/node": "^20", 40 | "@types/react": "^19", 41 | "@types/react-dom": "^19", 42 | "eslint": "^9", 43 | "eslint-config-next": "15.5.0", 44 | "eslint-config-prettier": "^10.1.8", 45 | "prettier": "^3.6.2", 46 | "prettier-plugin-tailwindcss": "^0.6.14", 47 | "prisma": "^6.14.0", 48 | "tailwindcss": "^4", 49 | "tw-animate-css": "^1.3.7", 50 | "typescript": "^5" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/app/(main)/verify-email/resend-verification-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LoadingButton } from "@/components/loading-button"; 4 | import { authClient } from "@/lib/auth-client"; 5 | import { useState } from "react"; 6 | 7 | interface ResendVerificationButtonProps { 8 | email: string; 9 | } 10 | 11 | export function ResendVerificationButton({ 12 | email, 13 | }: ResendVerificationButtonProps) { 14 | const [isLoading, setIsLoading] = useState(false); 15 | const [success, setSuccess] = useState(null); 16 | const [error, setError] = useState(null); 17 | 18 | async function resendVerificationEmail() { 19 | setSuccess(null); 20 | setError(null); 21 | setIsLoading(true); 22 | 23 | const { error } = await authClient.sendVerificationEmail({ 24 | email, 25 | callbackURL: "/email-verified", 26 | }); 27 | 28 | setIsLoading(false); 29 | 30 | if (error) { 31 | setError(error.message || "Something went wrong"); 32 | } else { 33 | setSuccess("Verification email sent successfully"); 34 | } 35 | } 36 | 37 | return ( 38 |
39 | {success && ( 40 |
41 | {success} 42 |
43 | )} 44 | {error && ( 45 |
46 | {error} 47 |
48 | )} 49 | 50 | 55 | Resend verification email 56 | 57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | export default function DashboardLoading() { 4 | return ( 5 |
6 |
7 |
8 | 9 | 10 |
11 | 12 | 13 | 14 |
15 |
16 |
17 |
18 | 19 | 20 |
21 | 22 |
23 | 24 |
25 |
26 | 27 | 28 |
29 | 30 |
31 |
32 | 33 | 34 |
35 | 36 |
37 |
38 | 39 | 40 |
41 | 42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | output = "../src/generated/prisma" 10 | } 11 | 12 | datasource db { 13 | provider = "postgresql" 14 | url = env("DATABASE_URL") 15 | } 16 | 17 | model User { 18 | id String @id 19 | name String 20 | email String 21 | emailVerified Boolean 22 | image String? 23 | role String? 24 | createdAt DateTime 25 | updatedAt DateTime 26 | sessions Session[] 27 | accounts Account[] 28 | 29 | @@unique([email]) 30 | @@map("user") 31 | } 32 | 33 | model Session { 34 | id String @id 35 | expiresAt DateTime 36 | token String 37 | createdAt DateTime 38 | updatedAt DateTime 39 | ipAddress String? 40 | userAgent String? 41 | userId String 42 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 43 | 44 | @@unique([token]) 45 | @@map("session") 46 | } 47 | 48 | model Account { 49 | id String @id 50 | accountId String 51 | providerId String 52 | userId String 53 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 54 | accessToken String? 55 | refreshToken String? 56 | idToken String? 57 | accessTokenExpiresAt DateTime? 58 | refreshTokenExpiresAt DateTime? 59 | scope String? 60 | password String? 61 | createdAt DateTime 62 | updatedAt DateTime 63 | 64 | @@map("account") 65 | } 66 | 67 | model Verification { 68 | id String @id 69 | identifier String 70 | value String 71 | expiresAt DateTime 72 | createdAt DateTime? 73 | updatedAt DateTime? 74 | 75 | @@map("verification") 76 | } 77 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import betterAuthLogo from "@/assets/better_auth_logo.png"; 2 | import codingInFlowLogo from "@/assets/coding_in_flow_logo.jpg"; 3 | import { Button } from "@/components/ui/button"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | 7 | export default function Home() { 8 | return ( 9 |
10 |
11 |
12 | Coding in Flow logo 19 | + 20 | Better Auth logo 27 |
28 |

29 | Better-Auth Tutorial 30 |

31 |

32 | Learn how to handle authentication in Next.js using Better-Auth with 33 | this tutorial by{" "} 34 | 40 | Coding in Flow 41 | 42 |

43 |
44 | 47 | 50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /src/app/(main)/profile/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 |
7 |
8 | 9 | 10 |
11 |
12 |
13 |
14 |
15 | 16 |
17 | 18 | 19 |
20 |
21 | 22 | 23 |
24 | 25 |
26 |
27 |
28 |
29 |
30 |
31 | 32 |
33 | 34 | 35 |
36 | 37 |
38 |
39 |
40 |
41 | 42 |
43 | 44 | 45 |
46 |
47 | 48 | 49 |
50 | 51 |
52 |
53 | 54 |
55 |
56 |
57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/components/user-dropdown.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { User } from "@/lib/auth"; 4 | import { authClient } from "@/lib/auth-client"; 5 | import { LogOutIcon, ShieldIcon, UserIcon } from "lucide-react"; 6 | import Image from "next/image"; 7 | import Link from "next/link"; 8 | import { useRouter } from "next/navigation"; 9 | import { toast } from "sonner"; 10 | import { Button } from "./ui/button"; 11 | import { 12 | DropdownMenu, 13 | DropdownMenuContent, 14 | DropdownMenuItem, 15 | DropdownMenuLabel, 16 | DropdownMenuSeparator, 17 | DropdownMenuTrigger, 18 | } from "./ui/dropdown-menu"; 19 | 20 | interface UserDropdownProps { 21 | user: User; 22 | } 23 | 24 | export function UserDropdown({ user }: UserDropdownProps) { 25 | return ( 26 | 27 | 28 | 42 | 43 | 44 | {user.email} 45 | 46 | 47 | 48 | Profile 49 | 50 | 51 | {user.role === "admin" && } 52 | 53 | 54 | 55 | ); 56 | } 57 | 58 | function AdminItem() { 59 | return ( 60 | 61 | 62 | Admin 63 | 64 | 65 | ); 66 | } 67 | 68 | function SignOutItem() { 69 | const router = useRouter(); 70 | 71 | async function handleSignOut() { 72 | const { error } = await authClient.signOut(); 73 | if (error) { 74 | toast.error(error.message || "Something went wrong"); 75 | } else { 76 | toast.success("Signed out successfully"); 77 | router.push("/sign-in"); 78 | } 79 | } 80 | 81 | return ( 82 | 83 | Sign out 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from "better-auth"; 2 | import { prismaAdapter } from "better-auth/adapters/prisma"; 3 | import { APIError, createAuthMiddleware } from "better-auth/api"; 4 | import { sendEmail } from "./email"; 5 | import prisma from "./prisma"; 6 | import { passwordSchema } from "./validation"; 7 | 8 | export const auth = betterAuth({ 9 | database: prismaAdapter(prisma, { 10 | provider: "postgresql", 11 | }), 12 | socialProviders: { 13 | google: { 14 | clientId: process.env.GOOGLE_CLIENT_ID!, 15 | clientSecret: process.env.GOOGLE_CLIENT_SECRET!, 16 | }, 17 | github: { 18 | clientId: process.env.GITHUB_CLIENT_ID!, 19 | clientSecret: process.env.GITHUB_CLIENT_SECRET!, 20 | }, 21 | }, 22 | emailAndPassword: { 23 | enabled: true, 24 | // requireEmailVerification: true, // Only if you want to block login completely 25 | async sendResetPassword({ user, url }) { 26 | await sendEmail({ 27 | to: user.email, 28 | subject: "Reset your password", 29 | text: `Click the link to reset your password: ${url}`, 30 | }); 31 | }, 32 | }, 33 | emailVerification: { 34 | sendOnSignUp: true, 35 | autoSignInAfterVerification: true, 36 | async sendVerificationEmail({ user, url }) { 37 | await sendEmail({ 38 | to: user.email, 39 | subject: "Verify your email", 40 | text: `Click the link to verify your email: ${url}`, 41 | }); 42 | }, 43 | }, 44 | user: { 45 | changeEmail: { 46 | enabled: true, 47 | async sendChangeEmailVerification({ user, newEmail, url }) { 48 | await sendEmail({ 49 | to: user.email, 50 | subject: "Approve email change", 51 | text: `Your email has been changed to ${newEmail}. Click the link to approve the change: ${url}`, 52 | }); 53 | }, 54 | }, 55 | additionalFields: { 56 | role: { 57 | type: "string", 58 | input: false, 59 | }, 60 | }, 61 | }, 62 | hooks: { 63 | before: createAuthMiddleware(async (ctx) => { 64 | if ( 65 | ctx.path === "/sign-up/email" || 66 | ctx.path === "/reset-password" || 67 | ctx.path === "/change-password" 68 | ) { 69 | const password = ctx.body.password || ctx.body.newPassword; 70 | const { error } = passwordSchema.safeParse(password); 71 | if (error) { 72 | throw new APIError("BAD_REQUEST", { 73 | message: "Password not strong enough", 74 | }); 75 | } 76 | } 77 | }), 78 | }, 79 | }); 80 | 81 | export type Session = typeof auth.$Infer.Session; 82 | export type User = typeof auth.$Infer.Session.user; 83 | -------------------------------------------------------------------------------- /src/app/(auth)/forgot-password/forgot-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LoadingButton } from "@/components/loading-button"; 4 | import { Card, CardContent } from "@/components/ui/card"; 5 | import { 6 | Form, 7 | FormControl, 8 | FormField, 9 | FormItem, 10 | FormLabel, 11 | FormMessage, 12 | } from "@/components/ui/form"; 13 | import { Input } from "@/components/ui/input"; 14 | import { authClient } from "@/lib/auth-client"; 15 | import { zodResolver } from "@hookform/resolvers/zod"; 16 | import { useState } from "react"; 17 | import { useForm } from "react-hook-form"; 18 | import { z } from "zod"; 19 | 20 | const forgotPasswordSchema = z.object({ 21 | email: z.email({ message: "Please enter a valid email" }), 22 | }); 23 | 24 | type ForgotPasswordValues = z.infer; 25 | 26 | export function ForgotPasswordForm() { 27 | const [success, setSuccess] = useState(null); 28 | const [error, setError] = useState(null); 29 | 30 | const form = useForm({ 31 | resolver: zodResolver(forgotPasswordSchema), 32 | defaultValues: { email: "" }, 33 | }); 34 | 35 | async function onSubmit({ email }: ForgotPasswordValues) { 36 | setSuccess(null); 37 | setError(null); 38 | 39 | const { error } = await authClient.requestPasswordReset({ 40 | email, 41 | redirectTo: "/reset-password", 42 | }); 43 | 44 | if (error) { 45 | setError(error.message || "Something went wrong"); 46 | } else { 47 | setSuccess( 48 | "If an account exists for this email, we've sent a password reset link.", 49 | ); 50 | form.reset(); 51 | } 52 | } 53 | 54 | const loading = form.formState.isSubmitting; 55 | 56 | return ( 57 | 58 | 59 |
60 | 61 | ( 65 | 66 | Email 67 | 68 | 73 | 74 | 75 | 76 | )} 77 | /> 78 | 79 | {success && ( 80 |
81 | {success} 82 |
83 | )} 84 | {error && ( 85 |
86 | {error} 87 |
88 | )} 89 | 90 | 91 | Send reset link 92 | 93 | 94 | 95 |
96 |
97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/app/(main)/profile/email-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LoadingButton } from "@/components/loading-button"; 4 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 5 | import { 6 | Form, 7 | FormControl, 8 | FormField, 9 | FormItem, 10 | FormLabel, 11 | FormMessage, 12 | } from "@/components/ui/form"; 13 | import { Input } from "@/components/ui/input"; 14 | import { authClient } from "@/lib/auth-client"; 15 | import { zodResolver } from "@hookform/resolvers/zod"; 16 | import { useState } from "react"; 17 | import { useForm } from "react-hook-form"; 18 | import z from "zod"; 19 | 20 | export const updateEmailSchema = z.object({ 21 | newEmail: z.email({ message: "Enter a valid email" }), 22 | }); 23 | 24 | export type UpdateEmailValues = z.infer; 25 | 26 | interface EmailFormProps { 27 | currentEmail: string; 28 | } 29 | 30 | export function EmailForm({ currentEmail }: EmailFormProps) { 31 | const [status, setStatus] = useState(null); 32 | const [error, setError] = useState(null); 33 | 34 | const form = useForm({ 35 | resolver: zodResolver(updateEmailSchema), 36 | defaultValues: { 37 | newEmail: currentEmail, 38 | }, 39 | }); 40 | 41 | async function onSubmit({ newEmail }: UpdateEmailValues) { 42 | setStatus(null); 43 | setError(null); 44 | 45 | const { error } = await authClient.changeEmail({ 46 | newEmail, 47 | callbackURL: "/email-verified", 48 | }); 49 | 50 | if (error) { 51 | setError(error.message || "Failed to initiate email change"); 52 | } else { 53 | setStatus("Verification email sent to your current address"); 54 | } 55 | } 56 | 57 | const loading = form.formState.isSubmitting; 58 | 59 | return ( 60 | 61 | 62 | Change Email 63 | 64 | 65 |
66 | 67 | ( 71 | 72 | New Email 73 | 74 | 79 | 80 | 81 | 82 | )} 83 | /> 84 | 85 | {error && ( 86 |
87 | {error} 88 |
89 | )} 90 | {status && ( 91 |
92 | {status} 93 |
94 | )} 95 | 96 | Request change 97 | 98 | 99 | 100 |
101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /src/app/(auth)/reset-password/reset-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LoadingButton } from "@/components/loading-button"; 4 | import { PasswordInput } from "@/components/password-input"; 5 | import { Card, CardContent } from "@/components/ui/card"; 6 | import { 7 | Form, 8 | FormControl, 9 | FormField, 10 | FormItem, 11 | FormLabel, 12 | FormMessage, 13 | } from "@/components/ui/form"; 14 | import { authClient } from "@/lib/auth-client"; 15 | import { passwordSchema } from "@/lib/validation"; 16 | import { zodResolver } from "@hookform/resolvers/zod"; 17 | import { useRouter } from "next/navigation"; 18 | import { useState } from "react"; 19 | import { useForm } from "react-hook-form"; 20 | import { z } from "zod"; 21 | 22 | const resetPasswordSchema = z.object({ 23 | newPassword: passwordSchema, 24 | }); 25 | 26 | type ResetPasswordValues = z.infer; 27 | 28 | interface ResetPasswordFormProps { 29 | token: string; 30 | } 31 | 32 | export function ResetPasswordForm({ token }: ResetPasswordFormProps) { 33 | const [success, setSuccess] = useState(null); 34 | const [error, setError] = useState(null); 35 | 36 | const router = useRouter(); 37 | 38 | const form = useForm({ 39 | resolver: zodResolver(resetPasswordSchema), 40 | defaultValues: { newPassword: "" }, 41 | }); 42 | 43 | async function onSubmit({ newPassword }: ResetPasswordValues) { 44 | setSuccess(null); 45 | setError(null); 46 | 47 | const { error } = await authClient.resetPassword({ 48 | newPassword, 49 | token, 50 | }); 51 | 52 | if (error) { 53 | setError(error.message || "Something went wrong"); 54 | } else { 55 | setSuccess("Password has been reset. You can now sign in."); 56 | setTimeout(() => router.push("/sign-in"), 3000); 57 | form.reset(); 58 | } 59 | } 60 | 61 | const loading = form.formState.isSubmitting; 62 | 63 | return ( 64 | 65 | 66 |
67 | 68 | ( 72 | 73 | New password 74 | 75 | 80 | 81 | 82 | 83 | )} 84 | /> 85 | 86 | {success && ( 87 |
88 | {success} 89 |
90 | )} 91 | {error && ( 92 |
93 | {error} 94 |
95 | )} 96 | 97 | 98 | Reset password 99 | 100 | 101 | 102 |
103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/app/(main)/profile/password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LoadingButton } from "@/components/loading-button"; 4 | import { PasswordInput } from "@/components/password-input"; 5 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 6 | import { 7 | Form, 8 | FormControl, 9 | FormField, 10 | FormItem, 11 | FormLabel, 12 | FormMessage, 13 | } from "@/components/ui/form"; 14 | import { authClient } from "@/lib/auth-client"; 15 | import { passwordSchema } from "@/lib/validation"; 16 | import { zodResolver } from "@hookform/resolvers/zod"; 17 | import { useState } from "react"; 18 | import { useForm } from "react-hook-form"; 19 | import { z } from "zod"; 20 | 21 | const updatePasswordSchema = z.object({ 22 | currentPassword: z 23 | .string() 24 | .min(1, { message: "Current password is required" }), 25 | newPassword: passwordSchema, 26 | }); 27 | 28 | type UpdatePasswordValues = z.infer; 29 | 30 | export function PasswordForm() { 31 | const [status, setStatus] = useState(null); 32 | const [error, setError] = useState(null); 33 | 34 | const form = useForm({ 35 | resolver: zodResolver(updatePasswordSchema), 36 | defaultValues: { 37 | currentPassword: "", 38 | newPassword: "", 39 | }, 40 | }); 41 | 42 | async function onSubmit({ 43 | currentPassword, 44 | newPassword, 45 | }: UpdatePasswordValues) { 46 | setStatus(null); 47 | setError(null); 48 | 49 | const { error } = await authClient.changePassword({ 50 | currentPassword, 51 | newPassword, 52 | revokeOtherSessions: true, 53 | }); 54 | 55 | if (error) { 56 | setError(error.message || "Failed to change password"); 57 | } else { 58 | setStatus("Password changed"); 59 | form.reset(); 60 | } 61 | } 62 | 63 | const loading = form.formState.isSubmitting; 64 | 65 | return ( 66 | 67 | 68 | Change Password 69 | 70 | 71 |
72 | 73 | {/* OAuth users (without a password) can use the "forgot password" flow */} 74 | ( 78 | 79 | Current Password 80 | 81 | 82 | 83 | 84 | 85 | )} 86 | /> 87 | ( 91 | 92 | New Password 93 | 94 | 95 | 96 | 97 | 98 | )} 99 | /> 100 | 101 | {error && ( 102 |
103 | {error} 104 |
105 | )} 106 | {status && ( 107 |
108 | {status} 109 |
110 | )} 111 | 112 | Change password 113 | 114 | 115 | 116 |
117 |
118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { Badge } from "@/components/ui/badge"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardHeader, 8 | CardTitle, 9 | } from "@/components/ui/card"; 10 | import { UserAvatar } from "@/components/user-avatar"; 11 | import { User } from "@/lib/auth"; 12 | import { getServerSession } from "@/lib/get-session"; 13 | import { format } from "date-fns"; 14 | import { CalendarDaysIcon, MailIcon, ShieldIcon, UserIcon } from "lucide-react"; 15 | import type { Metadata } from "next"; 16 | import Link from "next/link"; 17 | import { unauthorized } from "next/navigation"; 18 | 19 | export const metadata: Metadata = { 20 | title: "Dashboard", 21 | }; 22 | 23 | export default async function DashboardPage() { 24 | const session = await getServerSession(); 25 | const user = session?.user; 26 | 27 | if (!user) unauthorized(); 28 | 29 | return ( 30 |
31 |
32 |
33 |

Dashboard

34 |

35 | Welcome back! Here's your account overview. 36 |

37 |
38 | {!user.emailVerified && } 39 | 40 |
41 |
42 | ); 43 | } 44 | 45 | interface ProfileInformationProps { 46 | user: User; 47 | } 48 | 49 | function ProfileInformation({ user }: ProfileInformationProps) { 50 | return ( 51 | 52 | 53 | 54 | 55 | Profile Information 56 | 57 | 58 | Your account details and current status 59 | 60 | 61 | 62 |
63 |
64 | 69 | {user.role && ( 70 | 71 | 72 | {user.role} 73 | 74 | )} 75 |
76 | 77 |
78 |
79 |

{user.name}

80 |

{user.email}

81 |
82 | 83 |
84 |
85 | 86 | Member Since 87 |
88 |

89 | {format(user.createdAt, "MMMM d, yyyy")} 90 |

91 |
92 |
93 |
94 |
95 |
96 | ); 97 | } 98 | 99 | function EmailVerificationAlert() { 100 | return ( 101 |
102 |
103 |
104 | 105 | 106 | Please verify your email address to access all features. 107 | 108 |
109 | 112 |
113 |
114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { Slot } from "@radix-ui/react-slot" 6 | import { 7 | Controller, 8 | FormProvider, 9 | useFormContext, 10 | useFormState, 11 | type ControllerProps, 12 | type FieldPath, 13 | type FieldValues, 14 | } from "react-hook-form" 15 | 16 | import { cn } from "@/lib/utils" 17 | import { Label } from "@/components/ui/label" 18 | 19 | const Form = FormProvider 20 | 21 | type FormFieldContextValue< 22 | TFieldValues extends FieldValues = FieldValues, 23 | TName extends FieldPath = FieldPath, 24 | > = { 25 | name: TName 26 | } 27 | 28 | const FormFieldContext = React.createContext( 29 | {} as FormFieldContextValue 30 | ) 31 | 32 | const FormField = < 33 | TFieldValues extends FieldValues = FieldValues, 34 | TName extends FieldPath = FieldPath, 35 | >({ 36 | ...props 37 | }: ControllerProps) => { 38 | return ( 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | const useFormField = () => { 46 | const fieldContext = React.useContext(FormFieldContext) 47 | const itemContext = React.useContext(FormItemContext) 48 | const { getFieldState } = useFormContext() 49 | const formState = useFormState({ name: fieldContext.name }) 50 | const fieldState = getFieldState(fieldContext.name, formState) 51 | 52 | if (!fieldContext) { 53 | throw new Error("useFormField should be used within ") 54 | } 55 | 56 | const { id } = itemContext 57 | 58 | return { 59 | id, 60 | name: fieldContext.name, 61 | formItemId: `${id}-form-item`, 62 | formDescriptionId: `${id}-form-item-description`, 63 | formMessageId: `${id}-form-item-message`, 64 | ...fieldState, 65 | } 66 | } 67 | 68 | type FormItemContextValue = { 69 | id: string 70 | } 71 | 72 | const FormItemContext = React.createContext( 73 | {} as FormItemContextValue 74 | ) 75 | 76 | function FormItem({ className, ...props }: React.ComponentProps<"div">) { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
86 | 87 | ) 88 | } 89 | 90 | function FormLabel({ 91 | className, 92 | ...props 93 | }: React.ComponentProps) { 94 | const { error, formItemId } = useFormField() 95 | 96 | return ( 97 |