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.
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
@themeblocks, 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-pgfor Neon compatibility. Client generated intosrc/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
- Free user clicks "Upgrade to Pro" in the user context menu
- App displays an information popup about the Pro tier and redirects to the Whop hosted checkout URL stored on the Plan record
- User completes payment on Whop
- Whop fires a
membership.activatedwebhook. The app makes the Membership record for the user with theACTIVEstatus getUserTier()reads the Membership on every request and unlocks Pro templates and unlimited generations- 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:
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:
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:
npm i -g vercel
Then, use the command below to log in and link the directory we're working in to a new project:
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:
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:
| Variable | Where to get it |
|---|---|
WHOP_CLIENT_ID | Whop app > OAuth tab > Client ID |
WHOP_CLIENT_SECRET | Whop app > OAuth tab > Client Secret |
WHOP_API_KEY | Whop Developer Dashboard > API keys > create a new Company API Key |
WHOP_COMPANY_ID | Whop Dashboard > your company settings (looks like biz_xxxxx) |
WHOP_SANDBOX | true |
SESSION_SECRET | Generate with openssl rand -base64 32 |
NEXT_PUBLIC_APP_URL | The Vercel deployment URL (e.g. https://pencraft-xxxx.vercel.app) |
DATABASE_URL | Auto-populated by the Neon integration |
DATABASE_URL_UNPOOLED | Auto-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:
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:
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:
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:
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:
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.
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:
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:
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:
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:
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:
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:
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:
<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 "next/navigation";
import { getSession } from "./session";
export async function requireAuth() {
const session = await getSession();
if (!session.whopUserId) redirect("/");
return {
whopUserId: session.whopUserId,
email: session.email ?? "",
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 ?? "",
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:
@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:
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:
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>
);
}
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:
"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>
);
}
Header
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:
"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:
"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.
git add -A && git commit -m "feat: scaffold, dark theme, app shell, auth"
vercel
Checkpoint
Verify the following before moving on:
- The Vercel deployment URL loads the app with the correct theme based on system preference
- Open
/api/auth/logindirectly in the browser to start the OAuth flow. After authorizing, we should be redirected back to/with the session active - The session cookie
pencraft_sessionis present in browser dev tools under Application > Cookies - Visiting
/api/auth/logoutclears the session and redirects back to/ - Clicking the username in the header opens a dropdown with theme, upgrade, and sign out options
- Switching between Light, Dark, and Auto in the theme dropdown updates the UI instantly
- 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:
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.
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:
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.
npm install -D tsx
Add the following to the top level of package.json (not inside scripts):
"prisma": {
"seed": "npx tsx prisma/seed.ts"
}
Then, run the seed command:
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:
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:
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:
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:
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:
"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:
"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:
"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:
"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:
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.
git add -A && git commit -m "feat: database, templates, IDE layout"
vercel
Checkpoint
Verify the following before moving on:
- Running
npx prisma db seedcompletes without errors and reports 8 templates seeded - The home page loads the full three-panel layout
- The right sidebar shows three free templates and five pro templates
- Clicking a free template in the right sidebar switches to the form view with the correct input fields
- Clicking "Sign in with Whop" on the header (or in the center panel empty state) opens the login modal with a blurred backdrop
- After signing in, the left history sidebar shows "No generations yet" (since we have no generate API yet)
- 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:
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.
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:
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:
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:
"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:
"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:
"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:
- Click "Blog Post" in the right sidebar and fill in all fields
- Click "Generate" and wait for the response
- The center panel switches from the welcome screen to the generated output
- The generation title appears in the center panel header
- The new generation appears at the top of the left sidebar history list
- 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:
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:
"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:
"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:
- Generate content from the Blog Post template
- The "Refine" section appears below the output in the center panel
- Type "Make the introduction shorter" in the input and press Send
- Send two more refinement messages and see the full conversation thread accumulate
- Refresh the page, then click the same history item in the left sidebar to restore the complete chat thread
- Generate content from a different template and confirm both items appear in the history sidebar
- 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:
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:
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:
https://the-deployment-url.vercel.app/api/webhooks/whop
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:
WHOP_WEBHOOK_SECRET=your_secret
Then, pull the updated environment variables locally using the command:
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:
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:
"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:
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:
| Variable | Value |
|---|---|
WHOP_CLIENT_ID | Production app client ID |
WHOP_CLIENT_SECRET | Production client secret |
WHOP_API_KEY | Production Company API Key |
WHOP_COMPANY_ID | Production company ID |
WHOP_WEBHOOK_SECRET | Production webhook secret |
WHOP_SANDBOX | Remove this variable, or set to false |
NEXT_PUBLIC_APP_URL | https://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:
vercel env pull .env.local
npx tsx prisma/create-pro-plan.ts
vercel --prod
Checkpoint
Verify the following before moving on:
- Sign in as a free user and confirm the Pro templates in the right sidebar are shown as locked with a padlock icon
- Click a locked Pro template and confirm the upgrade modal opens
- Close the modal and generate content 5 times from a free template. On the sixth attempt, confirm the limit modal opens automatically
- Confirm the Generate button is visually disabled after the fifth generation (the
isAtLimitflag is true) - Upgrade to Pro and complete the checkout at the Whop-hosted sandbox checkout
- After checkout, reload the app and confirm the Pro templates are now selectable
- Generate content from a Pro template to confirm tier enforcement is lifted
- Generate more than 5 times in one day as a Pro user and confirm there is no daily limit
- The production deployment at your Vercel production URL loads correctly
- 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:
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:
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:
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:
"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:
"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:
"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:
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:
"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:
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:
"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:
"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'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:
"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:
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:
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:
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: “shorter intro,” “more casual,” “new CTA.”</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:
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:
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'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>
);
}
Final CTA and footer
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:
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:
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">© {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:
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.
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:
- The production URL now shows the landing page with the hero section and gradient background
- Opening
/studioin an incognito window redirects back to/ - Signing in from the landing page completes OAuth and lands you on
/studio - Signing out from the studio header returns you to
/(landing) - The template showcase on the landing page lists all 8 seeded templates, with Pro badges on the Pro ones
- The theme toggle inside the studio still works, and the landing respects the same theme (light/dark/system)
- 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)
- 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.