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
- Update
app/api/field-guide/route.tswith subscription check - Return 403 for unauthorized users
- Test with and without a subscription
Hands-on Exercise 3.4
Add subscription protection to the field guide API:
Requirements:
- Import and use the Supabase server client
- Check subscription status before processing
- Return 403 with text "Membership required" for non-subscribers
- Return the foraging data for subscribers
- Handle errors gracefully
Implementation hints:
- Use
createSupabaseClient()andhasActiveSubscription() - 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
-
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:
curl -X POST http://localhost:3000/api/field-guideExpected:
Membership requiredwith 403 status -
With subscription:
- Sign in with a subscribed account
- Visit http://localhost:3000/protected/paid-content
- Click "Discover New Entry"
- You should see foraging data
-
Verify in terminal:
POST /api/field-guide 403 12ms POST /api/field-guide 200 45ms
Commit
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:
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:
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?
- Server Component check prevents UI from rendering—good UX, prevents confusion
- 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 (
statusisactiveortrialing) - Check the
subscriptionstable 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
Was this helpful?