Vercel Logo

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

  1. Create components/field-guide-card.tsx
  2. Add button that calls /api/field-guide
  3. Display fetched data with loading states

Hands-on Exercise 3.3

Build an interactive premium feature component:

Requirements:

  1. Create a client component for the field guide
  2. Add a button that fetches a random foraging entry
  3. Display loading state while fetching
  4. Show the foraging data when complete
  5. 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

  1. With subscription:

  2. Multiple fetches:

    • Click the button again
    • A new random entry should appear
    • Previous entry is replaced
  3. 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:

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:

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