User Authentication in Next.js
Next.js user authentication sign up sign in email verification App RouterThis 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.
Full-Stack Next.js Development with Nhost
Section titled “Full-Stack Next.js Development with Nhost”Prerequisites
Section titled “Prerequisites”- Complete the Protected Routes part first
- The project from the previous part set up and running
Step-by-Step Guide
Section titled “Step-by-Step Guide”Create the Sign In Flow
Section titled “Create the Sign In 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> );}The sign-in form is a client component that handles user interactions, loading states, and form submissions. It communicates with server actions and provides real-time feedback to users.
"use client";
import { useRouter } from "next/navigation";import { useId, useState } from "react";import { signIn } from "./actions";
interface SignInFormProps { initialError?: string;}
export default function SignInForm({ initialError }: SignInFormProps) { const [error, setError] = useState<string | undefined>(initialError); const [isLoading, setIsLoading] = useState(false); const router = useRouter();
const emailId = useId(); const passwordId = useId();
const handleSubmit = async (formData: FormData) => { setIsLoading(true); setError(undefined);
try { const result = await signIn(formData);
if (result.redirect) { router.push(result.redirect); } else if (result.error) { setError(result.error); } } catch (err: unknown) { setError( err instanceof Error ? err.message : "An error occurred during sign in", ); } finally { setIsLoading(false); } };
return ( <form action={handleSubmit} className="auth-form"> <div className="auth-form-field"> <label htmlFor={emailId}>Email</label> <input id={emailId} name="email" type="email" required className="auth-input" /> </div>
<div className="auth-form-field"> <label htmlFor={passwordId}>Password</label> <input id={passwordId} name="password" type="password" required className="auth-input" /> </div>
{error && <div className="auth-error">{error}</div>}
<button type="submit" disabled={isLoading} className="auth-button secondary" > {isLoading ? "Signing In..." : "Sign In"} </button> </form> );}Server actions handle the authentication logic securely on the server side. They validate form data, communicate with Nhost, and return appropriate responses for success or error states.
"use server";
import type { ErrorResponse } from "@nhost/nhost-js/auth";import type { FetchError } from "@nhost/nhost-js/fetch";import { createNhostClient } from "../../lib/nhost/server";
export async function signIn(formData: FormData) { const email = formData.get("email") as string; const password = formData.get("password") as string;
if (!email || !password) { return { error: "Email and password are required", }; }
try { const nhost = await createNhostClient();
const response = await nhost.auth.signInEmailPassword({ email, password, });
if (response.body?.session) { return { redirect: "/profile" }; } else { return { error: "Failed to sign in. Please check your credentials.", }; } } catch (err) { const error = err as FetchError<ErrorResponse>; return { error: `An error occurred during sign in: ${error.message}`, }; }}Create the Sign Up Flow
Section titled “Create the Sign Up Flow”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> );}The registration form is a client component that collects user information (display name, email, password) and handles form validation, loading states, and error feedback during the sign-up process.
"use client";
import { useRouter } from "next/navigation";import { useId, useState } from "react";import { signUp } from "./actions";
interface SignUpFormProps { initialError?: string;}
export default function SignUpForm({ initialError }: SignUpFormProps) { const [error, setError] = useState<string | undefined>(initialError); const [isLoading, setIsLoading] = useState(false); const router = useRouter();
const displayNameId = useId(); const emailId = useId(); const passwordId = useId();
const handleSubmit = async (formData: FormData) => { setIsLoading(true); setError(undefined);
try { const result = await signUp(formData);
if (result.redirect) { router.push(result.redirect); } else if (result.error) { setError(result.error); } } catch (err: unknown) { setError( err instanceof Error ? err.message : "An error occurred during sign up", ); } finally { setIsLoading(false); } };
return ( <form action={handleSubmit} className="auth-form"> <div className="auth-form-field"> <label htmlFor={displayNameId}>Display Name</label> <input id={displayNameId} name="displayName" type="text" required className="auth-input" /> </div>
<div className="auth-form-field"> <label htmlFor={emailId}>Email</label> <input id={emailId} name="email" type="email" required className="auth-input" /> </div>
<div className="auth-form-field"> <label htmlFor={passwordId}>Password</label> <input id={passwordId} name="password" type="password" required minLength={8} className="auth-input" /> <small className="help-text">Minimum 8 characters</small> </div>
{error && <div className="auth-error">{error}</div>}
<button type="submit" disabled={isLoading} className="auth-button primary" > {isLoading ? "Creating Account..." : "Sign Up"} </button> </form> );}Server actions handle user registration with Nhost, including setting up email verification. They process form data, create user accounts, and coordinate the email verification flow by setting the appropriate redirect URLs.
"use server";
import type { ErrorResponse } from "@nhost/nhost-js/auth";import type { FetchError } from "@nhost/nhost-js/fetch";import { createNhostClient } from "../../lib/nhost/server";
export async function signUp(formData: FormData) { const email = formData.get("email") as string; const password = formData.get("password") as string; const displayName = formData.get("displayName") as string;
if (!email || !password || !displayName) { return { error: "All fields are required", }; }
try { const nhost = await createNhostClient();
const response = await nhost.auth.signUpEmailPassword({ email, password, options: { displayName, // Set the redirect URL for email verification redirectTo: `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/verify`, }, });
if (response.body?.session) { // Successfully signed up and automatically signed in return { redirect: "/profile" }; } else { // Verification email sent return { redirect: `/signup?verify=success&email=${encodeURIComponent(email)}`, }; } } catch (err) { const error = err as FetchError<ErrorResponse>; return { error: `An error occurred during sign up: ${error.message}`, }; }}Create the Email Verification System
Section titled “Create the Email Verification System”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), ); }}The verification error page is a server component that displays helpful error messages and debugging information when email verification fails. It shows the specific error message and any URL parameters that might help diagnose the issue.
import Link from "next/link";
export default async function VerifyError({ searchParams,}: { searchParams: Promise<Record<string, string>>;}) { const params = await searchParams; const message = params?.message || "Unknown verification error";
// Filter out the message to show other URL parameters const urlParams = Object.entries(params).filter(([key]) => key !== "message");
return ( <div> <h1>Email Verification</h1>
<div className="page-center"> <p className="verification-status error">Verification failed</p> <p className="margin-bottom">{message}</p>
{urlParams.length > 0 && ( <div className="debug-panel"> <p className="debug-title">URL Parameters:</p> {urlParams.map(([key, value]) => ( <div key={key} className="debug-item"> <span className="debug-key">{key}:</span>{" "} <span className="debug-value">{value}</span> </div> ))} </div> )}
<Link href="/signin" className="auth-button secondary"> Back to Sign In </Link> </div> </div> );}Create the Sign Out System
Section titled “Create the Sign Out System”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> );}The sign-out server action handles the authentication logic securely on the server side. It retrieves the current session, calls Nhost’s sign-out method with the refresh token, and redirects the user to the home page after successful sign-out.
"use server";
import { redirect } from "next/navigation";import { createNhostClient } from "./server";
export async function signOut() { try { const nhost = await createNhostClient(); const session = nhost.getUserSession();
if (session) { await nhost.auth.signOut({ refreshToken: session.refreshToken, }); } } catch (err) { console.error("Error signing out:", err); throw err; }
redirect("/");}Update Navigation Component
Section titled “Update Navigation Component”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> );}Update Public Routes in Middleware
Section titled “Update Public Routes in Middleware”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 authenticationconst 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 onexport 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).*)", ],};Run and Test the Application
Section titled “Run and Test the Application”Start your Next.js development server and test the complete authentication flow to ensure everything works properly.
npm run devThings to try out:
- 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.
- Sign In/Out Flow: Try signing out and then signing back in with the same credentials using the server actions.
- 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.
- Route Protection: Try accessing protected routes while logged out to see the middleware-based protection in action.
- 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.
Key Features Demonstrated
Section titled “Key Features Demonstrated”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.