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:

prisma/schema.prisma
// 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:

.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.

app/components/image-uploader.tsx
"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.

app/components/experience-prompt.tsx
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.

app/components/edit-experience-prompt.tsx
"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.

app/experiences/[experienceId]/page.tsx
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.

app/experiences/[experienceId]/edit/page.tsx
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.

app/api/experiences/[experienceId]/generate/route.ts
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.

app/api/experiences/[experienceId]/route.ts
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

  1. Add this script to the scripts section of your package.json to generate the Prisma client:
package.json
"scripts": {
    "postinstall": "prisma generate"
  }
  1. Push your code to GitHub
  2. Create a new project on Vercel
  3. Import your GitHub repository
  4. Add all environment variables
  5. Deploy and copy your Vercel URL
  6. 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?