WDD 430: Web Full-Stack Development

Adding Authentication

Overview

Authentication controls who can access your application and what they can do once inside. Without it, any visitor could create, edit, or delete data. In this activity you will add authentication to your portfolio project using Auth.js v5 (formerly NextAuth.js), the authentication library recommended by the Next.js team.

By the end of this activity your portfolio app will require the owner to sign in before creating, editing, or deleting projects.

Auth.js vs. NextAuth.js

You may see both names used online. Auth.js is the official rebrand of NextAuth.js starting with version 5. The npm package is still next-auth, but the project site moved to authjs.dev. When you see next-auth@beta in tutorials, that refers to Auth.js v5.

Prepare

Authentication vs. Authorization

These two terms are often confused but describe different things:

The authentication library, Auth.js, handles authentication and developers write the authorization logic. The developer is responsible for implementing authorization rules based on the application's requirements.

Why use Auth.js?

Writing authentication from scratch requires handling password hashing, secure session tokens, CSRF (Cross-Site Request Forgery) protection, and more. Auth.js handles all of that so you can focus on your application. It is open source, free, and runs on your own infrastructure which means that no third-party auth service is required. You can optionally integrate OAuth providers like Google or GitHub, but the authentication logic itself stays in your codebase.

The Core Concepts

Activity Instructions

The following steps guide you through adding Auth.js v5 to your portfolio project and wiring it into the create, edit, and delete flows for portfolio items.

Install the Auth.js Package

npm install next-auth@beta bcryptjs zod
npm install --save-dev @types/bcryptjs

The bcryptjs package is used for password hashing, and zod is used for input validation.

Then generate a secret key and add it to your .env.local automatically:

npx auth secret

This writes AUTH_SECRET to your .env.local file.

Create auth.config.ts

This file holds configuration that is safe to run in middleware. It must not import Node.js-only APIs like bcrypt.

// auth.config.ts  (project root)
import type { NextAuthConfig } from 'next-auth';

export const authConfig = {
  pages: {
    signIn: '/login', // use your own login page instead of the Auth.js default
  },
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;

      // Protect all routes under /dashboard
      const isProtected = nextUrl.pathname.startsWith('/dashboard');

      if (isProtected) {
        if (isLoggedIn) return true;
        return false; // redirects to /login
      }

      // Redirect already-logged-in users away from the login page
      if (isLoggedIn && nextUrl.pathname === '/login') {
        return Response.redirect(new URL('/dashboard', nextUrl));
      }

      return true;
    },
  },
  providers: [], // providers are added in auth.ts
} satisfies NextAuthConfig;

The authorized callback runs before the page renders. Returning false from a protected path automatically redirects the user to the signIn page defined above.

Create auth.ts

This file extends auth.config.ts with the full provider list. Bcrypt and database calls are safe here because this file is never imported by middleware.

// auth.ts  (project root)
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import { authConfig } from './auth.config';
import { getUserByEmail } from '@/lib/data'; // your DB query function

export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      async authorize(credentials) {
        const parsed = z
          .object({ email: z.string().email(), password: z.string().min(6) })
          .safeParse(credentials);

        if (!parsed.success) return null;

        const { email, password } = parsed.data;
        const user = await getUserByEmail(email);
        if (!user) return null;

        const passwordsMatch = await bcrypt.compare(password, user.passwordHash);
        if (passwordsMatch) return user;

        return null;
      },
    }),
  ],
});

Create middleware.ts

This tells Next.js to run the Auth.js session check on every relevant request.

// middleware.ts  (project root)
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';

export default NextAuth(authConfig).auth;

export const config = {
  // Run middleware on all routes except static files and Next.js internals
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

Create the Login Page and Server Action

Create the login page at app/login/page.tsx:

// app/login/page.tsx
import { LoginForm } from '@/components/login-form';

export default function LoginPage() {
  return (
    <main className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-sm">
        <h1>Sign In</h1>
        <LoginForm />
      </div>
    </main>
  );
}

Add an authenticate Server Action to your actions file:

// app/lib/actions.ts
'use server';

import { signIn } from '@/auth';
import { AuthError } from 'next-auth';

export async function authenticate(
  prevState: string | undefined,
  formData: FormData,
) {
  try {
    await signIn('credentials', formData);
  } catch (error) {
    if (error instanceof AuthError) {
      switch (error.type) {
        case 'CredentialsSignin':
          return 'Invalid email or password.';
        default:
          return 'Something went wrong.';
      }
    }
    throw error; // re-throw so Next.js handles redirects correctly
  }
}

Create the client-side form component using useActionState:

// components/login-form.tsx
'use client';

import { useActionState } from 'react';
import { authenticate } from '@/app/lib/actions';

export function LoginForm() {
  const [errorMessage, formAction, isPending] = useActionState(
    authenticate,
    undefined,
  );

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" name="email" required />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" type="password" name="password" minLength={6} required />
      </div>
      <button aria-disabled={isPending} type="submit">
        {isPending ? 'Signing in...' : 'Sign In'}
      </button>
      {errorMessage && <p role="alert">{errorMessage}</p>}
    </form>
  );
}

Sign Out

Call signOut inside a Server Action — the cleanest pattern with the App Router:

// components/sign-out-button.tsx
import { signOut } from '@/auth';

export function SignOutButton() {
  return (
    <form
      action={async () => {
        'use server';
        await signOut({ redirectTo: '/' });
      }}
    >
      <button type="submit">Sign Out</button>
    </form>
  );
}

Reading the Session

Once a user is signed in, you can access their session anywhere in the app.

In a Server Component or Server Action:

import { auth } from '@/auth';

export default async function DashboardPage() {
  const session = await auth();
  const user = session?.user;

  return <p>Welcome, {user?.name}!</p>;
}

In a Client Component, wrap your layout in SessionProvider and use the useSession hook:

// app/layout.tsx
import { SessionProvider } from 'next-auth/react';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <SessionProvider>{children}</SessionProvider>
      </body>
    </html>
  );
}

// In any client component:
'use client';
import { useSession } from 'next-auth/react';

export function UserGreeting() {
  const { data: session } = useSession();
  return <span>{session?.user?.name}</span>;
}

Applying Authentication to Your Portfolio Project

In this portfolio project, there is one authorized owner account that can create, edit, and delete projects. You are not implementing a multi-user ownership model here. The middleware from Step 4 protects the /dashboard routes so unauthenticated visitors cannot reach those pages.

Owner-Only Model

For this course project, authentication confirms the signed-in user is the portfolio owner. You do not need per-record userId ownership checks between multiple users unless you intentionally choose to extend the app later.

Protecting Server Actions for the Owner

Keep all project mutations in Server Actions and verify a valid owner session before writing to the database:

// app/lib/actions.ts
'use server';

import { auth } from '@/auth';
import { db } from '@/lib/db'; // your database client
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

async function requireOwnerSession() {
  const session = await auth();
  if (!session?.user) throw new Error('Not authenticated');
  return session;
}

export async function createProject(formData: FormData) {
  await requireOwnerSession();

  await db.project.create({
    data: {
      title: formData.get('title') as string,
      description: formData.get('description') as string,
      imageUrl: formData.get('imageUrl') as string,
    },
  });

  revalidatePath('/dashboard/projects');
  redirect('/dashboard/projects');
}

export async function updateProject(id: string, formData: FormData) {
  await requireOwnerSession();

  await db.project.update({
    where: { id },
    data: {
      title: formData.get('title') as string,
      description: formData.get('description') as string,
      imageUrl: formData.get('imageUrl') as string,
    },
  });

  revalidatePath('/dashboard/projects');
  redirect('/dashboard/projects');
}

export async function deleteProject(id: string) {
  await requireOwnerSession();

  await db.project.delete({ where: { id } });

  revalidatePath('/dashboard/projects');
}

Never Trust the Client

Always verify authentication on the server before modifying data. Even if mutation controls are only rendered in your dashboard UI, a motivated user can still send direct HTTP requests to your Server Actions.

Rendering Mutation Controls

Place create, edit, and delete controls in protected dashboard routes. Public project pages can remain read-only:

// app/projects/[id]/page.tsx  (Server Component)
import { getProjectById } from '@/lib/data';

export default async function ProjectPage({ params }: { params: { id: string } }) {
  const project = await getProjectById(params.id);

  return (
    <article>
      <h1>{project.title}</h1>
      <p>{project.description}</p>
      {project.imageUrl && (
        <img src={project.imageUrl} alt={project.title} />
      )}
    </article>
  );
}

Public vs. Protected Views

Your portfolio app has two kinds of routes worth thinking about:

Keep this distinction intentional in your routing structure. Public-facing portfolio views live outside /dashboard; create/edit/delete UI lives inside it.

File Structure Summary

After completing this activity, your project root should include these Auth.js files alongside your existing application code:

your-project/
├── auth.config.ts          # Safe config used in middleware (no bcrypt)
├── auth.ts                 # Full config with Credentials provider
├── middleware.ts           # Runs on every request, checks session
├── .env.local              # AUTH_SECRET — never commit this file
└── app/
    ├── login/
    │   └── page.tsx        # Login page
    ├── dashboard/
    │   └── projects/
    │       ├── page.tsx          # List projects in owner dashboard
    │       ├── new/page.tsx      # Create form
    │       └── [id]/edit/
    │           └── page.tsx      # Edit form
    ├── projects/
    │   ├── page.tsx              # Public project listing
    │   └── [id]/page.tsx         # Public project detail (read-only)
    ├── lib/
    │   ├── actions.ts      # createProject, updateProject, deleteProject, authenticate
    │   └── data.ts         # getUserByEmail, getProjectById, and so on.
    └── components/
        ├── login-form.tsx        # Client form with useActionState
        ├── sign-out-button.tsx   # Server Action sign-out
        └── project-actions.tsx  # EditButton, DeleteButton

Alternative: Using Clerk

If you find Auth.js setup difficult to work through, Clerk is a managed authentication service that handles sign-up, sign-in, session management, and user profiles for you. You interact with it through a hosted dashboard and a Next.js SDK rather than configuring providers and middleware manually.

The trade-off: your user data lives on Clerk's servers, not your own database. For this course your project data (the portfolio items themselves) still lives in your own database. Clerk just manages the identity layer.

Clerk Setup

Install the SDK:

npm install @clerk/nextjs

Create an account at clerk.com, create an application, and copy the keys to .env.local:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...

Add the Clerk middleware (replaces the Auth.js middleware.ts):

// middleware.ts  (project root)
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';

const isProtected = createRouteMatcher(['/dashboard(.*)']);

export default clerkMiddleware(async (auth, req) => {
  if (isProtected(req)) await auth.protect();
});

export const config = {
  matcher: ['/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', '/(api|trpc)(.*)'],
};

Wrap your root layout with ClerkProvider:

// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>{children}</body>
      </html>
    </ClerkProvider>
  );
}

Clerk provides pre-built sign-in and sign-up components you can drop into pages:

// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from '@clerk/nextjs';

export default function SignInPage() {
  return (
    <main className="flex min-h-screen items-center justify-center">
      <SignIn />
    </main>
  );
}

Owner-Only Access with Clerk

Use Clerk's auth() helper to confirm an authenticated owner session before running mutations:

// app/lib/actions.ts  (Clerk version)
'use server';

import { auth } from '@clerk/nextjs/server';
import { db } from '@/lib/db';

async function requireOwnerSession() {
  const { userId } = await auth();
  if (!userId) throw new Error('Not authenticated');
  return userId;
}

export async function createProject(formData: FormData) {
  await requireOwnerSession();

  await db.project.create({
    data: {
      title: formData.get('title') as string,
      description: formData.get('description') as string,
    },
  });
}

export async function updateProject(id: string, formData: FormData) {
  await requireOwnerSession();

  await db.project.update({
    where: { id },
    data: {
      title: formData.get('title') as string,
      description: formData.get('description') as string,
    },
  });
}