---
title: "Protected API Routes"
description: "Protect API routes with subscription checks, returning appropriate errors for unauthorized access and completing the access control system."
canonical_url: "https://vercel.com/academy/subscription-store/protected-api-routes"
md_url: "https://vercel.com/academy/subscription-store/protected-api-routes.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-04-11T17:30:30.964Z"
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>

# Protected API Routes

# Protected API Routes

The client component from lesson 3.3 calls `/api/field-guide` on button click. Right now, anyone can hit that endpoint directly—even without a subscription. API routes are your last line of defense. When you check subscriptions here, you guarantee that premium operations only execute for paying users, regardless of how the request arrives.

## Outcome

Protect the `/api/field-guide` route with a subscription check, returning 403 for unauthorized users.

## Fast Track

1. Update `app/api/field-guide/route.ts` with subscription check
2. Return 403 for unauthorized users
3. Test with and without a subscription

## Hands-on Exercise 3.4

Add subscription protection to the field guide API:

**Requirements:**

1. Import and use the Supabase server client
2. Check subscription status before processing
3. Return 403 with text "Membership required" for non-subscribers
4. Return the foraging data for subscribers
5. Handle errors gracefully

**Implementation hints:**

- Use `createSupabaseClient()` and `hasActiveSubscription()`
- Return early with 403 if no active subscription
- The client component already handles 403 responses
- Test the endpoint directly with curl to verify protection

## Try It

1. **Without subscription:**
   - Sign out or use an account without a subscription
   - Visit <http://localhost:3000/protected/paid-content>
   - (You'll be blocked by the server component)
   - Or test directly with curl:
   ```bash
   curl -X POST http://localhost:3000/api/field-guide
   ```
   Expected: `Membership required` with 403 status

2. **With subscription:**
   - Sign in with a subscribed account
   - Visit <http://localhost:3000/protected/paid-content>
   - Click "Discover New Entry"
   - You should see foraging data

3. **Verify in terminal:**
   ```
   POST /api/field-guide 403 12ms
   POST /api/field-guide 200 45ms
   ```

## Commit

```bash
git add -A
git commit -m "feat(access): protect API route with subscription check"
```

## Done-When

- [ ] API route checks subscription status
- [ ] 403 returned for non-subscribers
- [ ] Foraging data returned for subscribers
- [ ] Client component displays appropriate error message

## Solution

### Step 1: Update the API Route

Update `app/api/field-guide/route.ts`:

```typescript title="app/api/field-guide/route.ts"
import { createSupabaseClient } from "@/utils/supabase/server";
import { hasActiveSubscription } from "@/utils/supabase/queries";

const forageDatabase = [
  {
    name: "Chanterelle",
    type: "mushroom",
    edibility: "edible",
    season: "Summer to Fall",
    habitat: "Oak and conifer forests, mossy areas",
    description:
      "Golden-yellow trumpet-shaped mushroom with a fruity, apricot-like aroma. One of the most prized edible wild mushrooms.",
    tips: "Look for false gills that fork and run down the stem. True chanterelles have solid flesh.",
  },
  {
    name: "Ramps (Wild Leeks)",
    type: "plant",
    edibility: "edible",
    season: "Early Spring",
    habitat: "Rich, moist deciduous forests",
    description:
      "Broad, smooth green leaves with a strong garlic-onion flavor. The entire plant is edible.",
    tips: "Harvest sustainably by taking only one leaf per plant, leaving the bulb to regenerate.",
  },
  {
    name: "Morel",
    type: "mushroom",
    edibility: "poisonous-lookalike",
    season: "Spring",
    habitat: "Burned areas, dying elms, orchards, river bottoms",
    description:
      "Honeycomb-patterned cap with a hollow interior. Highly sought after for their rich, earthy flavor.",
    tips: "Always slice in half to verify hollow interior. False morels have brain-like caps.",
  },
  {
    name: "Chicken of the Woods",
    type: "mushroom",
    edibility: "caution",
    season: "Late Summer to Fall",
    habitat: "Dead or dying hardwood trees, especially oak",
    description:
      "Bright orange and yellow shelf fungus with a meaty texture. Tastes similar to chicken.",
    tips: "Only harvest from hardwoods - those on conifers can cause reactions.",
  },
];

export async function POST() {
  const supabase = await createSupabaseClient();
  const hasAccess = await hasActiveSubscription(supabase);

  if (!hasAccess) {
    return new Response("Membership required", { status: 403 });
  }

  // Return a random item from the database
  const randomItem =
    forageDatabase[Math.floor(Math.random() * forageDatabase.length)];

  return Response.json(randomItem);
}
```

That's it. Three lines of protection code.

### Step 2: Verify Client Handling

The client component from lesson 3.3 already handles 403 responses:

```typescript title="components/field-guide-card.tsx" {5-7}
if (!response.ok) {
  if (response.status === 403) {
    setError("Membership required to access the Field Guide");
  } else {
    setError("Failed to fetch entry");
  }
  // ...
}
```

No changes needed—the client and API are now working together.

## Defense in Depth

You now have two layers of protection:

```
User visits /protected/paid-content
    ↓
Server Component checks subscription
    ↓
No access? → Render upgrade prompt (user never sees field guide)
Has access? → Render FieldGuideCard
    ↓
User clicks "Discover"
    ↓
API Route checks subscription again
    ↓
No access? → Return 403
Has access? → Return foraging data
```

Why check twice?

1. **Server Component check** prevents UI from rendering—good UX, prevents confusion
2. **API Route check** prevents operation from executing—real security

A malicious user could bypass the UI and call the API directly. The API check stops them.

## Request Flow Comparison

**Unauthorized user via UI:**

```
Browser → Server Component → "Upgrade" card rendered
(API never called)
```

**Unauthorized user via curl:**

```
curl → API Route → Subscription check → 403 response
(Data never returned)
```

**Authorized user:**

```
Browser → Server Component → FieldGuideCard rendered
User clicks → API Route → Subscription check → Foraging data returned
```

## File Structure After This Lesson

```
app/api/
└── field-guide/
    └── route.ts        ← Now protected with subscription check

app/protected/
├── paid-content/
│   ├── page.tsx        ← Server-side subscription check
│   └── loading.tsx
└── ...

components/
└── field-guide-card.tsx  ← Handles 403 responses
```

## Section Complete

You've now built a complete access control system:

- **Lesson 3.1**: Understood subscription-based access control
- **Lesson 3.2**: Implemented server-side checks for pages
- **Lesson 3.3**: Built interactive premium components
- **Lesson 3.4**: Protected API routes

Next up in Section 4: Error handling, navigation, and deploying to production.

## Troubleshooting

**403 for subscribed users:**

- Verify the subscription is active (`status` is `active` or `trialing`)
- Check the `subscriptions` table in Supabase
- Ensure the user is signed in (check Supabase session via cookies)

**200 for non-subscribed users:**

- Make sure you saved the API route file
- Restart the dev server if needed
- Check the subscription check is running (add a console.log)
- Verify the user doesn't have an active subscription you forgot about

**Empty error message in UI:**

- Confirm the API returns a text body with 403
- Check the client component is handling `response.status === 403`
- Verify the error state is being displayed in the component


---

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