Client-Side Subscription Checks
Server-side checks handle security. Client-side components handle interactivity. When building features like a field guide that fetches data on button click, the client component manages UI state while the API route enforces access. This lesson builds the interactive part.
Outcome
Build a client component with interactive premium features that calls a protected API endpoint.
Fast Track
- Create
components/field-guide-card.tsx - Add button that calls
/api/field-guide - Display fetched data with loading states
Hands-on Exercise 3.3
Build an interactive premium feature component:
Requirements:
- Create a client component for the field guide
- Add a button that fetches a random foraging entry
- Display loading state while fetching
- Show the foraging data when complete
- Handle API errors gracefully (including 403)
Implementation hints:
- The component doesn't check subscriptions itself - the server already did
- Use
fetch('/api/field-guide', { method: 'POST' })to get data - The API will be protected in lesson 3.4
- Store the fetched data in component state
Try It
-
With subscription:
- Sign in with a subscribed account
- Visit http://localhost:3000/protected/paid-content
- Click "Discover New Entry"
- You should see a loading state, then foraging info
-
Multiple fetches:
- Click the button again
- A new random entry should appear
- Previous entry is replaced
-
Check loading state:
- The button should be disabled while fetching
- Text should change to "Discovering..."
Commit
git add -A
git commit -m "feat(access): add client-side premium feature component"Done-When
- FieldGuideCard component created
- Button calls API endpoint
- Loading state displays during fetch
- Foraging data displays on success
- Error messages display for failures
- Component integrates with paid content page
Solution
Step 1: Create Field Guide Card Component
Create components/field-guide-card.tsx:
"use client";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { cn } from "@/utils/styles";
interface ForageEntry {
name: string;
type: string;
edibility: string;
season: string;
habitat: string;
description: string;
tips: string;
}
interface FieldGuideCardProps {
className?: string;
}
export default function FieldGuideCard({ className }: FieldGuideCardProps) {
const [isLoading, setIsLoading] = useState(false);
const [entry, setEntry] = useState<ForageEntry | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleDiscover() {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/field-guide", {
method: "POST",
});
if (!response.ok) {
if (response.status === 403) {
setError("Membership required to access the Field Guide");
} else {
setError("Failed to fetch entry");
}
setIsLoading(false);
return;
}
const data = await response.json();
setEntry(data);
} catch (err) {
setError("Network error. Please try again.");
}
setIsLoading(false);
}
const edibilityColors: Record<string, string> = {
edible: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
caution: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
"poisonous-lookalike": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
return (
<Card className={cn("p-6", className)}>
<div className="flex items-center justify-between">
<div>
<h2 className="font-medium">Discover Foraging Entries</h2>
<p className="text-muted-foreground text-sm">
Explore our curated database of edible plants and mushrooms
</p>
</div>
<Button onClick={handleDiscover} disabled={isLoading}>
{isLoading ? "Discovering..." : "Discover New Entry"}
</Button>
</div>
{error && (
<p className="mt-4 text-sm text-destructive">{error}</p>
)}
{entry && (
<div className="mt-6 space-y-4 border-t pt-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{entry.name}</h3>
<span
className={cn(
"px-2 py-1 text-xs rounded-full capitalize",
edibilityColors[entry.edibility] || "bg-gray-100"
)}
>
{entry.edibility}
</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Type:</span>{" "}
<span className="capitalize">{entry.type}</span>
</div>
<div>
<span className="text-muted-foreground">Season:</span>{" "}
{entry.season}
</div>
</div>
<div className="text-sm">
<span className="text-muted-foreground">Habitat:</span>{" "}
{entry.habitat}
</div>
<p className="text-sm">{entry.description}</p>
<div className="bg-muted p-3 rounded-lg text-sm">
<span className="font-medium">Foraging Tips:</span> {entry.tips}
</div>
</div>
)}
</Card>
);
}Step 2: Create Placeholder API Route
For now, create a placeholder API route that we'll protect in lesson 3.4.
Create app/api/field-guide/route.ts:
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.",
tips: "Look for false gills that fork and run down the stem.",
},
{
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.",
tips: "Harvest sustainably by taking only one leaf per plant.",
},
// ... more entries
];
export async function POST() {
// TODO: Add subscription check (lesson 3.4)
const randomItem =
forageDatabase[Math.floor(Math.random() * forageDatabase.length)];
return Response.json(randomItem);
}This is intentionally unprotected for now - you'll add the subscription check in the next lesson.
Server + Client Pattern
PaidContent (Server Component)
↓
Check subscription server-side
↓
If no access → render upgrade prompt (secure)
↓
If has access → render FieldGuideCard (Client Component)
↓
User clicks "Discover"
↓
fetch("/api/field-guide") → API checks subscription again
↓
Return foraging data or 403
The server component controls what gets rendered. The API route provides a second layer of protection for the actual data.
Why Not Check Client-Side?
You might wonder: why not check subscriptions in the client component?
Problems with client-only checks:
- User could modify JavaScript to skip the check
- API endpoint would still be accessible
- Premium content would be in the JavaScript bundle
The correct pattern:
- Server Component checks → controls what renders
- API Route checks → controls what operations execute
- Client Component → handles UI state and interactions
File Structure After This Lesson
components/
├── field-guide-card.tsx ← New: interactive premium feature
├── pricing-card.tsx
├── subscription-actions.tsx
└── ui/
└── ...
app/api/
└── field-guide/
└── route.ts ← New: placeholder API (unprotected)
Troubleshooting
Button click does nothing:
- Check browser console for JavaScript errors
- Verify the API route exists at
/api/field-guide - Check Network tab for failed requests
403 error immediately:
- The API protection might already be in place
- Verify you have an active subscription
- Check the API route code
Data doesn't display:
- Check if the API returned valid JSON
- Verify the entry state is being set
- Look for React errors in console
Was this helpful?