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:
- Authentication — verifying who someone is. ("Is this really Maya?") Handled by login, password, tokens, or OAuth.
- Authorization — deciding what an authenticated person is allowed to do ("Can Maya edit this project?"). Handled by roles, permissions, and ownership checks.
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
- Provider — the login method (credentials, Google, GitHub, and so on).
- Session — a record Auth.js creates and stores after a successful login. It holds user identity so you do not have to re-authenticate on every request.
- JWT — by default, Auth.js stores the session in a signed JSON Web Token in a cookie. No database table is needed for sessions.
- Middleware — a Next.js file (middleware.ts) that runs on every request before the page renders. Auth.js uses it to check for a valid session and redirect unauthenticated users.
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:
- Public —
/projectsand/projects/[id]. Anyone can browse projects without signing in. You do not need to protect these. - Protected —
/dashboardand all routes beneath it (/dashboard/projects/new,/dashboard/projects/[id]/edit, and so on). Only authenticated users should reach these. The middleware covers this already.
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,
},
});
}