Protected Dashboard with Supabase Auth
Build a Next.js 16 app with cookie-based auth and a protected dashboard.
Introduction
30 Second Summary
Every time you log in to an app, there's a system behind the scenes verifying who you are and deciding what you're allowed to see. This invisible gatekeeper is authentication, and building one from scratch teaches you how real apps keep user data safe.
In this project, you will build a Next.js 16 application with Supabase authentication. You'll create a login page, protect a dashboard route, and use modern server-side patterns to validate user sessions securely.
What You'll Build
A full-stack authenticated web app with email/password login, route protection using Next.js 16's proxy convention, and a protected dashboard that only signed-in users can access.
By the end of this project, you'll have:
- A login and signup page that handles both flows using Server Actions for secure server-side authentication.
- A proxy.ts file that refreshes auth tokens and protects routes using Next.js 16's new proxy convention (the replacement for middleware).
- A protected dashboard that validates user identity with getClaims() for secure JWT signature verification.
- Secret Mission: Add sign-out functionality and an email confirmation endpoint for production-ready auth.
Are there any prerequisites?
You'll need basic React knowledge and Node.js installed (v22 or higher). You'll also need a free Supabase account and Cursor installed on your machine.
Set Up Your Project
Every authentication system starts with two pieces: a frontend framework to build the login page, and a backend service to handle user accounts securely.
In this step, you will create a Next.js 16 app and connect it to a Supabase project. Supabase provides the authentication backend (user signup, login, sessions) while Next.js handles the pages your users interact with.
In this step, get ready to:
- Create a Next.js 16 app with TypeScript and Tailwind CSS.
- Install the Supabase authentication packages.
- Connect your app to a Supabase project with environment variables.
Create a Next.js app and install Supabase packages
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- Open a new terminal by pressing Ctrl+` (backtick key) or clicking Terminal in the top menu bar, then New Terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Next.js project by running this command:
npx create-next-app@latest supabase-auth-app
What will the wizard ask?
Since you passed supabase-auth-app as the project name, the wizard skips the naming prompt and jumps straight to a defaults question.
Select Yes, use recommended defaults when prompted. This gives you TypeScript, ESLint, Tailwind CSS, and the App Router. No src/ directory is included in the defaults.
- Once the setup finishes, move into your new project folder and install the Supabase packages by running these commands:
cd supabase-auth-app
npm install @supabase/supabase-js @supabase/ssr
What are these packages?
The @supabase/supabase-js package is the main Supabase client library for JavaScript. It handles authentication, database queries, and storage.
The @supabase/ssr package adds server-side rendering support. It manages cookies so your authentication sessions work across both server and client components.
- Start the development server by running:
npm run dev
- Open your browser and navigate to http://localhost:3000.
- You should see the default Next.js welcome page with the Next.js logo and some getting-started links.
Not seeing the welcome page?
- Check your terminal for error messages. If it says the port is in use, stop whatever is running on port 3000 and try again.
- Make sure you ran npm run dev from inside the supabase-auth-app folder, not from the Desktop.
Why isn't my Next.js dev server starting?
Create a Supabase project and get credentials
Your Next.js app needs to know where to send authentication requests. You will create a Supabase project and grab its URL and publishable key.
- Open a new browser tab and go to https://database.new.
- This takes you to the Supabase Dashboard to create a new project.
- Give your project a name (for example, nextwork-auth) and set a database password.
- Select a region close to you, then click Create new project.
- Wait for the project to finish setting up. This usually takes about a minute.
What is Supabase?
Supabase is an open-source backend platform that gives you a PostgreSQL database, user authentication, file storage, and real-time subscriptions out of the box. The free tier includes 50,000 monthly active users and costs nothing for this project.
- Once your project is ready, click Connect in the top navigation bar to open the Connect panel.
- Copy the Project URL value. It looks like https://abcdefgh.supabase.co.
- Copy the publishable key (also called the anon key). This is safe to use in client-side code.
Now store these credentials in your project as environment variables.
- Go back to Cursor. Stop the running dev server by pressing Ctrl+C in the terminal.
- Create a new file called .env.local in the root of your supabase-auth-app folder by running:
touch .env.local
- Open .env.local in the Cursor editor by clicking on it in the file explorer sidebar.
- Paste the following into the file, replacing the placeholder values with the URL and key you copied from the Supabase Dashboard:
NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key
Why NEXT_PUBLIC_ prefix?
Environment variables prefixed with NEXT_PUBLIC_ are exposed to the browser. This is intentional for the Supabase URL and publishable key because the client-side JavaScript needs them to communicate with Supabase.
The publishable key is designed to be safe in client code. It only allows operations that pass your Row Level Security policies.
- Save .env.local.
✔️ Awesome, I've got everything!
Great. Double check you have both values pasted in and saved the file.
ⓧ I'd like to double check the full code
Your .env.local should look like this (with your actual values from the Supabase Dashboard instead of the placeholders):
NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key
Verify the full setup
Environment variables are only loaded when the dev server starts. You need to restart it for your new .env.local values to take effect.
- Start the dev server again by running:
npm run dev
- Refresh http://localhost:3000 in your browser.
- You should see the same Next.js welcome page as before, with no errors in your terminal. This confirms your environment variables loaded successfully.
Seeing an error about missing environment variables?
- Make sure your file is named exactly .env.local (with the leading dot). Files starting with a dot are hidden by default in some file explorers.
- Check that you stopped the old server with Ctrl+C before running npm run dev again. The server must fully restart to pick up new environment variables.
Help me troubleshoot my .env.local setup in Next.js
Your project foundation is solid. Next up, you will create the Supabase client utilities and the proxy file that keeps your authentication sessions alive across page navigations.
Create Supabase Client Utilities and Proxy
Your Next.js app and Supabase project are connected through environment variables. Now it's time to build the bridge that lets your app actually talk to Supabase.
Supabase needs two types of clients to work with Next.js: one for the browser and one for the server. You'll also create a proxy file that refreshes authentication tokens on every request and protects routes from unauthenticated visitors.
In this step, get ready to:
- Create browser and server Supabase client utilities.
- Create a proxy file that refreshes auth tokens and protects routes.
- Understand how the three pieces work together.
Create the Supabase client utility files
Supabase's official docs recommend a lib/supabase/ folder with a separate file for each client type. This keeps your auth logic organized and reusable across your app.
- Switch back to your terminal and create the utility folder by running this command:
mkdir -p lib/supabase
- Confirm the folder exists by running:
ls lib/supabase
You should see no output (the folder is empty). If you see an error, double-check that your terminal is in the supabase-auth-app directory.
- Create a new file called client.ts inside lib/supabase/ and add the following code:
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
);
}
What does this code do?
This creates a Supabase client that runs in the browser. The createBrowserClient function from @supabase/ssr uses a singleton pattern internally, so you only ever create one instance no matter how many times you call createClient().
The ! after each environment variable tells TypeScript you guarantee these values exist (which they do, since you set them in .env.local).
- Save lib/supabase/client.ts.
Seeing red squiggly lines?
If Cursor shows a TypeScript error on the import, make sure you installed @supabase/ssr in the previous step. You can verify by checking your package.json for the dependency.
help me fix TypeScript import errors in my Supabase client file
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
);
}
- Create a new file called server.ts inside lib/supabase/ and add the following code:
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// The `setAll` method is called from a Server Component.
// This can be ignored if you have proxy refreshing sessions.
}
},
},
}
);
}
What does this code do?
This creates a Supabase client that runs on the server. Unlike the browser client, createServerClient must be created fresh on every request because it needs the cookies from that specific request.
The cookies() function from next/headers is a dynamic API that must be awaited in Next.js 16. The getAll and setAll methods let Supabase read and write auth cookies on the server.
The try/catch in setAll handles cases where cookies can't be set (like inside a Server Component). This is safe because your proxy handles session refresh separately.
- Save lib/supabase/server.ts.
- Confirm there are no red underlines or TypeScript errors in either file.
TypeScript errors in server.ts?
If you see an error on await cookies(), make sure your createClient function is marked async. In Next.js 16, dynamic APIs like cookies() must be awaited.
help me fix TypeScript errors in my Supabase server client file
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// The `setAll` method is called from a Server Component.
// This can be ignored if you have proxy refreshing sessions.
}
},
},
}
);
}
Create the proxy for session refresh
In Next.js 16, route protection lives in a file called proxy.ts at the project root. This replaces the old middleware.ts convention from earlier versions of Next.js.
You'll create two files: one with the session update logic, and one at the root that Next.js uses as the entry point.
- Create a new file called proxy.ts inside lib/supabase/ and add the following code:
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function updateSession(request: NextRequest) {
// Start with a "pass-through" response
let supabaseResponse = NextResponse.next({
request,
});
// Create a Supabase client wired to the request/response cookies
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet, headers) {
// Forward cookies to the request so Server Components can read them
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({
request,
});
// Forward cookies to the response so the browser stores them
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
// Apply cache headers to prevent CDN caching leaks
if (headers) {
Object.entries(headers).forEach(([key, value]) =>
supabaseResponse.headers.set(key, value)
);
}
},
},
}
);
// Refresh the session by validating the JWT
const {
data: { claims },
} = await supabase.auth.getClaims();
// Redirect unauthenticated users away from protected routes
if (
!claims &&
request.nextUrl.pathname.startsWith("/dashboard")
) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return supabaseResponse;
}
What does this code do?
- updateSession runs on every incoming request that matches the proxy's URL pattern.
- It creates a Supabase client wired to the request and response cookies. This lets it read existing tokens and write refreshed ones.
- getClaims() validates the JWT signature against the project's published public keys. If the token is expired but refreshable, this call also refreshes it.
- If claims is null (no valid session) and the user is trying to access /dashboard, the proxy redirects them to /login.
- The headers argument in setAll contains cache headers (Cache-Control, Expires, Pragma) that must be applied to the response to prevent CDN caching leaks.
- Save lib/supabase/proxy.ts.
Seeing errors on NextResponse or NextRequest?
These types come from next/server. If you see red underlines, confirm that next is installed in your package.json and that you spelled the import path exactly as shown.
help me fix import errors in my proxy.ts file
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({
request,
});
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
if (headers) {
Object.entries(headers).forEach(([key, value]) =>
supabaseResponse.headers.set(key, value)
);
}
},
},
}
);
const {
data: { claims },
} = await supabase.auth.getClaims();
if (
!claims &&
request.nextUrl.pathname.startsWith("/dashboard")
) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return supabaseResponse;
}
Now you need the entry point file that Next.js 16 looks for at the project root.
- Create a new file called proxy.ts at the root of your supabase-auth-app project (next to package.json) and add the following code:
import { type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/proxy";
// This is Next.js 16's proxy convention (replaces middleware.ts)
export async function proxy(request: NextRequest) {
return await updateSession(request);
}
// Skip running the proxy on static assets and images
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
What does this code do?
This is Next.js 16's entry point for request interception. In earlier versions of Next.js, you'd export a middleware function from middleware.ts. In Next.js 16, you export a proxy function from proxy.ts instead.
The config.matcher tells Next.js which routes should run through the proxy. The regex pattern excludes static files and images so they load without delay.
- Save proxy.ts.
- Return to your browser and confirm the app still loads at http://localhost:3000 without errors.
App not loading or showing an error?
If you see a build error in the terminal, check that your root proxy.ts file is in the same folder as package.json (not inside app/ or lib/). Also confirm the import path uses @/lib/supabase/proxy (with the @ alias).
help me fix my proxy.ts build error
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/proxy";
export async function proxy(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
Understand the architecture
You now have four files that form the authentication backbone of your app. Here's how they fit together.
How do the three client types work together?
- Browser client (client.ts): Uses a singleton pattern. No matter how many components call createClient(), they all share one instance. Runs in the browser only.
- Server client (server.ts): Created fresh on every request because it needs the cookies from that specific request. Used inside Server Components, Server Actions, and Route Handlers.
- Proxy (proxy.ts): Runs on every matched request before your page loads. It refreshes expired tokens and passes them to both Server Components (via request.cookies.set) and the browser (via response.cookies.set).
Why getClaims() instead of getSession()?
getClaims() validates the JWT signature against the project's published public keys every time. This proves the token is authentic and not tampered with.
getSession() reads the session from cookies but does not re-validate the token. On the server, this means a malicious user could forge a cookie and bypass your protection. Always use getClaims() to protect pages and user data.
Your auth infrastructure is ready. Next up, you'll build the login and signup page that lets users create accounts and sign in.
Build the Login and Signup Page
Your Supabase infrastructure is wired up. The client utilities handle cookies, and the proxy refreshes tokens on every request.
Now you need a way for users to actually create accounts and sign in. In this step, you will build a login page with a form, connect it to Server Actions that talk to Supabase Auth, and configure your Supabase project to allow instant testing without email verification.
In this step, get ready to:
- Create a login and signup form page.
- Create Server Actions that handle authentication logic.
- Configure Supabase email settings and test the full sign-in flow.
Create the login page UI
The login page needs a form with email and password fields, plus two buttons. One for signing in (existing users) and one for signing up (new users).
- Create a new folder and file at app/login/page.tsx by running these commands:
mkdir -p app/login
touch app/login/page.tsx
- Open app/login/page.tsx in Cursor and paste the following code:
import { login, signup } from "./actions";
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-6 text-center text-2xl font-bold text-gray-900">
Welcome
</h1>
<form className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password
</label>
<input
id="password"
name="password"
type="password"
required
minLength={6}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
/>
</div>
<div className="flex gap-4">
<button
formAction={login}
className="flex-1 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Sign In
</button>
<button
formAction={signup}
className="flex-1 rounded-md border border-blue-600 px-4 py-2 text-blue-600 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Sign Up
</button>
</div>
</form>
</div>
</div>
);
}
What does this code do?
This component renders a centered card with a login form. The two <button> elements each use the formAction prop to point at different Server Actions.
When the user clicks Sign In, the form data is sent to the login action. When they click Sign Up, it goes to signup instead. Both actions share the same form fields (email and password).
- Save app/login/page.tsx.
- Navigate to http://localhost:3000/login in your browser. You should see a centered white card with email and password fields plus two buttons (Sign In and Sign Up).
Seeing a build error?
The page imports login and signup from ./actions, which does not exist yet. If your dev server shows an error, that is expected. You will create the actions file in the next substep.
If you see a different error (like a missing module for Supabase), make sure you ran npm install @supabase/supabase-js @supabase/ssr in the previous step.
help me fix a build error on my login page
Create the Server Actions for authentication
Server Actions run on the server, not the browser. They receive the form data directly and can safely call Supabase Auth without exposing any logic to the client.
- Create the actions file by running:
touch app/login/actions.ts
- Open app/login/actions.ts in Cursor and paste the following code:
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export async function login(formData: FormData) {
const supabase = await createClient();
// Extract email and password from the submitted form
const data = {
email: formData.get("email") as string,
password: formData.get("password") as string,
};
const { error } = await supabase.auth.signInWithPassword(data);
if (error) {
redirect("/error?message=" + encodeURIComponent(error.message));
}
revalidatePath("/", "layout");
redirect("/dashboard");
}
export async function signup(formData: FormData) {
const supabase = await createClient();
// Extract email and password from the submitted form
const data = {
email: formData.get("email") as string,
password: formData.get("password") as string,
};
const { error } = await supabase.auth.signUp(data);
if (error) {
redirect("/error?message=" + encodeURIComponent(error.message));
}
revalidatePath("/", "layout");
redirect("/dashboard");
}
What does this code do?
- "use server" at the top marks every exported function in this file as a Server Action. Next.js ensures these functions only run on the server.
- The login function calls supabase.auth.signInWithPassword() with the email and password. On success, it redirects to /dashboard.
- The signup function calls supabase.auth.signUp() to create a new account. On success, it also redirects to /dashboard.
- Both functions redirect to /error with the error message if something goes wrong. The revalidatePath call clears Next.js cache so protected pages pick up the new session.
- Save app/login/actions.ts.
Now you need an error page to display when authentication fails.
- Create the error page folder and file by running:
mkdir -p app/error
touch app/error/page.tsx
- Open app/error/page.tsx in Cursor and paste the following code:
export default async function ErrorPage({
searchParams,
}: {
searchParams: Promise<{ message?: string }>;
}) {
const { message } = await searchParams;
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-4 text-2xl font-bold text-red-600">
Something went wrong
</h1>
<p className="mb-6 text-gray-700">
{message || "An unexpected error occurred."}
</p>
<a
href="/login"
className="text-blue-600 hover:underline"
>
Back to login
</a>
</div>
</div>
);
}
Why is searchParams a Promise?
In Next.js 16, the searchParams prop passed to page components is a Promise that must be awaited. This is a change from earlier versions where it was a plain object.
The error page reads the message query parameter from the URL (set by the redirect in your actions) and displays it to the user.
- Save app/error/page.tsx.
- Refresh http://localhost:3000/login in your browser. The page should now load without errors, showing the form with both buttons.
Still seeing a build error?
Make sure app/login/actions.ts has "use server" as the very first line. Without it, Next.js does not know to treat these functions as Server Actions.
Also check that the import path @/lib/supabase/server resolves correctly. The @/ alias should point to your project root (this is configured automatically by create-next-app).
help me fix my Server Actions build error
✔️ Awesome, I've got everything!
Great. Double check you have saved both app/login/actions.ts and app/error/page.tsx.
ⓧ I'd like to double check the full code
Here is app/login/page.tsx:
import { login, signup } from "./actions";
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-6 text-center text-2xl font-bold text-gray-900">
Welcome
</h1>
<form className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password
</label>
<input
id="password"
name="password"
type="password"
required
minLength={6}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
/>
</div>
<div className="flex gap-4">
<button
formAction={login}
className="flex-1 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Sign In
</button>
<button
formAction={signup}
className="flex-1 rounded-md border border-blue-600 px-4 py-2 text-blue-600 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Sign Up
</button>
</div>
</form>
</div>
</div>
);
}
Here is app/login/actions.ts:
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export async function login(formData: FormData) {
const supabase = await createClient();
const data = {
email: formData.get("email") as string,
password: formData.get("password") as string,
};
const { error } = await supabase.auth.signInWithPassword(data);
if (error) {
redirect("/error?message=" + encodeURIComponent(error.message));
}
revalidatePath("/", "layout");
redirect("/dashboard");
}
export async function signup(formData: FormData) {
const supabase = await createClient();
const data = {
email: formData.get("email") as string,
password: formData.get("password") as string,
};
const { error } = await supabase.auth.signUp(data);
if (error) {
redirect("/error?message=" + encodeURIComponent(error.message));
}
revalidatePath("/", "layout");
redirect("/dashboard");
}
Here is app/error/page.tsx:
export default async function ErrorPage({
searchParams,
}: {
searchParams: Promise<{ message?: string }>;
}) {
const { message } = await searchParams;
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-4 text-2xl font-bold text-red-600">
Something went wrong
</h1>
<p className="mb-6 text-gray-700">
{message || "An unexpected error occurred."}
</p>
<a
href="/login"
className="text-blue-600 hover:underline"
>
Back to login
</a>
</div>
</div>
);
}
Configure Supabase email settings for the project
Before you can test signup, you need to tell Supabase where your app lives and disable email verification so you can test instantly without checking an inbox.
- Open your Supabase project dashboard at https://supabase.com/dashboard.
- In the left sidebar, click Authentication.
- Click URL Configuration in the sub-navigation.
- In the Site URL field, enter http://localhost:3000 and click Save.
Why set the Site URL?
The Site URL tells Supabase where to redirect users after email confirmations and password resets. Setting it to your local dev server ensures these flows work correctly during development.
- Now click Sign In / Providers in the sub-navigation (still under Authentication).
- Expand the Email provider.
- Toggle off the Confirm email setting.
- Click Save.
Why disable Confirm email?
With Confirm email enabled, new users must click a verification link in their email before they can sign in. Disabling it lets you test signup and sign-in instantly without waiting for emails.
You will re-enable this in the Secret Mission when you build a proper email confirmation endpoint.
Now test the full authentication flow.
- Return to http://localhost:3000/login in your browser.
- Enter a test email (like test@example.com) and a password (at least 6 characters).
- Click Sign Up.
- You should be redirected to /dashboard, which shows a 404 page. That is expected because you have not built the dashboard yet.
- To verify the user was created, go back to your Supabase Dashboard.
- Click Authentication in the left sidebar, then click Users.
- You should see your test email listed as a registered user.
Redirected to the error page instead?
If you see the error page after clicking Sign Up, check the error message displayed. Common causes:
- Your password is fewer than 6 characters. Supabase requires a minimum of 6 characters.
- Your .env.local has incorrect values. Double-check the URL and publishable key match your Supabase project.
- You did not restart the dev server after creating .env.local. Stop the server and run npm run dev again.
help me fix my Supabase signup error
Your login and signup flow is working. Next up, you will build the protected dashboard page that only authenticated users can access.
Build the Protected Dashboard
Your login and signup flow is working. Users can create accounts and sign in via Server Actions that run securely on the server.
Now you need somewhere for authenticated users to land. In this step, you will build a protected dashboard page that validates the user's identity on the server using getClaims() and redirects unauthenticated visitors back to the login page.
In this step, get ready to:
- Create a protected dashboard page that validates the user's JWT.
- Understand how the proxy and dashboard page work together for defense in depth.
- Test the full authentication flow end-to-end.
Create the dashboard page with auth protection
The dashboard is a Server Component that runs entirely on the server. This means it can validate the user's JWT before any HTML is sent to the browser.
- Create a new folder called dashboard inside the app/ directory of your supabase-auth-app project.
- Create a file called page.tsx inside app/dashboard/ and paste the following code:
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export default async function DashboardPage() {
// Create a server-side Supabase client using cookies from the request
const supabase = await createClient();
// Validate the JWT signature and extract claims
const {
data: { claims },
} = await supabase.auth.getClaims();
// If no valid claims exist, redirect to login
if (!claims) {
redirect("/login");
}
// Render the dashboard with the authenticated user's ID
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-4 text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="mb-2 text-gray-700">Welcome! You are signed in as:</p>
<p className="mb-6 rounded bg-gray-100 p-3 font-mono text-sm text-gray-800">
{claims.sub}
</p>
<p className="text-sm text-gray-500">
This page is protected. Only authenticated users can see it.
</p>
</div>
</div>
);
}
What does this code do?
This Server Component runs on every request to /dashboard. It creates a Supabase client using the cookies attached to the request, then calls getClaims() to validate the JWT signature.
If the token is missing or invalid, claims will be null and the user gets redirected to /login. If valid, claims.sub contains the user's unique ID, which gets displayed on the page.
Why getClaims() instead of getSession()?
getClaims() validates the JWT signature against the project's published public keys every time. This means a tampered or expired token is always rejected.
getSession() does not re-validate the JWT on the server, so a spoofed token could slip through. Always use getClaims() to protect pages and user data in server code.
- Save app/dashboard/page.tsx.
- In your browser, visit http://localhost:3000/dashboard without being signed in. You should be redirected to /login.
Not being redirected to login?
Make sure you saved the file and that the dev server is still running. If you see a 404 instead of a redirect, check that your file is at app/dashboard/page.tsx (not app/dashboard/dashboard.tsx or similar).
help me debug why /dashboard is not redirecting unauthenticated users
Understand how the proxy protects your routes
Your lib/supabase/proxy.ts file from the previous step already contains route protection for /dashboard. This gives you two layers of protection working together.
What is defense in depth?
Layer 1: The proxy runs on every request before it reaches the page. It checks getClaims() and redirects unauthenticated users to /login at the network level. The page never even loads.
Layer 2: The dashboard page itself also validates the claims. If someone somehow bypasses the proxy (or if you add more protected routes later and forget to update the proxy), the page still rejects them.
This pattern is called defense in depth. One layer failing does not compromise the whole system.
- Open lib/supabase/proxy.ts and locate this section near the bottom:
if (
!claims &&
request.nextUrl.pathname.startsWith("/dashboard")
) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
This is the proxy's route guard. It checks if there are no valid claims AND the user is trying to access a path starting with /dashboard. If both conditions are true, the user never reaches the page.
Test the full flow end-to-end
Now that both layers are in place, test the complete authentication loop to confirm everything works together.
- Navigate to http://localhost:3000/login in your browser.
- Sign in with the test email and password you created in the previous step.
- Confirm you are redirected to /dashboard and see your user ID displayed in the gray box.
What is the user ID?
The value displayed is claims.sub, which is the unique UUID that Supabase assigns to each user when they sign up. It is the subject of the JWT token.
- Open a new incognito or private browser window.
- Visit http://localhost:3000/dashboard directly in the incognito window.
- Confirm you get redirected to /login since the incognito window has no auth cookies.
- Go back to your original (non-incognito) browser window where you are signed in.
- Close the tab showing the dashboard.
- Open a new tab and visit http://localhost:3000/dashboard again.
- Confirm the dashboard still loads with your user ID displayed. The proxy refreshed your session from the cookies automatically.
Why does the session persist?
Your auth tokens are stored in cookies, not in-memory state. The proxy runs on every request and calls getClaims() to refresh expired tokens. As long as the cookies exist, the session survives tab closes and page reloads.
Seeing a redirect loop or an error?
If you are stuck in a redirect loop between /dashboard and /login, make sure /login is not included in the proxy's redirect logic. Only paths starting with /dashboard should trigger the redirect.
If the session does not persist after closing a tab, check that both your proxy and server client are correctly reading and writing cookies. The setAll callback in lib/supabase/proxy.ts must set cookies on both the request and the response.
help me debug my authentication redirect loop
✔️ Awesome, I've got everything!
Great. Double check you have saved app/dashboard/page.tsx and that all three test scenarios pass.
ⓧ I'd like to double check the full code
Here is the complete app/dashboard/page.tsx file:
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export default async function DashboardPage() {
const supabase = await createClient();
const {
data: { claims },
} = await supabase.auth.getClaims();
if (!claims) {
redirect("/login");
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-4 text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="mb-2 text-gray-700">Welcome! You are signed in as:</p>
<p className="mb-6 rounded bg-gray-100 p-3 font-mono text-sm text-gray-800">
{claims.sub}
</p>
<p className="text-sm text-gray-500">
This page is protected. Only authenticated users can see it.
</p>
</div>
</div>
);
}
Secret mission
Sign Out and Email Confirmation
Your auth flow works, but it is missing two things every production app needs: a way to sign out, and a way to verify email addresses. In this extension, you will build both using Next.js Route Handlers and Supabase's OTP verification.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on Supabase's free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- A Supabase project (free tier, includes Auth, Database, and Storage).
- Local project folder supabase-auth-app on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project soon.
- Your Supabase project stays active on the free tier. The free tier includes 50,000 monthly active users, 500MB database, and 1GB storage, so it will not cost anything.
- Your local supabase-auth-app folder remains on your Desktop.
- To resume development, open the folder in Cursor and run npm run dev.
Pause - I'll come back to this later
Pause your Supabase project to free it from the active project limit. Your data is preserved and you can resume later.
- In the Supabase Dashboard, click **Settings** in the left sidebar.
- Select **General**.
- Scroll down to **Project availability** and click **Pause Project**.
- Stop the dev server in your terminal by pressing Ctrl+C.
To resume later, return to the same settings page and restore your project with one click.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is irreversible for the Supabase project.
- In the Supabase Dashboard, click **Settings** in the left sidebar.
- Select **General**.
- Scroll down to the **Delete project** section and click **Delete Project**.
- Enter your project name exactly as it appears to confirm, then click **Delete** in the confirmation dialog.
- Stop the dev server in your terminal by pressing Ctrl+C.
- Delete the local project folder by running this command:
cd ~/Desktop
rm -rf supabase-auth-app
On Windows, run this instead:
cd ~\Desktop
Remove-Item -Recurse -Force supabase-auth-app
- Confirm the folder is gone by running:
ls
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-ready authenticated web app using Next.js 16 and Supabase Auth.
You've learned how to:
- Configured cookie-based authentication using @supabase/ssr with separate browser and server clients for secure session management.
- Created a proxy.ts file using Next.js 16's new proxy convention for automatic token refresh and route protection.
- Built a protected dashboard that validates user identity with getClaims() for secure JWT signature verification on every request.
- Secret Mission: Added sign-out functionality and an email confirmation endpoint for production-ready auth.
Ready to quiz yourself?