Vercel Logo

Server-Side Subscription Checks

Server-side checks are authoritative. When you check subscriptions in a Server Component, users without access never receive the premium content - it's not hidden with CSS or JavaScript, it simply doesn't exist in their response. This is the secure foundation for access control.

Outcome

Gate Server Components based on subscription status, rendering premium content or upgrade prompts.

Fast Track

  1. Create app/protected/paid-content/page.tsx
  2. Check subscription with hasActiveSubscription(supabase)
  3. Render premium content or upgrade prompt based on result

Hands-on Exercise 3.2

Build a paid content page with server-side subscription checks:

Requirements:

  1. Create paid content page at /protected/paid-content
  2. Check subscription status server-side with hasActiveSubscription()
  3. Render premium content for subscribers
  4. Render upgrade prompt for non-subscribers
  5. Add loading skeleton

Implementation hints:

  • Use the Supabase server client in an async Server Component
  • hasActiveSubscription() returns a boolean
  • Return early with different JSX for each state
  • Include a link to the pricing page in the upgrade prompt

Try It

  1. Without subscription:

  2. With subscription:

  3. Verify security:

    • View page source on the upgrade prompt page
    • The premium content markup should not be present at all

Commit

git add -A
git commit -m "feat(access): add server-side subscription checks"

Done-When

  • Paid content page renders at /protected/paid-content
  • Subscription check runs server-side
  • Premium content shows for subscribers
  • Upgrade prompt shows for non-subscribers
  • Loading skeleton displays during fetch

Solution

Step 1: Create Loading State

The loading state is already in the starter at app/protected/paid-content/loading.tsx.

Step 2: Create Paid Content Page

Update app/protected/paid-content/page.tsx:

app/protected/paid-content/page.tsx
import { createSupabaseClient } from "@/utils/supabase/server";
import { hasActiveSubscription } from "@/utils/supabase/queries";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import FieldGuideCard from "@/components/field-guide-card";
import Link from "next/link";
 
export default async function PaidContent() {
  const supabase = await createSupabaseClient();
  const hasAccess = await hasActiveSubscription(supabase);
 
  if (!hasAccess) {
    return (
      <div className="flex min-h-[400px] items-center justify-center">
        <Card className="p-6">
          <h2 className="text-xl font-semibold">Rangers & Elders Only</h2>
          <p className="mt-2 text-muted-foreground">
            The Field Guide is available to Ranger and Elder members. Upgrade
            your membership to access our complete database of edible plants,
            mushrooms, and foraging guides.
          </p>
          <Button className="mt-4" variant="outline" asChild>
            <Link href="/protected/pricing">Upgrade Membership</Link>
          </Button>
        </Card>
      </div>
    );
  }
 
  return (
    <div>
      <div>
        <h1 className="text-2xl font-medium">Field Guide</h1>
        <p className="text-muted-foreground mt-2">
          Discover edible plants and mushrooms from our curated database
        </p>
      </div>
      <FieldGuideCard className="mt-4" />
    </div>
  );
}

Key patterns:

  • Server Component - No "use client" directive, runs on the server
  • hasActiveSubscription() - Queries Supabase for active subscriptions
  • Early return - Different JSX for each access state
  • Premium component - FieldGuideCard is only rendered for subscribers

Update your navigation or sidebar to include a link to the paid content page:

<Link href="/protected/paid-content">Field Guide</Link>

How Server-Side Checks Work

Browser requests /protected/paid-content
    ↓
Next.js calls PaidContent (Server Component)
    ↓
createSupabaseClient() → server client with session
    ↓
hasActiveSubscription(supabase)
    ↓
Query subscriptions table for active/trialing status
    ↓
Returns true/false
    ↓
Server Component renders appropriate JSX
    ↓
Only rendered HTML sent to browser

Users without access never receive the premium content - it's not in the HTML, JavaScript bundle, or anywhere in their response.

Response Comparison

With Subscription:

<div>
  <h1>Field Guide</h1>
  <p>Discover edible plants and mushrooms...</p>
  <!-- FieldGuideCard content here -->
</div>

Without Subscription:

<div class="flex min-h-[400px] items-center justify-center">
  <div class="p-6">
    <h2>Rangers & Elders Only</h2>
    <p>The Field Guide is available to Ranger and Elder members...</p>
    <a href="/protected/pricing">Upgrade Membership</a>
  </div>
</div>

The premium content simply doesn't exist in the second response.

File Structure After This Lesson

app/protected/
├── page.tsx              ← Account page
├── layout.tsx            ← Protected layout
├── pricing/
│   ├── page.tsx
│   └── loading.tsx
├── subscription/
│   ├── page.tsx
│   └── loading.tsx
└── paid-content/
    ├── page.tsx          ← Updated: subscription-gated page
    └── loading.tsx       ← Loading skeleton

components/
└── field-guide-card.tsx  ← Premium content component

Troubleshooting

Always shows upgrade prompt:

  • Verify you have an active subscription (check /protected/subscription)
  • The subscription status must be active or trialing
  • Check the subscriptions table in Supabase directly

Always shows premium content:

  • The user may have an active subscription you forgot about
  • Check subscription status in Stripe dashboard
  • Query the subscriptions table with the user's ID

Page shows loading skeleton forever:

  • Check browser console for errors
  • Verify Supabase environment variables are set
  • Ensure the user is authenticated