Vercel Logo

Proxy and Protected Routes

In Next.js 16, proxy.ts replaces middleware.ts. It runs on every request before your routes render, making it perfect for session refresh and route protection. Without it, users would be logged out when their session token expires.

Outcome

Create a proxy that refreshes auth sessions and protects the /protected route from unauthenticated access.

Fast Track

  1. Implement utils/supabase/proxy.ts with updateSession function
  2. The root proxy.ts is already configured to call updateSession
  3. The app/protected/page.tsx is ready to display user info once auth works

Hands-on Exercise 1.5

Implement route protection with Next.js 16 proxy (TODO stub is provided):

Requirements:

  1. Implement updateSession in utils/supabase/proxy.ts to refresh auth cookies
  2. The root proxy.ts is already set up to call your function
  3. Redirect unauthenticated users from /protected/* to /sign-in
  4. Redirect authenticated users from / to /protected
  5. Verify the protected page displays user information

Implementation hints:

  • The proxy creates its own Supabase client with request/response cookie handling
  • Always call supabase.auth.getUser() to refresh the session
  • Return the modified response to ensure cookies are set correctly
  • Use a matcher config to skip static files

Try It

  1. Test unauthenticated access:

  2. Test authenticated redirect:

  3. Test protected page:

    • While signed in, visit /protected
    • You should see your email and user ID
    • You should see a Sign Out button
  4. Test sign out:

    • Click Sign Out
    • You should be redirected to /sign-in
    • Visiting /protected should redirect to /sign-in

Commit

git add -A
git commit -m "feat(auth): add proxy and protected routes"

Done-When

  • utils/supabase/proxy.ts exports updateSession function
  • proxy.ts exists at project root
  • Unauthenticated users redirected from /protected/* to /sign-in
  • Authenticated users redirected from / to /protected
  • Protected page displays user email and ID
  • Session persists across page refreshes
  • Sign out works and redirects to /sign-in

Solution

Step 1: Implement Session Update Utility

Replace the TODO stub in utils/supabase/proxy.ts:

utils/supabase/proxy.ts
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_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          );
          supabaseResponse = NextResponse.next({
            request,
          });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );
        },
      },
    }
  );
 
  // IMPORTANT: Avoid writing any logic between createServerClient and
  // supabase.auth.getUser(). A simple mistake could make it very hard to debug
  // issues with users being randomly logged out.
 
  const user = await supabase.auth.getUser();
 
  if (request.nextUrl.pathname.startsWith("/protected") && user.error) {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }
 
  if (request.nextUrl.pathname === "/" && !user.error) {
    return NextResponse.redirect(new URL("/protected", request.url));
  }
 
  // IMPORTANT: You *must* return the supabaseResponse object as it is. If you're
  // creating a new response object with NextResponse.next() make sure to:
  // 1. Pass the request in it, like so:
  //    const myNewResponse = NextResponse.next({ request })
  // 2. Copy over the cookies, like so:
  //    myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
  // 3. Change the myNewResponse object to fit your needs, but avoid changing
  //    the cookies!
  // 4. Finally:
  //    return myNewResponse
  // If this is not done, you may be causing the browser and server to go out
  // of sync and terminate the user's session prematurely!
 
  return supabaseResponse;
}

Step 2: Verify Root Proxy

The root proxy.ts is already configured in the starter:

proxy.ts
import { type NextRequest } from "next/server";
import { updateSession } from "@/utils/supabase/proxy";
 
export async function proxy(request: NextRequest) {
  return await updateSession(request);
}
 
export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * Feel free to modify this pattern to include more paths.
     */
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

This file calls your updateSession function on every request.

Step 3: Verify Protected Layout

The protected layout is already in the starter at app/protected/layout.tsx:

app/protected/layout.tsx
import Content from "@/components/content";
 
export default function ProtectedLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return <Content>{children}</Content>;
}

Step 4: Verify Protected Page

The protected page is already in the starter at app/protected/page.tsx:

app/protected/page.tsx
import { createSupabaseClient } from "@/utils/supabase/server";
import AuthPageSignOutButton from "@/components/auth-sign-out-button";
 
export default async function ProtectedPage() {
  const supabase = await createSupabaseClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
 
  return (
    <div className="space-y-8">
      <div className="flex items-center justify-between">
        <div className="flex flex-col">
          <h1 className="text-2xl font-medium">My Guild Profile</h1>
          <p className="text-muted-foreground mt-2">
            Manage your guild membership
          </p>
        </div>
        <AuthPageSignOutButton />
      </div>
 
      <div className="space-y-6">
        <div className="border rounded-lg p-6 space-y-4">
          <h2 className="font-medium">Guild Member Information</h2>
          <div className="grid gap-2 text-sm">
            <div className="grid grid-cols-[120px_1fr]">
              <div className="text-muted-foreground">Email</div>
              <div>{user?.email}</div>
            </div>
            <div className="grid grid-cols-[120px_1fr]">
              <div className="text-muted-foreground">Member ID</div>
              <div className="font-mono text-xs">{user?.id}</div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

The AuthPageSignOutButton is a pre-built client component in the starter that handles sign-out with a loading spinner.

File Structure After This Lesson

subscription-storefront/
├── proxy.ts                    ← Calls your updateSession function
├── app/
│   ├── (auth)/
│   │   ├── layout.tsx
│   │   ├── sign-up/page.tsx
│   │   └── sign-in/page.tsx
│   ├── protected/
│   │   ├── layout.tsx
│   │   └── page.tsx            ← Displays user info
│   └── actions.ts              ← Implemented in lesson 1.4
└── utils/
    ├── redirect.ts
    └── supabase/
        ├── client.ts           ← Implemented in lesson 1.3
        ├── server.ts           ← Implemented in lesson 1.3
        └── proxy.ts            ← Implemented in this lesson

How the Proxy Works

Browser Request → proxy.ts → updateSession()
                              ↓
                    Create Supabase client
                    (reads cookies from request)
                              ↓
                    Call auth.getUser()
                    (refreshes session if needed)
                              ↓
                    Check protection rules
                    ↓                    ↓
             /protected?          Authenticated?
             + no user            + at root?
                    ↓                    ↓
             Redirect to         Redirect to
             /sign-in            /protected
                              ↓
                    Return response
                    (with updated cookies)

Why proxy.ts Instead of middleware.ts?

Next.js 16 renamed middleware to proxy to clarify its purpose. The proxy runs in front of your app on every request, making it ideal for:

  • Session refresh (what we're doing)
  • Redirects based on auth state
  • A/B testing
  • Geolocation-based routing

Troubleshooting

"Users randomly logged out":

Make sure you return the supabaseResponse object, not a new NextResponse.next(). The response contains updated cookies that must be sent to the browser.

Redirect loops:

Check your conditions in updateSession. Make sure you're not redirecting authenticated users away from pages they should access.

Proxy not running:

  • Verify proxy.ts is at the project root (same level as package.json)
  • Check the matcher config includes your routes
  • Restart the dev server

Session not persisting:

  • Check browser cookies for sb- prefixed cookies
  • Verify environment variables are correct
  • Make sure auth.getUser() is called in the proxy

Section Complete

You now have a working authentication system:

  • Lesson 1.1: Deployed starter repo
  • Lesson 1.2: Configured Supabase credentials
  • Lesson 1.3: Created browser and server clients
  • Lesson 1.4: Built sign-up and sign-in pages
  • Lesson 1.5: Protected routes with proxy

Next up in Section 2: You'll integrate Stripe to add subscription billing to your storefront.