You can build an AI writing tool using Next.js and the Whop infrastructure in just a few hours. In this tutorial, we walk you through building such a project from scratch.

Key takeaways

  • Developers can build a subscription-based AI writing tool using Next.js and Whop to handle payments and authentication without managing infrastructure.
  • Whop OAuth and Whop Payments eliminate the complexity of user credentials, recurring billing, and checkout flows for indie SaaS projects.
  • A deploy-first approach with Vercel, Neon Postgres, and Prisma provides a production URL early, enabling proper OAuth redirect configuration from the start.
Build this with AI

Open the tutorial prompt in your favorite AI coding tool:

Building an AI writing tool that allows its users to generate or polish all kinds of can be done using Next.js and the Whop infrastructure.

In this tutorial, we're going to build an AI writing app where users sign up, choose from a library of templates (like blog post, email, and ad copy), fill in some details, and get content they can refine through a regular AI chat.

We're going to change $20 per month for Pro access which unlocks all templates and gives unlimited generations to members.

You can preview the demo of our project here, and see the full GitHub repository here.

Project overview

Before we dive deep into coding, let's take a general look at our project:

  • Template based generation where users pick from 8 writing templates (blog post, email, social post, ad copy, landing page, product description, SEO article, press release) and fill in a few inputs
  • AI made drafts generated through the Vercel AI SDK with Anthropic and OpenAI models, picked per template
  • Regular AI chat where users send follow-up messages to revise the output until it's ready to copy
  • Generation history where the last 20 generations are saved per user and can be reopened with their full chat thread
  • Subscriptions through Whop: A free tier (3 templates, 5 generations per day) and a Pro tier ($20/month, all templates, unlimited generations)

Tech stack

  • Next.js - Server Components, API routes, and Vercel deployment in one framework
  • React - Server Components for data fetching, Client Components for interactivity
  • Tailwind CSS - CSS-first configuration with @theme blocks, no config file
  • Vercel AI SDK - Unified interface for Anthropic and OpenAI with streaming chat via useChat
  • Whop OAuth - Sign-in and identity with PKCE (OAuth 2.1)
  • Whop Payments - Subscription checkout and webhooks for a direct SaaS model (no connected accounts)
  • Neon - Serverless Postgres via the Vercel integration. Auto-populated connection strings
  • Prisma - ESM-only ORM with @prisma/adapter-pg for Neon compatibility. Client generated into src/generated/prisma
  • Zod - Runtime validation for env vars, API inputs, and form data
  • iron-session - Encrypted cookie sessions. No session store, no Redis
  • Vercel - Deployment with automatic builds from GitHub

Pages

  • / - Marketing landing page with hero, features, templates, and pricing. Anyone can visit.
  • /studio - The three-panel IDE where users generate and refine drafts. Authenticated only; guests get redirected to /.

API routes

  • /api/auth/login - OAuth initiation (PKCE)
  • /api/auth/callback - OAuth callback + user upsert
  • /api/auth/logout - Session destroy
  • /api/generate - POST: runs AI generation, creates Generation record, enforces the daily limit for free users
  • /api/chat - POST: streams refinement chat responses and persists messages
  • /api/webhooks/whop - POST: Whop subscription webhooks

Payment flow

  1. Free user clicks "Upgrade to Pro" in the user context menu
  2. App displays an information popup about the Pro tier and redirects to the Whop hosted checkout URL stored on the Plan record
  3. User completes payment on Whop
  4. Whop fires a membership.activated webhook. The app makes the Membership record for the user with the ACTIVE status
  5. getUserTier() reads the Membership on every request and unlocks Pro templates and unlimited generations
  6. Subsequent webhooks (membership.deactivated, membership.cancel_at_period_end_changed) keep the Membership row in sync

Why we use Whop

In a project like this, we're going to face two problems and they can be quite tricky to solve: the payments system and user authentication.

Since this project isn't an enterprise-level app, we prefer not to keep track of user payment details, manage the payment routing, or store user credentials. Whop helps us solve these issues:

  • For payments, we're going to use the Whop payments. It will handle recurring billing, checkouts, and all payment-related systems.
  • For user authentication, we're going to use Whop OAuth to integrate a simple and secure user authentication system.

Part 1: Scaffold, deploy, and authenticate

Starting off, we're going to follow a deploy-first approach. Instead of building the app first and deploying last, we're going to start off by deploying the app so we can get a production URL, which we'll use for setting up the Whop OAuth.

Create the project

Firstly, let's get the Next.js scaffold using the command below:

Terminal
npx create-next-app@latest pencraft --typescript --tailwind --eslint --app --src-dir --no-import-alias

Then, install the dependencies for authentication, sessions, validation, and database access:

Terminal
npm install @whop/sdk iron-session zod prisma @prisma/adapter-pg pg
npm install -D @types/pg dotenv

Deploy to Vercel

Now, it's time to deploy the scaffold to Vercel. Whop OAuth, which we'll work on soon, needs a real redirect URI, and we'll use Vercel as a source of truth for the environment variables of our project as well. First of all, get the Vercel CLI with the command below:

Terminal
npm i -g vercel

Then, use the command below to log in and link the directory we're working in to a new project:

Terminal
vercel

Once you do this, the deployment will start and you'll get a production URL. We'll use it later.

Add the Neon database

We're going to use Neon as a database solution in this project instead of setting up a local database. In your Vercel project, go to the Storage tab and add the Neon Postgres option to your project. Once added, Vercel will automatically populate the database-related environment variables.

Create a Whop app

Go to sandbox.whop.com and navigate to the dashboard of your whop (create one if you haven't already). There, find the Apps section and click Create app. After giving your app a name and completing the app creation process, go to its OAuth tab and add your production URL with /api/auth/callback at the end of it:

env
https://the-deployment-url.vercel.app/api/auth/callback

Also add http://localhost:3000/api/auth/callback for local development.

Environment variables

Now, let's go to project settings in Vercel's website, then the Environment Variables page, and fill out all of our environment variables:

VariableWhere to get it
WHOP_CLIENT_IDWhop app > OAuth tab > Client ID
WHOP_CLIENT_SECRETWhop app > OAuth tab > Client Secret
WHOP_API_KEYWhop Developer Dashboard > API keys > create a new Company API Key
WHOP_COMPANY_IDWhop Dashboard > your company settings (looks like biz_xxxxx)
WHOP_SANDBOXtrue
SESSION_SECRETGenerate with openssl rand -base64 32
NEXT_PUBLIC_APP_URLThe Vercel deployment URL (e.g. https://pencraft-xxxx.vercel.app)
DATABASE_URLAuto-populated by the Neon integration
DATABASE_URL_UNPOOLEDAuto-populated by the Neon integration

We will add database and webhook variables in later parts. For local development, pull the variables down using the command below:

Terminal
vercel env pull .env.local

For local development, also set NEXT_PUBLIC_APP_URL=http://localhost:3000 in .env.local.

Vercel configuration

We want the Prisma client to exist before the Next.js project builds. Otherwise, we'd encounter import errors. Vercel reads a vercel.ts file at the project root for build configuration so by creating that file, we can make Vercel run prisma generate before next build.

To do this, go to the project root and create a file called vercel.ts with the following content:

vercel.ts
const config = {
  buildCommand: "npx prisma generate && next build",
  framework: "nextjs" as const,
};

export default config;

Environment validation

Every environment variable in this project will get validated through a Zod schema. This helps us by giving us clear errors instead of cryptic ones that we'll have to debug later.

Go to src/lib/ and create a file called env.ts with the following content:

env.ts
import { z } from "zod";

const envSchema = z.object({
  WHOP_CLIENT_ID: z.string(),
  WHOP_CLIENT_SECRET: z.string(),
  WHOP_API_KEY: z.string(),
  WHOP_COMPANY_ID: z.string(),
  WHOP_SANDBOX: z.string().optional().default("true"),
  DATABASE_URL: z.string(),
  DATABASE_URL_UNPOOLED: z.string(),
  SESSION_SECRET: z.string().min(32),
  NEXT_PUBLIC_APP_URL: z.string().url(),
  WHOP_WEBHOOK_SECRET: z.string(),
});

type Env = z.infer<typeof envSchema>;

function createEnvProxy(): Env {
  return new Proxy({} as Env, {
    get(_, key: string) {
      const value = process.env[key];
      const shape = envSchema.shape as Record<string, z.ZodTypeAny>;
      const field = shape[key];
      if (!field) throw new Error(`Unknown env var: ${key}`);
      return field.parse(value);
    },
  });
}

export const env = createEnvProxy();

Database client

Prisma needs three files to work, a schema describing the tables, a configuration file that manages how to connect, and a client we can import from anywhere on the app.

We're going to define the full data model later down the guide. For now, let's just set up a basic foundation so the rest of the app can import prisma without errors.

First, go to prisma/ and create a file called schema.prisma with the following content:

schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

Then, let's create the configuration. The Prisma CLI needs the database URL and it doesn't read the local environment variables automatically, so we're going to load it with dotenv and hand it the unpooled connection string.

Go to the project root and create a file called prisma.config.ts with the following content:

prisma.config.ts
import { config } from "dotenv";
config({ path: ".env.local" });

import { defineConfig } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  datasource: {
    url: process.env["DATABASE_URL_UNPOOLED"] || process.env["DATABASE_URL"],
  },
});

Now run prisma generate. This will read the schema and generate a typed client into src/generated/prisma. Even though our schema is currently empty, this step is required so imports like PrismaClient resolve.

Terminal
npx prisma generate

Lastly, let's wrap the generated client in a singleton. Since Next.js hot-reloads server code in development, this can create a new Prisma client on every save.

To avoid this, go to src/lib/ and create a file called prisma.ts with the following content:

prisma.ts
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { Pool } from "pg";
import { env } from "./env";

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };

function createPrismaClient() {
  const pool = new Pool({ connectionString: env.DATABASE_URL });
  const adapter = new PrismaPg(pool);
  return new PrismaClient({ adapter });
}

export const prisma = globalForPrisma.prisma ?? createPrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

Session management

After a user signs in with Whop, we need a way to keep their identity across requests. We're going to use iron-session for it.

It will encrypt session data and store it inside a cookie, so we don't have to do database lookups. Go to src/lib/ and create a file called session.ts with the following content:

session.ts
import { getIronSession, type SessionOptions } from "iron-session";
import { cookies } from "next/headers";
import { env } from "./env";

export interface SessionData {
  whopUserId?: string;
  email?: string;
  name?: string;
  avatarUrl?: string;
}

const sessionOptions: SessionOptions = {
  password: env.SESSION_SECRET,
  cookieName: "pencraft_session",
  cookieOptions: {
    secure: process.env.NODE_ENV === "production",
    httpOnly: true,
    sameSite: "lax" as const,
  },
};

export async function getSession() {
  return getIronSession<SessionData>(await cookies(), sessionOptions);
}

Whop SDK client

We're going to make calls to the Whop API from several places, so we need to set up a single configured client all calls can share. Go to src/lib/ and create a file called whop.ts with the following content:

whop.ts
import Whop from "@whop/sdk";
import { env } from "./env";

export const whop = new Whop({
  apiKey: env.WHOP_API_KEY,
});

export function getWhopBaseUrl() {
  return env.WHOP_SANDBOX === "true"
    ? "https://sandbox-api.whop.com"
    : "https://api.whop.com";
}

Sign-in flow

The sign-in flow of the project have three routes: login (redirects to Whop), callback (exchanges code for tokens), and logout (clears the session).

Login route

The login route creates a PKCE code verifier and a challenge, stores the verifier in a cookie, and redirects the user to Whop's authorization page.

Go to src/app/api/auth/login/ and create a file called route.ts with the following content:

route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { env } from "@/lib/env";
import { getWhopBaseUrl } from "@/lib/whop";

function base64url(bytes: Uint8Array): string {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=/g, "");
}

function randomString(len: number): string {
  return base64url(crypto.getRandomValues(new Uint8Array(len)));
}

async function sha256(str: string): Promise<string> {
  return base64url(
    new Uint8Array(
      await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str))
    )
  );
}

export async function GET() {
  const codeVerifier = randomString(32);
  const codeChallenge = await sha256(codeVerifier);
  const state = randomString(16);
  const nonce = randomString(16);

  const cookieStore = await cookies();
  cookieStore.set("pkce_verifier", codeVerifier, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 600,
    path: "/",
  });
  cookieStore.set("oauth_state", state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 600,
    path: "/",
  });

  const params = new URLSearchParams({
    response_type: "code",
    client_id: env.WHOP_CLIENT_ID,
    redirect_uri: `${env.NEXT_PUBLIC_APP_URL}/api/auth/callback`,
    scope: "openid profile email",
    state,
    nonce,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  });

  const baseUrl = getWhopBaseUrl();
  return NextResponse.redirect(`${baseUrl}/oauth/authorize?${params}`);
}

Callback route

The callback route validates the parameters and exchanges the authorization code for tokens via the PKCE verifier.

Then, it fetches the user profile from Whop, stores it in the session, and redirects the user to our home page.

Go to src/app/api/auth/callback/ and create a file called route.ts with the following content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { env } from "@/lib/env";
import { getWhopBaseUrl } from "@/lib/whop";
import { getSession } from "@/lib/session";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const code = searchParams.get("code");
  const state = searchParams.get("state");
  const error = searchParams.get("error");

  if (error) {
    return NextResponse.redirect(new URL("/?error=oauth_denied", env.NEXT_PUBLIC_APP_URL));
  }

  const cookieStore = await cookies();
  const storedState = cookieStore.get("oauth_state")?.value;
  const codeVerifier = cookieStore.get("pkce_verifier")?.value;

  cookieStore.delete("oauth_state");
  cookieStore.delete("pkce_verifier");

  if (!code || !state || !codeVerifier || state !== storedState) {
    return NextResponse.redirect(new URL("/?error=invalid_state", env.NEXT_PUBLIC_APP_URL));
  }

  const baseUrl = getWhopBaseUrl();

  const tokenResponse = await fetch(`${baseUrl}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      grant_type: "authorization_code",
      code,
      redirect_uri: `${env.NEXT_PUBLIC_APP_URL}/api/auth/callback`,
      client_id: env.WHOP_CLIENT_ID,
      client_secret: env.WHOP_CLIENT_SECRET,
      code_verifier: codeVerifier,
    }),
  });

  if (!tokenResponse.ok) {
    return NextResponse.redirect(
      new URL("/?error=token_exchange_failed", env.NEXT_PUBLIC_APP_URL)
    );
  }

  const tokens = await tokenResponse.json();

  const userInfoResponse = await fetch(`${baseUrl}/oauth/userinfo`, {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
  });

  if (!userInfoResponse.ok) {
    return NextResponse.redirect(
      new URL("/?error=userinfo_failed", env.NEXT_PUBLIC_APP_URL)
    );
  }

  const userInfo = await userInfoResponse.json();

  const session = await getSession();
  session.whopUserId = userInfo.sub;
  session.email = userInfo.email;
  session.name = userInfo.name;
  session.avatarUrl = userInfo.picture;
  await session.save();

  return NextResponse.redirect(new URL("/", env.NEXT_PUBLIC_APP_URL));
}

Logout route

The logout route simply clears the session cookie and redirects the user back to the home page.

To build it, go to src/app/api/auth/logout/ and create a file called route.ts with the following content:

route.ts
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { getSession } from "@/lib/session";

export async function GET() {
  const session = await getSession();
  session.destroy();
  return NextResponse.redirect(new URL("/", env.NEXT_PUBLIC_APP_URL));
}

Authentication helpers

Most pages and API routes either have differing access rules or content according to who the current user is. Instead of checking the session each time, we will create two small helpers.

One for pages only signed-in users should see (which kicks guests back to the home page), and one for pages that work for everyone but want to show different content when someone is signed in. Go to src/lib/ and create a file called auth.ts with the following content:

auth.ts
<div class="ucb-box">
  <div class="ucb-header">
    <span class="ucb-title">route.ts</span>
    <button class="ucb-copy" onclick="
      const code = this.closest('.ucb-box').querySelector('code').innerText;
      navigator.clipboard.writeText(code);
      const originalText = this.innerText;
      this.innerText = 'Copied!';
      setTimeout(() => this.innerText = originalText, 2000);
    ">Copy</button>
  </div>
  <div class="ucb-content">
    <pre class="ucb-pre"><code class="language-typescript">import { redirect } from &quot;next/navigation&quot;;
import { getSession } from &quot;./session&quot;;

export async function requireAuth() {
  const session = await getSession();
  if (!session.whopUserId) redirect(&quot;/&quot;);
  return {
    whopUserId: session.whopUserId,
    email: session.email ?? &quot;&quot;,
    name: session.name ?? null,
    avatarUrl: session.avatarUrl ?? null,
  };
}

export async function getOptionalUser() {
  const session = await getSession();
  if (!session.whopUserId) return null;
  return {
    whopUserId: session.whopUserId,
    email: session.email ?? &quot;&quot;,
    name: session.name ?? null,
    avatarUrl: session.avatarUrl ?? null,
  };
}</code></pre>
  </div>
</div>

Styling

Our project supports light and dark themes, plus a system theme that auto-selects light or dark based on the system theme of the user. Go to src/app and replace the contents of the globals.css file with:

globals.css
@import "tailwindcss";

@theme {
  --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;

  --color-bg: var(--bg);
  --color-surface: var(--surface);
  --color-surface-hover: var(--surface-hover);
  --color-surface-active: var(--surface-active);
  --color-border: var(--border);
  --color-border-subtle: var(--border-subtle);

  --color-text-primary: var(--text-primary);
  --color-text-secondary: var(--text-secondary);
  --color-text-tertiary: var(--text-tertiary);
  --color-text-muted: var(--text-muted);

  --color-accent: var(--accent);
  --color-accent-hover: var(--accent-hover);
  --color-accent-muted: var(--accent-muted);
  --color-accent-subtle: var(--accent-subtle);

  --color-success: var(--success);
  --color-warning: var(--warning);
  --color-error: var(--error);

  --color-scrollbar: var(--scrollbar);
  --color-scrollbar-hover: var(--scrollbar-hover);
}

:root {
  --bg: #ffffff;
  --surface: #f4f4f5;
  --surface-hover: #e4e4e7;
  --surface-active: #d4d4d8;
  --border: #e4e4e7;
  --border-subtle: #f4f4f5;

  --text-primary: #09090b;
  --text-secondary: #52525b;
  --text-tertiary: #71717a;
  --text-muted: #a1a1aa;

  --accent: #6366f1;
  --accent-hover: #4f46e5;
  --accent-muted: #818cf8;
  --accent-subtle: rgba(99, 102, 241, 0.08);

  --success: #16a34a;
  --warning: #d97706;
  --error: #dc2626;

  --scrollbar: #d4d4d8;
  --scrollbar-hover: #a1a1aa;
}

.dark {
  --bg: #0a0a0b;
  --surface: #141416;
  --surface-hover: #1c1c1f;
  --surface-active: #232326;
  --border: #27272a;
  --border-subtle: #1e1e21;

  --text-primary: #fafafa;
  --text-secondary: #a1a1aa;
  --text-tertiary: #71717a;
  --text-muted: #52525b;

  --accent: #6366f1;
  --accent-hover: #818cf8;
  --accent-muted: #4f46e5;
  --accent-subtle: rgba(99, 102, 241, 0.1);

  --success: #22c55e;
  --warning: #f59e0b;
  --error: #ef4444;

  --scrollbar: #27272a;
  --scrollbar-hover: #3f3f46;
}

* {
  scrollbar-width: thin;
  scrollbar-color: var(--color-scrollbar) transparent;
}

::selection {
  background: var(--color-accent-subtle);
  color: var(--color-text-primary);
}

body {
  background: var(--color-bg);
  color: var(--color-text-primary);
}

Root layout

Our layout loads fonts and applies the saved theme through an inline script. Go to src/app and replace the contents of the layout.tsx with:

layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({
  subsets: ["latin"],
  variable: "--font-inter",
});

export const metadata: Metadata = {
  title: "Pencraft | AI Writing Studio",
  description: "Generate polished content from templates, then refine it with AI.",
  icons: { icon: "/favicon.svg" },
};

const themeScript = `
  (function() {
    var theme = localStorage.getItem('pencraft-theme') || 'system';
    var isDark = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
    if (isDark) document.documentElement.classList.add('dark');
  })();
`;

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={inter.variable} suppressHydrationWarning>
      <head>
        <script dangerouslySetInnerHTML={{ __html: themeScript }} />
      </head>
      <body className="min-h-dvh bg-bg text-text-primary antialiased font-sans">
        {children}
      </body>
    </html>
  );
}

Placeholder home page

The home page will transform into our vision in the later parts but for now, we render a minimal placeholder so the app builds and OAuth has somewhere to redirect to. Go to src/app and replace the contents of the page.tsx file with:

page.tsx
export default function HomePage() {
  return (
    <div className="flex h-dvh items-center justify-center">
      <p className="text-text-secondary text-sm">Pencraft | coming soon</p>
    </div>
  );
}
We call our project "Pencraft," feel free to change it to your liking.

AppShell and context

AppShell is the outer frame of our project that renders the three panels we'll create (history, main content, and templates). Go to src/components/ and create a file called app-shell.tsx with the following content:

app-shell.tsx
"use client";

import { useState, createContext, useContext, type ReactNode } from "react";

interface AppState {
  selectedGenerationId: string | null;
  selectedTemplateSlug: string | null;
  leftSidebarOpen: boolean;
  rightSidebarOpen: boolean;
  loginModalOpen: boolean;
  upgradeModalOpen: boolean;
  limitModalOpen: boolean;
  generationsRemaining: number;
}

interface AppContextType extends AppState {
  selectGeneration: (id: string | null) => void;
  selectTemplate: (slug: string | null) => void;
  toggleLeftSidebar: () => void;
  toggleRightSidebar: () => void;
  openLoginModal: () => void;
  closeLoginModal: () => void;
  openUpgradeModal: () => void;
  closeUpgradeModal: () => void;
  openLimitModal: () => void;
  closeLimitModal: () => void;
  setGenerationsRemaining: (n: number) => void;
  isAtLimit: boolean;
}

const AppContext = createContext<AppContextType | null>(null);

export function useApp() {
  const ctx = useContext(AppContext);
  if (!ctx) throw new Error("useApp must be used within AppShell");
  return ctx;
}

export function AppShell({
  header,
  leftSidebar,
  centerPanel,
  rightSidebar,
  loginModal,
  upgradeModal,
  limitModal,
  initialRemaining,
}: {
  header: ReactNode;
  leftSidebar: ReactNode;
  centerPanel: ReactNode;
  rightSidebar: ReactNode;
  loginModal: ReactNode;
  upgradeModal: ReactNode;
  limitModal?: ReactNode;
  initialRemaining?: number;
}) {
  const [state, setState] = useState<AppState>({
    selectedGenerationId: null,
    selectedTemplateSlug: null,
    leftSidebarOpen: true,
    rightSidebarOpen: true,
    loginModalOpen: false,
    upgradeModalOpen: false,
    limitModalOpen: false,
    generationsRemaining: initialRemaining ?? 5,
  });

  const isAtLimit = state.generationsRemaining <= 0;

  const ctx: AppContextType = {
    ...state,
    isAtLimit,
    selectGeneration: (id) =>
      setState((s) => ({ ...s, selectedGenerationId: id, rightSidebarOpen: id ? false : s.rightSidebarOpen })),
    selectTemplate: (slug) =>
      setState((s) => ({ ...s, selectedTemplateSlug: slug, rightSidebarOpen: true })),
    toggleLeftSidebar: () =>
      setState((s) => ({ ...s, leftSidebarOpen: !s.leftSidebarOpen })),
    toggleRightSidebar: () =>
      setState((s) => ({ ...s, rightSidebarOpen: !s.rightSidebarOpen })),
    openLoginModal: () =>
      setState((s) => ({ ...s, loginModalOpen: true })),
    closeLoginModal: () =>
      setState((s) => ({ ...s, loginModalOpen: false })),
    openUpgradeModal: () =>
      setState((s) => ({ ...s, upgradeModalOpen: true })),
    closeUpgradeModal: () =>
      setState((s) => ({ ...s, upgradeModalOpen: false })),
    openLimitModal: () =>
      setState((s) => ({ ...s, limitModalOpen: true })),
    closeLimitModal: () =>
      setState((s) => ({ ...s, limitModalOpen: false })),
    setGenerationsRemaining: (n) =>
      setState((s) => ({ ...s, generationsRemaining: n })),
  };

  return (
    <AppContext.Provider value={ctx}>
      <div className="flex h-dvh flex-col overflow-hidden">
        {header}
        <div className="flex flex-1 overflow-hidden">
          <aside
            className={`flex-shrink-0 border-r border-border bg-surface overflow-y-auto transition-all duration-200 ${
              state.leftSidebarOpen ? "w-64" : "w-0"
            }`}
          >
            {state.leftSidebarOpen && leftSidebar}
          </aside>

          <main className="flex-1 overflow-y-auto">
            {centerPanel}
          </main>

          <aside
            className={`flex-shrink-0 border-l border-border bg-surface overflow-y-auto transition-all duration-200 ${
              state.rightSidebarOpen ? "w-[40rem]" : "w-0"
            }`}
          >
            {state.rightSidebarOpen && rightSidebar}
          </aside>
        </div>
      </div>

      {state.loginModalOpen && loginModal}
      {state.upgradeModalOpen && upgradeModal}
      {state.limitModalOpen && limitModal && limitModal}
    </AppContext.Provider>
  );
}

The header is a client component holds user content like sign in buttons, theme, tier upgrade CTA buttons, log out button, and sidebar collapse buttons.

Go to src/components/ and create a file called header.tsx with the following content:

header.tsx
"use client";

import { useState, useRef, useEffect } from "react";
import { useApp } from "./app-shell";

type Theme = "light" | "dark" | "system";

function getTheme(): Theme {
  if (typeof window === "undefined") return "system";
  return (localStorage.getItem("pencraft-theme") as Theme) || "system";
}

function applyTheme(theme: Theme) {
  localStorage.setItem("pencraft-theme", theme);
  const isDark =
    theme === "dark" ||
    (theme === "system" &&
      window.matchMedia("(prefers-color-scheme: dark)").matches);
  document.documentElement.classList.toggle("dark", isDark);
}

export function Header({
  user,
  tier,
}: {
  user: { name: string | null; email: string } | null;
  tier: "FREE" | "PRO";
}) {
  const { toggleLeftSidebar, toggleRightSidebar, openLoginModal, openUpgradeModal } = useApp();
  const [dropdownOpen, setDropdownOpen] = useState(false);
  const [theme, setTheme] = useState<Theme>(() => getTheme());
  const dropdownRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    function handleClickOutside(e: MouseEvent) {
      if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
        setDropdownOpen(false);
      }
    }
    function handleEscape(e: KeyboardEvent) {
      if (e.key === "Escape") setDropdownOpen(false);
    }
    if (dropdownOpen) {
      document.addEventListener("mousedown", handleClickOutside);
      document.addEventListener("keydown", handleEscape);
    }
    return () => {
      document.removeEventListener("mousedown", handleClickOutside);
      document.removeEventListener("keydown", handleEscape);
    };
  }, [dropdownOpen]);

  function handleTheme(t: Theme) {
    setTheme(t);
    applyTheme(t);
  }

  return (
    <header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
      <div className="flex items-center gap-3">
        <button
          onClick={toggleLeftSidebar}
          className="rounded p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary transition-colors cursor-pointer"
          aria-label="Toggle history sidebar"
        >
          <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>
        </button>
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 24" fill="none" className="h-5 w-auto text-text-primary">
          <path d="M2 20 L5 4 L8 4 L11 12 L10 4 L13 4 L10 20 L7 20 L4 12 L5 20 Z" fill="#6366f1"/>
          <text x="18" y="18" fontFamily="Inter, system-ui, sans-serif" fontSize="16" fontWeight="700" fill="currentColor" letterSpacing="-0.03em">pencraft</text>
        </svg>
      </div>

      <div className="flex items-center gap-2">
        {user ? (
          <>
            {tier === "PRO" && (
              <span className="rounded-md bg-accent-subtle px-2 py-0.5 text-xs font-medium text-accent">
                Pro
              </span>
            )}
            <div className="relative" ref={dropdownRef}>
              <button
                onClick={() => setDropdownOpen((o) => !o)}
                className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-tertiary hover:bg-surface-hover hover:text-text-primary transition-colors cursor-pointer"
              >
                {user.name || user.email}
                <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
              </button>

              {dropdownOpen && (
                <div className="absolute right-0 top-full mt-1 w-48 rounded-lg border border-border bg-surface shadow-lg z-50">
                  <div className="px-3 py-2">
                    <p className="text-xs font-medium text-text-muted mb-1.5">Theme</p>
                    <div className="flex gap-1">
                      <button
                        onClick={() => handleTheme("light")}
                        className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors cursor-pointer ${
                          theme === "light"
                            ? "bg-accent text-white"
                            : "text-text-secondary hover:bg-surface-hover"
                        }`}
                      >
                        <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="inline mr-1"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>
                        Light
                      </button>
                      <button
                        onClick={() => handleTheme("dark")}
                        className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors cursor-pointer ${
                          theme === "dark"
                            ? "bg-accent text-white"
                            : "text-text-secondary hover:bg-surface-hover"
                        }`}
                      >
                        <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="inline mr-1"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
                        Dark
                      </button>
                      <button
                        onClick={() => handleTheme("system")}
                        className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors cursor-pointer ${
                          theme === "system"
                            ? "bg-accent text-white"
                            : "text-text-secondary hover:bg-surface-hover"
                        }`}
                      >
                        <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="inline mr-1"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>
                        Auto
                      </button>
                    </div>
                  </div>
                  <div className="border-t border-border">
                    {tier === "FREE" && (
                      <button
                        onClick={() => { setDropdownOpen(false); openUpgradeModal(); }}
                        className="flex w-full items-center gap-2 px-3 py-2 text-xs text-text-secondary hover:bg-surface-hover transition-colors cursor-pointer"
                      >
                        <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
                        Upgrade to Pro
                      </button>
                    )}
                    <a
                      href="/api/auth/logout"
                      className="flex w-full items-center gap-2 px-3 py-2 text-xs text-text-secondary hover:bg-surface-hover transition-colors cursor-pointer"
                    >
                      <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
                      Sign out
                    </a>
                  </div>
                </div>
              )}
            </div>
          </>
        ) : (
          <button
            onClick={openLoginModal}
            className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover transition-colors cursor-pointer"
          >
            Sign in with Whop
          </button>
        )}
        <button
          onClick={toggleRightSidebar}
          className="rounded p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary transition-colors cursor-pointer"
          aria-label="Toggle templates sidebar"
        >
          <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>
        </button>
      </div>
    </header>
  );
}

Login modal

Until we add the landing page in Part 6, everything lives at /, so we show the sign-in prompt as a modal rather than a separate page. The modal renders over a blurred backdrop, and clicking the backdrop closes it.

We'll swap this out for a real landing page later, but we need something in place now so guests have a way to sign in. Go to src/components/ and create a file called login-modal.tsx with the following content:

login-modal.tsx
"use client";

import { useApp } from "./app-shell";

export function LoginModal() {
  const { closeLoginModal } = useApp();

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div
        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
        onClick={closeLoginModal}
      />
      <div className="relative z-10 w-full max-w-sm rounded-xl border border-border bg-surface p-8 shadow-2xl">
        <button
          onClick={closeLoginModal}
          className="absolute right-3 top-3 rounded p-1 text-text-tertiary hover:bg-surface-hover hover:text-text-primary transition-colors cursor-pointer"
          aria-label="Close"
        >
          <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
        </button>

        <div className="text-center">
          <h2 className="text-lg font-semibold text-text-primary">
            Sign in to Pencraft
          </h2>
          <p className="mt-2 text-sm text-text-secondary">
            Sign in with your Whop account to generate content, refine it with AI, and save your work.
          </p>
        </div>

        <a
          href="/api/auth/login"
          className="mt-6 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white hover:bg-accent-hover transition-colors cursor-pointer"
        >
          <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
          Sign in with Whop
        </a>

        <p className="mt-4 text-center text-xs text-text-muted">
          Free users get 3 templates and 5 generations per day.
        </p>
      </div>
    </div>
  );
}

Deploy and test

Commit and redeploy. The new build will pick up the environment variables we added and serve the app at the URL registered as the OAuth redirect.

Terminal
git add -A && git commit -m "feat: scaffold, dark theme, app shell, auth"
vercel

Checkpoint

Verify the following before moving on:

  1. The Vercel deployment URL loads the app with the correct theme based on system preference
  2. Open /api/auth/login directly in the browser to start the OAuth flow. After authorizing, we should be redirected back to / with the session active
  3. The session cookie pencraft_session is present in browser dev tools under Application > Cookies
  4. Visiting /api/auth/logout clears the session and redirects back to /
  5. Clicking the username in the header opens a dropdown with theme, upgrade, and sign out options
  6. Switching between Light, Dark, and Auto in the theme dropdown updates the UI instantly
  7. Reloading the page preserves the selected theme

Part 2: Data models and IDE layout

In this part, we're going to define the full data model, seed eight writing templates, and build the three-panel IDE-like interface. By the end, you'll see a full layout with a working history sidebar, template picker, and center panel.

Data model

We're going to use six tables in our app: users, their subscriptions plans, membership record, our templates, the generations they create, and the chat messages that refine each generation.

Open prisma/schema.prisma and replace its contents:

schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

enum MembershipStatus {
  ACTIVE
  CANCELLED
}

enum Tier {
  FREE
  PRO
}

enum MessageRole {
  USER
  ASSISTANT
}

model User {
  id         String   @id @default(cuid())
  whopUserId String   @unique
  email      String
  name       String?
  avatarUrl  String?
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt

  membership  Membership?
  generations Generation[]
}

model Plan {
  id            String   @id @default(cuid())
  name          String
  price         Int
  whopProductId String
  whopPlanId    String
  checkoutUrl   String
  isActive      Boolean  @default(true)
  createdAt     DateTime @default(now())

  memberships Membership[]
}

model Membership {
  id                 String           @id @default(cuid())
  userId             String           @unique
  planId             String
  whopMembershipId   String           @unique
  status             MembershipStatus
  periodStart        DateTime
  periodEnd          DateTime
  cancelAtPeriodEnd  Boolean          @default(false)
  lastWebhookEventId String?
  createdAt          DateTime         @default(now())
  updatedAt          DateTime         @updatedAt

  user User @relation(fields: [userId], references: [id])
  plan Plan @relation(fields: [planId], references: [id])
}

model Template {
  id           String   @id @default(cuid())
  name         String
  slug         String   @unique
  description  String
  category     String
  systemPrompt String
  inputFields  Json
  tier         Tier
  model        String
  isActive     Boolean  @default(true)
  createdAt    DateTime @default(now())

  generations Generation[]
}

model Generation {
  id         String   @id @default(cuid())
  userId     String
  templateId String
  inputs     Json
  output     String
  title      String
  createdAt  DateTime @default(now())

  user     User     @relation(fields: [userId], references: [id])
  template Template @relation(fields: [templateId], references: [id])
  messages Message[]
}

model Message {
  id           String      @id @default(cuid())
  generationId String
  role         MessageRole
  content      String
  createdAt    DateTime    @default(now())

  generation Generation @relation(fields: [generationId], references: [id], onDelete: Cascade)
}

Generate the updated Prisma client and push the schema to Neon.

Terminal
npx prisma generate
npx prisma db push

Seed templates

We're going to use a seed script to populate eight writing templates (three free and five pro) and a placeholder for Pro plan record.

One thing you should keep in mind is that if you want to add a ninth template or tweak an existing one, you should edit the file below.

Go to prisma/ and create a file called seed.ts with the following content:

seed.ts
import { PrismaClient, Tier } from "../src/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { Pool } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL_UNPOOLED || process.env.DATABASE_URL,
});
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });

const templates = [
  {
    name: "Blog Post",
    slug: "blog-post",
    description: "Well-structured articles with headings, introduction, and conclusion",
    category: "Content",
    tier: Tier.FREE,
    model: "claude-haiku-4-5-20251001",
    systemPrompt: `You are a professional blog writer. Write a well-structured blog post based on the user's inputs.

Include:
- An engaging introduction that hooks the reader
- Clear section headings formatted with ## in Markdown
- Well-organized body paragraphs under each heading
- A conclusion that summarizes key points and includes a call to action

Match the requested tone. Default to conversational and informative if no tone is specified. Format the output in Markdown.`,
    inputFields: [
      { name: "topic", label: "Topic", placeholder: "e.g., Remote work productivity tips", type: "text" },
      { name: "audience", label: "Target Audience", placeholder: "e.g., Remote workers and managers", type: "text" },
      { name: "tone", label: "Tone", placeholder: "e.g., Professional, casual, humorous", type: "text" },
      { name: "keyPoints", label: "Key Points", placeholder: "List the main points to cover", type: "textarea" },
    ],
  },
  {
    name: "Email",
    slug: "email",
    description: "Professional emails with subject line, greeting, and clear structure",
    category: "Communication",
    tier: Tier.FREE,
    model: "claude-haiku-4-5-20251001",
    systemPrompt: `You are a professional email writer. Write a clear, well-structured email based on the user's inputs.

Include:
- A concise, descriptive subject line (prefixed with "Subject: ")
- An appropriate greeting
- A clear body organized by purpose
- A professional sign-off

Match the requested tone. Keep the email focused and actionable. Format the output in plain text (not Markdown).`,
    inputFields: [
      { name: "purpose", label: "Purpose", placeholder: "e.g., Follow up on a meeting, Request feedback", type: "text" },
      { name: "recipient", label: "Recipient", placeholder: "e.g., Team lead, Client, Job interviewer", type: "text" },
      { name: "keyMessage", label: "Key Message", placeholder: "The main point you want to communicate", type: "textarea" },
      { name: "tone", label: "Tone", placeholder: "e.g., Formal, friendly, urgent", type: "text" },
    ],
  },
  {
    name: "Social Media Post",
    slug: "social-media-post",
    description: "Platform-optimized posts with hashtags and engagement hooks",
    category: "Social",
    tier: Tier.FREE,
    model: "gpt-4o-mini",
    systemPrompt: `You are a social media content specialist. Write a platform-appropriate social media post based on the user's inputs.

Guidelines:
- Twitter/X: Keep under 280 characters, punchy and direct
- LinkedIn: Professional tone, can be longer, use line breaks for readability
- Instagram: Visual-focused caption, generous with relevant hashtags, aim for five to ten
- Facebook: Conversational, can include questions to drive engagement
- General: Adapt length and style to the specified platform

Include relevant hashtags and a clear call to action. Make it scroll-stopping.`,
    inputFields: [
      { name: "platform", label: "Platform", placeholder: "e.g., Twitter, LinkedIn, Instagram", type: "text" },
      { name: "topic", label: "Topic", placeholder: "e.g., Product launch, Industry insight", type: "text" },
      { name: "tone", label: "Tone", placeholder: "e.g., Excited, professional, witty", type: "text" },
      { name: "cta", label: "Call to Action", placeholder: "e.g., Visit our website, Share your thoughts", type: "text" },
    ],
  },
  {
    name: "Ad Copy",
    slug: "ad-copy",
    description: "Attention-grabbing ad copy with headline, body, and CTA",
    category: "Marketing",
    tier: Tier.PRO,
    model: "gpt-4o-mini",
    systemPrompt: `You are an advertising copywriter. Write compelling ad copy based on the user's inputs.

Include:
- A headline that grabs attention (under 10 words)
- A subheadline that expands on the promise
- Body copy that highlights the unique selling point and addresses the audience's needs
- A clear, action-oriented CTA

Adapt the format to the specified platform (Google Ads = shorter, Facebook = can be longer, etc.). Focus on benefits over features.`,
    inputFields: [
      { name: "product", label: "Product / Service", placeholder: "e.g., Project management SaaS tool", type: "text" },
      { name: "audience", label: "Target Audience", placeholder: "e.g., Startup founders, small business owners", type: "text" },
      { name: "platform", label: "Ad Platform", placeholder: "e.g., Google Ads, Facebook, Instagram", type: "text" },
      { name: "usp", label: "Unique Selling Point", placeholder: "What makes this product different?", type: "textarea" },
    ],
  },
  {
    name: "Landing Page",
    slug: "landing-page",
    description: "Landing page copy with hero, benefits, social proof, and CTA sections",
    category: "Marketing",
    tier: Tier.PRO,
    model: "claude-haiku-4-5-20251001",
    systemPrompt: `You are a conversion copywriter specializing in landing pages. Write landing page copy based on the user's inputs.

Structure the output with these clearly labeled sections:
## Hero Section
- Headline (under 12 words, benefit-driven)
- Subheadline (1-2 sentences expanding the promise)
- CTA button text

## Benefits
- 3-4 benefit blocks, each with a short title and 1-2 sentence description
- Focus on outcomes, not features

## Social Proof
- A template for a testimonial quote
- A stats/numbers section (suggest realistic metrics)

## Final CTA
- A closing headline
- CTA button text
- A brief urgency or reassurance line

Format in Markdown.`,
    inputFields: [
      { name: "product", label: "Product / Service", placeholder: "e.g., AI-powered resume builder", type: "text" },
      { name: "headline", label: "Headline Idea", placeholder: "Optional starting point for the headline", type: "text" },
      { name: "audience", label: "Target Audience", placeholder: "e.g., Job seekers, career changers", type: "text" },
      { name: "benefits", label: "Key Benefits", placeholder: "List the top 3-4 benefits", type: "textarea" },
    ],
  },
  {
    name: "Product Description",
    slug: "product-description",
    description: "Compelling product descriptions with features and buyer benefits",
    category: "E-commerce",
    tier: Tier.PRO,
    model: "gpt-4o-mini",
    systemPrompt: `You are an e-commerce copywriter. Write a compelling product description based on the user's inputs.

Include:
- An opening hook that captures the product's essence (1-2 sentences)
- Key features presented as buyer benefits (use bullet points)
- A paragraph connecting the product to the buyer's lifestyle or needs
- Specifications or details section if relevant

Match the requested tone. Use sensory language where appropriate. Focus on how the product improves the buyer's life, not just what it does.`,
    inputFields: [
      { name: "productName", label: "Product Name", placeholder: "e.g., CloudWalk Running Shoes", type: "text" },
      { name: "features", label: "Key Features", placeholder: "List the main features and specs", type: "textarea" },
      { name: "audience", label: "Target Buyer", placeholder: "e.g., Marathon runners, casual joggers", type: "text" },
      { name: "tone", label: "Tone", placeholder: "e.g., Premium, playful, technical", type: "text" },
    ],
  },
  {
    name: "SEO Article",
    slug: "seo-article",
    description: "SEO-optimized articles with natural keyword placement and meta description",
    category: "Content",
    tier: Tier.PRO,
    model: "claude-haiku-4-5-20251001",
    systemPrompt: `You are an SEO content writer. Write an SEO-optimized article based on the user's inputs.

Requirements:
- Include a meta description (under 160 characters) at the top, labeled "Meta Description:"
- Use the target keyword naturally in the title, first paragraph, and 2-3 subheadings
- Structure with clear ## headings for scannability
- Aim for the requested word count (default 1000 words if not specified)
- Include an FAQ section at the end with 3-4 questions (uses ## FAQ heading)
- Write for humans first, search engines second. No keyword stuffing

Format in Markdown.`,
    inputFields: [
      { name: "keyword", label: "Target Keyword", placeholder: "e.g., best project management tools 2025", type: "text" },
      { name: "audience", label: "Target Audience", placeholder: "e.g., Small business owners", type: "text" },
      { name: "wordCount", label: "Word Count Target", placeholder: "e.g., 1500", type: "text" },
      { name: "keyPoints", label: "Key Points to Cover", placeholder: "Main topics and subtopics", type: "textarea" },
    ],
  },
  {
    name: "Press Release",
    slug: "press-release",
    description: "Standard-format press releases with headline, lead, body, and boilerplate",
    category: "PR",
    tier: Tier.PRO,
    model: "claude-haiku-4-5-20251001",
    systemPrompt: `You are a PR professional. Write a press release in standard AP format based on the user's inputs.

Structure:
- "FOR IMMEDIATE RELEASE" header
- Headline (compelling, under 15 words)
- Dateline (City, State - Date)
- Lead paragraph answering who, what, when, where, why
- 2-3 body paragraphs with supporting details
- A direct quote from a company representative (use the provided quote or generate an appropriate one)
- Boilerplate "About [Company]" paragraph
- Media contact section (placeholder)

Use third person. Keep sentences concise. Avoid marketing language - focus on newsworthy facts.`,
    inputFields: [
      { name: "announcement", label: "Announcement", placeholder: "e.g., Launch of new product line", type: "text" },
      { name: "company", label: "Company Name", placeholder: "e.g., Acme Corp", type: "text" },
      { name: "details", label: "Key Details", placeholder: "Who, what, when, where, why", type: "textarea" },
      { name: "quote", label: "Quote (optional)", placeholder: "A quote from a spokesperson", type: "textarea" },
    ],
  },
];

async function main() {
  console.log("Seeding templates...");

  for (const template of templates) {
    await prisma.template.upsert({
      where: { slug: template.slug },
      update: {
        name: template.name,
        description: template.description,
        category: template.category,
        systemPrompt: template.systemPrompt,
        inputFields: template.inputFields,
        tier: template.tier,
        model: template.model,
      },
      create: template,
    });
  }

  console.log(`Seeded ${templates.length} templates.`);

  console.log("Seeding Pro plan placeholder...");

  await prisma.plan.upsert({
    where: { id: "pro-plan" },
    update: {
      name: "Pro",
      price: 2000,
      whopProductId: "prod_placeholder",
      whopPlanId: "plan_placeholder",
      checkoutUrl: "https://sandbox.whop.com/checkout/plan_placeholder",
    },
    create: {
      id: "pro-plan",
      name: "Pro",
      price: 2000,
      whopProductId: "prod_placeholder",
      whopPlanId: "plan_placeholder",
      checkoutUrl: "https://sandbox.whop.com/checkout/plan_placeholder",
      isActive: true,
    },
  });

  console.log("Seeded Pro plan placeholder (update with real Whop IDs later).");
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(() => prisma.$disconnect());

Add the seed script configuration to package.json and install tsx for running TypeScript files.

Terminal
npm install -D tsx

Add the following to the top level of package.json (not inside scripts):

package.json
"prisma": {
  "seed": "npx tsx prisma/seed.ts"
}

Then, run the seed command:

Terminal
npx prisma db seed

Switch to database-backed sessions

Since we've connected the database, we'll now switch from storing the user profiles in the session to storing just the database user ID.

This means session cookies stay light and the profile data is always fresh. Go to src/lib and replace the contents of the session.ts file with:

session.ts
import { getIronSession, type SessionOptions } from "iron-session";
import { cookies } from "next/headers";
import { env } from "./env";

export interface SessionData {
  userId?: string;
}

const sessionOptions: SessionOptions = {
  password: env.SESSION_SECRET,
  cookieName: "pencraft_session",
  cookieOptions: {
    secure: process.env.NODE_ENV === "production",
    httpOnly: true,
    sameSite: "lax" as const,
  },
};

export async function getSession() {
  return getIronSession<SessionData>(await cookies(), sessionOptions);
}

Then, go to src/lib and replace the contents of the auth.ts file with:

auth.ts
import { redirect } from "next/navigation";
import { getSession } from "./session";
import { prisma } from "./prisma";

export async function requireAuth() {
  const session = await getSession();
  if (!session.userId) redirect("/");

  const user = await prisma.user.findUnique({
    where: { id: session.userId },
  });

  if (!user) {
    session.destroy();
    redirect("/");
  }

  return user;
}

export async function getOptionalUser() {
  const session = await getSession();
  if (!session.userId) return null;

  return prisma.user.findUnique({
    where: { id: session.userId },
  });
}

Now, the callback saves the user as a row in the database table and stores their database ID on the session. For this, go to src/app/api/auth/callback and replace the contents of the route.ts file with:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { env } from "@/lib/env";
import { getWhopBaseUrl } from "@/lib/whop";
import { getSession } from "@/lib/session";
import { prisma } from "@/lib/prisma";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const code = searchParams.get("code");
  const state = searchParams.get("state");
  const error = searchParams.get("error");

  if (error) {
    return NextResponse.redirect(new URL("/?error=oauth_denied", env.NEXT_PUBLIC_APP_URL));
  }

  const cookieStore = await cookies();
  const storedState = cookieStore.get("oauth_state")?.value;
  const codeVerifier = cookieStore.get("pkce_verifier")?.value;

  cookieStore.delete("oauth_state");
  cookieStore.delete("pkce_verifier");

  if (!code || !state || !codeVerifier || state !== storedState) {
    return NextResponse.redirect(new URL("/?error=invalid_state", env.NEXT_PUBLIC_APP_URL));
  }

  const baseUrl = getWhopBaseUrl();

  const tokenResponse = await fetch(`${baseUrl}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      grant_type: "authorization_code",
      code,
      redirect_uri: `${env.NEXT_PUBLIC_APP_URL}/api/auth/callback`,
      client_id: env.WHOP_CLIENT_ID,
      client_secret: env.WHOP_CLIENT_SECRET,
      code_verifier: codeVerifier,
    }),
  });

  if (!tokenResponse.ok) {
    return NextResponse.redirect(
      new URL("/?error=token_exchange_failed", env.NEXT_PUBLIC_APP_URL)
    );
  }

  const tokens = await tokenResponse.json();

  const userInfoResponse = await fetch(`${baseUrl}/oauth/userinfo`, {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
  });

  if (!userInfoResponse.ok) {
    return NextResponse.redirect(
      new URL("/?error=userinfo_failed", env.NEXT_PUBLIC_APP_URL)
    );
  }

  const userInfo = await userInfoResponse.json();

  const user = await prisma.user.upsert({
    where: { whopUserId: userInfo.sub },
    update: {
      email: userInfo.email ?? "",
      name: userInfo.name ?? null,
      avatarUrl: userInfo.picture ?? null,
    },
    create: {
      whopUserId: userInfo.sub,
      email: userInfo.email ?? "",
      name: userInfo.name ?? null,
      avatarUrl: userInfo.picture ?? null,
    },
  });

  const session = await getSession();
  session.userId = user.id;
  await session.save();

  return NextResponse.redirect(new URL("/", env.NEXT_PUBLIC_APP_URL));
}

Tier helper

A couple of places in the project needs to know if the user is a free user or a paid one. To make this simpler, go to src/lib/ and create a file called tier.ts with the following content:

tier.ts
import { prisma } from "./prisma";

export type UserTier = "FREE" | "PRO";

const FREE_DAILY_LIMIT = 5;

export async function getUserTier(userId: string): Promise<UserTier> {
  const membership = await prisma.membership.findUnique({
    where: { userId },
  });

  if (membership?.status === "ACTIVE") return "PRO";
  return "FREE";
}

export async function checkGenerationLimit(userId: string): Promise<{
  allowed: boolean;
  remaining: number;
  limit: number;
}> {
  const tier = await getUserTier(userId);

      if (tier === "PRO") {
    return { allowed: true, remaining: -1, limit: -1 };
  }

  const today = new Date();
  today.setHours(0, 0, 0, 0);

  const count = await prisma.generation.count({
    where: {
      userId,
      createdAt: { gte: today },
    },
  });

  return {
    allowed: count < FREE_DAILY_LIMIT,
    remaining: Math.max(0, FREE_DAILY_LIMIT - count),
    limit: FREE_DAILY_LIMIT,
  };
}

export async function getCheckoutUrl(): Promise<string | null> {
  const plan = await prisma.plan.findFirst({
    where: { isActive: true },
  });
  return plan?.checkoutUrl ?? null;
}

export async function getProPlanId(): Promise<string | null> {
  const plan = await prisma.plan.findFirst({
    where: { isActive: true },
  });
  return plan?.whopPlanId ?? null;
}

History sidebar

The left sidebar will contain the chat history of users (up to 20) as a scrollable list. To build it, go to src/components/ and create a file called history-sidebar.tsx with the following content:

history-sidebar.tsx
"use client";

import { useApp } from "./app-shell";

interface GenerationItem {
  id: string;
  title: string;
  templateName: string;
  createdAt: string;
}

export function HistorySidebar({
  generations,
  isAuthenticated,
}: {
  generations: GenerationItem[];
  isAuthenticated: boolean;
}) {
  const { selectedGenerationId, selectGeneration, openLoginModal } = useApp();

  if (!isAuthenticated) {
    return (
      <div className="flex h-full flex-col items-center justify-center p-4 text-center">
        <p className="text-xs text-text-muted">
          Sign in to see your generation history.
        </p>
        <button
          onClick={openLoginModal}
          className="mt-2 text-xs font-medium text-accent hover:text-accent-hover cursor-pointer"
        >
          Sign in
        </button>
      </div>
    );
  }

  return (
    <div className="flex h-full flex-col">
      <div className="flex items-center justify-between px-4 py-3">
        <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
          History
        </h2>
      </div>
      {generations.length === 0 ? (
        <div className="flex flex-1 items-center justify-center p-4">
          <p className="text-xs text-text-muted text-center">
            No generations yet. Pick a template to get started.
          </p>
        </div>
      ) : (
        <div className="flex-1 overflow-y-auto">
          {generations.map((g) => (
            <button
              key={g.id}
              onClick={() => selectGeneration(g.id)}
              className={`w-full px-4 py-2.5 text-left transition-colors cursor-pointer ${
                selectedGenerationId === g.id
                  ? "bg-surface-active border-l-2 border-accent"
                  : "hover:bg-surface-hover border-l-2 border-transparent"
              }`}
            >
              <p className={`text-sm truncate ${
                selectedGenerationId === g.id
                  ? "text-text-primary font-medium"
                  : "text-text-secondary"
              }`}>
                {g.title}
              </p>
              <p className="mt-0.5 text-xs text-text-muted truncate">
                {g.templateName}
              </p>
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Template sidebar

The right sidebar lists every template (Pro ones show a lock for free users). Pick one and the panel swaps to a form for its inputs.

If the user isn't allowed to generate yet (not signed in, or a free user on a Pro template), the right modal pops up instead. To build it, go to src/components/ and create a file called template-sidebar.tsx with the following content:

template-sidebar.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { useApp } from "./app-shell";

interface TemplateItem {
  id: string;
  name: string;
  slug: string;
  description: string;
  category: string;
  tier: "FREE" | "PRO";
  inputFields: { name: string; label: string; placeholder: string; type: "text" | "textarea" }[];
}

export function TemplateSidebar({
  templates,
  userTier,
  isAuthenticated,
}: {
  templates: TemplateItem[];
  userTier: "FREE" | "PRO";
  isAuthenticated: boolean;
}) {
  const { selectedTemplateSlug, selectTemplate, openLoginModal, openUpgradeModal } = useApp();
  const selectedTemplate = templates.find((t) => t.slug === selectedTemplateSlug);

  return (
    <div className="flex h-full flex-col">
      <div className="flex items-center justify-between px-4 py-3">
        <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
          Templates
        </h2>
      </div>

      {selectedTemplate ? (
        <TemplateForm
          template={selectedTemplate}
          isAuthenticated={isAuthenticated}
          userTier={userTier}
          onBack={() => selectTemplate(null)}
          onLoginRequired={openLoginModal}
          onUpgradeRequired={openUpgradeModal}
        />
      ) : (
        <TemplateList
          templates={templates}
          userTier={userTier}
          onSelect={(slug) => selectTemplate(slug)}
        />
      )}
    </div>
  );
}

function TemplateList({
  templates,
  userTier,
  onSelect,
}: {
  templates: TemplateItem[];
  userTier: "FREE" | "PRO";
  onSelect: (slug: string) => void;
}) {
  const freeTemplates = templates.filter((t) => t.tier === "FREE");
  const proTemplates = templates.filter((t) => t.tier === "PRO");

  return (
    <div className="flex-1 overflow-y-auto px-3 pb-4">
      <p className="mb-2 px-1 text-xs font-medium text-text-muted uppercase tracking-wider">Free</p>
      <div className="space-y-1">
        {freeTemplates.map((t) => (
          <button
            key={t.id}
            onClick={() => onSelect(t.slug)}
            className="w-full rounded-lg px-3 py-2.5 text-left hover:bg-surface-hover transition-colors cursor-pointer"
          >
            <p className="text-sm font-medium text-text-primary">{t.name}</p>
            <p className="mt-0.5 text-xs text-text-muted truncate">{t.description}</p>
          </button>
        ))}
      </div>

      <p className="mb-2 mt-4 px-1 text-xs font-medium text-text-muted uppercase tracking-wider">Pro</p>
      <div className="space-y-1">
        {proTemplates.map((t) => {
          const locked = userTier === "FREE";
          return (
            <button
              key={t.id}
              onClick={() => !locked && onSelect(t.slug)}
              className={`w-full rounded-lg px-3 py-2.5 text-left transition-colors ${
                locked
                  ? "opacity-50 cursor-not-allowed"
                  : "hover:bg-surface-hover cursor-pointer"
              }`}
            >
              <div className="flex items-center justify-between">
                <p className={`text-sm font-medium ${locked ? "text-text-tertiary" : "text-text-primary"}`}>
                  {t.name}
                </p>
                {locked && (
                  <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-muted flex-shrink-0"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
                )}
              </div>
              <p className="mt-0.5 text-xs text-text-muted truncate">{t.description}</p>
            </button>
          );
        })}
      </div>
    </div>
  );
}

function TemplateForm({
  template,
  isAuthenticated,
  userTier,
  onBack,
  onLoginRequired,
  onUpgradeRequired,
}: {
  template: TemplateItem;
  isAuthenticated: boolean;
  userTier: "FREE" | "PRO";
  onBack: () => void;
  onLoginRequired: () => void;
  onUpgradeRequired: () => void;
}) {
  const router = useRouter();
  const { selectGeneration, isAtLimit, openLimitModal, setGenerationsRemaining } = useApp();
  const [values, setValues] = useState<Record<string, string>>({});
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    if (!isAuthenticated) {
      onLoginRequired();
      return;
    }

    if (template.tier === "PRO" && userTier === "FREE") {
      onUpgradeRequired();
      return;
    }

    if (isAtLimit) {
      openLimitModal();
      return;
    }

    setLoading(true);
    setError(null);

    const res = await fetch("/api/generate", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ slug: template.slug, inputs: values }),
    });

    if (!res.ok) {
      const data = await res.json().catch(() => ({}));
      if (res.status === 429) {
        openLimitModal();
      }
      setError(data.error || "Generation failed.");
      setLoading(false);
      return;
    }

    const { generationId, remaining } = await res.json();
    selectGeneration(generationId);
    if (typeof remaining === "number") setGenerationsRemaining(remaining);
    router.refresh();
    setLoading(false);
    setValues({});
  }

  return (
    <div className="flex flex-1 flex-col overflow-hidden">
      <button
        onClick={onBack}
        className="flex items-center gap-1 px-4 py-2 text-xs text-text-tertiary hover:text-text-primary transition-colors cursor-pointer"
      >
        <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m15 18-6-6 6-6"/></svg>
        All templates
      </button>

      <div className="px-4 pb-2">
        <span className="text-xs font-medium text-accent">{template.category}</span>
        <h3 className="text-sm font-semibold text-text-primary">{template.name}</h3>
        <p className="mt-0.5 text-xs text-text-muted">{template.description}</p>
      </div>

      <form onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-y-auto px-4 pb-4">
        <div className="flex-1 space-y-3">
          {template.inputFields.map((field) => (
            <div key={field.name}>
              <label htmlFor={field.name} className="block text-xs font-medium text-text-secondary mb-1">
                {field.label}
              </label>
              {field.type === "textarea" ? (
                <textarea
                  id={field.name}
                  placeholder={field.placeholder}
                  value={values[field.name] || ""}
                  onChange={(e) => setValues((v) => ({ ...v, [field.name]: e.target.value }))}
                  rows={2}
                  className="w-full rounded-md border border-border bg-bg px-3 py-2 text-sm text-text-primary placeholder-text-muted focus:border-accent focus:outline-none resize-none"
                  required
                />
              ) : (
                <input
                  id={field.name}
                  type="text"
                  placeholder={field.placeholder}
                  value={values[field.name] || ""}
                  onChange={(e) => setValues((v) => ({ ...v, [field.name]: e.target.value }))}
                  className="w-full rounded-md border border-border bg-bg px-3 py-2 text-sm text-text-primary placeholder-text-muted focus:border-accent focus:outline-none"
                  required
                />
              )}
            </div>
          ))}
        </div>

        {error && <p className="mt-2 text-xs text-error">{error}</p>}

        <button
          type="submit"
          disabled={loading || isAtLimit}
          className="mt-3 w-full rounded-md bg-accent px-3 py-2 text-sm font-medium text-white hover:bg-accent-hover disabled:opacity-50 transition-colors cursor-pointer"
        >
          {loading ? "Generating..." : "Generate"}
        </button>
      </form>
    </div>
  );
}

Center panel

The center panel shows one of two things: a welcome message if nothing is selected, or the selected generation's output plus its chat thread.

We hand the panel all of the user's generations at once (as a lookup keyed by ID) so switching between them in the history sidebar feels instant: there's no loading spinner, it just renders whichever one is active.

Go to src/components/ and create a file called center-panel.tsx with the following content:

center-panel.tsx
"use client";

import { useApp } from "./app-shell";
import { GenerationOutput } from "./generation-output";
import { RefinementChat } from "./refinement-chat";

interface GenerationData {
  id: string;
  title: string;
  output: string;
  templateName: string;
  messages: { id: string; role: "USER" | "ASSISTANT"; content: string }[];
}

export function CenterPanel({
  generations,
  isAuthenticated,
}: {
  generations: Map<string, GenerationData>;
  isAuthenticated: boolean;
}) {
  const { selectedGenerationId, openLoginModal } = useApp();
  const generation = selectedGenerationId
    ? generations.get(selectedGenerationId)
    : null;

  if (!generation) {
    return (
      <div className="flex h-full flex-col items-center justify-center text-center px-8">
        <div className="max-w-md">
          <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="mx-auto text-text-muted mb-4"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
          <h2 className="text-lg font-semibold text-text-primary">
            AI Writing Studio
          </h2>
          <p className="mt-2 text-sm text-text-secondary">
            {isAuthenticated
              ? "Pick a template from the right sidebar to generate content. Your previous generations appear on the left."
              : "Sign in to start generating polished content from templates and refine it through conversation."}
          </p>
          {!isAuthenticated && (
            <button
              onClick={openLoginModal}
              className="mt-4 rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover transition-colors cursor-pointer"
            >
              Sign in with Whop
            </button>
          )}
        </div>
      </div>
    );
  }

  return (
    <div className="flex h-full flex-col">
      <div className="border-b border-border px-6 py-3">
        <h2 className="text-sm font-medium text-text-primary">{generation.title}</h2>
        <p className="text-xs text-text-muted">{generation.templateName}</p>
      </div>
      <div className="flex-1 overflow-y-auto px-6 py-4">
        <GenerationOutput content={generation.output} />
        <RefinementChat
          generationId={generation.id}
          existingMessages={generation.messages}
        />
      </div>
    </div>
  );
}

Upgrade modal

The upgrade modal is the "Unlock Pro" screen. It lists what Pro gets you, the price, and a button that opens the embedded checkout popup. Clicking the "Upgrade now" button closes this modal and opens the checkout popup where the user can complete payment without leaving the app.

Go to src/components/ and create a file called upgrade-modal.tsx with the following content:

upgrade-modal.tsx
"use client";

import { useApp } from "./app-shell";

export function UpgradeModal() {
  const { closeUpgradeModal, openCheckoutPopup } = useApp();

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div
        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
        onClick={closeUpgradeModal}
      />
      <div className="relative z-10 mx-4 w-full max-w-md rounded-xl border border-border bg-surface p-6 shadow-2xl sm:mx-auto sm:p-8">
        <button
          onClick={closeUpgradeModal}
          className="absolute right-3 top-3 rounded p-1 text-text-tertiary hover:bg-surface-hover hover:text-text-primary transition-colors cursor-pointer"
          aria-label="Close"
        >
          <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
        </button>

        <div className="text-center">
          <span className="inline-block rounded-full bg-accent-subtle px-3 py-1 text-xs font-semibold text-accent">
            Pro
          </span>
          <h2 className="mt-3 text-xl font-semibold text-text-primary">
            Unlock the full studio
          </h2>
          <p className="mt-2 text-sm text-text-secondary">
            Get access to all 8 templates and unlimited generations.
          </p>
        </div>

        <div className="mt-6 space-y-3">
          <div className="flex items-center gap-3 text-sm text-text-secondary">
            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-success flex-shrink-0"><path d="M20 6 9 17l-5-5"/></svg>
            All 8 writing templates
          </div>
          <div className="flex items-center gap-3 text-sm text-text-secondary">
            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-success flex-shrink-0"><path d="M20 6 9 17l-5-5"/></svg>
            Unlimited generations per day
          </div>
          <div className="flex items-center gap-3 text-sm text-text-secondary">
            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-success flex-shrink-0"><path d="M20 6 9 17l-5-5"/></svg>
            Chat-based content refinement
          </div>
          <div className="flex items-center gap-3 text-sm text-text-secondary">
            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-success flex-shrink-0"><path d="M20 6 9 17l-5-5"/></svg>
            Generation history saved
          </div>
        </div>

        <div className="mt-6 text-center">
          <p className="text-3xl font-bold text-text-primary">
            $20<span className="text-sm font-normal text-text-tertiary">/mo</span>
          </p>
        </div>

        <button
          onClick={() => {
            closeUpgradeModal();
            openCheckoutPopup();
          }}
          className="mt-4 flex w-full items-center justify-center rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white hover:bg-accent-hover transition-colors cursor-pointer"
        >
          Upgrade now
        </button>
      </div>
    </div>
  );
}

Home page

The home page loads everything users will see on our project, like the user, their tier, recent generations, and templates.

To build it, go to src/app and update the contents of the page.tsx file with:

page.tsx
import { getOptionalUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getUserTier } from "@/lib/tier";
import { AppShell } from "@/components/app-shell";
import { Header } from "@/components/header";
import { HistorySidebar } from "@/components/history-sidebar";
import { TemplateSidebar } from "@/components/template-sidebar";
import { CenterPanel } from "@/components/center-panel";
import { LoginModal } from "@/components/login-modal";
import { UpgradeModal } from "@/components/upgrade-modal";

export default async function HomePage() {
  const user = await getOptionalUser();
  const isAuthenticated = !!user;

  let tier: "FREE" | "PRO" = "FREE";
  let generations: { id: string; title: string; templateName: string; createdAt: string }[] = [];
  let generationDetails = new Map<string, {
    id: string;
    title: string;
    output: string;
    templateName: string;
    messages: { id: string; role: "USER" | "ASSISTANT"; content: string }[];
  }>();

  if (user) {
    tier = await getUserTier(user.id);

    const gens = await prisma.generation.findMany({
      where: { userId: user.id },
      include: {
        template: true,
        messages: { orderBy: { createdAt: "asc" } },
      },
      orderBy: { createdAt: "desc" },
      take: 20,
    });

    generations = gens.map((g: (typeof gens)[number]) => ({
      id: g.id,
      title: g.title,
      templateName: g.template.name,
      createdAt: g.createdAt.toISOString(),
    }));

    for (const g of gens) {
      generationDetails.set(g.id, {
        id: g.id,
        title: g.title,
        output: g.output,
        templateName: g.template.name,
        messages: g.messages.map((m: (typeof g.messages)[number]) => ({
          id: m.id,
          role: m.role,
          content: m.content,
        })),
      });
    }
  }

  const templates = await prisma.template.findMany({
    where: { isActive: true },
    orderBy: { createdAt: "asc" },
  });

  const templateData = templates.map((t: (typeof templates)[number]) => ({
    id: t.id,
    name: t.name,
    slug: t.slug,
    description: t.description,
    category: t.category,
    tier: t.tier as "FREE" | "PRO",
    inputFields: t.inputFields as unknown as { name: string; label: string; placeholder: string; type: "text" | "textarea" }[],
  }));

  return (
    <AppShell
      header={
        <Header
          user={user ? { name: user.name, email: user.email } : null}
          tier={tier}
        />
      }
      leftSidebar={
        <HistorySidebar
          generations={generations}
          isAuthenticated={isAuthenticated}
        />
      }
      centerPanel={
        <CenterPanel
          generations={generationDetails}
          isAuthenticated={isAuthenticated}
        />
      }
      rightSidebar={
        <TemplateSidebar
          templates={templateData}
          userTier={tier}
          isAuthenticated={isAuthenticated}
        />
      }
      loginModal={<LoginModal />}
      upgradeModal={<UpgradeModal />}
    />
  );
}

Deploy and test

Commit and redeploy. Vercel will rebuild with the new schema and the seeded templates in the database.

Terminal
git add -A && git commit -m "feat: database, templates, IDE layout"
vercel

Checkpoint

Verify the following before moving on:

  1. Running npx prisma db seed completes without errors and reports 8 templates seeded
  2. The home page loads the full three-panel layout
  3. The right sidebar shows three free templates and five pro templates
  4. Clicking a free template in the right sidebar switches to the form view with the correct input fields
  5. Clicking "Sign in with Whop" on the header (or in the center panel empty state) opens the login modal with a blurred backdrop
  6. After signing in, the left history sidebar shows "No generations yet" (since we have no generate API yet)
  7. Signing out via the dropdown in the header returns to the unauthenticated state

Part 3: AI generation

Now, it's time to make our project actually generate content. After a user selects a template, fills out its form, and clicks the Generate button, we send the form inputs to an AI model and show the result in the middle panel.

Install the AI SDK

We're going to use Vercel's AI SDK to the same code can talk to both Claude and ChatGPT models. Use the command below to install the dependencies:

Terminal
npm install ai @ai-sdk/react @ai-sdk/anthropic @ai-sdk/openai react-markdown remark-gfm

Then, add ANTHROPIC_API_KEY and OPENAI_API_KEY (your Claude and ChatGPT API keys) to the Vercel project environment variables, then pull them locally.

Terminal
vercel env pull .env.local

Prompt builder

Now, we need a way to combine each template's system prompt and the user's form inputs, then deliver them with a single prompt to the AI. Go to src/lib/ and create a file called ai.ts with the following content:

ai.ts
import { anthropic } from "@ai-sdk/anthropic";
import { openai } from "@ai-sdk/openai";
import type { LanguageModel } from "ai";

export function getModel(modelId: string): LanguageModel {
  if (modelId.startsWith("claude")) return anthropic(modelId);
  if (modelId.startsWith("gpt")) return openai(modelId);
  throw new Error(`Unknown model: ${modelId}`);
}

export function buildPrompt(
  systemPrompt: string,
  inputs: Record<string, string>,
  inputFields: { name: string; label: string }[]
): string {
  const inputSection = inputFields
    .map((field) => `**${field.label}:** ${inputs[field.name] || "Not provided"}`)
    .join("\n");

  return `${systemPrompt}\n\nThe user has provided the following inputs:\n\n${inputSection}\n\nGenerate the content based on these inputs.`;
}

Generation API route

The generation API route will check if the user is signed in and within their daily limit. Then, it will call the AI, save the result as a new generation record, and return the new ID plus how many generations the user has.

Go to src/app/api/generate/ and create a file called route.ts with the following content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { generateText } from "ai";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSession } from "@/lib/session";
import { buildPrompt, getModel } from "@/lib/ai";
import { getUserTier, checkGenerationLimit } from "@/lib/tier";

const requestSchema = z.object({
  slug: z.string(),
  inputs: z.record(z.string(), z.string()),
});

export async function POST(request: NextRequest) {
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await request.json();
  const parsed = requestSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: "Invalid request" }, { status: 400 });
  }

  const { slug, inputs } = parsed.data;

  const template = await prisma.template.findUnique({ where: { slug } });
  if (!template || !template.isActive) {
    return NextResponse.json({ error: "Template not found" }, { status: 404 });
  }

  const tier = await getUserTier(session.userId);
  if (template.tier === "PRO" && tier === "FREE") {
    return NextResponse.json({ error: "Pro template requires upgrade" }, { status: 403 });
  }

  const { allowed } = await checkGenerationLimit(session.userId);
  if (!allowed) {
    return NextResponse.json(
      { error: "Daily generation limit reached. Upgrade to Pro for unlimited." },
      { status: 429 }
    );
  }

  const inputFields = template.inputFields as unknown as { name: string; label: string }[];
  const prompt = buildPrompt(template.systemPrompt, inputs, inputFields);

  const result = await generateText({
    model: getModel(template.model),
    prompt,
  });

  const firstValue = Object.values(inputs)[0] || "Untitled";
  const title =
    firstValue.length > 50 ? firstValue.slice(0, 47) + "..." : firstValue;

  const generation = await prisma.generation.create({
    data: {
      userId: session.userId,
      templateId: template.id,
      inputs,
      output: result.text,
      title,
    },
  });

  const generations = await prisma.generation.findMany({
    where: { userId: session.userId },
    orderBy: { createdAt: "desc" },
    select: { id: true },
  });

  if (generations.length > 20) {
    const toDelete = generations.slice(20).map((g: { id: string }) => g.id);
    await prisma.generation.deleteMany({
      where: { id: { in: toDelete } },
    });
  }

  const updatedLimit = await checkGenerationLimit(session.userId);
  return NextResponse.json({ generationId: generation.id, remaining: updatedLimit.remaining });
}

Markdown renderer

The default system prompts of our project always request the AIs to use markdown in their outputs, so, we need a way to render this output to make sure the generated content doesn't look cluttered.

Go to src/components/ and create a file called markdown.tsx with the following content:

markdown.tsx
"use client";

import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

export function Markdown({ content }: { content: string }) {
  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm]}
      components={{
        h1: ({ children }) => (
          <h1 className="mt-4 mb-2 text-lg font-semibold text-text-primary">{children}</h1>
        ),
        h2: ({ children }) => (
          <h2 className="mt-4 mb-2 text-base font-semibold text-text-primary">{children}</h2>
        ),
        h3: ({ children }) => (
          <h3 className="mt-3 mb-1.5 text-sm font-semibold text-text-primary">{children}</h3>
        ),
        p: ({ children }) => (
          <p className="my-2 leading-relaxed">{children}</p>
        ),
        ul: ({ children }) => (
          <ul className="my-2 list-disc pl-5 space-y-1">{children}</ul>
        ),
        ol: ({ children }) => (
          <ol className="my-2 list-decimal pl-5 space-y-1">{children}</ol>
        ),
        li: ({ children }) => <li className="leading-relaxed">{children}</li>,
        strong: ({ children }) => (
          <strong className="font-semibold text-text-primary">{children}</strong>
        ),
        em: ({ children }) => <em className="italic">{children}</em>,
        a: ({ href, children }) => (
          <a
            href={href}
            target="_blank"
            rel="noreferrer"
            className="text-accent hover:text-accent-hover underline"
          >
            {children}
          </a>
        ),
        code: ({ children }) => (
          <code className="rounded bg-surface-hover px-1 py-0.5 text-xs font-mono">
            {children}
          </code>
        ),
        pre: ({ children }) => (
          <pre className="my-2 overflow-x-auto rounded-md bg-surface-hover p-3 text-xs font-mono">
            {children}
          </pre>
        ),
        blockquote: ({ children }) => (
          <blockquote className="my-2 border-l-2 border-border pl-3 italic text-text-tertiary">
            {children}
          </blockquote>
        ),
        hr: () => <hr className="my-4 border-border-subtle" />,
        table: ({ children }) => (
          <table className="my-2 w-full border-collapse text-xs">{children}</table>
        ),
        th: ({ children }) => (
          <th className="border border-border px-2 py-1 text-left font-semibold">
            {children}
          </th>
        ),
        td: ({ children }) => (
          <td className="border border-border px-2 py-1">{children}</td>
        ),
      }}
    >
      {content}
    </ReactMarkdown>
  );
}

Generation output component

Now, we're going to build a small component that holds the AI's generated text in a card with a "Copy" button.

Go to src/components/ and create a file called generation-output.tsx with the following content:

generation-output.tsx
"use client";

import { useState } from "react";
import { Markdown } from "./markdown";

export function GenerationOutput({ content }: { content: string }) {
  const [copied, setCopied] = useState(false);

  async function handleCopy() {
    await navigator.clipboard.writeText(content);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  }

  return (
    <div className="rounded-lg border border-border bg-surface">
      <div className="flex items-center justify-between border-b border-border-subtle px-4 py-2">
        <span className="text-xs font-medium text-text-muted">
          Generated content
        </span>
        <button
          onClick={handleCopy}
          className="rounded px-2 py-0.5 text-xs font-medium text-accent hover:bg-accent-subtle transition-colors cursor-pointer"
        >
          {copied ? "Copied!" : "Copy"}
        </button>
      </div>
      <div className="px-4 py-3 text-sm text-text-secondary">
        <Markdown content={content} />
      </div>
    </div>
  );
}

Center panel

It's time to build the center panel. It shows a welcome message when nothing is selected, or the AI's output when text is generated.

Go to src/components/ and create a file called center-panel.tsx with the following content:

center-panel.tsx
"use client";

import { useApp } from "./app-shell";
import { GenerationOutput } from "./generation-output";
import { RefinementChat } from "./refinement-chat";

interface GenerationData {
  id: string;
  title: string;
  output: string;
  templateName: string;
  messages: { id: string; role: "USER" | "ASSISTANT"; content: string }[];
}

export function CenterPanel({
  generations,
  isAuthenticated,
}: {
  generations: Map<string, GenerationData>;
  isAuthenticated: boolean;
}) {
  const { selectedGenerationId, openLoginModal } = useApp();
  const generation = selectedGenerationId
    ? generations.get(selectedGenerationId)
    : null;

  if (!generation) {
    return (
      <div className="flex h-full flex-col items-center justify-center text-center px-8">
        <div className="max-w-md">
          <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="mx-auto text-text-muted mb-4"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
          <h2 className="text-lg font-semibold text-text-primary">
            AI Writing Studio
          </h2>
          <p className="mt-2 text-sm text-text-secondary">
            {isAuthenticated
              ? "Pick a template from the right sidebar to generate content. Your previous generations appear on the left."
              : "Sign in to start generating polished content from templates and refine it through conversation."}
          </p>
          {!isAuthenticated && (
            <button
              onClick={openLoginModal}
              className="mt-4 rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover transition-colors cursor-pointer"
            >
              Sign in with Whop
            </button>
          )}
        </div>
      </div>
    );
  }

  return (
    <div className="flex h-full flex-col">
      <div className="border-b border-border px-6 py-3">
        <h2 className="text-sm font-medium text-text-primary">{generation.title}</h2>
        <p className="text-xs text-text-muted">{generation.templateName}</p>
      </div>
      <div className="flex-1 overflow-y-auto px-6 py-4">
        <GenerationOutput content={generation.output} />
        <RefinementChat
          generationId={generation.id}
          existingMessages={generation.messages}
        />
      </div>
    </div>
  );
}

Checkpoint

Verify the following before moving on:

  1. Click "Blog Post" in the right sidebar and fill in all fields
  2. Click "Generate" and wait for the response
  3. The center panel switches from the welcome screen to the generated output
  4. The generation title appears in the center panel header
  5. The new generation appears at the top of the left sidebar history list
  6. Click "All templates" in the right sidebar, select a different template, generate again, and confirm both items are visible in history

Part 4: Refinement chat and history

In this part, we're going to build the refinement chat where users ask the AI to tweak the content they've generated and tweak the history sidebar so past chats can actually be visited.

Chat API route

Now, we need the endpoint that powers the chat. It takes the conversation so far, loads the generation the user is refining, builds a prompt that tells the AI to revise that original content based on the new message, and streams the response back.

We save the user's message before the stream starts (so nothing is lost if the connection drops) and save the AI's reply once the stream finishes. Go to src/app/api/chat/ and create a file called route.ts with the following content:

route.ts
import { NextRequest, NextResponse } from "next/server";
import { streamText } from "ai";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { getSession } from "@/lib/session";
import { getModel } from "@/lib/ai";

const requestSchema = z.object({
  messages: z.array(
    z.object({
      role: z.enum(["user", "assistant"]),
      content: z.string(),
    })
  ),
  generationId: z.string(),
});

export async function POST(request: NextRequest) {
  const session = await getSession();
  if (!session.userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await request.json();
  const parsed = requestSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: "Invalid request" }, { status: 400 });
  }

  const { messages, generationId } = parsed.data;

  const generation = await prisma.generation.findUnique({
    where: { id: generationId },
    include: { template: true },
  });

  if (!generation || generation.userId !== session.userId) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }

  const lastUserMessage = messages[messages.length - 1];
  if (lastUserMessage.role === "user") {
    await prisma.message.create({
      data: {
        generationId,
        role: "USER",
        content: lastUserMessage.content,
      },
    });
  }

  const systemPrompt = `${generation.template.systemPrompt}

The user previously generated the following content:

${generation.output}

The user will ask you to revise the content. Maintain the same format and style while applying their feedback.`;

  const result = streamText({
    model: getModel(generation.template.model),
    system: systemPrompt,
    messages,
    onFinish: async ({ text }) => {
      await prisma.message.create({
        data: {
          generationId,
          role: "ASSISTANT",
          content: text,
        },
      });
    },
  });

  return result.toUIMessageStreamResponse();
}

Refinement chat component

Now, we need the chat UI that sits below the generation output. It uses the AI SDK's useChat hook to stream responses, with the generationId attached to every request so the server knows which generation we're refining.

When the user clicks a past generation in the history, we pre-load its full message thread so the conversation shows up instantly (no extra fetch). And if they've hit their daily limit, sending a message opens the limit modal instead of going through to the AI.

Go to src/components/ and create a file called refinement-chat.tsx with the following content:

refinement-chat.tsx
"use client";

import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, type UIMessage } from "ai";
import { useApp } from "./app-shell";
import { Markdown } from "./markdown";

interface ExistingMessage {
  id: string;
  role: "USER" | "ASSISTANT";
  content: string;
}

function getMessageText(message: UIMessage): string {
  return message.parts
    .filter((p): p is { type: "text"; text: string } => p.type === "text")
    .map((p) => p.text)
    .join("");
}

export function RefinementChat({
  generationId,
  existingMessages,
}: {
  generationId: string;
  existingMessages: ExistingMessage[];
}) {
  const [input, setInput] = useState("");
  const { isAtLimit, openLimitModal } = useApp();

  const { messages, sendMessage, status } = useChat({
    transport: new DefaultChatTransport({
      api: "/api/chat",
      body: { generationId },
    }),
    messages: existingMessages.map((m) => ({
      id: m.id,
      role: m.role.toLowerCase() as "user" | "assistant",
      parts: [{ type: "text" as const, text: m.content }],
    })),
  });

  const isLoading = status === "streaming" || status === "submitted";

  return (
    <div className="mt-4">
      <h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
        Refine
      </h3>

      {messages.length > 0 && (
        <div className="space-y-2 mb-3">
          {messages.map((m) => (
            <div
              key={m.id}
              className={`rounded-lg px-3 py-2 text-sm ${
                m.role === "user"
                  ? "bg-accent-subtle text-text-primary ml-8"
                  : "bg-surface text-text-secondary mr-8"
              }`}
            >
              <span className="mb-0.5 block text-xs font-medium text-text-muted">
                {m.role === "user" ? "You" : "AI"}
              </span>
              {m.role === "user" ? (
                <div className="whitespace-pre-wrap">{getMessageText(m)}</div>
              ) : (
                <Markdown content={getMessageText(m)} />
              )}
            </div>
          ))}
        </div>
      )}

      <form
        onSubmit={(e) => {
          e.preventDefault();
          if (!input.trim() || isLoading) return;
          if (isAtLimit) { openLimitModal(); return; }
          sendMessage({ text: input });
          setInput("");
        }}
        className="flex gap-2"
      >
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Make it shorter, more formal..."
          className="flex-1 rounded-md border border-border bg-bg px-3 py-2 text-sm text-text-primary placeholder-text-muted focus:border-accent focus:outline-none"
          disabled={isLoading}
        />
        <button
          type="submit"
          disabled={isLoading || !input.trim() || isAtLimit}
          className="rounded-md bg-accent px-3 py-2 text-sm font-medium text-white hover:bg-accent-hover disabled:opacity-50 transition-colors cursor-pointer"
        >
          {isLoading ? "..." : "Send"}
        </button>
      </form>
    </div>
  );
}

History sidebar

Now, we need the left sidebar to list the user's past generations. Clicking one switches the center panel to that generation's output and chat thread instantly, with no new request, since the home page already loaded each generation's full message history up front.

To build it, go to src/components/ and create a file called history-sidebar.tsx with the following content:

history-sidebar.tsx
"use client";

import { useApp } from "./app-shell";

interface GenerationItem {
  id: string;
  title: string;
  templateName: string;
  createdAt: string;
}

export function HistorySidebar({
  generations,
  isAuthenticated,
}: {
  generations: GenerationItem[];
  isAuthenticated: boolean;
}) {
  const { selectedGenerationId, selectGeneration, openLoginModal } = useApp();

  if (!isAuthenticated) {
    return (
      <div className="flex h-full flex-col items-center justify-center p-4 text-center">
        <p className="text-xs text-text-muted">
          Sign in to see your generation history.
        </p>
        <button
          onClick={openLoginModal}
          className="mt-2 text-xs font-medium text-accent hover:text-accent-hover cursor-pointer"
        >
          Sign in
        </button>
      </div>
    );
  }

  return (
    <div className="flex h-full flex-col">
      <div className="flex items-center justify-between px-4 py-3">
        <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
          History
        </h2>
      </div>
      {generations.length === 0 ? (
        <div className="flex flex-1 items-center justify-center p-4">
          <p className="text-xs text-text-muted text-center">
            No generations yet. Pick a template to get started.
          </p>
        </div>
      ) : (
        <div className="flex-1 overflow-y-auto">
          {generations.map((g) => (
            <button
              key={g.id}
              onClick={() => selectGeneration(g.id)}
              className={`w-full px-4 py-2.5 text-left transition-colors cursor-pointer ${
                selectedGenerationId === g.id
                  ? "bg-surface-active border-l-2 border-accent"
                  : "hover:bg-surface-hover border-l-2 border-transparent"
              }`}
            >
              <p className={`text-sm truncate ${
                selectedGenerationId === g.id
                  ? "text-text-primary font-medium"
                  : "text-text-secondary"
              }`}>
                {g.title}
              </p>
              <p className="mt-0.5 text-xs text-text-muted truncate">
                {g.templateName}
              </p>
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Checkpoint

Verify the following before moving on:

  1. Generate content from the Blog Post template
  2. The "Refine" section appears below the output in the center panel
  3. Type "Make the introduction shorter" in the input and press Send
  4. Send two more refinement messages and see the full conversation thread accumulate
  5. Refresh the page, then click the same history item in the left sidebar to restore the complete chat thread
  6. Generate content from a different template and confirm both items appear in the history sidebar
  7. Click between the two history items and confirm the center panel switches between their respective outputs and chat threads

Part 5: Payments, access gating, and deploying to production

In this final part, we're going to set up the Whop payments so users can upgrade their membership to Pro, create the necessary access gating for Pro content, and deploy the project to production.

Create the Whop product and plan

We need a Whop product and a plan. Rather than creating them manually on Whop, we're going to use the Whop SDK. Go to prisma/ and create a file called create-pro-plan.ts with the following content:

create-pro-plan.ts
import { config } from "dotenv";
config({ path: ".env.local" });

import { PrismaClient } from "../src/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { Pool } from "pg";
import Whop from "@whop/sdk";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL_UNPOOLED || process.env.DATABASE_URL,
});
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });

const isSandbox = process.env.WHOP_SANDBOX === "true";

const whop = new Whop({
  apiKey: process.env.WHOP_API_KEY!,
  baseURL: isSandbox
    ? "https://sandbox-api.whop.com/api/v1"
    : "https://api.whop.com/api/v1",
});

async function main() {
  const companyId = process.env.WHOP_COMPANY_ID;
  if (!companyId) throw new Error("WHOP_COMPANY_ID is not set");

  console.log("Creating 'Pencraft Pro' product on Whop...");
  const product = await whop.products.create({
    company_id: companyId,
    title: "Pencraft Pro",
    description: "All 8 writing templates and unlimited generations.",
  });

  console.log("Creating monthly $20 plan...");
  const plan = await whop.plans.create({
    company_id: companyId,
    product_id: product.id,
    billing_period: 30,
    currency: "usd",
    initial_price: 20,
    renewal_price: 20,
  });

  const checkoutBase = isSandbox ? "sandbox.whop.com" : "whop.com";
  const checkoutUrl = `https://${checkoutBase}/checkout/${plan.id}`;

  await prisma.plan.upsert({
    where: { id: "pro-plan" },
    update: {
      name: "Pro",
      price: 2000,
      whopProductId: product.id,
      whopPlanId: plan.id,
      checkoutUrl,
      isActive: true,
    },
    create: {
      id: "pro-plan",
      name: "Pro",
      price: 2000,
      whopProductId: product.id,
      whopPlanId: plan.id,
      checkoutUrl,
      isActive: true,
    },
  });

  console.log("Done.");
  console.log("  Product ID:", product.id);
  console.log("  Plan ID:   ", plan.id);
  console.log("  Checkout:  ", checkoutUrl);
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(() => prisma.$disconnect());

Then run the script once:

Terminal
npx tsx prisma/create-pro-plan.ts

Set up webhooks

Now, go to the Developer page of your whop and find the Webhooks section. There, create a new webhook with the endpoint:

Terminal
https://the-deployment-url.vercel.app/api/webhooks/whop
Make sure to use your real production URL.

Then, select the membership_activated, membership_deactivated, and membership_cancel_at_period_end_changed permissions. They cover membership activations, cancellations, and cancellation schedules at renewal. Once you're done, copy the webhook secret (starts with ws_).

Then, add the webhook secret to Vercel via the Environment Variables page of the project settings:

.env
WHOP_WEBHOOK_SECRET=your_secret

Then, pull the updated environment variables locally using the command:

Terminal
vercel env pull .env.local

Webhook handler

Now, we need the route that listens to webhook events from Whop. It verifies the signature, makes sure we don't process the same event more than once, and updates the user's database records.

Go to src/app/api/webhooks/whop/ and create a file called route.ts with the following content:

route.ts
import { NextRequest } from "next/server";
import Whop from "@whop/sdk";
import { prisma } from "@/lib/prisma";
import { env } from "@/lib/env";

const whopWebhook = new Whop({
  apiKey: env.WHOP_API_KEY,
  webhookKey: btoa(env.WHOP_WEBHOOK_SECRET),
});

export async function POST(request: NextRequest) {
  const body = await request.text();
  const headers = Object.fromEntries(request.headers);

  let webhookData: { type: string; data: Record<string, unknown> };
  try {
    webhookData = whopWebhook.webhooks.unwrap(body, { headers }) as unknown as {
      type: string;
      data: Record<string, unknown>;
    };
  } catch {
    return new Response("Invalid signature", { status: 401 });
  }

  const { type, data } = webhookData;

  switch (type) {
    case "membership.activated": {
      const membershipId = data.id as string;
      const userId = (data.user as { id: string }).id;
      const planId = (data.plan as { id: string }).id;

      const existing = await prisma.membership.findUnique({
        where: { whopMembershipId: membershipId },
      });
      if (existing?.lastWebhookEventId === membershipId) break;

      const user = await prisma.user.findUnique({
        where: { whopUserId: userId },
      });

      const plan = await prisma.plan.findFirst({
        where: { whopPlanId: planId },
      });

      if (!user || !plan) break;

      await prisma.membership.upsert({
        where: { whopMembershipId: membershipId },
        update: {
          status: "ACTIVE",
          lastWebhookEventId: membershipId,
          periodStart: new Date(),
          periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
        },
        create: {
          userId: user.id,
          planId: plan.id,
          whopMembershipId: membershipId,
          status: "ACTIVE",
          lastWebhookEventId: membershipId,
          periodStart: new Date(),
          periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
        },
      });
      break;
    }

    case "membership.deactivated": {
      const membershipId = data.id as string;
      await prisma.membership.updateMany({
        where: { whopMembershipId: membershipId },
        data: { status: "CANCELLED", cancelAtPeriodEnd: false },
      });
      break;
    }

    case "membership.cancel_at_period_end_changed": {
      const membershipId = data.id as string;
      const cancelAtPeriodEnd = data.cancel_at_period_end as boolean;
      await prisma.membership.updateMany({
        where: { whopMembershipId: membershipId },
        data: { cancelAtPeriodEnd },
      });
      break;
    }
  }

  return new Response("OK", { status: 200 });
}

Limit modal

Then, update the home page so it also loads how many generations the users (free ones) have left. Open src/app/page.tsx and replace its contents:

limit-modal.tsx
"use client";

import { useApp } from "./app-shell";

export function LimitModal() {
  const { closeLimitModal } = useApp();

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div
        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
        onClick={closeLimitModal}
      />
      <div className="relative z-10 mx-4 w-full max-w-sm rounded-xl border border-border bg-surface p-6 shadow-2xl sm:mx-auto sm:p-8">
        <button
          onClick={closeLimitModal}
          className="absolute right-3 top-3 rounded p-1 text-text-tertiary hover:bg-surface-hover hover:text-text-primary transition-colors cursor-pointer"
          aria-label="Close"
        >
          <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
        </button>

        <div className="text-center">
          <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-warning/10">
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
          </div>
          <h2 className="text-lg font-semibold text-text-primary">
            Demo limit reached
          </h2>
          <p className="mt-2 text-sm text-text-secondary">
            This is a demo application. The daily generation limit has been reached. Limits reset at midnight UTC.
          </p>
        </div>

        <button
          onClick={closeLimitModal}
          className="mt-6 w-full rounded-lg bg-surface-hover px-4 py-2.5 text-sm font-medium text-text-primary hover:bg-surface-active transition-colors cursor-pointer"
        >
          Got it
        </button>
      </div>
    </div>
  );
}

Wire up the home page

Now, let's update the home page so that it also loads how many generations the user has left. Go to src/app and update the contents of page.tsx with:

page.tsx
import { getOptionalUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getUserTier, checkGenerationLimit } from "@/lib/tier";
import { AppShell } from "@/components/app-shell";
import { Header } from "@/components/header";
import { HistorySidebar } from "@/components/history-sidebar";
import { TemplateSidebar } from "@/components/template-sidebar";
import { CenterPanel } from "@/components/center-panel";
import { LoginModal } from "@/components/login-modal";
import { UpgradeModal } from "@/components/upgrade-modal";
import { LimitModal } from "@/components/limit-modal";

export default async function HomePage() {
  const user = await getOptionalUser();
  const isAuthenticated = !!user;

  let tier: "FREE" | "PRO" = "FREE";
  let remaining = 0;
  let generations: { id: string; title: string; templateName: string; createdAt: string }[] = [];
  let generationDetails = new Map<string, {
    id: string;
    title: string;
    output: string;
    templateName: string;
    messages: { id: string; role: "USER" | "ASSISTANT"; content: string }[];
  }>();

  if (user) {
    tier = await getUserTier(user.id);
    const limitCheck = await checkGenerationLimit(user.id);
    remaining = limitCheck.remaining;

    const gens = await prisma.generation.findMany({
      where: { userId: user.id },
      include: {
        template: true,
        messages: { orderBy: { createdAt: "asc" } },
      },
      orderBy: { createdAt: "desc" },
      take: 20,
    });

    generations = gens.map((g: (typeof gens)[number]) => ({
      id: g.id,
      title: g.title,
      templateName: g.template.name,
      createdAt: g.createdAt.toISOString(),
    }));

    for (const g of gens) {
      generationDetails.set(g.id, {
        id: g.id,
        title: g.title,
        output: g.output,
        templateName: g.template.name,
        messages: g.messages.map((m: (typeof g.messages)[number]) => ({
          id: m.id,
          role: m.role,
          content: m.content,
        })),
      });
    }
  }

  const templates = await prisma.template.findMany({
    where: { isActive: true },
    orderBy: { createdAt: "asc" },
  });

  const templateData = templates.map((t: (typeof templates)[number]) => ({
    id: t.id,
    name: t.name,
    slug: t.slug,
    description: t.description,
    category: t.category,
    tier: t.tier as "FREE" | "PRO",
    inputFields: t.inputFields as unknown as { name: string; label: string; placeholder: string; type: "text" | "textarea" }[],
  }));

  return (
    <AppShell
      header={
        <Header
          user={user ? { name: user.name, email: user.email } : null}
          tier={tier}
        />
      }
      leftSidebar={
        <HistorySidebar
          generations={generations}
          isAuthenticated={isAuthenticated}
        />
      }
      centerPanel={
        <CenterPanel
          generations={generationDetails}
          isAuthenticated={isAuthenticated}
        />
      }
      rightSidebar={
        <TemplateSidebar
          templates={templateData}
          userTier={tier}
          isAuthenticated={isAuthenticated}
        />
      }
      loginModal={<LoginModal />}
      upgradeModal={<UpgradeModal />}
      limitModal={<LimitModal />}
      initialRemaining={remaining}
    />
  );
}

Deploying to production

We've been using the Whop sandbox throughout the tutorial. Before deploying the project to production, we need to switch to live Whop.com.

Set up the production Whop app

Go to Whop.com (not sandbox.whop.com) and create a company. Then, in its Developer page, create an app.

In its OAuth section, add your production URL with api/auth/callback and the end as a redirect URL (like https://pencraft.vercel.app/api/auth/callback).

Then, back in the developer page, create a new webhook pointing to your production URL with api/webhooks/whopat the end (like https://pencraft.vercel.app/api/webhooks/whop) and select the three membership events we did earlier.

Copy the client ID, client secret, the company API key, company ID, and the webhook secret. We'll add them to Vercel next.

Switch to production environment variables

In the Vercel dashboard, set the variables for the production deployment:

VariableValue
WHOP_CLIENT_IDProduction app client ID
WHOP_CLIENT_SECRETProduction client secret
WHOP_API_KEYProduction Company API Key
WHOP_COMPANY_IDProduction company ID
WHOP_WEBHOOK_SECRETProduction webhook secret
WHOP_SANDBOXRemove this variable, or set to false
NEXT_PUBLIC_APP_URLhttps://pencraft.vercel.app
DATABASE_URL, DATABASE_URL_UNPOOLED, SESSION_SECRET, ANTHROPIC_API_KEY, and OPENAI_API_KEY stay the same across environments.

Create the production Pro plan and deploy

Pull the new variables locally, re-run the product/plan script (it hits production this time because WHOP_SANDBOX is no longer true), and deploy using the commands below in order:

Terminal
vercel env pull .env.local
npx tsx prisma/create-pro-plan.ts
vercel --prod

Checkpoint

Verify the following before moving on:

  1. Sign in as a free user and confirm the Pro templates in the right sidebar are shown as locked with a padlock icon
  2. Click a locked Pro template and confirm the upgrade modal opens
  3. Close the modal and generate content 5 times from a free template. On the sixth attempt, confirm the limit modal opens automatically
  4. Confirm the Generate button is visually disabled after the fifth generation (the isAtLimit flag is true)
  5. Upgrade to Pro and complete the checkout at the Whop-hosted sandbox checkout
  6. After checkout, reload the app and confirm the Pro templates are now selectable
  7. Generate content from a Pro template to confirm tier enforcement is lifted
  8. Generate more than 5 times in one day as a Pro user and confirm there is no daily limit
  9. The production deployment at your Vercel production URL loads correctly
  10. Production OAuth sign-in works with the production Whop app credentials

Part 6: Landing page

Our app is fully working, but we're missing a landing page to improve our marketing. In this part, we move the three-panel studio to /studio and build a new landing page at / with a hero, features grid, pricing, and a final CTA.

As part of this change, we delete the login modal (guests will now see the landing page instead of the studio), protect /studio with middleware so only authenticated users can enter, and redirect the OAuth callback to the studio after sign-in.

Move the studio to /studio

First, we need to move the current three-panel IDE out of / so the root can become the landing page. The studio lives at /studio and is authenticated-only, so we can simplify it.

Go to src/app/studio and create a file called page.tsx with the following content:

page.tsx
import { requireAuth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { env } from "@/lib/env";
import { getUserTier, getProPlanId, checkGenerationLimit } from "@/lib/tier";
import { AppShell } from "@/components/app-shell";
import { Header } from "@/components/header";
import { HistorySidebar } from "@/components/history-sidebar";
import { TemplateSidebar } from "@/components/template-sidebar";
import { CenterPanel } from "@/components/center-panel";
import { UpgradeModal } from "@/components/upgrade-modal";
import { LimitModal } from "@/components/limit-modal";

export default async function StudioPage({
  searchParams,
}: {
  searchParams: Promise<{ upgrade?: string; welcome?: string }>;
}) {
  const user = await requireAuth();
  const params = await searchParams;

  const tier = await getUserTier(user.id);
  const proWhopPlanId = tier === "FREE" ? await getProPlanId() : null;
  const checkoutEnv = env.WHOP_SANDBOX === "true" ? "sandbox" as const : "production" as const;
  const limitCheck = await checkGenerationLimit(user.id);
  const remaining = limitCheck.remaining;

  const autoOpenCheckout = params.upgrade === "true" && tier === "FREE";

  const gens = await prisma.generation.findMany({
    where: { userId: user.id },
    include: {
      template: true,
      messages: { orderBy: { createdAt: "asc" } },
    },
    orderBy: { createdAt: "desc" },
    take: 20,
  });

  const generations = gens.map((g: (typeof gens)[number]) => ({
    id: g.id,
    title: g.title,
    templateName: g.template.name,
    createdAt: g.createdAt.toISOString(),
  }));

  const generationDetails = new Map<string, {
    id: string;
    title: string;
    output: string;
    templateName: string;
    messages: { id: string; role: "USER" | "ASSISTANT"; content: string }[];
  }>();

  for (const g of gens) {
    generationDetails.set(g.id, {
      id: g.id,
      title: g.title,
      output: g.output,
      templateName: g.template.name,
      messages: g.messages.map((m: (typeof g.messages)[number]) => ({
        id: m.id,
        role: m.role,
        content: m.content,
      })),
    });
  }

  const templates = await prisma.template.findMany({
    where: { isActive: true },
    orderBy: { createdAt: "asc" },
  });

  const templateData = templates.map((t: (typeof templates)[number]) => ({
    id: t.id,
    name: t.name,
    slug: t.slug,
    description: t.description,
    category: t.category,
    tier: t.tier as "FREE" | "PRO",
    inputFields: t.inputFields as unknown as { name: string; label: string; placeholder: string; type: "text" | "textarea" }[],
  }));

  return (
    <AppShell
      header={<Header user={{ name: user.name, email: user.email }} tier={tier} />}
      leftSidebar={<HistorySidebar generations={generations} />}
      centerPanel={<CenterPanel generations={generationDetails} />}
      rightSidebar={<TemplateSidebar templates={templateData} userTier={tier} />}
      upgradeModal={<UpgradeModal />}
      limitModal={<LimitModal />}
      initialRemaining={remaining}
      proWhopPlanId={proWhopPlanId}
      checkoutEnvironment={checkoutEnv}
      autoOpenCheckout={autoOpenCheckout}
    />
  );
}

Protect /studio with middleware

Now, we need middleware that redirects any unauthenticated request for /studio to the landing page. This means the studio never renders for guests.

Open middleware.ts (at the project root) and replace its contents:

middleware.ts
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";

interface SessionData {
  whopUserId?: string;
  userId?: string;
}

export async function middleware(request: NextRequest) {
  const session = await getIronSession<SessionData>(await cookies(), {
    password: process.env.SESSION_SECRET!,
    cookieName: "pencraft_session",
  });

  const isAuthenticated = session.userId || session.whopUserId;
  if (!isAuthenticated) {
    return NextResponse.redirect(new URL("/", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/studio/:path*"],
};

Redirect the OAuth callback to /studio

The callback currently sends signed-in users back to /, which is now the landing page. We want them to land in the studio instead.

Go to src/app/api/auth/callback and change the final redirect at the bottom of the route.ts file with:

route.ts
return NextResponse.redirect(new URL("/studio", env.NEXT_PUBLIC_APP_URL));

Remove the login modal and guest branches

The login modal existed because guests could reach the studio. With /studio now protected, that's no longer true. Guests always see the landing page.

We delete the modal and strip every reference to it. You can now delete the src/components/login-modal.tsx file.

Go to src/components and replace the contents of the app-shell.tsx file to drop the login modal state, context, and prop:

app-shell.tsx
"use client";

import { useState, useEffect, useCallback, createContext, useContext, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { CheckoutPopup } from "./checkout-popup";
import { WelcomePopup } from "./welcome-popup";

interface AppState {
  selectedGenerationId: string | null;
  selectedTemplateSlug: string | null;
  leftSidebarOpen: boolean;
  rightSidebarOpen: boolean;
  upgradeModalOpen: boolean;
  limitModalOpen: boolean;
  checkoutPopupOpen: boolean;
  welcomePopupOpen: boolean;
  processingUpgrade: boolean;
  generationsRemaining: number;
}

interface AppContextType extends AppState {
  selectGeneration: (id: string | null) => void;
  selectTemplate: (slug: string | null) => void;
  toggleLeftSidebar: () => void;
  toggleRightSidebar: () => void;
  openUpgradeModal: () => void;
  closeUpgradeModal: () => void;
  openLimitModal: () => void;
  closeLimitModal: () => void;
  openCheckoutPopup: () => void;
  closeCheckoutPopup: () => void;
  closeWelcomePopup: () => void;
  setGenerationsRemaining: (n: number) => void;
  isAtLimit: boolean;
}

const AppContext = createContext<AppContextType | null>(null);

export function useApp() {
  const ctx = useContext(AppContext);
  if (!ctx) throw new Error("useApp must be used within AppShell");
  return ctx;
}

export function AppShell({
  header,
  leftSidebar,
  centerPanel,
  rightSidebar,
  upgradeModal,
  limitModal,
  initialRemaining,
  proWhopPlanId,
  checkoutEnvironment,
  autoOpenCheckout,
}: {
  header: ReactNode;
  leftSidebar: ReactNode;
  centerPanel: ReactNode;
  rightSidebar: ReactNode;
  upgradeModal: ReactNode;
  limitModal: ReactNode;
  initialRemaining: number;
  proWhopPlanId: string | null;
  checkoutEnvironment: "sandbox" | "production";
  autoOpenCheckout?: boolean;
}) {
  const router = useRouter();

  const [state, setState] = useState<AppState>({
    selectedGenerationId: null,
    selectedTemplateSlug: null,
    leftSidebarOpen: false,
    rightSidebarOpen: false,
    upgradeModalOpen: false,
    limitModalOpen: false,
    checkoutPopupOpen: false,
    welcomePopupOpen: false,
    processingUpgrade: false,
    generationsRemaining: initialRemaining,
  });

  useEffect(() => {
    if (window.innerWidth >= 768) {
      setState((s) => ({ ...s, leftSidebarOpen: true, rightSidebarOpen: true }));
    }
  }, []);

  useEffect(() => {
    if (autoOpenCheckout && proWhopPlanId) {
      setState((s) => ({ ...s, checkoutPopupOpen: true }));
    }
  }, [autoOpenCheckout, proWhopPlanId]);

  useEffect(() => {
    if (typeof window !== "undefined") {
      const params = new URLSearchParams(window.location.search);
      if (params.get("welcome") === "true") {
        setState((s) => ({ ...s, welcomePopupOpen: true }));
        window.history.replaceState({}, "", window.location.pathname);
      }
    }
  }, []);

  const handleCheckoutComplete = useCallback(() => {
    setState((s) => ({ ...s, checkoutPopupOpen: false, processingUpgrade: true }));
    setTimeout(() => {
      router.refresh();
      setState((s) => ({ ...s, processingUpgrade: false, welcomePopupOpen: true }));
    }, 3000);
  }, [router]);

  const isAtLimit = state.generationsRemaining <= 0;

  const ctx: AppContextType = {
    ...state,
    isAtLimit,
    selectGeneration: (id) =>
      setState((s) => ({ ...s, selectedGenerationId: id, rightSidebarOpen: id ? false : s.rightSidebarOpen })),
    selectTemplate: (slug) =>
      setState((s) => ({ ...s, selectedTemplateSlug: slug, rightSidebarOpen: true })),
    toggleLeftSidebar: () =>
      setState((s) => ({ ...s, leftSidebarOpen: !s.leftSidebarOpen })),
    toggleRightSidebar: () =>
      setState((s) => ({ ...s, rightSidebarOpen: !s.rightSidebarOpen })),
    openUpgradeModal: () =>
      setState((s) => ({ ...s, upgradeModalOpen: true })),
    closeUpgradeModal: () =>
      setState((s) => ({ ...s, upgradeModalOpen: false })),
    openLimitModal: () =>
      setState((s) => ({ ...s, limitModalOpen: true })),
    closeLimitModal: () =>
      setState((s) => ({ ...s, limitModalOpen: false })),
    openCheckoutPopup: () =>
      setState((s) => ({ ...s, checkoutPopupOpen: true })),
    closeCheckoutPopup: () =>
      setState((s) => ({ ...s, checkoutPopupOpen: false })),
    closeWelcomePopup: () =>
      setState((s) => ({ ...s, welcomePopupOpen: false })),
    setGenerationsRemaining: (n) =>
      setState((s) => ({ ...s, generationsRemaining: n })),
  };

  return (
    <AppContext.Provider value={ctx}>
      <div className="flex h-dvh flex-col overflow-hidden">
        {header}
        <div className="relative flex flex-1 overflow-hidden">
          {state.leftSidebarOpen && (
            <div
              className="absolute inset-0 z-10 bg-black/40 md:hidden"
              onClick={() => setState((s) => ({ ...s, leftSidebarOpen: false }))}
            />
          )}
          <aside
            className={`flex-shrink-0 border-r border-border bg-surface overflow-y-auto transition-all duration-200 ${
              state.leftSidebarOpen
                ? "absolute inset-y-0 left-0 z-20 w-[280px] md:relative md:w-64 md:z-auto"
                : "w-0"
            }`}
          >
            {state.leftSidebarOpen && leftSidebar}
          </aside>

          <main className="flex-1 overflow-y-auto">
            {centerPanel}
          </main>

          {state.rightSidebarOpen && (
            <div
              className="absolute inset-0 z-10 bg-black/40 md:hidden"
              onClick={() => setState((s) => ({ ...s, rightSidebarOpen: false }))}
            />
          )}
          <aside
            className={`flex-shrink-0 border-l border-border bg-surface overflow-y-auto transition-all duration-200 ${
              state.rightSidebarOpen
                ? "absolute inset-y-0 right-0 z-20 w-full max-w-[340px] md:relative md:w-[40rem] md:max-w-none md:z-auto"
                : "w-0"
            }`}
          >
            {state.rightSidebarOpen && rightSidebar}
          </aside>
        </div>
      </div>

      {state.upgradeModalOpen && upgradeModal}
      {state.limitModalOpen && limitModal}

      {state.checkoutPopupOpen && proWhopPlanId && (
        <CheckoutPopup
          planId={proWhopPlanId}
          environment={checkoutEnvironment}
          onClose={() => setState((s) => ({ ...s, checkoutPopupOpen: false }))}
          onComplete={handleCheckoutComplete}
        />
      )}

      {state.welcomePopupOpen && (
        <WelcomePopup onClose={() => setState((s) => ({ ...s, welcomePopupOpen: false }))} />
      )}

      {state.processingUpgrade && (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
          <div className="flex flex-col items-center gap-3 text-center">
            <div className="h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent" />
            <p className="text-sm font-medium text-white">Setting up your Pro account...</p>
          </div>
        </div>
      )}
    </AppContext.Provider>
  );
}

Open src/components and replace the contents of history-sidebar.tsx with:

history-sidebar.tsx
"use client";

import { useApp } from "./app-shell";

interface GenerationItem {
  id: string;
  title: string;
  templateName: string;
  createdAt: string;
}

export function HistorySidebar({
  generations,
}: {
  generations: GenerationItem[];
}) {
  const { selectedGenerationId, selectGeneration } = useApp();

  return (
    <div className="flex h-full flex-col">
      <div className="flex items-center justify-between px-4 py-3">
        <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
          History
        </h2>
      </div>
      {generations.length === 0 ? (
        <div className="flex flex-1 items-center justify-center p-4">
          <p className="text-xs text-text-muted text-center">
            No generations yet. Pick a template to get started.
          </p>
        </div>
      ) : (
        <div className="flex-1 overflow-y-auto">
          {generations.map((g) => (
            <button
              key={g.id}
              onClick={() => selectGeneration(g.id)}
              className={`w-full px-4 py-2.5 text-left transition-colors cursor-pointer ${
                selectedGenerationId === g.id
                  ? "bg-surface-active border-l-2 border-accent"
                  : "hover:bg-surface-hover border-l-2 border-transparent"
              }`}
            >
              <p className={`text-sm truncate ${
                selectedGenerationId === g.id
                  ? "text-text-primary font-medium"
                  : "text-text-secondary"
              }`}>
                {g.title}
              </p>
              <p className="mt-0.5 text-xs text-text-muted truncate">
                {g.templateName}
              </p>
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Go to src/components and replace the contents of center-panel.tsx with:

center-panel.tsx
"use client";

import { useApp } from "./app-shell";
import { GenerationOutput } from "./generation-output";
import { RefinementChat } from "./refinement-chat";

interface GenerationData {
  id: string;
  title: string;
  output: string;
  templateName: string;
  messages: { id: string; role: "USER" | "ASSISTANT"; content: string }[];
}

export function CenterPanel({
  generations,
}: {
  generations: Map<string, GenerationData>;
}) {
  const { selectedGenerationId } = useApp();
  const generation = selectedGenerationId
    ? generations.get(selectedGenerationId)
    : null;

  if (!generation) {
    return (
      <div className="flex h-full flex-col items-center justify-center text-center px-8">
        <div className="max-w-md">
          <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="mx-auto text-text-muted mb-4"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
          <h2 className="text-lg font-semibold text-text-primary">
            AI Writing Studio
          </h2>
          <p className="mt-2 text-sm text-text-secondary">
            Pick a template from the right sidebar to generate content. Your previous generations appear on the left.
          </p>
        </div>
      </div>
    );
  }

  return (
    <div className="flex h-full flex-col">
      <div className="border-b border-border px-6 py-3">
        <h2 className="text-sm font-medium text-text-primary">{generation.title}</h2>
        <p className="text-xs text-text-muted">{generation.templateName}</p>
      </div>
      <div className="flex-1 overflow-y-auto px-6 py-4">
        <GenerationOutput content={generation.output} />
        <RefinementChat
          generationId={generation.id}
          existingMessages={generation.messages}
        />
      </div>
    </div>
  );
}

Open src/components/template-sidebar.tsx and make two changes: drop isAuthenticated from both TemplateSidebar and TemplateForm, and drop the onLoginRequired + openLoginModal wiring. The outer component's destructure and props now look like this:

template-sidebar.tsx
export function TemplateSidebar({
  templates,
  userTier,
}: {
  templates: TemplateItem[];
  userTier: "FREE" | "PRO";
}) {
  const { selectedTemplateSlug, selectTemplate, openUpgradeModal } = useApp();
  const selectedTemplate = templates.find((t) => t.slug === selectedTemplateSlug);

  return (
    <div className="flex h-full flex-col">
      <div className="flex items-center justify-between px-4 py-3">
        <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
          Templates
        </h2>
      </div>

      {selectedTemplate ? (
        <TemplateForm
          template={selectedTemplate}
          userTier={userTier}
          onBack={() => selectTemplate(null)}
          onUpgradeRequired={openUpgradeModal}
        />
      ) : (
        <TemplateList
          templates={templates}
          userTier={userTier}
          onSelect={(slug) => selectTemplate(slug)}
        />
      )}
    </div>
  );
}

And inside TemplateForm, update the signature and the submit guard to match:

function TemplateForm({
  template,
  userTier,
  onBack,
  onUpgradeRequired,
}: {
  template: TemplateItem;
  userTier: "FREE" | "PRO";
  onBack: () => void;
  onUpgradeRequired: () => void;
}) {
  const router = useRouter();
  const { selectGeneration, isAtLimit, openLimitModal, setGenerationsRemaining } = useApp();
  const [values, setValues] = useState<Record<string, string>>({});
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    if (template.tier === "PRO" && userTier === "FREE") {
      onUpgradeRequired();
      return;
    }

    // ...the rest of handleSubmit and the returned JSX stay the same.

Finally, go to src/components and replace the content of header.tsx with:

header.tsx
"use client";

import { useState, useRef, useEffect } from "react";
import { useApp } from "./app-shell";

type Theme = "light" | "dark" | "system";

function getTheme(): Theme {
  if (typeof window === "undefined") return "system";
  return (localStorage.getItem("pencraft-theme") as Theme) || "system";
}

function applyTheme(theme: Theme) {
  localStorage.setItem("pencraft-theme", theme);
  const isDark =
    theme === "dark" ||
    (theme === "system" &&
      window.matchMedia("(prefers-color-scheme: dark)").matches);
  document.documentElement.classList.toggle("dark", isDark);
}

export function Header({
  user,
  tier,
}: {
  user: { name: string | null; email: string };
  tier: "FREE" | "PRO";
}) {
  const { toggleLeftSidebar, toggleRightSidebar, openUpgradeModal } = useApp();
  const [dropdownOpen, setDropdownOpen] = useState(false);
  const [theme, setTheme] = useState<Theme>(() => getTheme());
  const dropdownRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    function handleClickOutside(e: MouseEvent) {
      if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
        setDropdownOpen(false);
      }
    }
    function handleEscape(e: KeyboardEvent) {
      if (e.key === "Escape") setDropdownOpen(false);
    }
    if (dropdownOpen) {
      document.addEventListener("mousedown", handleClickOutside);
      document.addEventListener("keydown", handleEscape);
    }
    return () => {
      document.removeEventListener("mousedown", handleClickOutside);
      document.removeEventListener("keydown", handleEscape);
    };
  }, [dropdownOpen]);

  function handleTheme(t: Theme) {
    setTheme(t);
    applyTheme(t);
  }

  return (
    <header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
      <div className="flex items-center gap-3">
        <button
          onClick={toggleLeftSidebar}
          className="rounded p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary transition-colors cursor-pointer"
          aria-label="Toggle history sidebar"
        >
          <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>
        </button>
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 24" fill="none" className="h-5 w-auto text-text-primary">
          <path d="M2 20 L5 4 L8 4 L11 12 L10 4 L13 4 L10 20 L7 20 L4 12 L5 20 Z" fill="#6366f1"/>
          <text x="18" y="18" fontFamily="Inter, system-ui, sans-serif" fontSize="16" fontWeight="700" fill="currentColor" letterSpacing="-0.03em">pencraft</text>
        </svg>
      </div>

      <div className="flex items-center gap-2">
        {tier === "PRO" && (
          <span className="rounded-md bg-accent-subtle px-2 py-0.5 text-xs font-medium text-accent">
            Pro
          </span>
        )}
        <div className="relative" ref={dropdownRef}>
          <button
            onClick={() => setDropdownOpen((o) => !o)}
            className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-tertiary hover:bg-surface-hover hover:text-text-primary transition-colors cursor-pointer"
          >
            {user.name || user.email}
            <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
          </button>

          {dropdownOpen && (
            <div className="absolute right-0 top-full mt-1 w-48 max-w-[calc(100vw-2rem)] rounded-lg border border-border bg-surface shadow-lg z-50">
              <div className="px-3 py-2">
                <p className="text-xs font-medium text-text-muted mb-1.5">Theme</p>
                <div className="flex gap-1">
                  <button
                    onClick={() => handleTheme("light")}
                    className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors cursor-pointer ${
                      theme === "light"
                        ? "bg-accent text-white"
                        : "text-text-secondary hover:bg-surface-hover"
                    }`}
                  >
                    Light
                  </button>
                  <button
                    onClick={() => handleTheme("dark")}
                    className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors cursor-pointer ${
                      theme === "dark"
                        ? "bg-accent text-white"
                        : "text-text-secondary hover:bg-surface-hover"
                    }`}
                  >
                    Dark
                  </button>
                  <button
                    onClick={() => handleTheme("system")}
                    className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors cursor-pointer ${
                      theme === "system"
                        ? "bg-accent text-white"
                        : "text-text-secondary hover:bg-surface-hover"
                    }`}
                  >
                    Auto
                  </button>
                </div>
              </div>
              <div className="border-t border-border">
                {tier === "FREE" && (
                  <button
                    onClick={() => { setDropdownOpen(false); openUpgradeModal(); }}
                    className="flex w-full items-center gap-2 px-3 py-2 text-xs text-text-secondary hover:bg-surface-hover transition-colors cursor-pointer"
                  >
                    Upgrade to Pro
                  </button>
                )}
                <a
                  href="/api/auth/logout"
                  className="flex w-full items-center gap-2 px-3 py-2 text-xs text-text-secondary hover:bg-surface-hover transition-colors cursor-pointer"
                >
                  Sign out
                </a>
              </div>
            </div>
          )}
        </div>
        <button
          onClick={toggleRightSidebar}
          className="rounded p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary transition-colors cursor-pointer"
          aria-label="Toggle templates sidebar"
        >
          <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/></svg>
        </button>
      </div>
    </header>
  );
}

Landing navigation

We need a sticky top nav for the landing page. Go to src/components/landing and create a file called landing-nav.tsx with the following content:

landing-nav.tsx
import Link from "next/link";

export function LandingNav({ isAuthenticated }: { isAuthenticated: boolean }) {
  return (
    <nav className="sticky top-0 z-30 border-b border-border-subtle bg-bg/70 backdrop-blur-md">
      <div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-3 sm:px-6">
        <Link href="/" className="flex items-center" aria-label="Pencraft home">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 24" fill="none" className="h-5 w-auto text-text-primary">
            <path d="M2 20 L5 4 L8 4 L11 12 L10 4 L13 4 L10 20 L7 20 L4 12 L5 20 Z" fill="#6366f1"/>
            <text x="18" y="18" fontFamily="Inter, system-ui, sans-serif" fontSize="16" fontWeight="700" fill="currentColor" letterSpacing="-0.03em">pencraft</text>
          </svg>
        </Link>
        <div className="flex items-center gap-1 sm:gap-2">
          <Link
            href="#pricing"
            className="rounded px-2 py-1.5 text-xs text-text-secondary transition-colors hover:text-text-primary sm:px-3 sm:text-sm"
          >
            Pricing
          </Link>
          <Link
            href="#templates"
            className="rounded px-2 py-1.5 text-xs text-text-secondary transition-colors hover:text-text-primary sm:px-3 sm:text-sm"
          >
            Templates
          </Link>
          {isAuthenticated ? (
            <Link
              href="/studio"
              className="rounded-md bg-accent px-2 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent-hover whitespace-nowrap sm:px-3 sm:text-sm"
            >
              Studio
            </Link>
          ) : (
            <Link
              href="/api/auth/login"
              className="rounded-md bg-accent px-2 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent-hover whitespace-nowrap sm:px-3 sm:text-sm"
            >
              <span className="sm:hidden">Sign in</span>
              <span className="hidden sm:inline">Sign in with Whop</span>
            </Link>
          )}
        </div>
      </div>
    </nav>
  );
}

Checkout popup

We want the checkout to happen inside app instead of redirecting users to external pages. The Whop infrastructure allows us to use embedded checkouts for this. Go to src/components/ and create a file called checkout-popup.tsx with the content:

checkout-popup.tsx
"use client";

import { WhopCheckoutEmbed } from "@whop/checkout/react";

export function CheckoutPopup({
  planId,
  environment,
  onClose,
  onComplete,
}: {
  planId: string;
  environment: "sandbox" | "production";
  onClose: () => void;
  onComplete: () => void;
}) {
  const theme = typeof document !== "undefined" && document.documentElement.classList.contains("dark")
    ? "dark" as const
    : "light" as const;

  return (
    <div className="fixed inset-0 z-50 flex items-end sm:items-center sm:justify-center">
      <div
        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
        onClick={onClose}
      />
      <div className="relative z-10 w-full rounded-t-xl border border-border bg-surface shadow-2xl overflow-hidden sm:mx-auto sm:max-w-lg sm:rounded-xl">
        <div className="flex items-center justify-between border-b border-border px-4 py-3 sm:px-6 sm:py-4">
          <h2 className="text-sm font-semibold text-text-primary sm:text-base">Upgrade to Pro</h2>
          <button
            onClick={onClose}
            className="rounded p-1 text-text-tertiary hover:bg-surface-hover hover:text-text-primary transition-colors cursor-pointer"
            aria-label="Close"
          >
            <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
          </button>
        </div>

        <div className="min-h-[400px] max-h-[80vh] overflow-y-auto">
          <WhopCheckoutEmbed
            planId={planId}
            environment={environment}
            theme={theme}
            skipRedirect
            onComplete={() => onComplete()}
          />
        </div>
      </div>
    </div>
  );
}

Welcome popup

After the checkout is completed and the page is refreshed with the new Pro tier of the user, we want to show a welcome to the Pro message to the user. To build it, go to src/components/ and create a file called welcome-popup.tsx with the content:

welcome-popup.tsx
"use client";

import { useEffect, useState } from "react";

const DISPLAY_MS = 5000;

const PRO_FEATURES = [
  "All 8 writing templates unlocked",
  "Unlimited generations per day",
  "Chat-based content refinement",
  "Generation history saved",
];

export function WelcomePopup({ onClose }: { onClose: () => void }) {
  const [progress, setProgress] = useState(100);

  useEffect(() => {
    const start = Date.now();
    const interval = setInterval(() => {
      const elapsed = Date.now() - start;
      const remaining = Math.max(0, 100 - (elapsed / DISPLAY_MS) * 100);
      setProgress(remaining);
      if (remaining <= 0) {
        clearInterval(interval);
        onClose();
      }
    }, 50);
    return () => clearInterval(interval);
  }, [onClose]);

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
      <div className="relative z-10 mx-4 w-full max-w-sm rounded-xl border border-border bg-surface p-6 shadow-2xl sm:mx-auto sm:p-8">
        <button
          onClick={onClose}
          className="absolute right-3 top-3 rounded p-1 text-text-tertiary hover:bg-surface-hover hover:text-text-primary transition-colors cursor-pointer"
          aria-label="Close"
        >
          <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
        </button>

        <div className="text-center">
          <div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent-subtle">
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent"><path d="M20 6 9 17l-5-5"/></svg>
          </div>
          <h2 className="mt-4 text-xl font-semibold text-text-primary">
            Welcome to Pro!
          </h2>
          <p className="mt-2 text-sm text-text-secondary">
            Your account has been upgraded. Here&apos;s what you can do now:
          </p>
        </div>

        <div className="mt-6 space-y-3">
          {PRO_FEATURES.map((f) => (
            <div key={f} className="flex items-center gap-3 text-sm text-text-secondary">
              <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-success flex-shrink-0"><path d="M20 6 9 17l-5-5"/></svg>
              {f}
            </div>
          ))}
        </div>

        <div className="mt-6 h-1 w-full overflow-hidden rounded-full bg-surface-hover">
          <div
            className="h-full rounded-full bg-accent transition-all duration-100 ease-linear"
            style={{ width: `${progress}%` }}
          />
        </div>
      </div>
    </div>
  );
}

Pro checkout CTA

We want the Pro membership CTA button on the home page to do two different things based on if the user is logged in or not. If the user is logged it, we'll redirect the user to the studio and display the upgrade popup. Otherwise, we redirect them back to Whop's login to authenticate them first.

Go to src/components/landing/ and create a file called pro-checkout-cta.tsx with the content:

pro-checkout-cta.tsx
"use client";

import { useState } from "react";
import Link from "next/link";
import { CheckoutPopup } from "@/components/checkout-popup";

export function ProCheckoutCta({
  isAuthenticated,
  planId,
  environment,
}: {
  isAuthenticated: boolean;
  planId: string | null;
  environment: "sandbox" | "production";
}) {
  const [showCheckout, setShowCheckout] = useState(false);

  if (!isAuthenticated) {
    return (
      <Link
        href={`/api/auth/login?redirect=${encodeURIComponent("/studio?upgrade=true")}`}
        className="mt-auto inline-flex items-center justify-center rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-hover"
      >
        Get started
      </Link>
    );
  }

  return (
    <>
      <button
        onClick={() => planId && setShowCheckout(true)}
        disabled={!planId}
        className="mt-auto inline-flex items-center justify-center rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-hover cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
      >
        Get started
      </button>
      {showCheckout && planId && (
        <CheckoutPopup
          planId={planId}
          environment={environment}
          onClose={() => setShowCheckout(false)}
          onComplete={() => {
            setShowCheckout(false);
            setTimeout(() => {
              window.location.href = "/studio?welcome=true";
            }, 3000);
          }}
        />
      )}
    </>
  );
}

Hero section

Now, we need the hero. It carries the headline, subhead, primary CTA ("Start writing free" for guests, "Go to Studio" for signed-in users), and a secondary CTA that anchor-links to How It Works. Dual radial gradients layer in the background for depth.

Go to src/components/landing and create a file called hero.tsx with the following content:

hero.tsx
import Link from "next/link";

export function Hero({ isAuthenticated }: { isAuthenticated: boolean }) {
  return (
    <section className="relative isolate overflow-hidden">
      <div
        aria-hidden="true"
        className="absolute inset-0 opacity-70"
        style={{
          background:
            "radial-gradient(ellipse 90% 60% at 50% 0%, rgba(139, 92, 246, 0.18) 0%, transparent 55%), radial-gradient(ellipse 70% 50% at 50% 100%, rgba(99, 102, 241, 0.15) 0%, transparent 60%)",
        }}
      />
      <div className="relative mx-auto flex min-h-[520px] sm:min-h-[720px] max-w-4xl flex-col items-center justify-center px-6 py-32 text-center">
        <h1 className="font-sans text-[clamp(3rem,6vw,5.5rem)] font-semibold leading-[0.95] tracking-[-0.04em] text-text-primary">
          Draft anything.
          <br />
          <span className="bg-gradient-to-r from-accent via-[#8b5cf6] to-accent bg-clip-text text-transparent">
            Refine with AI.
          </span>
        </h1>
        <p className="mx-auto mt-6 max-w-xl text-base leading-relaxed text-text-secondary sm:text-lg">
          Eight writing templates. Streaming output you can polish through chat.
          Pencraft turns a few inputs into a finished draft: blog posts, emails,
          ad copy, and more.
        </p>
        <div className="mt-10 flex flex-col items-center gap-3 sm:flex-row">
          <Link
            href={isAuthenticated ? "/studio" : "/api/auth/login"}
            className="inline-flex items-center justify-center rounded-lg bg-accent px-6 py-3 text-sm font-medium text-white shadow-[0_0_40px_rgba(99,102,241,0.35)] transition-colors hover:bg-accent-hover"
          >
            {isAuthenticated ? "Go to Studio" : "Start writing free"}
          </Link>
          <Link
            href="#how-it-works"
            className="inline-flex items-center justify-center rounded-lg border border-border bg-surface/70 px-6 py-3 text-sm font-medium text-text-primary backdrop-blur-sm transition-colors hover:bg-surface-hover"
          >
            See how it works
          </Link>
        </div>
        <p className="mt-6 text-xs text-text-muted">
          Free tier: 3 templates, 5 generations per day. No credit card.
        </p>
      </div>
    </section>
  );
}

How it works

Now, we need a short three-step explainer. Pick a template, fill in the inputs, refine through chat. Go to src/components/landing and create a file called how-it-works.tsx with the following content:

how-it-works.tsx
const STEPS = [
  {
    number: "01",
    title: "Pick a template",
    body: "Choose from eight templates: blog post, email, ad copy, landing page, and more. Each knows its own format and prompt.",
  },
  {
    number: "02",
    title: "Fill in a few inputs",
    body: "Topic, audience, tone, key points. A handful of fields is all it takes. No prompt engineering required.",
  },
  {
    number: "03",
    title: "Refine through chat",
    body: "The AI produces a full draft. Ask for a shorter intro, a new tone, a tighter CTA, and the chat thread keeps going until it's right.",
  },
];

export function HowItWorks() {
  return (
    <section id="how-it-works" className="relative border-t border-border-subtle bg-bg py-24">
      <div className="mx-auto max-w-6xl px-6">
        <div className="mx-auto max-w-2xl text-center">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-accent">How it works</p>
          <h2 className="mt-3 text-[clamp(2rem,3vw,2.75rem)] font-semibold leading-tight tracking-tight text-text-primary">
            From blank page to finished draft in three steps.
          </h2>
        </div>
        <div className="mt-16 grid gap-6 md:grid-cols-3">
          {STEPS.map((step) => (
            <div
              key={step.number}
              className="relative rounded-xl border border-border bg-surface p-6"
            >
              <span className="font-mono text-xs font-medium text-accent">{step.number}</span>
              <h3 className="mt-4 text-lg font-semibold text-text-primary">{step.title}</h3>
              <p className="mt-2 text-sm leading-relaxed text-text-secondary">{step.body}</p>
            </div>
          ))}
        </div>
      </div>
    </section>
  );
}

Features bento

Let's add a features list to our landing page. Go to src/components/landing and create a file called features-bento.tsx with the following content:

features-bento.tsx
export function FeaturesBento() {
  return (
    <section className="relative border-t border-border-subtle bg-bg py-24">
      <div className="mx-auto max-w-6xl px-6">
        <div className="mx-auto max-w-2xl text-center">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-accent">Features</p>
          <h2 className="mt-3 text-[clamp(2rem,3vw,2.75rem)] font-semibold leading-tight tracking-tight text-text-primary">
            Everything you need to get a draft off the page.
          </h2>
        </div>

        <div className="mt-16 grid gap-4 md:grid-cols-6 md:grid-rows-2">
          <div className="relative overflow-hidden rounded-xl border border-border bg-surface p-6 md:col-span-4 md:row-span-2">
            <div
              aria-hidden="true"
              className="absolute inset-0 opacity-60"
              style={{
                background:
                  "radial-gradient(ellipse 70% 60% at 80% 20%, rgba(99, 102, 241, 0.18), transparent 65%)",
              }}
            />
            <div className="relative">
              <span className="text-xs font-semibold uppercase tracking-wider text-accent">Eight templates</span>
              <h3 className="mt-3 text-2xl font-semibold leading-tight tracking-tight text-text-primary">
                One tool for every kind of writing.
              </h3>
              <p className="mt-3 max-w-md text-sm leading-relaxed text-text-secondary">
                Blog posts, emails, social copy, ad copy, landing pages, product
                descriptions, SEO articles, press releases. Each template tuned
                with its own system prompt and input fields.
              </p>
            </div>
          </div>

          <div className="rounded-xl border border-border bg-surface p-6 md:col-span-2">
            <h3 className="text-base font-semibold text-text-primary">Streaming output</h3>
            <p className="mt-1 text-sm text-text-secondary">Watch each draft write itself in real time, no waiting for the spinner.</p>
          </div>

          <div className="rounded-xl border border-border bg-surface p-6 md:col-span-2">
            <h3 className="text-base font-semibold text-text-primary">Refinement chat</h3>
            <p className="mt-1 text-sm text-text-secondary">Iterate the draft like a conversation: &ldquo;shorter intro,&rdquo; &ldquo;more casual,&rdquo; &ldquo;new CTA.&rdquo;</p>
          </div>

          <div className="rounded-xl border border-border bg-surface p-6 md:col-span-3">
            <h3 className="text-base font-semibold text-text-primary">Every draft saved</h3>
            <p className="mt-2 text-sm text-text-secondary">Your last 20 generations are one click away in the sidebar.</p>
          </div>

          <div className="rounded-xl border border-border bg-surface p-6 md:col-span-3">
            <h3 className="text-base font-semibold text-text-primary">Light, dark, or system</h3>
            <p className="mt-2 text-sm text-text-secondary">Pick your theme, or let your OS decide. Everything from the landing to the studio stays in sync.</p>
          </div>
        </div>
      </div>
    </section>
  );
}

Template showcase

Let's build a section that shows all eight templates with their names, categories, and descriptions. Go to src/components/landing and create a file called template-showcase.tsx with the following content:

template-showcase.tsx
interface TemplateShowcaseItem {
  id: string;
  name: string;
  slug: string;
  description: string;
  category: string;
  tier: "FREE" | "PRO";
}

export function TemplateShowcase({ templates }: { templates: TemplateShowcaseItem[] }) {
  return (
    <section id="templates" className="relative border-t border-border-subtle bg-bg py-24">
      <div className="mx-auto max-w-6xl px-6">
        <div className="mx-auto max-w-2xl text-center">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-accent">Templates</p>
          <h2 className="mt-3 text-[clamp(2rem,3vw,2.75rem)] font-semibold leading-tight tracking-tight text-text-primary">
            Eight ways to start a draft.
          </h2>
          <p className="mt-4 text-sm leading-relaxed text-text-secondary sm:text-base">
            Each template ships with its own system prompt and input fields.
            Three are free; five unlock with Pro.
          </p>
        </div>

        <div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
          {templates.map((t) => (
            <div
              key={t.id}
              className="group relative rounded-xl border border-border bg-surface p-5 transition-colors hover:border-accent/50"
            >
              <div className="flex items-start justify-between gap-3">
                <span className="text-xs font-medium uppercase tracking-wider text-accent">{t.category}</span>
                {t.tier === "PRO" && (
                  <span className="inline-flex items-center gap-1 rounded-full bg-accent-subtle px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent">
                    Pro
                  </span>
                )}
              </div>
              <h3 className="mt-3 text-base font-semibold text-text-primary">{t.name}</h3>
              <p className="mt-1.5 text-sm leading-relaxed text-text-secondary">{t.description}</p>
            </div>
          ))}
        </div>
      </div>
    </section>
  );
}

Pricing

Now, we need two pricing cards side by side. Go to src/components/landing and create a file called pricing.tsx with the following content:

pricing.tsx
import Link from "next/link";
import { ProCheckoutCta } from "./pro-checkout-cta";

const FREE_FEATURES = [
  "3 writing templates",
  "5 generations per day",
  "Refinement chat on every draft",
  "Last 20 generations saved",
];

const PRO_FEATURES = [
  "All 8 writing templates",
  "Unlimited generations",
  "Refinement chat on every draft",
  "Last 20 generations saved",
  "Priority support",
];

export function Pricing({ isAuthenticated, planId, environment }: { isAuthenticated: boolean; planId: string | null; environment: "sandbox" | "production" }) {
  return (
    <section id="pricing" className="relative border-t border-border-subtle bg-bg py-24">
      <div className="mx-auto max-w-6xl px-6">
        <div className="mx-auto max-w-2xl text-center">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-accent">Pricing</p>
          <h2 className="mt-3 text-[clamp(2rem,3vw,2.75rem)] font-semibold leading-tight tracking-tight text-text-primary">
            Start free. Upgrade when you&apos;re ready.
          </h2>
        </div>

        <div className="mx-auto mt-14 grid max-w-4xl gap-4 md:grid-cols-2">
          {/* Free card */}
          <div className="flex flex-col rounded-xl border border-border bg-surface p-8">
            <h3 className="text-lg font-semibold text-text-primary">Free</h3>
            <p className="mt-1 text-sm text-text-secondary">For occasional drafts.</p>
            <div className="mt-6 flex items-baseline gap-1">
              <span className="text-4xl font-bold tracking-tight text-text-primary">$0</span>
              <span className="text-sm text-text-tertiary">/mo</span>
            </div>
            <ul className="mt-8 space-y-3 text-sm text-text-secondary">
              {FREE_FEATURES.map((f) => (
                <li key={f} className="flex items-start gap-2">
                  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-0.5 flex-shrink-0 text-success"><path d="M20 6 9 17l-5-5"/></svg>
                  {f}
                </li>
              ))}
            </ul>
            <Link
              href={isAuthenticated ? "/studio" : "/api/auth/login"}
              className="mt-auto inline-flex items-center justify-center rounded-lg border border-border bg-bg px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover"
            >
              {isAuthenticated ? "Open studio" : "Get started"}
            </Link>
          </div>

          {/* Pro card — highlighted */}
          <div className="relative flex flex-col overflow-hidden rounded-xl border-2 border-accent bg-surface p-8 shadow-[0_0_40px_rgba(99,102,241,0.15)]">
            <div
              aria-hidden="true"
              className="absolute inset-0 opacity-60"
              style={{
                background:
                  "radial-gradient(ellipse 90% 50% at 50% 0%, rgba(139, 92, 246, 0.12), transparent 70%)",
              }}
            />
            <div className="relative flex flex-col flex-1">
              <div className="flex items-center justify-between">
                <h3 className="text-lg font-semibold text-text-primary">Pro</h3>
                <span className="rounded-full bg-accent-subtle px-2 py-0.5 text-xs font-semibold text-accent">Most popular</span>
              </div>
              <p className="mt-1 text-sm text-text-secondary">For anyone writing regularly.</p>
              <div className="mt-6 flex items-baseline gap-1">
                <span className="text-4xl font-bold tracking-tight text-text-primary">$20</span>
                <span className="text-sm text-text-tertiary">/mo</span>
              </div>
              <ul className="mt-8 space-y-3 text-sm text-text-secondary">
                {PRO_FEATURES.map((f) => (
                  <li key={f} className="flex items-start gap-2">
                    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mt-0.5 flex-shrink-0 text-accent"><path d="M20 6 9 17l-5-5"/></svg>
                    {f}
                  </li>
                ))}
              </ul>
              <ProCheckoutCta isAuthenticated={isAuthenticated} planId={planId} environment={environment} />
            </div>
          </div>
        </div>

        <p className="mt-8 text-center text-xs text-text-muted">
          Billing handled by Whop. Cancel anytime.
        </p>
      </div>
    </section>
  );
}

Now, we need a closing call-to-action and a minimal footer. The CTA is one headline, one line of subcopy, and the same primary button as the hero. The footer has the logo, copyright, and a couple of links.

Go to src/components/landing and create a file called final-cta.tsx with the following content:

final-cta.tsx
import Link from "next/link";

export function FinalCta({ isAuthenticated }: { isAuthenticated: boolean }) {
  return (
    <section className="relative border-t border-border-subtle bg-bg py-28">
      <div
        aria-hidden="true"
        className="absolute inset-0 opacity-60"
        style={{
          background:
            "radial-gradient(ellipse 60% 60% at 50% 50%, rgba(99, 102, 241, 0.2), transparent 70%)",
        }}
      />
      <div className="relative mx-auto max-w-2xl px-6 text-center">
        <h2 className="text-[clamp(2rem,4vw,3rem)] font-semibold leading-tight tracking-tight text-text-primary">
          Your next draft is one click away.
        </h2>
        <p className="mx-auto mt-4 max-w-md text-sm leading-relaxed text-text-secondary sm:text-base">
          Sign in with Whop, pick a template, and let Pencraft do the first
          draft. You refine the rest.
        </p>
        <Link
          href={isAuthenticated ? "/studio" : "/api/auth/login"}
          className="mt-10 inline-flex items-center justify-center rounded-lg bg-accent px-6 py-3 text-sm font-medium text-white shadow-[0_0_40px_rgba(99,102,241,0.35)] transition-colors hover:bg-accent-hover"
        >
          {isAuthenticated ? "Go to Studio" : "Start writing free"}
        </Link>
      </div>
    </section>
  );
}

Go to src/components/landing and create a file called landing-footer.tsx with the following content:

landing-footer.tsx
import Link from "next/link";

export function LandingFooter() {
  return (
    <footer className="border-t border-border-subtle bg-bg py-10">
      <div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 px-6 sm:flex-row">
        <div className="flex items-center gap-3">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 24" fill="none" className="h-4 w-auto text-text-primary">
            <path d="M2 20 L5 4 L8 4 L11 12 L10 4 L13 4 L10 20 L7 20 L4 12 L5 20 Z" fill="#6366f1"/>
            <text x="18" y="18" fontFamily="Inter, system-ui, sans-serif" fontSize="16" fontWeight="700" fill="currentColor" letterSpacing="-0.03em">pencraft</text>
          </svg>
          <span className="text-xs text-text-muted">&copy; {new Date().getFullYear()} Pencraft</span>
        </div>
        <div className="flex items-center gap-5 text-xs text-text-tertiary">
          <Link href="https://github.com/whopio/whop-tutorials/" target="_blank" rel="noreferrer" className="transition-colors hover:text-text-primary">
            GitHub
          </Link>
          <Link href="https://whop.com" target="_blank" rel="noreferrer" className="transition-colors hover:text-text-primary">
            Built with Whop
          </Link>
        </div>
      </div>
    </footer>
  );
}

Wire up the landing page

Now, we replace the root home page. It's a server component that loads the optional user (to pick CTA labels) and the template list (to populate the template showcase), then stitches the landing sections together.

Open src/app/page.tsx and replace its contents:

page.tsx
import { getOptionalUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { getProPlanId } from "@/lib/tier";
import { env } from "@/lib/env";
import { LandingNav } from "@/components/landing/landing-nav";
import { Hero } from "@/components/landing/hero";
import { HowItWorks } from "@/components/landing/how-it-works";
import { FeaturesBento } from "@/components/landing/features-bento";
import { TemplateShowcase } from "@/components/landing/template-showcase";
import { Pricing } from "@/components/landing/pricing";
import { FinalCta } from "@/components/landing/final-cta";
import { LandingFooter } from "@/components/landing/landing-footer";

export default async function LandingPage() {
  const user = await getOptionalUser();
  const isAuthenticated = !!user;
  const proWhopPlanId = await getProPlanId();
  const checkoutEnv = env.WHOP_SANDBOX === "true" ? "sandbox" as const : "production" as const;

  const templates = await prisma.template.findMany({
    where: { isActive: true },
    orderBy: { createdAt: "asc" },
  });

  const templateShowcase = templates.map((t: (typeof templates)[number]) => ({
    id: t.id,
    name: t.name,
    slug: t.slug,
    description: t.description,
    category: t.category,
    tier: t.tier as "FREE" | "PRO",
  }));

  return (
    <>
      <LandingNav isAuthenticated={isAuthenticated} />
      <main>
        <Hero isAuthenticated={isAuthenticated} />
        <HowItWorks />
        <FeaturesBento />
        <TemplateShowcase templates={templateShowcase} />
        <Pricing isAuthenticated={isAuthenticated} planId={proWhopPlanId} environment={checkoutEnv} />
        <FinalCta isAuthenticated={isAuthenticated} />
      </main>
      <LandingFooter />
    </>
  );
}

Deploy

Now, we ship everything to production. The build will pick up the new / and /studio routes, the middleware guarding /studio, and the landing page components.

Terminal
vercel --prod

Once the deployment finishes, visit the production URL in an incognito window to confirm the landing page loads for guests, then sign in and make sure the callback drops you on /studio.

Checkpoint

Verify the following before wrapping up:

  1. The production URL now shows the landing page with the hero section and gradient background
  2. Opening /studio in an incognito window redirects back to /
  3. Signing in from the landing page completes OAuth and lands you on /studio
  4. Signing out from the studio header returns you to / (landing)
  5. The template showcase on the landing page lists all 8 seeded templates, with Pro badges on the Pro ones
  6. The theme toggle inside the studio still works, and the landing respects the same theme (light/dark/system)
  7. Clicking "Get started" on the Pro pricing card opens the embedded checkout popup (for signed-in users) or redirects to sign in first (for guests)
  8. After completing checkout, the welcome popup appears confirming the Pro upgrade and auto-dismisses after 5 seconds

Build more SaaS and platforms with Whop

Our AI writing tool project is now fully functional and live, but it's not the only project you can create with Whop. Using the Whop Payments Network and the Whop infrastructure, you can create endless projects from an AI chatbot SaaS to a Gumroad clone.

If you want to learn more about the Whop infrastructure and what you can do with it, read our other tutorials and visit the Whop developer documentation.