---
title: "Client-Side Checks"
description: "Build interactive premium features in Client Components, with UI states that respond to user actions while relying on server-side security."
canonical_url: "https://vercel.com/academy/subscription-store/client-side-subscription-checks"
md_url: "https://vercel.com/academy/subscription-store/client-side-subscription-checks.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-04-11T13:58:24.009Z"
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>

# Client-Side Checks

# 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:**
   - 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

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

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

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

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


---

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