Add a Premium Tier System to Your App
Gate premium features with Clerk metadata and Express middleware.
Introduction
30 Second Summary
Every fitness app eventually needs a way to separate free users from paying customers. Without a tier system, every user gets everything, and there is no path to monetization.
In this project, you will add a complete free/pro tier system to your Sculpt fitness app. You will store tier data in Clerk publicMetadata, enforce it with Express middleware on the backend, and conditionally render premium features on the frontend.
What You'll Build
You will build a full feature-gating system that controls access to premium features on both the server and the client, using Clerk's session token and Express middleware.
By the end of this project, you'll have:
- A Clerk session token customized to expose the user's tier to both your frontend and backend without extra API calls.
- A reusable requireTier Express middleware that enforces tier checks server-side so premium routes cannot be bypassed from the browser.
- A React frontend that conditionally renders premium features and shows upgrade prompts based on the user's tier.
- A polished upgrade page with a pricing comparison card built using shadcn/ui (Secret Mission).
Are there any prerequisites?
This project builds on an existing Sculpt app that already has Clerk authentication, an Express 5 backend with Drizzle ORM and PostgreSQL, and a React + Vite frontend running on Replit. You do not need to install any new tools or packages.
Configure Clerk Session Token to Include User Tier
Right now, your Sculpt app authenticates users with Clerk, but every user gets the same experience. To build a tier system, your app needs to know whether each user is on the free or pro plan.
Clerk stores this kind of information in publicMetadata, a field on each user that your backend can write to and both frontend and backend can read. In this step, you will customize the Clerk session token so the tier travels with every request automatically.
In this step, get ready to:
- Customize the Clerk session token to include user metadata.
- Set your own user to the pro tier for testing.
- Verify the tier appears in your Express backend.
Add publicMetadata to the session token
By default, Clerk's session token only includes basic claims like the user ID and session ID. It does not include custom data like a user's tier.
You can customize the session token to include publicMetadata, which makes the tier available on every request without extra API calls.
Why publicMetadata instead of privateMetadata?
Clerk offers three metadata types. publicMetadata is readable from both the frontend and backend, but can only be written from the backend (using your Clerk Secret Key). This makes it perfect for storing tier information because users cannot modify it from the browser.
privateMetadata is only readable from the backend, so your React frontend would not be able to conditionally render UI based on the tier. unsafeMetadata is writable from the frontend, which means users could tamper with their own tier.
- Open the Clerk Dashboard in your browser.
- In the left sidebar, click Sessions.
- Scroll down to the Customize session token section.
- In the Claims editor, enter the following JSON:
{
"metadata": "{{user.public_metadata}}"
}
What does this JSON do?
The {{user.public_metadata}} part is a Clerk shortcode. At runtime, Clerk replaces it with the actual contents of the user's publicMetadata object. The key metadata is the name you will use to access it in your code (e.g. sessionClaims.metadata.tier).
- Click Save.
What if I already have custom session claims?
If you have already customized your session token with other claims, merge this new metadata key into your existing JSON object. Do not replace the entire editor contents.
Set your user to the pro tier for testing
The session token is now configured to carry publicMetadata, but your user does not have any metadata yet. You need to manually set a tier value so you have a pro account to test with.
- In the Clerk Dashboard, click Users in the left sidebar.
- Select your own user account from the list.
- Scroll down to the User metadata section.
- Next to the Public option, click Edit.
- Enter the following JSON:
{
"tier": "pro"
}
What does this metadata do?
This sets your user's publicMetadata to include a tier field with the value pro. Because of the session token customization you just set up, this value will now appear in every session token your user generates.
- Click Save.
Pro tip: token refresh
When you update publicMetadata, the change does not appear in the session token instantly. The session token refreshes automatically, but it may take a few seconds to propagate. If you do not see the updated tier right away, wait a moment and try again.
Verify the tier in your Express backend
Now you need to confirm that the session token customization is actually working. You will add a temporary log to one of your existing Express routes to inspect the auth object.
- Open one of your existing route files in your Sculpt backend (for example, a route that already uses authentication).
- Add a temporary test route by pasting the following code:
// Temporary route to verify session token includes tier
app.get('/api/debug-session', (req, res) => {
const auth = getAuth(req);
// Log the full session claims to see the metadata
console.log('Session claims:', JSON.stringify(auth.sessionClaims, null, 2));
res.json({
userId: auth.userId,
tier: auth.sessionClaims?.metadata?.tier || 'none',
fullClaims: auth.sessionClaims
});
});
What does this code do?
- getAuth(req) retrieves the authentication state from the request. This works because clerkMiddleware() is already applied to all routes in your existing app.
- auth.sessionClaims?.metadata?.tier navigates into the custom claims you configured. The metadata key matches what you entered in the Claims editor, and tier is the field you set in publicMetadata.
- Save your route file.
- Make sure your app is running in Replit.
- Open your Sculpt app in the browser and make sure you are signed in.
- Visit the debug route by navigating to /api/debug-session in your browser.
You should see a JSON response that includes "tier": "pro". Your terminal console should also show the full session claims object with the metadata field containing your tier.
Seeing tier as "none" or metadata is missing?
- Make sure you clicked Save in both the session token Claims editor and the user metadata editor. Changes are not applied until saved.
- The session token may take a few seconds to refresh after you update metadata. Try signing out of your Sculpt app and signing back in, then visit the debug route again.
- Double-check that your JSON in the Claims editor is exactly {"metadata": "{{user.public_metadata}}"} with no typos in the shortcode.
- Confirm that clerkMiddleware() is applied before your routes in your Express app. Without it, getAuth(req) returns empty values.
Why isn't my Clerk session token showing the tier metadata in my Express route?
- Once you have confirmed the tier appears in the response, remove the debug route from your code. It was only needed for verification.
Your session token now carries the user's tier on every request. Next up, you will add tier tracking to your database schema so you can query tier data directly in PostgreSQL.
Add Tier Tracking to Your Drizzle Schema
In the previous step, you configured your Clerk session token to include the user's tier from publicMetadata. Your Express backend and React frontend can now read the tier in real time.
Now you need your PostgreSQL database to know about tiers too. Storing the tier in your database lets you run queries like "how many pro users signed up this week." Clerk metadata handles real-time access control, but your database is the right place for analytics and reporting.
In this step, get ready to:
- Add a tierEnum and a tier column to your Drizzle schema.
- Generate and run a Drizzle migration to apply the schema change to your database.
Create a tier enum and add it to your users table
PostgreSQL supports custom enum types that restrict a column to a fixed set of values. Drizzle ORM exposes this through pgEnum, which lets you define the allowed values in TypeScript and have them enforced at the database level.
- Open your schema file (likely src/db/schema.ts or wherever your Drizzle table definitions live).
- Add pgEnum to your existing import from drizzle-orm/pg-core if it is not already there.
- Add the following code above your users table definition:
// Define the allowed tier values as a PostgreSQL enum
export const tierEnum = pgEnum('tier', ['free', 'pro']);
What does this code do?
- pgEnum('tier', ['free', 'pro']) creates a PostgreSQL enum type called tier that only allows the values free or pro.
- If anyone tries to insert a different value (like premium), PostgreSQL rejects it at the database level.
- Find your users table definition in the same file.
- Add this line inside your pgTable columns object, alongside your existing columns:
// Track the user's subscription tier, defaulting to 'free'
tier: tierEnum().default('free'),
What does this column do?
- tierEnum() references the enum you defined above, so this column only accepts free or pro.
- .default('free') means every new user starts on the free tier automatically. No extra logic needed during sign-up.
- Save your schema file.
Why store the tier in both Clerk and the database?
Clerk publicMetadata is perfect for real-time access control because it lives in the session token. Your PostgreSQL database is the right place for analytics queries like "how many pro users do we have" or "which users upgraded this month."
Keeping both in sync gives you the best of both worlds.
Awesome, I've got everything!
Double check that you have saved your schema file before moving on to the migration.
I'd like to double check the full code
Your users table definition should now look something like this. Your existing columns may differ, but the key additions are the tierEnum above the table and the tier column inside it.
import { pgEnum, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
// Define the allowed tier values as a PostgreSQL enum
export const tierEnum = pgEnum('tier', ['free', 'pro']);
export const users = pgTable('users', {
id: serial('id').primaryKey(),
clerkId: varchar('clerk_id', { length: 255 }).notNull().unique(),
email: text('email').notNull(),
name: text('name'),
// Track the user's subscription tier, defaulting to 'free'
tier: tierEnum().default('free'),
createdAt: timestamp('created_at').defaultNow(),
});
Seeing a TypeScript error on tierEnum?
- Make sure you added pgEnum to your import from drizzle-orm/pg-core.
- Confirm that the tierEnum definition appears above the users table definition. JavaScript reads top to bottom, so the enum must be declared before it is used.
- Help me fix a TypeScript error in my Drizzle schema.
Generate and run the migration
Your schema file describes what you want the database to look like. A migration is the set of SQL commands that transforms the database from its current state to match your updated schema.
- Generate a new migration file by running this command in your Replit Shell:
npx drizzle-kit generate
You should see output confirming a new migration file was created in your migrations folder (typically drizzle/).
What does this command do?
Drizzle Kit reads your schema file, compares it to the last migration snapshot, and generates a new .sql file containing only the changes. In this case, it creates the tier enum type and adds the tier column to your users table.
- Apply the migration to your database by running:
npx drizzle-kit migrate
You should see output confirming the migration was applied successfully.
What does this command do?
Drizzle Kit connects to your PostgreSQL database, checks which migrations have already been applied, and runs any new ones. Your users table now has a tier column, and all existing users default to free.
Migration failed or command not found?
- If you see command not found: drizzle-kit, make sure drizzle-kit is listed in your package.json dev dependencies. It should already be there from your initial project setup.
- If the migration fails with a connection error, check that your drizzle.config.ts has the correct database connection URL. Replit stores this in your Secrets as DATABASE_URL.
- If you see an error about the enum already existing, a previous migration may have partially applied. Try running npx drizzle-kit push instead, which syncs your schema directly to the database without migration files.
- Help me debug my Drizzle migration error.
Your database is now tier-aware. Next up, you will build an Express middleware that reads the tier from the session token and blocks free users from accessing premium API routes.
Build the requireTier Express Middleware
Your database now tracks user tiers, and your session token carries the tier value. But there is nothing stopping a free user from calling your premium API routes directly.
In this step, you will build a reusable Express middleware function called requireTier that reads the user's tier from the session token and blocks requests that do not meet the required tier level.
In this step, get ready to:
- Create a reusable requireTier middleware that enforces tier-based access control.
- Apply the middleware to your premium API routes so free users receive upgrade-required responses.
- Test the middleware by switching your user between free and pro tiers in the Clerk Dashboard.
Create the requireTier middleware function
Express middleware is a function that runs before your route handler. It can inspect the request, modify it, or stop it entirely by sending a response. A configurable middleware is a function that accepts parameters and returns the actual middleware function.
- Create a new file called middleware/requireTier.js inside your server directory by adding the following code:
import { getAuth } from '@clerk/express';
// Factory function that accepts a required tier and returns middleware
export const requireTier = (requiredTier) => {
return (req, res, next) => {
// Read the auth state from the request
const auth = getAuth(req);
// Block unauthenticated users
if (!auth.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
// Extract the tier from the custom session claims
const userTier = auth.sessionClaims?.metadata?.tier || 'free';
// Compare the user's tier against the required tier
if (userTier !== requiredTier) {
return res.status(403).json({
error: 'Upgrade required',
message: `This feature requires the ${requiredTier} plan.`,
currentTier: userTier,
requiredTier: requiredTier,
});
}
// User meets the tier requirement, continue to the route handler
next();
};
};
What does this code do?
- requireTier is a factory function. You call it with a tier name like 'pro', and it returns a middleware function that Express can use.
- getAuth(req) reads the session claims from the request. This is the Clerk-recommended approach for protecting API routes instead of requireAuth(), which redirects and is meant for full-stack apps.
- auth.sessionClaims?.metadata?.tier reads the tier from the custom session token you configured in Step 1. If no tier is set, it defaults to 'free'.
- The 403 response includes both the user's current tier and the required tier. This makes it easy for the frontend to display a helpful upgrade prompt.
Why getAuth instead of requireAuth?
Clerk's requireAuth() middleware redirects unauthenticated users to a sign-in page. That makes sense for full-stack web pages, but for API routes you want to return a JSON error instead. getAuth() gives you the same auth data with full control over the response.
- Save middleware/requireTier.js.
Seeing a "Cannot find module" error?
- Make sure the middleware folder exists inside your server directory. Create it if it does not.
- Confirm that your project uses ES module imports. If your package.json has "type": "module", the import syntax above is correct.
- Help me debug my requireTier middleware import error.
Apply the middleware to your premium routes
The middleware is ready, but it is not protecting anything yet. You need to add it to the specific routes that should be pro-only.
- Open your routes file that handles AI coach requests (for example, routes/ai.js or wherever your AI coach endpoint lives).
- Import the requireTier middleware and add it to your premium route definitions by updating the file like this:
import { requireTier } from '../middleware/requireTier.js';
// Protect the unlimited AI coach endpoint
router.post('/ai/coach', requireTier('pro'), async (req, res) => {
// Your existing AI coach handler logic
// Only pro users reach this code
});
// Protect the detailed macro breakdown endpoint
router.get('/meals/:id/macros', requireTier('pro'), async (req, res) => {
// Your existing macro breakdown handler logic
// Only pro users reach this code
});
What's happening here?
- requireTier('pro') is placed between the route path and the route handler. Express runs middleware in order, so the tier check happens before your handler code.
- If the user is on the free tier, the middleware sends the 403 response and never calls next(). The route handler never executes.
- You can reuse requireTier('pro') on as many routes as you need. Add it to any endpoint that should be gated behind the pro tier.
- Save your routes file.
Pro tip: bulk protection
If you have many premium routes grouped under a common path like /api/pro, you can apply the middleware to the entire group at once with router.use('/pro', requireTier('pro')). Every route under that path is automatically protected.
Getting a 401 instead of 403?
- A 401 means the user is not authenticated at all. Make sure clerkMiddleware() is applied before your routes in your main server file with app.use(clerkMiddleware()). It must be set before any other middleware.
- Confirm you are sending your session token with the request. If you are testing from the browser, the session cookie is sent automatically. If you are using a tool like Postman, you need to include the session token in the Authorization header.
- Help me figure out why I'm getting 401 instead of 403 on my tier-protected route.
Test the tier gating end to end
The real proof is seeing a free user get blocked and a pro user get through. You will toggle your own tier in the Clerk Dashboard to test both scenarios.
- Open the Clerk Dashboard in your browser.
- Click Users in the left sidebar.
- Select your test user.
- Scroll down to the Metadata section and click Edit next to Public.
- Set the publicMetadata to the free tier by pasting this JSON:
{
"tier": "free"
}
- Click Save.
- Go back to your Sculpt app in the browser and try to access one of your pro-only features (like the AI coach).
You should see a 403 response with a JSON body containing "error": "Upgrade required" and your current tier listed as free.
Why might there be a short delay?
Clerk session tokens are short-lived JWTs. When you update publicMetadata in the Dashboard, the change takes effect the next time the session token refreshes. This can take a few seconds. If the old tier still appears, wait a moment and try again.
- Now switch your user back to the pro tier. Go back to the Clerk Dashboard, edit the publicMetadata to:
{
"tier": "pro"
}
- Click Save.
- Try accessing the same pro-only feature again.
This time the request should succeed. The route handler runs normally and returns the expected data.
Still getting through as a free user?
- Double-check your session token template in the Clerk Dashboard. It should contain { "metadata": "{{user.public_metadata}}" }. Without this, sessionClaims.metadata is undefined and the middleware defaults to 'free'.
- Make sure the middleware is placed in the correct position in the route definition. It goes between the route path and the handler function: router.post('/path', requireTier('pro'), handler).
- Try logging auth.sessionClaims inside the middleware to see what Clerk is actually returning. This helps you spot typos in the session token template.
- Help me debug why my requireTier middleware is not blocking free users.
Awesome, I've got everything!
Double-check you have saved middleware/requireTier.js and your routes file.
I'd like to double check the full code
Here is the complete requireTier.js middleware file for reference.
import { getAuth } from '@clerk/express';
// Factory function that accepts a required tier and returns middleware
export const requireTier = (requiredTier) => {
return (req, res, next) => {
// Read the auth state from the request
const auth = getAuth(req);
// Block unauthenticated users
if (!auth.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
// Extract the tier from the custom session claims
const userTier = auth.sessionClaims?.metadata?.tier || 'free';
// Compare the user's tier against the required tier
if (userTier !== requiredTier) {
return res.status(403).json({
error: 'Upgrade required',
message: `This feature requires the ${requiredTier} plan.`,
currentTier: userTier,
requiredTier: requiredTier,
});
}
// User meets the tier requirement, continue to the route handler
next();
};
};
Your backend is now locked down. Free users cannot bypass the tier check, even if they call the API directly. Next up, you will make the frontend aware of the user's tier so it can show upgrade prompts instead of broken features.
Gate Premium Features in the React Frontend
Your backend is now locked down. Free users get a 403 if they try to hit premium API routes directly.
But the frontend still shows every feature to every user. That creates a confusing experience where buttons lead to error messages. In this step, you will read the user's tier from Clerk's publicMetadata and conditionally render premium features or upgrade prompts.
In this step, get ready to:
- Create a reusable useTier custom hook that reads the user's tier from Clerk.
- Wrap premium features with conditional rendering based on the user's tier.
- Build an upgrade CTA component that appears for free-tier users.
Create the useTier custom hook
Every component that needs to check the user's tier would have to repeat the same logic: call useUser(), dig into publicMetadata, and handle loading states. A custom hook centralizes this so you write it once and reuse it everywhere.
- Create a new file called useTier.js inside your src/hooks folder. Create the hooks folder if it does not exist yet.
- Add the following code to useTier.js:
import { useUser } from '@clerk/clerk-react';
// Custom hook to read the user's tier from Clerk publicMetadata
export function useTier() {
const { user, isLoaded } = useUser();
// Default to 'free' if metadata hasn't been set yet
const tier = user?.publicMetadata?.tier || 'free';
// Convenience boolean for conditional rendering
const isPro = tier === 'pro';
return { tier, isPro, isLoaded };
}
What does this code do?
- useUser() returns the current Clerk User object, which includes publicMetadata set from the backend.
- user?.publicMetadata?.tier safely reads the tier field you set in the Clerk Dashboard earlier. If the field does not exist, it defaults to 'free'.
- isPro is a boolean that makes conditional rendering cleaner. Instead of checking tier === 'pro' in every component, you just check isPro.
- isLoaded lets consuming components show a loading state while Clerk initializes.
- Save src/hooks/useTier.js.
Why read from publicMetadata instead of the session token?
On the frontend, Clerk's useUser() hook gives you direct access to publicMetadata without any extra configuration. The session token customization you did in Step 1 is primarily for the backend, where getAuth(req) reads session claims.
On the frontend, useUser() is simpler and does not count against Clerk's backend API rate limits.
Hook returning undefined for the tier?
- Make sure useTier.js is inside src/hooks/ and the import path matches your project structure.
- Confirm your component tree is wrapped in a <ClerkProvider>. This should already be the case from your existing setup.
- Check that your user's publicMetadata in the Clerk Dashboard actually contains a tier field.
- Help me debug my useTier hook
Wrap premium features with conditional rendering
Now that you have a clean way to check the user's tier, you can gate any premium feature in your app. Free users will see an upgrade prompt card instead of the actual premium content.
- Open the component file where you render one of your premium features (for example, the AI coach component or the detailed meal macros component).
- Import your new hook and add conditional rendering by following the pattern below:
import { useTier } from '../hooks/useTier';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
export function AiCoach() {
const { isPro, isLoaded } = useTier();
// Show a loading state while Clerk initializes
if (!isLoaded) return <div>Loading...</div>;
// Gate the feature: show upgrade prompt for free users
if (!isPro) {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>AI Coach</CardTitle>
<CardDescription>
Unlock unlimited AI coaching conversations with a Pro subscription.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => window.location.href = '/upgrade'}>
Upgrade to Pro
</Button>
</CardContent>
</Card>
);
}
// Pro users see the full feature
return (
<div>
{/* Your existing AI coach UI goes here */}
</div>
);
}
What does this code do?
- useTier() returns isPro and isLoaded so the component knows the user's current tier.
- The if (!isPro) guard renders a shadcn/ui Card with an upgrade prompt instead of the premium content.
- The Button navigates free users to the /upgrade page where they can see the full pricing comparison.
- Pro users skip the guard entirely and see the full AI coach UI.
- Apply this same pattern to your other premium features: the detailed meal macros component and the progress analytics component.
- Save all modified component files.
- Open your app in the browser and confirm that premium features show the upgrade card when your user's publicMetadata.tier is set to free in the Clerk Dashboard.
You should see a Card with the title of the feature, a short description of what Pro unlocks, and an Upgrade to Pro button.
Pro tip: keep it DRY
If you have many gated features, extract the upgrade prompt into its own reusable component. You will do exactly this in the next substep, so each premium component only needs a two-line guard instead of duplicating the entire Card.
Still seeing premium content as a free user?
- Double-check that your user's publicMetadata in the Clerk Dashboard is set to { "tier": "free" }.
- Changes to publicMetadata may take a few seconds to propagate. Try refreshing the page or signing out and back in.
- Confirm you imported useTier from the correct path and are calling it inside the component.
- Help me debug why premium content still shows for free users
Build the upgrade CTA component
Right now, each premium component has its own copy of the upgrade prompt. Extracting it into a standalone component keeps your code consistent and makes it easy to update the messaging in one place.
- Create a new file called UpgradePrompt.jsx inside your src/components folder.
- Add the following code to UpgradePrompt.jsx:
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
// Reusable upgrade prompt shown when a free user hits a gated feature
export function UpgradePrompt({ feature, description }) {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{feature}
<Badge variant="secondary">Pro</Badge>
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Upgrade to Pro to unlock this feature and get the most out of Sculpt.
</p>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={() => (window.location.href = '/upgrade')}
>
Upgrade to Pro
</Button>
</CardFooter>
</Card>
);
}
What does this code do?
- UpgradePrompt accepts two props: feature (the name of the locked feature) and description (a short explanation of what Pro unlocks).
- The Badge component displays a "Pro" label next to the feature name, making it immediately clear this is a premium feature.
- The Button navigates to /upgrade where users can see the full pricing comparison.
- You can now replace the inline upgrade cards in your premium components with a single <UpgradePrompt /> call.
- Save src/components/UpgradePrompt.jsx.
- Go back to your premium feature components and replace the inline upgrade Card with the new UpgradePrompt component. For example, update AiCoach.jsx to match the following:
import { useTier } from '../hooks/useTier';
import { UpgradePrompt } from './UpgradePrompt';
export function AiCoach() {
const { isPro, isLoaded } = useTier();
if (!isLoaded) return <div>Loading...</div>;
// Show the reusable upgrade prompt for free users
if (!isPro) {
return (
<UpgradePrompt
feature="AI Coach"
description="Get unlimited AI coaching conversations to optimize your training."
/>
);
}
// Pro users see the full feature
return (
<div>
{/* Your existing AI coach UI goes here */}
</div>
);
}
What changed here?
The component now uses UpgradePrompt instead of building its own Card. You pass the feature name and description as props, keeping each gated component to just a few lines of tier-checking logic.
- Apply the same refactor to your other premium components (meal macros, progress analytics).
- Save all modified files.
- Refresh your app in the browser. Confirm that free users still see the UpgradePrompt card with the "Pro" badge and Upgrade to Pro button for each gated feature.
- Switch your user back to pro in the Clerk Dashboard and refresh the page. Confirm that Pro users see the full premium content without any upgrade prompts.
Upgrade prompt not rendering?
- Verify that you imported UpgradePrompt from the correct path (./UpgradePrompt or ../components/UpgradePrompt depending on your file structure).
- Make sure the shadcn/ui Card, Badge, and Button components are installed in your project. If they are missing, install them by running:
npx shadcn@latest add card badge button
- Help me debug my UpgradePrompt component
Awesome, I've got everything!
Great. Double-check you have saved all your modified component files.
I'd like to double check the full code
Here is the complete useTier.js hook:
import { useUser } from '@clerk/clerk-react';
export function useTier() {
const { user, isLoaded } = useUser();
const tier = user?.publicMetadata?.tier || 'free';
const isPro = tier === 'pro';
return { tier, isPro, isLoaded };
}
Here is the complete UpgradePrompt.jsx component:
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
export function UpgradePrompt({ feature, description }) {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{feature}
<Badge variant="secondary">Pro</Badge>
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Upgrade to Pro to unlock this feature and get the most out of Sculpt.
</p>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={() => (window.location.href = '/upgrade')}
>
Upgrade to Pro
</Button>
</CardFooter>
</Card>
);
}
Here is an example of a gated premium component using both:
import { useTier } from '../hooks/useTier';
import { UpgradePrompt } from './UpgradePrompt';
export function AiCoach() {
const { isPro, isLoaded } = useTier();
if (!isLoaded) return <div>Loading...</div>;
if (!isPro) {
return (
<UpgradePrompt
feature="AI Coach"
description="Get unlimited AI coaching conversations to optimize your training."
/>
);
}
return (
<div>
{/* Your existing AI coach UI goes here */}
</div>
);
}
Your frontend now matches your backend. Free users see clear upgrade prompts, and Pro users get the full experience. Next up, you will build an admin endpoint to upgrade and downgrade users programmatically.
Build the Admin Tier Management Endpoint
Your frontend now gates premium features and shows upgrade prompts to free users. The missing piece is a way to actually change a user's tier. Right now, the only way to do that is by manually editing publicMetadata in the Clerk Dashboard.
In this step, you'll build an admin API endpoint that upgrades or downgrades users programmatically. It updates both Clerk's publicMetadata and your Drizzle database in a single request, keeping both sources of truth in sync.
In this step, get ready to:
- Create a POST endpoint that updates a user's tier in both Clerk and PostgreSQL.
- Protect the endpoint so only admin users can change tiers.
- Test the full upgrade and downgrade flow end-to-end.
Create the set-tier admin endpoint
Clerk's publicMetadata can only be written from the backend using the Clerk Secret Key. This makes it the perfect place to store tier information because users can never modify it from the browser. Your endpoint needs to update two places: Clerk's metadata (so the session token reflects the new tier) and your PostgreSQL users table (so database queries stay accurate).
- In your routes file (e.g. routes/admin.js or wherever you organize your Express routes), create the admin tier endpoint by adding the following code:
import { clerkClient, getAuth } from '@clerk/express'
import { db } from '../db/index.js'
import { users } from '../db/schema.js'
import { eq } from 'drizzle-orm'
// POST /api/admin/set-tier
// Updates a user's tier in both Clerk metadata and the database
router.post('/set-tier', async (req, res) => {
try {
const { userId, tier } = req.body
// Validate that the tier value is either 'free' or 'pro'
if (!['free', 'pro'].includes(tier)) {
return res.status(400).json({ error: 'Invalid tier. Must be free or pro.' })
}
// Validate that a userId was provided
if (!userId) {
return res.status(400).json({ error: 'userId is required.' })
}
// Update the user's tier in Clerk publicMetadata
await clerkClient.users.updateUserMetadata(userId, {
publicMetadata: { tier }
})
// Keep the database in sync by updating the tier column
await db.update(users).set({ tier }).where(eq(users.clerkId, userId))
return res.json({ success: true, userId, tier })
} catch (error) {
console.error('Error updating tier:', error)
return res.status(500).json({ error: 'Failed to update tier.' })
}
})
What does this code do?
- The endpoint accepts a userId and tier from the request body. It validates that the tier is either free or pro before making any changes.
- clerkClient.users.updateUserMetadata() performs a deep merge on the user's existing publicMetadata. This means any other metadata fields the user has are preserved.
- The Drizzle update call keeps your PostgreSQL users table in sync so you can run database-level queries like "how many pro users do we have?" without calling the Clerk API.
- Save your routes file.
- Make sure the admin router is mounted in your main server file. If you created a new routes/admin.js file, add a line like app.use('/api/admin', adminRouter) in your main Express setup.
Getting a module not found error?
- Check that your import paths for db and users match the actual file locations in your project. Your Sculpt app may use a different folder structure.
- Confirm that clerkClient is imported from @clerk/express, not from the deprecated @clerk/clerk-sdk-node package.
- Make sure eq is imported from drizzle-orm, not from drizzle-orm/pg-core.
Help me debug my admin endpoint imports.
Protect the endpoint with an admin check
Right now, any authenticated user could call this endpoint and change anyone's tier. You need to lock it down so only admin users can make tier changes.
- Add an admin guard at the top of your /set-tier route handler. Find the line that says const { userId, tier } = req.body, then copy the following code above it:
// Check that the requesting user is authenticated
const auth = getAuth(req)
if (!auth.userId) {
return res.status(401).json({ error: 'Not authenticated.' })
}
// Check that the requesting user is an admin
// Replace this ID with your own Clerk user ID from the Dashboard
const ADMIN_USER_ID = 'your-clerk-user-id-here'
if (auth.userId !== ADMIN_USER_ID) {
return res.status(403).json({ error: 'Admin access required.' })
}
What does this code do?
- getAuth(req) reads the session claims from the request. If there is no userId, the user is not signed in and gets a 401 response.
- The admin check compares the requesting user's ID against a hardcoded admin user ID. In a production app, you would use Clerk roles or an isAdmin flag in publicMetadata. For this project, a hardcoded ID keeps things simple.
- Find your Clerk user ID by opening the Clerk Dashboard.
- Click Users in the left sidebar.
- Click on your user. Your user ID starts with user_ and appears near the top of the user detail page.
- Copy your user ID and replace your-clerk-user-id-here in the code above with your actual ID.
- Save your routes file.
Pro tip: use roles in production
Hardcoding an admin user ID works for learning, but Clerk supports a full RBAC system with roles and permissions. In a production app, you would assign an admin role to users and check it with auth.has({ role: 'admin' }) instead.
Not sure where to find your user ID?
In the Clerk Dashboard, click Users in the left sidebar, then click on your user's row. The ID is displayed at the top of the detail page and looks like user_2abc123....
Help me find my Clerk user ID for the admin check.
Awesome, I've got everything!
Double check you have saved your routes file before moving on.
I'd like to double check the full code
Here is the complete admin routes file for reference.
import express from 'express'
import { clerkClient, getAuth } from '@clerk/express'
import { db } from '../db/index.js'
import { users } from '../db/schema.js'
import { eq } from 'drizzle-orm'
const router = express.Router()
// POST /api/admin/set-tier
// Updates a user's tier in both Clerk metadata and the database
router.post('/set-tier', async (req, res) => {
try {
// Check that the requesting user is authenticated
const auth = getAuth(req)
if (!auth.userId) {
return res.status(401).json({ error: 'Not authenticated.' })
}
// Check that the requesting user is an admin
// Replace this ID with your own Clerk user ID from the Dashboard
const ADMIN_USER_ID = 'your-clerk-user-id-here'
if (auth.userId !== ADMIN_USER_ID) {
return res.status(403).json({ error: 'Admin access required.' })
}
const { userId, tier } = req.body
// Validate that the tier value is either 'free' or 'pro'
if (!['free', 'pro'].includes(tier)) {
return res.status(400).json({ error: 'Invalid tier. Must be free or pro.' })
}
// Validate that a userId was provided
if (!userId) {
return res.status(400).json({ error: 'userId is required.' })
}
// Update the user's tier in Clerk publicMetadata
await clerkClient.users.updateUserMetadata(userId, {
publicMetadata: { tier }
})
// Keep the database in sync by updating the tier column
await db.update(users).set({ tier }).where(eq(users.clerkId, userId))
return res.json({ success: true, userId, tier })
} catch (error) {
console.error('Error updating tier:', error)
return res.status(500).json({ error: 'Failed to update tier.' })
}
})
export default router
Test the full upgrade and downgrade flow
Time to verify that the entire tier system works end-to-end. You will use the admin endpoint to upgrade a user, confirm premium features unlock, then downgrade and confirm they lock again.
- Make sure your Sculpt app is running in Replit.
- First, set your test user to the free tier so you have a clean starting point. Open the Clerk Dashboard, click Users, select your user, and set the publicMetadata to:
{ "tier": "free" }
- Log in to your Sculpt app as that user. Confirm that premium features (AI coach, detailed macros, progress analytics) show the locked state or upgrade prompt you built in Step 4.
- Now test the upgrade. Open a new browser tab and call your admin endpoint by running this command in your Replit Shell:
curl -X POST http://localhost:3000/api/admin/set-tier \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_SESSION_TOKEN" \
-d '{"userId": "user_YOUR_TEST_USER_ID", "tier": "pro"}'
How do I get my session token?
The easiest way to test this is to call the endpoint directly from your frontend instead of using curl. Open your browser's developer console on your running Sculpt app and use fetch. Clerk automatically attaches the session token to requests made from your app's domain.
Alternatively, you can temporarily remove the admin check, test the endpoint, and then add the check back.
- You should see a JSON response with { "success": true, "userId": "...", "tier": "pro" }.
- Go back to your Sculpt app tab and refresh the page. The session token may take a few seconds to update with the new tier.
- Confirm that all premium features are now unlocked. The upgrade prompts should be replaced with the actual AI coach, detailed macro breakdowns, and progress analytics.
Why does the session take a moment to update?
Clerk's session token is automatically refreshed when metadata changes, but this can take a few seconds to propagate. If you do not see the change immediately, wait a moment and refresh again.
- Now test the downgrade. Call the endpoint again, this time setting the tier to free:
curl -X POST http://localhost:3000/api/admin/set-tier \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_SESSION_TOKEN" \
-d '{"userId": "user_YOUR_TEST_USER_ID", "tier": "free"}'
- Refresh your Sculpt app. Premium features should be gated again, showing the locked states and upgrade prompts.
- Verify the database is in sync by checking the Clerk Dashboard. Click Users, select your test user, and confirm that publicMetadata shows { "tier": "free" }.
Tier not changing after calling the endpoint?
- Check that your CLERK_SECRET_KEY environment variable is set correctly. The clerkClient needs it to write to Clerk's API.
- Make sure the userId you are passing in the request body matches a real user in your Clerk Dashboard. It should start with user_.
- If the endpoint returns a 403, your session token might not be attached to the request. Try calling the endpoint from the browser console using fetch while logged in to your Sculpt app.
- If the Clerk metadata updates but the frontend does not reflect the change, wait 5-10 seconds and refresh. The session token refreshes automatically but not instantly.
Help me debug my admin set-tier endpoint.
Your tier system is now fully operational. You have a secure admin endpoint that can upgrade or downgrade any user, with both Clerk and your database staying perfectly in sync. Next up, the Secret Mission challenges you to build a polished upgrade page that ties it all together.
Secret mission
Build a Pro Upgrade Page
Your tier system works, but free users have nowhere to go when they hit a paywall. Build a polished /upgrade page with a side-by-side pricing comparison card using shadcn/ui. Show users exactly what they are missing and wire up a one-click upgrade button.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the Clerk Hobby tier (free) and your existing PostgreSQL database, so there are no ongoing costs.
Resources you used:
- Clerk session token customization (custom metadata claim added in Clerk Dashboard).
- Clerk publicMetadata on your user account (tier value set to free or pro).
- Drizzle schema changes (tierEnum and tier column added to your users table in PostgreSQL).
- New backend files (requireTier middleware, admin tier management endpoint).
- New frontend files (useTier hook, upgrade CTA component, conditional rendering logic).
Keep everything running
No action needed. Choose this if you're still actively building on your Sculpt app or plan to extend the tier system further.
- Your Clerk session token customization stays active and continues to include the metadata claim.
- Your publicMetadata tier values remain on your Clerk user accounts.
- The tierEnum and tier column stay in your PostgreSQL database.
- All of this is free on the Clerk Hobby tier, so there are no costs to worry about.
Pause - I'll come back to this later
Reset your Clerk configuration to its original state while keeping your code files intact. This lets you pick up where you left off later.
- In the Clerk Dashboard, go to Sessions and remove the custom session token template you added. This reverts to the default session claims.
- Optionally, go to Users in the Clerk Dashboard and clear the publicMetadata on your test users by setting it back to {}.
- Keep your code files (middleware, hooks, components) in place. You can re-enable the session token customization and metadata when you return.
- The tier column in PostgreSQL can stay. It does not affect your existing app functionality.
Delete - I don't want to use this again
Remove all project resources and revert your Sculpt app to its pre-tier state.
- In the Clerk Dashboard, go to Sessions and delete the custom session token template.
- Go to Users in the Clerk Dashboard and clear the publicMetadata on all users by setting it to {}.
- Delete the requireTier middleware file and the admin tier management endpoint from your backend.
- Delete the useTier hook, upgrade CTA component, and any conditional rendering logic you added to the frontend.
- Remove the tier-gating logic from your premium API routes.
- To remove the tier column and tierEnum from PostgreSQL, create a new Drizzle migration that drops them, then run npx drizzle-kit generate and npx drizzle-kit migrate.
Mission accomplished
That's a Wrap!
Nice work! You just leveled up Sculpt from a free-for-all app to a real SaaS product with tiered access control. Your fitness app now gates premium features on both the server and client, using the same patterns that production SaaS products rely on every day.
You've learned how to:
- Store user tiers in Clerk publicMetadata and customize the session token so both your Express backend and React frontend can read the tier without extra API calls.
- Build a reusable requireTier Express middleware that enforces tier limits server-side, so premium routes cannot be bypassed from the browser.
- Gate premium features in React with conditional rendering and upgrade prompts, keeping your Drizzle database in sync with Clerk metadata through an admin endpoint.
- Build a polished upgrade page with a pricing comparison card using shadcn/ui, complete with feature checklists for Free vs Pro tiers.
Ready to quiz yourself?