7. Advanced Topics: Authentication and Database Integration
Building modern web applications often involves managing user identities (authentication) and storing/retrieving data (database integration). Next.js, especially with the App Router, provides powerful and secure ways to handle these concerns. This chapter will guide you through implementing robust authentication and integrating with a database using popular tools and best practices.
7.1 Authentication in Next.js
Authentication in Next.js has evolved significantly with the App Router. The core philosophy now emphasizes placing authentication checks as close to the data access layer as possible, leveraging Server Components. While proxy.ts (formerly middleware.ts) can filter unauthorized requests at the edge, it should not be the sole authentication layer due to potential bypass vulnerabilities (e.g., CVE-2025-29927).
The recommended approach involves a combination of:
- Data Access Layers (DAL): Centralizing authentication and authorization logic where data is fetched.
- Server Components: Performing direct authentication checks.
- NextAuth.js (now Auth.js): A popular open-source library that simplifies complex authentication flows.
Authentication with NextAuth.js (Auth.js)
NextAuth.js provides a comprehensive solution for authentication, supporting various providers (Google, GitHub, credentials), session management (JWT or database-backed), and route protection. It’s highly recommended for its security features and ease of integration.
Let’s integrate NextAuth.js for a basic email/password login and protect a dashboard route.
Prerequisites for NextAuth.js:
- Install
next-auth:npm install next-auth@latest - Environment Variables: Create or update your
.env.localfile with:# .env.local NEXTAUTH_SECRET="YOUR_VERY_SECRET_KEY_HERE_GENERATE_A_STRONG_ONE" NEXTAUTH_URL="http://localhost:3000" # Your app's URL # NEXT_PUBLIC_API_BASE_URL="http://localhost:3000/api" # If using external APINEXTAUTH_SECRET: A long, random string. You can generate one withopenssl rand -base64 32or similar tools.NEXTAUTH_URL: The canonical URL of your application. Important for production.
Step-by-Step Integration:
Create NextAuth.js API Route: This file handles all authentication logic.
- Create
src/app/api/auth/[...nextauth]/route.ts.
mkdir -p src/app/api/auth/[...nextauth] touch src/app/api/auth/[...nextauth]/route.ts// src/app/api/auth/[...nextauth]/route.ts import NextAuth, { NextAuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; // Simulate a user database (in a real app, this would query your DB) const usersDb = [ { id: "user1", email: "user@example.com", password: "password123", name: "Test User", role: "user" }, { id: "admin1", email: "admin@example.com", password: "adminpassword", name: "Admin User", role: "admin" }, ]; export const authOptions: NextAuthOptions = { providers: [ CredentialsProvider({ name: "Credentials", credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" }, }, async authorize(credentials) { if (!credentials?.email || !credentials?.password) { return null; // No credentials provided } // In a real app, hash password and compare securely with a database const user = usersDb.find(u => u.email === credentials.email && u.password === credentials.password); if (user) { // Return user object without sensitive password return { id: user.id, email: user.email, name: user.name, role: user.role }; } else { return null; // Invalid credentials } }, }), // Add other providers like GitHubProvider, GoogleProvider here // GitHubProvider({ // clientId: process.env.GITHUB_ID!, // clientSecret: process.env.GITHUB_SECRET!, // }), ], session: { strategy: "jwt", // Use JWT for sessions maxAge: 30 * 24 * 60 * 60, // 30 days }, jwt: { secret: process.env.NEXTAUTH_SECRET, }, callbacks: { async jwt({ token, user }) { if (user) { token.id = user.id; token.role = (user as any).role; // Store user role in token } return token; }, async session({ session, token }) { if (token) { session.user.id = token.id as string; (session.user as any).role = token.role; // Expose role to session } return session; }, }, pages: { signIn: "/auth/login", // Custom sign-in page error: "/auth/login", // Redirect to login page on error }, secret: process.env.NEXTAUTH_SECRET, debug: process.env.NODE_ENV === "development", }; const handler = NextAuth(authOptions); // App Router requires explicit exports for GET and POST export { handler as GET, handler as POST }; // Export a simpler 'auth' object for Server Component usage // This allows `import { auth } from "@/app/api/auth/[...nextauth]/route"` in Server Components export const { auth } = handler;- Create
Extend NextAuth Types: To add custom properties (like
role) to the session and JWT types.- Create
src/types/next-auth.d.ts.
mkdir src/types touch src/types/next-auth.d.ts// src/types/next-auth.d.ts import NextAuth, { DefaultSession } from "next-auth"; import { DefaultJWT } from "next-auth/jwt"; declare module "next-auth" { interface Session { user: { id: string; // Add custom ID role: "admin" | "user"; // Add custom role } & DefaultSession["user"]; } interface User { id: string; // Add custom ID role: "admin" | "user"; // Add custom role } } declare module "next-auth/jwt" { interface JWT extends DefaultJWT { id: string; // Add custom ID role: "admin" | "user"; // Add custom role } }This file needs to be globally available to TypeScript. Ensure your
tsconfig.jsonincludes it, e.g., ininclude:// tsconfig.json { "compilerOptions": { // ... }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "src/types/**/*.d.ts"] // Ensure this line includes your types folder }- Create
Create a Client Session Provider: Needed to make session data available to Client Components using
useSession.- Create
src/app/providers/SessionProvider.tsx.
mkdir src/app/providers touch src/app/providers/SessionProvider.tsx// src/app/providers/SessionProvider.tsx 'use client'; import { SessionProvider } from 'next-auth/react'; import { ReactNode } from 'react'; interface Props { children: ReactNode; } export default function AuthSessionProvider({ children }: Props) { return <SessionProvider>{children}</SessionProvider>; }- Create
Wrap your Root Layout with the Session Provider:
- Open
src/app/layout.tsx.
// src/app/layout.tsx import type { Metadata } from "next"; import { Inter, Roboto_Mono } from "next/font/google"; import "./globals.css"; import AuthSessionProvider from "./providers/SessionProvider"; // Import our provider const inter = Inter({ subsets: ["latin"], variable: '--font-inter' }); const roboto_mono = Roboto_Mono({ subsets: ["latin"], variable: '--font-roboto-mono' }); export const metadata: Metadata = { title: "My Next.js App", description: "Learning Next.js styling", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en" className={`${inter.variable} ${roboto_mono.variable}`}> <body> {/* Wrap your entire application with the SessionProvider */} <AuthSessionProvider> {children} </AuthSessionProvider> </body> </html> ); }- Open
Create a Login Page: This will be a Client Component.
- Create
src/app/auth/login/page.tsx.
mkdir -p src/app/auth/login touch src/app/auth/login/page.tsx// src/app/auth/login/page.tsx 'use client'; import { signIn } from 'next-auth/react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useState } from 'react'; import Link from 'next/link'; export default function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const router = useRouter(); const searchParams = useSearchParams(); const callbackUrl = searchParams.get('callbackUrl') || '/dashboard'; // Redirect after login const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(''); const result = await signIn('credentials', { redirect: false, // Prevent NextAuth.js from redirecting on error email, password, }); if (result?.error) { setError('Invalid credentials. Please try again.'); console.error('Login Error:', result.error); } else { router.push(callbackUrl); } setLoading(false); }; return ( <div className="flex items-center justify-center min-h-screen bg-gray-100 p-4"> <div className="bg-white p-8 rounded-lg shadow-lg w-full max-w-md"> <h1 className="text-3xl font-bold text-gray-800 mb-6 text-center">Login</h1> <form onSubmit={handleSubmit}> <div className="mb-4"> <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email"> Email </label> <input type="email" id="email" value={email} onChange={(e) => setEmail(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required disabled={loading} /> </div> <div className="mb-6"> <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password"> Password </label> <input type="password" id="password" value={password} onChange={(e) => setPassword(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" required disabled={loading} /> </div> {error && <p className="text-red-500 text-xs italic mb-4">{error}</p>} <div className="flex items-center justify-between"> <button type="submit" className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:opacity-50 disabled:cursor-not-allowed" disabled={loading} > {loading ? 'Logging in...' : 'Sign In'} </button> <Link href="/" className="inline-block align-baseline font-bold text-sm text-blue-600 hover:text-blue-800"> ← Back to Home </Link> </div> </form> </div> </div> ); }- Create
Create a Protected Dashboard Page: This can be a Server Component.
- Create
src/app/dashboard/page.tsx.
mkdir src/app/dashboard touch src/app/dashboard/page.tsx// src/app/dashboard/page.tsx import { auth } from "@/app/api/auth/[...nextauth]/route"; // Import the simplified 'auth' object import { redirect } from 'next/navigation'; import LogoutButton from "./components/LogoutButton"; // Client component for logout // Define custom layout for dashboard (optional) export const metadata = { title: 'Dashboard | Next.js App', description: 'User dashboard for your Next.js application.', }; export default async function DashboardPage() { // Use the 'auth' object directly in a Server Component const session = await auth(); // If no session, redirect to login if (!session || !session.user) { redirect('/auth/login'); } // Here you could fetch user-specific data from a database using session.user.id // const userData = await fetchUserSpecificData(session.user.id); return ( <main className="min-h-screen flex flex-col items-center p-6 bg-blue-50"> <div className="bg-white p-8 rounded-lg shadow-lg max-w-2xl w-full"> <h1 className="text-4xl font-bold text-gray-800 mb-6 text-center"> Welcome, {session.user.name || session.user.email}! </h1> <p className="text-gray-700 text-lg mb-4 text-center"> You are logged in as a `{session.user.role}`. </p> <p className="text-gray-600 text-md mb-8 text-center"> This is a protected dashboard only accessible to authenticated users. </p> {session.user.role === 'admin' && ( <div className="p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 mb-8" role="alert"> <p className="font-bold">Admin Privileges Detected!</p> <p>You have access to administrative functions.</p> </div> )} <div className="flex justify-center"> <LogoutButton /> </div> </div> </main> ); }- Create
Create a Logout Button (Client Component): The
signOutfunction is client-side.- Create
src/app/dashboard/components/LogoutButton.tsx.
touch src/app/dashboard/components/LogoutButton.tsx// src/app/dashboard/components/LogoutButton.tsx 'use client'; import { signOut } from 'next-auth/react'; import { useRouter } from 'next/navigation'; export default function LogoutButton() { const router = useRouter(); const handleLogout = async () => { // Sign out and redirect to the homepage await signOut({ redirect: false, callbackUrl: '/' }); router.push('/'); }; return ( <button onClick={handleLogout} className="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" > Logout </button> ); }- Create
Save all files. Restart your development server if needed.
- Navigate to
http://localhost:3000/dashboard. You should be redirected to/auth/login. - Log in with
user@example.com/password123oradmin@example.com/adminpassword. - After successful login, you should see the dashboard. Try logging out.
- Navigate to
This setup demonstrates a secure authentication flow using NextAuth.js, leveraging Server Components for protection and Client Components for interactive UI.
Exercises/Mini-Challenges (Authentication):
Implement an “Access Denied” Page:
- Create
src/app/auth/access-denied/page.tsx. - Modify
src/app/api/auth/[...nextauth]/route.tsto redirect to/auth/access-deniedifauthorizereturnsnullfor some specific reason (e.g., user is banned). For now, you can simulate this by always redirecting ifcredentials.email === 'banned@example.com'.
- Create
Display Session Info on Homepage:
- On your
src/app/page.tsx, use theuseSessionhook (remember'use client'ifpage.tsxbecomes a client component, or pass session as a prop from a layout/parent server component) to display “Welcome, [User Name]” if logged in, and “Please log in” if not. Also add a “Login” or “Logout” button conditionally.
- On your
7.2 Database Integration with Prisma ORM and PostgreSQL
Integrating a database is fundamental for most dynamic applications. Next.js works seamlessly with various databases and ORMs. We’ll use Prisma ORM with PostgreSQL, a powerful and type-safe combination.
What is Prisma?
Prisma is an open-source ORM (Object-Relational Mapper) that makes database access easy and type-safe. It consists of:
- Prisma Schema: A declarative way to define your database schema.
- Prisma Migrate: A tool for managing database schema changes.
- Prisma Client: A type-safe query builder for your database.
Prerequisites for Prisma & PostgreSQL:
- Install PostgreSQL: You’ll need a running PostgreSQL instance.
- Local Setup: Download from postgresql.org or use Docker (
docker run --name nextjs-postgres -e POSTGRES_USER=user -e POSTGRES_PASSWORD=password -e POSTGRES_DB=nextjsdb -p 5432:5432 -d postgres). - Cloud Hosted: Use a service like Vercel Postgres, Supabase, Neon, or Railway. For this guide, we’ll assume a local setup.
- Local Setup: Download from postgresql.org or use Docker (
- Install Prisma CLI & Client:
npm install prisma --save-dev npm install @prisma/client
Step-by-Step Integration:
We’ll integrate Prisma into our existing Todo application.
Initialize Prisma:
npx prisma initThis creates a
prismafolder withschema.prismaand an updated.envfile.Configure
.envfile: UpdateDATABASE_URLwith your PostgreSQL connection string.# .env DATABASE_URL="postgresql://user:password@localhost:5432/nextjsdb?schema=public"Replace
user,password,localhost,5432, andnextjsdbwith your actual database credentials.Define Prisma Schema (
prisma/schema.prisma):// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Todo { id String @id @default(uuid()) // Use UUID for IDs text String completed Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String? // Optional: Link todo to a user user User? @relation(fields: [userId], references: [id]) } model User { id String @id @default(uuid()) email String @unique password String // In a real app, store hashed passwords! name String? role String @default("user") // "user" or "admin" todos Todo[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }Important: For the
Usermodel, we are addingpasswordfor simplicity. In a real application, NEVER store plain passwords. Always hash them using a library likebcrypt.Run Prisma Migrations: This creates the actual tables in your database.
npx prisma migrate dev --name init--name init: Gives the migration a name.
Generate Prisma Client: This generates the type-safe client based on your schema.
npx prisma generateCreate a Prisma Client Instance (Singleton): To ensure only one instance of
PrismaClientis used across your application.- Create
src/lib/prisma.ts.
touch src/lib/prisma.ts// src/lib/prisma.ts import { PrismaClient } from '@prisma/client'; // Prevent multiple instances of Prisma Client in development const globalForPrisma = global as unknown as { prisma: PrismaClient }; export const prisma = globalForPrisma.prisma || new PrismaClient({ log: ['query', 'error', 'warn'], // Log database queries and errors }); if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;- Create
Update Server Actions to use Prisma: Now, let’s connect our Todo Server Actions to the real database.
- Open
src/app/actions.ts. Remove the in-memorytodosarray and replace the logic with Prisma queries.
// src/app/actions.ts 'use server'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; import { prisma } from '@/lib/prisma'; // Import our Prisma client import { auth } from '@/app/api/auth/[...nextauth]/route'; // Import auth for user session export async function addTodo(formData: FormData) { // 1. Authenticate user const session = await auth(); if (!session || !session.user || !session.user.id) { redirect('/auth/login?callbackUrl=/todos'); // Redirect if not logged in } const todoText = formData.get('todoText') as string; if (!todoText || todoText.trim() === '') { return { error: 'Todo text cannot be empty.' }; } try { // 2. Use Prisma to create a new todo await prisma.todo.create({ data: { text: todoText.trim(), userId: session.user.id, // Link todo to the authenticated user }, }); revalidatePath('/todos'); // Revalidate to show new todo return { success: true }; } catch (error) { console.error('Error adding todo:', error); return { error: 'Failed to add todo.' }; } } export async function deleteTodo(id: string) { // ID is now string (UUID) const session = await auth(); if (!session || !session.user || !session.user.id) { redirect('/auth/login?callbackUrl=/todos'); } try { // Find the todo to ensure it belongs to the current user const todoToDelete = await prisma.todo.findUnique({ where: { id: id }, }); if (!todoToDelete || todoToDelete.userId !== session.user.id) { return { error: 'Todo not found or unauthorized.' }; } // Use Prisma to delete the todo await prisma.todo.delete({ where: { id: id }, }); revalidatePath('/todos'); return { success: true }; } catch (error) { console.error('Error deleting todo:', error); return { error: 'Failed to delete todo.' }; } } export async function completeTodo(id: string, completed: boolean) { // ID is now string (UUID) const session = await auth(); if (!session || !session.user || !session.user.id) { redirect('/auth/login?callbackUrl=/todos'); } try { const todoToUpdate = await prisma.todo.findUnique({ where: { id: id }, }); if (!todoToUpdate || todoToUpdate.userId !== session.user.id) { return { error: 'Todo not found or unauthorized.' }; } // Use Prisma to update the todo status await prisma.todo.update({ where: { id: id }, data: { completed: completed }, }); revalidatePath('/todos'); return { success: true }; } catch (error) { console.error('Error completing todo:', error); return { error: 'Failed to update todo status.' }; } } // Helper to get todos for the Server Component, filtering by user export async function getTodos() { const session = await auth(); if (!session || !session.user || !session.user.id) { return []; // Return empty array if not logged in } return prisma.todo.findMany({ where: { userId: session.user.id, // Only fetch todos for the logged-in user }, orderBy: { createdAt: 'desc' }, // Order by newest first }); } // Server action to create a user (for initial setup or registration) export async function createUser(formData: FormData) { const email = formData.get('email') as string; const password = formData.get('password') as string; // Remember to hash in real app! const name = formData.get('name') as string; if (!email || !password) { return { error: 'Email and password are required.' }; } try { // In a real app, hash the password before saving const newUser = await prisma.user.create({ data: { email, password, name, role: 'user' }, }); console.log('New user created:', newUser.email); return { success: true, user: newUser }; } catch (error: any) { if (error.code === 'P2002') { // Prisma error code for unique constraint violation return { error: 'User with this email already exists.' }; } console.error('Error creating user:', error); return { error: 'Failed to create user.' }; } }- Open
Update
src/app/todos/page.tsx: Changeid: numbertoid: stringinTodoItemcomponent props.// src/app/todos/page.tsx (relevant parts) // ... // Client Component for individual todo items (to handle client-side interactions) function TodoItem({ todo }: { todo: { id: string; text: string; completed: boolean } }) { // Changed id: number to id: string // ... } // ...Create a Registration Page (Optional but good practice):
- Create
src/app/auth/register/page.tsx.
mkdir src/app/auth/register touch src/app/auth/register/page.tsx// src/app/auth/register/page.tsx 'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { createUser } from '@/app/actions'; // Import our server action export default function RegisterPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [name, setName] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const router = useRouter(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(''); const formData = new FormData(); formData.append('email', email); formData.append('password', password); formData.append('name', name); const result = await createUser(formData); // Invoke server action if (result.error) { setError(result.error); } else { // Registration successful, redirect to login or dashboard router.push('/auth/login?message=Registration successful, please log in.'); } setLoading(false); }; return ( <div className="flex items-center justify-center min-h-screen bg-gray-100 p-4"> <div className="bg-white p-8 rounded-lg shadow-lg w-full max-w-md"> <h1 className="text-3xl font-bold text-gray-800 mb-6 text-center">Register</h1> <form onSubmit={handleSubmit}> <div className="mb-4"> <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name"> Name </label> <input type="text" id="name" value={name} onChange={(e) => setName(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required disabled={loading} /> </div> <div className="mb-4"> <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email"> Email </label> <input type="email" id="email" value={email} onChange={(e) => setEmail(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" required disabled={loading} /> </div> <div className="mb-6"> <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password"> Password </label> <input type="password" id="password" value={password} onChange={(e) => setPassword(e.target.value)} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" required disabled={loading} /> </div> {error && <p className="text-red-500 text-xs italic mb-4">{error}</p>} <div className="flex items-center justify-between"> <button type="submit" className="bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline disabled:opacity-50 disabled:cursor-not-allowed" disabled={loading} > {loading ? 'Registering...' : 'Register'} </button> <Link href="/auth/login" className="inline-block align-baseline font-bold text-sm text-blue-600 hover:text-blue-800"> Already have an account? Login </Link> </div> </form> </div> </div> ); }- Create
Save all files. Make sure your PostgreSQL database is running.
- Restart
npm run dev. - Navigate to
http://localhost:3000/auth/registerto create a new user (e.g.,test@test.com/password). - Then, log in at
http://localhost:3000/auth/login. - Go to
http://localhost:3000/todos. Your todos will now be stored in and fetched from your PostgreSQL database, associated with the logged-in user!
- Restart
This advanced setup provides a solid foundation for secure authentication and persistent data storage in your Next.js applications, moving beyond in-memory data to a real database solution.
Exercises/Mini-Challenges (Database Integration):
Add a
Biofield toUserand display on Dashboard:- Update
prisma/schema.prismato add an optionalbiofield to theUsermodel (bio String?). - Run
npx prisma migrate devagain to apply schema changes (you’ll be prompted for a migration name). - Modify
src/app/api/auth/[...nextauth]/route.ts(theauthorizecallback) to also return thebiofield if it exists. - Update
src/types/next-auth.d.tsto includebioinSession.user. - Modify
src/app/dashboard/page.tsxto display the user’s bio (e.g.,session.user.bio). - Consider adding an input for the bio on the register page, and pass it to
createUserServer Action.
- Update
Filter Products by Category from Database:
- If you had a
Productmodel in Prisma (similar to ourTodomodel) with acategoryfield. - Modify your
src/app/products/page.tsx(which is a Server Component) to fetch products from your database using Prisma. - Add a dropdown/filter on the
productspage (Client Component) that allows users to select a category. When a category is selected, pass it as a search param to the Server Component, which then filters products usingprisma.product.findMany({ where: { category: selectedCategory } }). Remember to useuseSearchParamsfor accessing query parameters in Client Components and passing them to Server Components via props or as part of navigation.
- If you had a
By combining the strengths of NextAuth.js for authentication and Prisma ORM for database interaction, you’re now equipped to build sophisticated, secure, and data-driven full-stack applications with Next.js.