---
title: "Proxy and Protected Routes"
description: "Create a proxy for session management and route protection using Next.js 16's new proxy.ts pattern, replacing the legacy middleware approach."
canonical_url: "https://vercel.com/academy/subscription-store/proxy-and-protected-routes"
md_url: "https://vercel.com/academy/subscription-store/proxy-and-protected-routes.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-04-11T07:44:41.259Z"
content_type: "lesson"
course: "subscription-store"
course_title: "Launch a Subscription Store with Vercel and Stripe"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Proxy and Protected Routes

# 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:**
   - Clear your cookies or use incognito
   - Visit <http://localhost:3000/protected>
   - You should be redirected to `/sign-in`

2. **Test authenticated redirect:**
   - Sign in at `/sign-in`
   - Visit <http://localhost:3000> (root)
   - You should be redirected to `/protected`

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

```bash
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`:

```typescript title="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:

```typescript title="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`:

```typescript title="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`:

```typescript title="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.


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
