Skip to content

User Authentication in Next.js

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.

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.

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 Next.js Route Handlers. When users click the verification link in their email, it will process the token server-side and either redirect them to their profile or show an error page with debugging information.

The verification Route Handler is a server-side API endpoint that processes email verification tokens. It validates the token, handles edge cases (like already signed-in users), and redirects appropriately based on the verification result.

import type { ErrorResponse } from "@nhost/nhost-js/auth";
import type { FetchError } from "@nhost/nhost-js/fetch";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { createNhostClient } from "../../lib/nhost/server";
export async function GET(request: NextRequest) {
const refreshToken = request.nextUrl.searchParams.get("refreshToken");
if (!refreshToken) {
// Collect all query parameters for debugging
const params = new URLSearchParams(request.nextUrl.searchParams);
params.set("message", "No refresh token provided");
return NextResponse.redirect(
new URL(`/verify/error?${params.toString()}`, request.url),
);
}
try {
const nhost = await createNhostClient();
if (nhost.getUserSession()) {
// Collect all query parameters
const params = new URLSearchParams(request.nextUrl.searchParams);
params.set("message", "Already signed in");
return NextResponse.redirect(
new URL(`/verify/error?${params.toString()}`, request.url),
);
}
// Process the verification token
await nhost.auth.refreshToken({ refreshToken });
// Redirect to profile on successful verification
return NextResponse.redirect(new URL("/profile", request.url));
} catch (err) {
const error = err as FetchError<ErrorResponse>;
const errorMessage = `Failed to verify token: ${error.message}`;
// Collect all query parameters
const params = new URLSearchParams(request.nextUrl.searchParams);
params.set("message", errorMessage);
return NextResponse.redirect(
new URL(`/verify/error?${params.toString()}`, 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.

"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.

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 middleware 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.

import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { handleNhostMiddleware } from "./lib/nhost/server";
// Define public routes that don't require authentication
const publicRoutes = ["/", "/signin", "/signup", "/verify", "/verify/error"];
export async function middleware(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 handleNhostMiddleware(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 middleware 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.

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 route handler will process the token 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 middleware-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.

Route Handlers

Custom /verify Route Handler that securely processes email verification tokens server-side with proper error handling.

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.