Getting Started
Features
Build with AI
AI image generator
Build a ChatGPT-powered image generator and creators will be able to install it to their whops.
This tutorial was submitted by @s, a member of the Whop Developers community. Submit your own tutorial and get paid real $!
Summary
This tutorial will guide you through building a ChatGPT-powered image generator using Next.js, Shadcn UI, and OpenAI.
View the final product here by installing the app to your whop.
1. Set up your Next.js project
Clone our Next.js app template:
npx create-next-app@latest ai-image-generator -e https://github.com/whopio/whop-nextjs-app-template
Enter the project directory:
cd ai-image-generator
Install dependencies:
pnpm i
Run the app locally:
pnpm dev
Now open http://localhost:3000 and follow the directions on the page.
2. Start developing your app
After following the instructions on the page, you’ll be able to start developing your app. You should have:
- Created your app
- Set up your
.env.local
file - Installed your app into your whop
Ensure you’re developing in localhost
mode. See example:
3. Set up your database
Create a Supabase database
Go to Supabase and create a new account if you don’t have one
Create a new project and copy your database password
Set up Prisma
Now, let’s set up Prisma in your project:
pnpm add prisma @prisma/client
pnpm prisma init
The prisma init
command will create a new prisma
directory with a schema.prisma
file
Now, go copy your database connection strings from Supabase for Prisma to use. Then paste the values in your .env.local
file.
Replace your password with [YOUR-PASSWORD]
Replace the contents of prisma/schema.prisma
with:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
model Experience {
id String @unique
prompt String
}
Now generate your database and Prisma client:
These are custom scripts we defined in the package.json file to load your env from .env.local and run the prisma commands. If you want to use the native prisma CLI, you’ll need to move your
.env.local
to.env
and run the commands manually.
pnpm prisma:generate
pnpm prisma:db:push
4. Install additional dependencies
Add the required packages
pnpm add openai sharp react-dropzone @radix-ui/react-slot gsap
Install a Shadcn button
pnpm dlx shadcn@latest add button
Add your OpenAI API key
Add to your .env.local
:
# OpenAI API Key for image generation
OPENAI_API_KEY=your_openai_api_key_here
5. Create components
<ImageUploader>
This component handles image upload and generation.
"use client";
import { Button } from "@/components/ui/button";
import gsap from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
import Image from "next/image";
import { useCallback, useEffect, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
gsap.registerPlugin(DrawSVGPlugin);
function Loader() {
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!svgRef.current) return;
const mid = gsap.utils.toArray("#mid *").reverse();
const fatTl = gsap.timeline();
fatTl.fromTo(
"#fat *",
{
drawSVG: "0% 20%",
},
{
drawSVG: "40% 69%",
stagger: {
each: 0.05,
repeat: -1,
yoyo: true,
},
duration: 0.75,
ease: "sine.inOut",
}
);
const midTl = gsap.timeline();
midTl.fromTo(
mid,
{
drawSVG: "0% 20%",
},
{
drawSVG: "56% 86%",
stagger: {
each: 0.08,
repeat: -1,
yoyo: true,
},
duration: 0.81,
ease: "sine.inOut",
}
);
const thinTl = gsap.timeline();
thinTl.fromTo(
"#thin *",
{
drawSVG: "20% 51%",
},
{
drawSVG: "40% 80%",
stagger: {
each: 0.092,
repeat: -1,
yoyo: true,
},
duration: 1.4,
ease: "sine.inOut",
}
);
const mainTl = gsap.timeline();
mainTl.add([fatTl, midTl, thinTl], 0);
return () => {
mainTl.kill();
};
}, []);
return (
<div className="w-full h-full flex items-center justify-center">
<svg
ref={svgRef}
id="mainSVG"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 800 600"
className="w-100 h-100"
role="img"
aria-label="Loading animation"
>
<title>Loading animation</title>
<linearGradient
id="grad1"
x1="393.05"
y1="400"
x2="393.05"
y2="200"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#3D28F7" />
<stop offset="1" stopColor="#FF3C20" />
</linearGradient>
<linearGradient
id="grad2"
x1="393.05"
y1="391.01"
x2="393.05"
y2="247.71"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#F72785" />
<stop offset="1" stopColor="#FFEE2A" />
</linearGradient>
<linearGradient
id="grad3"
x1="393.05"
y1="400"
x2="393.05"
y2="200"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#FF6820" />
<stop offset="1" stopColor="#D1FE21" />
</linearGradient>
<linearGradient
id="grad4"
x1="393.05"
y1="400"
x2="393.05"
y2="250"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#35AAF9" />
<stop offset="1" stopColor="#993BDC" />
</linearGradient>
<g>
<g
id="bg"
stroke="url(#grad3)"
fill="none"
strokeLinecap="round"
strokeMiterlimit="10"
>
<path d="M594.5,250v-.29L594.6,350" />
<line x1="580.5" y1="390" x2="580.32" y2="210" />
<line x1="565.5" y1="415" x2="565.28" y2="185" />
<line x1="550.5" y1="434" x2="550.24" y2="166" />
<line x1="535.5" y1="449" x2="535.22" y2="151" />
<line x1="520.5" y1="462" x2="520.2" y2="138" />
<line x1="505.5" y1="472" x2="505.18" y2="128" />
<line x1="490.5" y1="480" x2="490.16" y2="120" />
<line x1="475.5" y1="487" x2="475.14" y2="113" />
<line x1="460.5" y1="492" x2="460.14" y2="108" />
<line x1="445.5" y1="496" x2="445.12" y2="104" />
<line x1="430.5" y1="499" x2="430.12" y2="101" />
<line x1="415.5" y1="501" x2="415.12" y2="99" />
<line x1="400.5" y1="501" x2="400.12" y2="99" />
<line x1="385.5" y1="501" x2="385.12" y2="99" />
<line x1="370.5" y1="499" x2="370.12" y2="101" />
<line x1="355.5" y1="496" x2="355.12" y2="104" />
<line x1="340.5" y1="492" x2="340.14" y2="108" />
<line x1="325.5" y1="487" x2="325.14" y2="113" />
<line x1="310.5" y1="480" x2="310.16" y2="120" />
<line x1="295.5" y1="472" x2="295.18" y2="128" />
<line x1="280.5" y1="462" x2="280.2" y2="138" />
<line x1="265.5" y1="449" x2="265.22" y2="151" />
<line x1="250.5" y1="434" x2="250.24" y2="166" />
<line x1="235.5" y1="415" x2="235.28" y2="185" />
<line x1="220.5" y1="390" x2="220.32" y2="210" />
<polyline points="204.5 250 204.5 350.29 204.5 350" />
</g>
<g
id="thin"
stroke="url(#grad1)"
fill="none"
strokeLinecap="round"
strokeMiterlimit="10"
strokeWidth="2"
>
<path d="M594.6,350l-.1-100.29V250" />
<line x1="580.5" y1="390" x2="580.32" y2="210" />
<line x1="565.5" y1="415" x2="565.28" y2="185" />
<line x1="550.5" y1="434" x2="550.24" y2="166" />
<line x1="535.5" y1="449" x2="535.22" y2="151" />
<line x1="520.5" y1="462" x2="520.2" y2="138" />
<line x1="505.5" y1="472" x2="505.18" y2="128" />
<line x1="490.5" y1="480" x2="490.16" y2="120" />
<line x1="475.5" y1="487" x2="475.14" y2="113" />
<line x1="460.5" y1="492" x2="460.14" y2="108" />
<line x1="445.5" y1="496" x2="445.12" y2="104" />
<line x1="430.5" y1="499" x2="430.12" y2="101" />
<line x1="415.5" y1="501" x2="415.12" y2="99" />
<line x1="400.5" y1="501" x2="400.12" y2="99" />
<line x1="385.5" y1="501" x2="385.12" y2="99" />
<line x1="370.5" y1="499" x2="370.12" y2="101" />
<line x1="355.5" y1="496" x2="355.12" y2="104" />
<line x1="340.5" y1="492" x2="340.14" y2="108" />
<line x1="325.5" y1="487" x2="325.14" y2="113" />
<line x1="310.5" y1="480" x2="310.16" y2="120" />
<line x1="295.5" y1="472" x2="295.18" y2="128" />
<line x1="280.5" y1="462" x2="280.2" y2="138" />
<line x1="265.5" y1="449" x2="265.22" y2="151" />
<line x1="250.5" y1="434" x2="250.24" y2="166" />
<line x1="235.5" y1="415" x2="235.28" y2="185" />
<line x1="220.5" y1="390" x2="220.32" y2="210" />
<polyline points="204.5 350 204.5 350.29 204.5 250" />
</g>
<g
id="mid"
stroke="url(#grad2)"
fill="none"
strokeLinecap="round"
strokeMiterlimit="10"
strokeWidth="4"
>
<path d="M594.6,350l-.1-100.29V250" />
<line x1="580.5" y1="390" x2="580.32" y2="210" />
<line x1="565.5" y1="415" x2="565.28" y2="185" />
<line x1="550.5" y1="434" x2="550.24" y2="166" />
<line x1="535.5" y1="449" x2="535.22" y2="151" />
<line x1="520.5" y1="462" x2="520.2" y2="138" />
<line x1="505.5" y1="472" x2="505.18" y2="128" />
<line x1="490.5" y1="480" x2="490.16" y2="120" />
<line x1="475.5" y1="487" x2="475.14" y2="113" />
<line x1="460.5" y1="492" x2="460.14" y2="108" />
<line x1="445.5" y1="496" x2="445.12" y2="104" />
<line x1="430.5" y1="499" x2="430.12" y2="101" />
<line x1="415.5" y1="501" x2="415.12" y2="99" />
<line x1="400.5" y1="501" x2="400.12" y2="99" />
<line x1="385.5" y1="501" x2="385.12" y2="99" />
<line x1="370.5" y1="499" x2="370.12" y2="101" />
<line x1="355.5" y1="496" x2="355.12" y2="104" />
<line x1="340.5" y1="492" x2="340.14" y2="108" />
<line x1="325.5" y1="487" x2="325.14" y2="113" />
<line x1="310.5" y1="480" x2="310.16" y2="120" />
<line x1="295.5" y1="472" x2="295.18" y2="128" />
<line x1="280.5" y1="462" x2="280.2" y2="138" />
<line x1="265.5" y1="449" x2="265.22" y2="151" />
<line x1="250.5" y1="434" x2="250.24" y2="166" />
<line x1="235.5" y1="415" x2="235.28" y2="185" />
<line x1="220.5" y1="390" x2="220.32" y2="210" />
<polyline points="204.5 350 204.5 350.29 204.5 250" />
</g>
<g
id="fat"
stroke="url(#grad4)"
fill="none"
strokeLinecap="round"
strokeMiterlimit="10"
strokeWidth="7"
>
<path d="M594.6,350l-.1-100.29V250" />
<line x1="580.5" y1="390" x2="580.32" y2="210" />
<line x1="565.5" y1="415" x2="565.28" y2="185" />
<line x1="550.5" y1="434" x2="550.24" y2="166" />
<line x1="535.5" y1="449" x2="535.22" y2="151" />
<line x1="520.5" y1="462" x2="520.2" y2="138" />
<line x1="505.5" y1="472" x2="505.18" y2="128" />
<line x1="490.5" y1="480" x2="490.16" y2="120" />
<line x1="475.5" y1="487" x2="475.14" y2="113" />
<line x1="460.5" y1="492" x2="460.14" y2="108" />
<line x1="445.5" y1="496" x2="445.12" y2="104" />
<line x1="430.5" y1="499" x2="430.12" y2="101" />
<line x1="415.5" y1="501" x2="415.12" y2="99" />
<line x1="400.5" y1="501" x2="400.12" y2="99" />
<line x1="385.5" y1="501" x2="385.12" y2="99" />
<line x1="370.5" y1="499" x2="370.12" y2="101" />
<line x1="355.5" y1="496" x2="355.12" y2="104" />
<line x1="340.5" y1="492" x2="340.14" y2="108" />
<line x1="325.5" y1="487" x2="325.14" y2="113" />
<line x1="310.5" y1="480" x2="310.16" y2="120" />
<line x1="295.5" y1="472" x2="295.18" y2="128" />
<line x1="280.5" y1="462" x2="280.2" y2="138" />
<line x1="265.5" y1="449" x2="265.22" y2="151" />
<line x1="250.5" y1="434" x2="250.24" y2="166" />
<line x1="235.5" y1="415" x2="235.28" y2="185" />
<line x1="220.5" y1="390" x2="220.32" y2="210" />
<polyline points="204.5 350 204.5 350.29 204.5 250" />
</g>
</g>
</svg>
</div>
);
}
export default function ImageUploader({
experienceId,
}: {
experienceId: string;
}) {
const [image, setImage] = useState<{
file: File;
preview: string;
} | null>(null);
const [generatedImage, setGeneratedImage] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
// Clean up the object URL when the image is changed
useEffect(() => {
const objectUrl = image?.preview;
if (objectUrl) {
return () => {
URL.revokeObjectURL(objectUrl);
};
}
}, [image?.preview]);
const onDrop = useCallback((acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (file) {
setImage({
file,
preview: URL.createObjectURL(file),
});
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"image/*": [".jpeg", ".jpg", ".png", ".gif"],
},
maxFiles: 1,
});
const handleUpload = async () => {
if (!image) return;
try {
const response = await fetch(
`/api/experiences/${experienceId}/generate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: image.file,
}
);
if (!response.ok) {
throw new Error("Failed to get upload URL");
}
const data = await response.json();
setGeneratedImage(data.imageUrl);
} catch (error) {
console.error("Error uploading image:", error);
throw error;
}
};
const handleGenerate = async () => {
if (!image) return;
setIsGenerating(true);
setUploadProgress(0);
try {
await handleUpload();
} catch (error) {
console.error("Error generating image:", error);
} finally {
setIsGenerating(false);
}
};
const handleReset = () => {
setImage(null);
setGeneratedImage(null);
setUploadProgress(0);
};
if (isGenerating) {
return (
<div className="w-full max-w-2xl mx-auto p-4 space-y-8">
<div className="w-full aspect-square flex items-center justify-center">
<Loader />
</div>
<div className="flex gap-4">
<Button onClick={handleReset} variant="outline" className="flex-1">
Cancel
</Button>
<Button disabled className="flex-1">
Generating...
</Button>
</div>
</div>
);
}
const displayImage = generatedImage || image?.preview;
return (
<div className="w-full max-w-2xl mx-auto p-4 space-y-8">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
${
isDragActive
? "border-blue-500 bg-blue-50"
: "border-gray-300 hover:border-blue-400"
}`}
>
<input {...getInputProps()} capture="environment" />
{displayImage ? (
<div className="relative w-full aspect-square">
<Image
src={displayImage}
alt="Uploaded image"
fill
className="object-contain rounded-lg"
/>
</div>
) : (
<div className="space-y-4">
<div className="text-4xl">📸</div>
<p className="text-gray-600">
{isDragActive
? "Drop the image here..."
: "Drag & drop an image here, or click to select"}
</p>
<p className="text-sm text-gray-500">Supports JPG, PNG, GIF</p>
</div>
)}
</div>
{image && (
<div className="flex flex-col gap-4">
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
<div className="flex gap-4">
<Button onClick={handleReset} variant="outline" className="flex-1">
Reset
</Button>
<Button onClick={handleGenerate} className="flex-1">
Generate Image
</Button>
</div>
</div>
)}
</div>
);
}
<ExperiencePrompt>
This component displays the experience prompt and image uploader.
import type { AccessLevel } from "@whop/api";
import Link from "next/link";
import ImageUploader from "./image-uploader";
import { Button } from "./ui/button";
export default function ExperiencePrompt({
prompt,
accessLevel,
experienceId,
}: {
prompt: string;
accessLevel: AccessLevel;
experienceId: string;
}) {
return (
<div>
<div className="flex justify-center items-center">
<div className="text-4xl font-bold text-center">
{prompt ? `"${prompt}"` : "Creator has not set a prompt yet."}
</div>
</div>
{accessLevel === "admin" && (
<div className="flex justify-center items-center">
<Link href={`/experiences/${experienceId}/edit`}>
<Button variant={"link"}>Edit prompt</Button>
</Link>
</div>
)}
{prompt ? <ImageUploader experienceId={experienceId} /> : null}
</div>
);
}
<EditExperiencePrompt>
This component allows admins to edit the experience prompt.
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
export default function EditExperiencePage({
experienceId,
}: {
experienceId: string;
}) {
const router = useRouter();
const [prompt, setPrompt] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const response = await fetch(`/api/experiences/${experienceId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt }),
});
if (!response.ok) {
throw new Error("Failed to update experience");
}
router.push(`/experiences/${experienceId}`);
router.refresh();
} catch (error) {
console.error("Error updating experience:", error);
// You might want to show an error message to the user here
} finally {
setIsLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Edit Prompt</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="prompt"
className="block text-sm font-medium text-gray-700 mb-2"
>
Prompt
</label>
<textarea
id="prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="w-full min-h-[200px] p-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter a new prompt here..."
required
/>
</div>
<div className="flex gap-4">
<Button
type="submit"
disabled={isLoading}
className="bg-blue-500 hover:bg-blue-600 text-white"
>
{isLoading ? "Saving..." : "Save Changes"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isLoading}
>
Cancel
</Button>
</div>
</form>
</div>
);
}
6. Create pages
app/experiences/[experienceId]/page.tsx
This page displays the experience prompt and the image uploader. If the user is an admin, they can edit the prompt.
import ExperiencePrompt from "@/components/experience-prompt";
import { whopApi } from "@/lib/whop-api";
import { PrismaClient } from "@prisma/client";
import { verifyUserToken } from "@whop/api";
import { headers } from "next/headers";
const prisma = new PrismaClient();
async function findOrCreateExperience(experienceId: string) {
let experience = await prisma.experience.findUnique({
where: { id: experienceId },
});
if (!experience) {
experience = await prisma.experience.create({
data: {
id: experienceId,
prompt: "",
},
});
}
return experience;
}
export default async function ExperiencePage({
params,
}: {
params: Promise<{ experienceId: string }>;
}) {
const headersList = await headers();
const { userId } = await verifyUserToken(headersList);
const { experienceId } = await params;
const experience = await findOrCreateExperience(experienceId);
const hasAccess = await whopApi.checkIfUserHasAccessToExperience({
userId,
experienceId,
});
return (
<div className="flex flex-col gap-4 p-4 h-screen items-center justify-center">
<ExperiencePrompt
prompt={experience.prompt}
accessLevel={hasAccess.hasAccessToExperience.accessLevel}
experienceId={experienceId}
/>
</div>
);
}
app/experiences/[experienceId]/edit/page.tsx
This page allows admins to edit the experience prompt.
import EditExperiencePrompt from "@/components/edit-experience-prompt";
export default async function Page({
params,
}: {
params: Promise<{ experienceId: string }>;
}) {
const { experienceId } = await params;
return <EditExperiencePrompt experienceId={experienceId} />;
}
7. Create the API routes
app/api/experiences/[experienceId]/generate/route.ts
This API route generates images using OpenAI’s DALL-E API.
import { verifyUserToken, whopApi } from "@/lib/whop-api";
import { PrismaClient } from "@prisma/client";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import OpenAI from "openai";
import sharp from "sharp";
const prisma = new PrismaClient();
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ experienceId: string }> }
) {
try {
const { experienceId } = await params;
if (!experienceId) {
return NextResponse.json(
{ error: "Missing experienceId" },
{ status: 400 }
);
}
const headersList = await headers();
const userToken = await verifyUserToken(headersList);
if (!userToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const hasAccess = await whopApi.checkIfUserHasAccessToExperience({
userId: userToken.userId,
experienceId,
});
if (!hasAccess.hasAccessToExperience.hasAccess) {
return NextResponse.json(
{ error: "Unauthorized, no access" },
{ status: 401 }
);
}
const [publicUser, experience] = await Promise.all([
whopApi.getUser({
userId: userToken.userId,
}),
prisma.experience.findUnique({
where: {
id: experienceId,
},
}),
]);
if (!request.body || !experience?.prompt) {
return NextResponse.json(
{ error: "Image and prompt are required" },
{ status: 400 }
);
}
const originalFile = new File(
[
await sharp(await request.clone().arrayBuffer())
.png()
.toBuffer(),
],
`${Date.now()}-original.png`,
{
type: "image/png",
}
);
// Generate image using DALL-E with prompt
const response = await openai.images.edit({
model: "gpt-image-1",
image: originalFile,
prompt: experience.prompt,
n: 1,
size: "auto",
quality: "low",
});
console.log("Response:", response);
// Get the base64 image data from the response
const base64Image = response.data?.[0]?.b64_json;
if (!base64Image) {
throw new Error("No image data returned from OpenAI");
}
const generatedImageBuffer = Buffer.from(base64Image, "base64");
const generationId = crypto.randomUUID();
const [originalFileUploadResponse, uploadResponse] = await Promise.all([
whopApi.uploadAttachment({
file: originalFile,
record: "forum_post",
}),
whopApi.uploadAttachment({
file: new File(
[generatedImageBuffer],
`${generationId}-generated.png`,
{
type: "image/png",
}
),
record: "forum_post",
}),
]);
const whopExperience = await whopApi.getExperience({ experienceId });
const companyId = whopExperience.experience.company.id;
const generatedAttachmentId = uploadResponse.directUploadId;
const originalAttachmentId = originalFileUploadResponse.directUploadId;
const forum = await whopApi.findOrCreateForum({
input: { experienceId: experience.id, name: "AI Uploads" },
});
const forumId = forum.createForum?.id;
const post = await whopApi.createForumPost({
input: {
forumExperienceId: forumId,
content: `@${publicUser.publicUser?.username} generated this image with the prompt: "${experience.prompt}"\n\nTry it yourself here: https://whop.com/hub/${companyId}/${experience.id}/app\n\nBefore vs After ⬇️`,
attachments: [
{ directUploadId: originalAttachmentId },
{ directUploadId: generatedAttachmentId },
],
},
});
return NextResponse.json({
success: true,
imageUrl: uploadResponse.attachment.source.url,
postId: post.createForumPost?.id,
});
} catch (error) {
console.error("Error generating image:", error);
return NextResponse.json(
{ error: "Failed to generate image" },
{ status: 500 }
);
}
}
app/api/experiences/[experienceId]/route.ts
This API route is a PUT operation to an experience in the database.
import { verifyUserToken, whopApi } from "@/lib/whop-api";
import { PrismaClient } from "@prisma/client";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function PUT(request: Request) {
try {
const { prompt } = await request.json();
const headersList = await headers();
const userToken = await verifyUserToken(headersList);
if (!userToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const match = url.pathname.match(/experiences\/([^/]+)/);
const experienceId = match ? match[1] : null;
if (!experienceId) {
return NextResponse.json(
{ error: "Missing experienceId" },
{ status: 400 }
);
}
const hasAccess = await whopApi.checkIfUserHasAccessToExperience({
userId: userToken.userId,
experienceId,
});
if (hasAccess.hasAccessToExperience.accessLevel !== "admin") {
return NextResponse.json(
{ error: "Unauthorized, not admin" },
{ status: 401 }
);
}
const updatedExperience = await prisma.experience.update({
where: {
id: experienceId,
},
data: {
prompt,
},
});
await whopApi.sendNotification({
input: {
content: prompt,
experienceId,
title: "Prompt updated ✨",
},
});
return NextResponse.json(updatedExperience);
} catch (error) {
console.error("Error updating experience:", error);
return NextResponse.json(
{ error: "Failed to update experience" },
{ status: 500 }
);
}
}
8. Deploy to Vercel
- Add this script to the
scripts
section of yourpackage.json
to generate the Prisma client:
"scripts": {
"postinstall": "prisma generate"
}
- Push your code to GitHub
- Create a new project on Vercel
- Import your GitHub repository
- Add all environment variables
- Deploy and copy your Vercel URL
- Update your Whop app settings with the new URL in the “Base URL” field
Vercel functions automatically timeout after 60 seconds on a hobby account. Images might take longer than 60 seconds. This will cause errors. You can upgrade to a paid account to avoid this or swap out the image generator to a different API.
Your AI image generation app is now ready! Users can upload images, apply AI transformations based on prompts, and share their creations in the community forum.
Need Help?
- Join the Developer Whop
- View the source code of this app here
- DM @s on Whop
Was this page helpful?
- Summary
- 1. Set up your Next.js project
- 2. Start developing your app
- 3. Set up your database
- Create a Supabase database
- Set up Prisma
- 4. Install additional dependencies
- Add the required packages
- Install a Shadcn button
- Add your OpenAI API key
- 5. Create components
- <ImageUploader>
- <ExperiencePrompt>
- <EditExperiencePrompt>
- 6. Create pages
- app/experiences/[experienceId]/page.tsx
- app/experiences/[experienceId]/edit/page.tsx
- 7. Create the API routes
- app/api/experiences/[experienceId]/generate/route.ts
- app/api/experiences/[experienceId]/route.ts
- 8. Deploy to Vercel
- Need Help?