Advanced Topics: Authentication and Database Integration

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:

  1. Install next-auth:
    npm install next-auth@latest
    
  2. Environment Variables: Create or update your .env.local file 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 API
    
    • NEXTAUTH_SECRET: A long, random string. You can generate one with openssl rand -base64 32 or similar tools.
    • NEXTAUTH_URL: The canonical URL of your application. Important for production.

Step-by-Step Integration:

  1. 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;
    
  2. 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.json includes it, e.g., in include:

    // tsconfig.json
    {
      "compilerOptions": {
        // ...
      },
      "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "src/types/**/*.d.ts"] // Ensure this line includes your types folder
    }
    
  3. 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>;
    }
    
  4. 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>
      );
    }
    
  5. 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">
                  &larr; Back to Home
                </Link>
              </div>
            </form>
          </div>
        </div>
      );
    }
    
  6. 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>
      );
    }
    
  7. Create a Logout Button (Client Component): The signOut function 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>
      );
    }
    
  8. 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 / password123 or admin@example.com / adminpassword.
    • After successful login, you should see the dashboard. Try logging out.

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):

  1. Implement an “Access Denied” Page:

    • Create src/app/auth/access-denied/page.tsx.
    • Modify src/app/api/auth/[...nextauth]/route.ts to redirect to /auth/access-denied if authorize returns null for some specific reason (e.g., user is banned). For now, you can simulate this by always redirecting if credentials.email === 'banned@example.com'.
  2. Display Session Info on Homepage:

    • On your src/app/page.tsx, use the useSession hook (remember 'use client' if page.tsx becomes 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.

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:

  1. 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.
  2. 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.

  1. Initialize Prisma:

    npx prisma init
    

    This creates a prisma folder with schema.prisma and an updated .env file.

  2. Configure .env file: Update DATABASE_URL with your PostgreSQL connection string.

    # .env
    DATABASE_URL="postgresql://user:password@localhost:5432/nextjsdb?schema=public"
    

    Replace user, password, localhost, 5432, and nextjsdb with your actual database credentials.

  3. 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 User model, we are adding password for simplicity. In a real application, NEVER store plain passwords. Always hash them using a library like bcrypt.

  4. Run Prisma Migrations: This creates the actual tables in your database.

    npx prisma migrate dev --name init
    
    • --name init: Gives the migration a name.
  5. Generate Prisma Client: This generates the type-safe client based on your schema.

    npx prisma generate
    
  6. Create a Prisma Client Instance (Singleton): To ensure only one instance of PrismaClient is 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;
    
  7. 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-memory todos array 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.' };
        }
    }
    
  8. Update src/app/todos/page.tsx: Change id: number to id: string in TodoItem component 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
      // ...
    }
    // ...
    
  9. 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>
      );
    }
    
  10. Save all files. Make sure your PostgreSQL database is running.

    • Restart npm run dev.
    • Navigate to http://localhost:3000/auth/register to 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!

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):

  1. Add a Bio field to User and display on Dashboard:

    • Update prisma/schema.prisma to add an optional bio field to the User model (bio String?).
    • Run npx prisma migrate dev again to apply schema changes (you’ll be prompted for a migration name).
    • Modify src/app/api/auth/[...nextauth]/route.ts (the authorize callback) to also return the bio field if it exists.
    • Update src/types/next-auth.d.ts to include bio in Session.user.
    • Modify src/app/dashboard/page.tsx to 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 createUser Server Action.
  2. Filter Products by Category from Database:

    • If you had a Product model in Prisma (similar to our Todo model) with a category field.
    • 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 products page (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 using prisma.product.findMany({ where: { category: selectedCategory } }). Remember to use useSearchParams for accessing query parameters in Client Components and passing them to Server Components via props or as part of navigation.

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.