How visitors try my Recipe App without signing up
I wanted strangers to see what my Recipe App actually does without making them register with their Gmail or GitHub. So /demo shows my real recipes from Postgres, behind one env var called DEMO_CLERK_ID, with every write button quietly removed.
Here's a question I kept asking myself while building my Recipe App: how does someone see what it does without signing up? It's a personal cookbook. To use it, you sign in with Gmail or GitHub through Clerk, and then it's your recipes. But a portfolio visitor doesn't want to register an account just to look at a grid of recipes for thirty seconds. They want to click around, get the full overview, and leave.
The short answer is that I added a public route at /demo that shows my own real recipes, the same ones I cook from, pulled live out of Postgres. There's no fake seed data and no screenshot tour. The whole trick is one environment variable called DEMO_CLERK_ID, plus a habit of hiding every button that would change anything. The interesting part is how little code it took.
Why not just use fake data?
I could have. The usual move is to write a list of pretend recipes, ship those to the demo, and call it a day. I even keep a fallback file of mock data for when the env var isn't set. But mock data has a tell. The recipes are too tidy, the photos are stock, and the visitor can sense there's no real app behind the glass. It feels like a showroom car with painted-on windows.
My real recipes are messier and better. There's the one with a photo I took badly in my kitchen, the folder I named something silly, the dish types I actually use. When a visitor opens /demo they're looking at the genuine app with genuine content. That's the whole point: the most honest demo is the live thing, just with the controls taken away.
How does the demo know whose recipes to show?
Every user in my database has a clerkId, which is the ID Clerk hands me when someone signs in. Recipes belong to a user by that user's row. So to show my recipes on a public page, I only need to answer one question: which user is the demo? I answer it with an env var. DEMO_CLERK_ID is set to my own Clerk ID in production, and the demo page looks me up by it.
The lookup itself is boring on purpose. It's a single query that finds the user row whose clerkId matches the env var, and returns it or null.
export async function getDemoUser(clerkId: string) {
const result = await db.select().from(users).where(eq(users.clerkId, clerkId)).limit(1);
return result[0] ?? null;
}Think of the env var as a name tag I pinned on one row in the users table. The demo page reads the name tag, finds that row, and serves whatever recipes hang off it. Here's the page that does it. It reads DEMO_CLERK_ID, resolves the owner, then loads that owner's recipes and folders, exactly like the real home page would.
export default async function DemoPage() {
const clerkId = process.env.DEMO_CLERK_ID;
if (clerkId) {
const owner = await getDemoUser(clerkId);
if (owner) {
const [recipes, folders] = await Promise.all([
getRecipes(owner.id),
getFolders(owner.id),
]);
return (
<HomeClient
recipes={recipes}
folders={folders}
isDemo
linkPrefix="/demo/recipe"
/>
);
}
}
// Fallback: static mock data (env var not set or owner not found)
return (
<HomeClient recipes={DEMO_RECIPES} folders={DEMO_FOLDERS} isDemo linkPrefix="/demo/recipe" />
);
}Notice the fallback at the bottom. If the env var is missing, or if nobody matches it, the page drops to the static mock recipes instead of crashing. That matters for local development, where I don't always have DEMO_CLERK_ID set. The demo always renders something.
What stops a visitor from editing my recipes?
This is the part I had to think about hardest. The demo shows my real data, so a stranger absolutely cannot be allowed to add, rename, or delete anything. My answer has two layers, and the simpler one carries most of the weight: the demo page never renders a button that writes.
Every component that can change data takes an isDemo flag. When it's true, the write affordances just aren't in the tree. The Add Recipe button becomes a Sign in link. The upload dialog never mounts. Bulk-select and delete disappear. Here's the header doing exactly that.
{isDemo ? (
<Link href="/sign-in" className="...">
Sign in
</Link>
) : (
<>
<button onClick={() => setUploadOpen(true)} className="...">
+ Add Recipe
</button>
<UserButton />
</>
)}
{/* the upload dialog only mounts outside demo mode */}
{!isDemo && (
<RecipeUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} folders={folders} />
)}You might be wondering whether hiding buttons is real security. On its own, no, and that's the second layer: the demo route never wires up any write path in the first place. There's no demo endpoint that mutates data, and the real write actions all check the signed-in Clerk session, which a demo visitor doesn't have. Hiding the buttons is the part visitors see. The missing write paths are the part that actually keeps my recipes safe.
Things that surprised me
A few things turned out smaller or trickier than I expected once I started building this.
The whole feature is basically one env var. I expected to build a separate demo data layer. I didn't. The demo reuses the exact same queries (getRecipes, getFolders) as the signed-in app, just pointed at a known user. The repo is on GitHub if you want to see it.
The fallback saved me more than once. Forgetting to set DEMO_CLERK_ID locally used to be a blank page. With the mock-data fallback, the demo just renders pretend recipes instead, and I notice the real ones are missing without anything breaking.
Read-only is mostly an absence, not a feature. I kept looking for a guard to write. The real work was deciding which buttons not to render and which write paths not to expose. There's almost no positive code that enforces read-only, which felt wrong until it didn't.
The links need their own prefix. Demo recipe pages live under /demo/recipe, not /recipe, so I pass a linkPrefix down to the grid. Easy to forget, and the symptom is demo links that bounce you to the sign-in wall.
Would I do it this way again?
For a portfolio app, yes, every time. The reason I like it is that it answers the visitor's real wish, which is to understand the app without committing to it. They open /demo, browse my actual recipes, open a few, see the folders and filters work, and get the full overview in under a minute. No Gmail, no GitHub, no account they'll never use again.
Where I'd be careful is anything past a personal showcase. This works because the demo data is mine, and I'm fine with the world reading it. If the demo exposed someone else's content, or anything private, the hide-the-buttons approach wouldn't be enough and I'd want real per-row authorization. But for showing strangers what I built, one env var and a habit of not rendering write buttons is the leanest honest demo I've found.


