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.
This is Part 3 in the Full-Stack Next.js Development with Nhost series. This part creates a production-ready authentication system with secure email verification and proper error handling using Next.js App Router.

Full-Stack Next.js Development with Nhost

Prerequisites

Step-by-Step Guide

1

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.
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>
  );
}
2

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.
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>
  );
}
3

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.
src/app/verify/route.ts
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),
    );
  }
}
Important Configuration Required: Before testing email verification, you must configure your Nhost project’s authentication settings:
  1. Go to your Nhost project dashboard
  2. Navigate to Settings → Authentication
  3. Add your local development URL (e.g., http://localhost:3000) to the Allowed Redirect URLs field
  4. Ensure your production domain is also added when deploying
Without this configuration, you’ll receive a redirectTo not allowed error when users attempt to sign up or verify their email addresses.
4

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.
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>
  );
}
5

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.
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>
  );
}
6

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.
src/middleware.ts
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).*)",
  ],
};
7

Run and Test the Application

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.

Key Features Demonstrated