Skip to content

User Authentication in Next.js

Learn how to implement user authentication in a Next.js application using Nhost

Next.js user authentication sign up sign in email verification App Router

This tutorial part builds upon the Protected Routes part by adding complete email/password authentication with email verification functionality. You’ll implement sign up, sign in, email verification, and sign out features to create a full authentication flow.

In this step, we’ll create a complete sign-in flow using Next.js App Router patterns. We’ll build three key files: a server component for the main page that handles URL parameters, a client component for the interactive form, and server actions for secure authentication processing.

The main sign-in page is a server component that handles URL parameters (like error messages) and renders the sign-in form. This component runs on the server and can access search parameters directly.

src/app/signin/page.tsx
import Link from "next/link";
import SignInForm from "./SignInForm";
export default async function SignIn({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
// Extract error from URL parameters
const params = await searchParams;
const error = params?.error;
return (
<div>
<h1>Sign In</h1>
<SignInForm initialError={error} />
<div className="auth-links">
<p>
Don't have an account? <Link href="/signup">Sign Up</Link>
</p>
</div>
</div>
);
}

In this step, we’ll build the user registration system with email verification support. The sign-up flow includes handling both the registration form and the email verification success state, all using Next.js server and client components.

The sign-up page is a server component that manages different states: showing the registration form or displaying the email verification success message. It handles URL parameters to determine which state to render.

src/app/signup/page.tsx
import Link from "next/link";
import SignUpForm from "./SignUpForm";
export default async function SignUp({
searchParams,
}: {
searchParams: Promise<{
error?: string;
verify?: string;
email?: string;
}>;
}) {
// Extract parameters from URL
const params = await searchParams;
const error = params?.error;
const verificationSent = params?.verify === "success";
const email = params?.email;
if (verificationSent) {
return (
<div>
<h1>Check Your Email</h1>
<div className="success-message">
<p>
We've sent a verification link to <strong>{email}</strong>
</p>
<p>
Please check your email and click the verification link to activate
your account.
</p>
</div>
<p>
<Link href="/signin">Back to Sign In</Link>
</p>
</div>
);
}
return (
<div>
<h1>Sign Up</h1>
<SignUpForm initialError={error} />
<div className="auth-links">
<p>
Already have an account? <Link href="/signin">Sign In</Link>
</p>
</div>
</div>
);
}

In this step, we’ll implement email verification using PKCE. When users click the verification link in their email, a Route Handler extracts the authorization code and exchanges it for a session using the PKCE verifier stored in a cookie during sign-up. On success it redirects to the profile page; on failure it redirects to an error page.

The verification endpoint is a Route Handler that exchanges the authorization code for a session using PKCE. It reads the code verifier from a cookie (set during sign-up), performs the token exchange server-side, and redirects to the profile page on success. We use a Route Handler instead of a Server Component because cookies can only be modified in a Server Action or Route Handler.

src/app/verify/route.ts
import { cookies } from "next/headers";
import { type NextRequest, NextResponse } from "next/server";
import { createNhostClient } from "../../lib/nhost/server";
const PKCE_VERIFIER_KEY = "nhost_pkce_verifier";
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get("code");
const errorUrl = new URL("/verify/error", request.url);
if (!code) {
errorUrl.searchParams.set("message", "No authorization code found in URL");
return NextResponse.redirect(errorUrl);
}
const cookieStore = await cookies();
const codeVerifier = cookieStore.get(PKCE_VERIFIER_KEY)?.value;
if (!codeVerifier) {
errorUrl.searchParams.set(
"message",
"No PKCE verifier found. The sign-in must be initiated from the same browser.",
);
return NextResponse.redirect(errorUrl);
}
// Remove the PKCE verifier cookie
cookieStore.delete(PKCE_VERIFIER_KEY);
try {
const nhost = await createNhostClient();
await nhost.auth.tokenExchange({ code, codeVerifier });
} catch (err) {
errorUrl.searchParams.set(
"message",
`Verification failed: ${(err as Error).message}`,
);
return NextResponse.redirect(errorUrl);
}
return NextResponse.redirect(new URL("/profile", request.url));
}

In this step, we’ll implement user sign-out functionality using Next.js patterns. We’ll create a client component for the sign-out button and a server action to handle the actual sign-out process securely on the server side.

The sign-out button is a client component that provides an interactive button for users to sign out. It handles the user interaction and calls the server action, then manages navigation and component refresh after sign-out.

src/components/SignOutButton.tsx
"use client";
import { useRouter } from "next/navigation";
import { signOut } from "../lib/nhost/actions";
export default function SignOutButton() {
const router = useRouter();
const handleSignOut = async () => {
try {
await signOut();
router.push("/");
router.refresh(); // Refresh to update server components
} catch (err) {
console.error("Error signing out:", err);
}
};
return (
<button
type="button"
onClick={handleSignOut}
className="nav-link nav-button"
>
Sign Out
</button>
);
}

In this step, we’ll update the server-side navigation component that shows different links based on the user’s authentication state. The navigation will display “Sign In” and “Sign Up” links for unauthenticated users, and “Profile” and “Sign Out” for authenticated users.

src/components/Navigation.tsx
import Link from "next/link";
import { createNhostClient } from "../lib/nhost/server";
import SignOutButton from "./SignOutButton";
export default async function Navigation() {
const nhost = await createNhostClient();
const session = nhost.getUserSession();
return (
<nav className="navigation">
<div className="nav-container">
<Link href="/" className="nav-logo">
Nhost Next.js Demo
</Link>
<div className="nav-links">
<Link href="/" className="nav-link">
Home
</Link>
{session ? (
<>
<Link href="/profile" className="nav-link">
Profile
</Link>
<SignOutButton />
</>
) : (
<>
<Link href="/signin" className="nav-link">
Sign In
</Link>
<Link href="/signup" className="nav-link">
Sign Up
</Link>
</>
)}
</div>
</div>
</nav>
);
}

In this step, we’ll configure the proxy to allow access to authentication-related routes without requiring authentication. This ensures that users can access sign-in, sign-up, and email verification pages even when not logged in.

src/proxy.ts
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { handleNhostProxy } from "./lib/nhost/server";
// Define public routes that don't require authentication
const publicRoutes = ["/", "/signin", "/signup", "/verify", "/verify/error"];
export async function proxy(request: NextRequest) {
// Create a response that we'll modify as needed
const response = NextResponse.next();
// Get the current path
const path = request.nextUrl.pathname;
// Check if this is a public route or a public asset
const isPublicRoute = publicRoutes.some(
(route) => path === route || path.startsWith(`${route}/`),
);
// Handle Nhost authentication and token refresh
// Always call this to ensure session is up-to-date
// even for public routes, so that session changes are detected
const session = await handleNhostProxy(request, response);
// If it's a public route, allow access without checking auth
if (isPublicRoute) {
return response;
}
// If no session and not a public route, redirect to signin
if (!session) {
const homeUrl = new URL("/", request.url);
return NextResponse.redirect(homeUrl);
}
// Session exists, allow access to protected route
return response;
}
// Define which routes this proxy should run on
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public files (public directory)
*/
"/((?!_next/static|_next/image|favicon.ico|public).*)",
],
};

Start your Next.js development server and test the complete authentication flow to ensure everything works properly.

Terminal window
npm run dev

Things to try out:

  1. Email Verification Flow: Try signing up with a new email address. Check your email for the verification link and click it. The verification page will exchange the authorization code for a session using PKCE and redirect you to your profile.
  2. Sign In/Out Flow: Try signing out and then signing back in with the same credentials using the server actions.
  3. Server-Side Navigation: Notice how navigation links change based on authentication state - the navigation component is rendered server-side and shows different content based on the session.
  4. Route Protection: Try accessing protected routes while logged out to see the proxy-based protection in action.
  5. Cross-Tab Consistency: Open multiple tabs and test signing out from one tab. Unlike client-side React apps, you’ll need to refresh or navigate to see changes in other tabs due to server-side rendering.
Server Components & Actions

Full authentication flow using Next.js App Router with server components and server actions for secure, server-side processing.

PKCE Verification

Server-side /verify page that exchanges authorization codes for sessions using PKCE, with the verifier stored in a cookie and the token exchange performed entirely on the server.

Client/Server Separation

Clear separation between server components for rendering and client components for interactivity, following Next.js best practices.

Error Handling

Comprehensive error handling with URL-based error states and dedicated error pages for different failure scenarios.

Session Management

Server-side session handling with sign out functionality using server actions and proper state management.