Build an AI Media Taste Roaster
Build a full-stack app that roasts your movie, anime, and book taste with AI.
Introduction
30 Second Summary
Everyone has opinions about their media taste. But could you handle an AI that reads your favorite movies, anime, music, and books, then absolutely destroys you with a personalized roast?
In this project, you will build a full-stack web app called RoastMyTaste where users sign in, search for their favorite media with cover art, save curated lists to their profile, and get roasted by an AI that analyzes their combined taste.
What You'll Build
A full-stack Next.js application with user authentication, a PostgreSQL database, four media search API integrations, and AI-powered roast generation, all deployed live on Vercel.
By the end of this project, you'll have:
- A live, deployed web app with Google/GitHub sign-in, persistent user profiles, and a visual gallery of saved media lists.
- Integration with four different media APIs (movies, anime, music, books) that return image-rich search results with cover art.
- AI-powered roast generation using Google Gemini that analyzes a user's combined media taste and delivers a personalized roast with animated text reveal.
- Secret Mission: Add a shareable roast card feature that lets users download a styled image of their roast for social media.
Are there any prerequisites?
Basic familiarity with React and JavaScript is helpful. No prior Next.js experience is required, but if you have some, you will move through the early steps faster.
Set Up the Project
To build RoastMyTaste, you need a solid foundation of tools and dependencies on your machine. Next.js will power the full-stack app, Tailwind CSS handles styling, and a handful of libraries cover authentication, database access, and AI.
In this step, you will install Node.js, scaffold the Next.js project, and pull in every dependency the app needs so you can focus on building features from Step 2 onward.
In this step, get ready to:
- Install and verify Node.js.
- Create the Next.js project with TypeScript, Tailwind CSS, and the App Router.
- Install all core dependencies (animation, auth, database, and AI packages).
Install Node.js
Node.js is the JavaScript runtime that powers Next.js and all the tools you will use in this project. You need version 18 or higher.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Check your Node.js version by running this command:
node --version
✔️ I see version 18 or higher
You are good to go. Move on to creating the Next.js project below.
ⓧ I see an older version
You need to upgrade Node.js to version 18 or higher.
- Visit the official Node.js downloads page at https://nodejs.org and download the LTS installer for your operating system.
- Run the installer and follow the prompts.
- Close and reopen your terminal, then run node --version again to confirm the update.
ⓧ Command not found
Node.js is not installed on your machine yet.
- Visit https://nodejs.org and download the LTS installer for your operating system.
- Run the installer and accept the defaults.
- Close and reopen your terminal, then run node --version to verify the install succeeded.
Create the Next.js project
The create-next-app CLI scaffolds a complete Next.js project with TypeScript, Tailwind CSS, and the App Router in one command.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project by running this command:
npx create-next-app@latest roastmytaste
What prompts will I see?
The CLI will ask you a series of configuration questions. If asked whether to use recommended defaults, select Yes. This gives you TypeScript, ESLint, Tailwind CSS, and the App Router out of the box.
If you see individual prompts instead, answer them as follows:
- TypeScript: Yes
- ESLint: Yes
- Tailwind CSS: Yes
- src/ directory: No
- App Router: Yes
- Customize import alias: No
- Once the setup finishes, move into the new project folder by running:
cd roastmytaste
- Confirm the project was created by running:
ls
You should see files like package.json, app/, and tailwind.config.ts in the output.
What did this create?
You now have a roastmytaste folder on your Desktop containing a full Next.js project. The App Router, TypeScript, and Tailwind CSS are all pre-configured.
The app/ directory is where all your routes and components will live. The public/ directory holds static assets like images.
Seeing permission errors or timeouts?
- If you see a permission error on macOS, prefix the command with sudo and enter your password when prompted.
- If the command hangs, check your internet connection. The CLI downloads packages from npm during setup.
Why is create-next-app failing for me?
Install core dependencies
RoastMyTaste relies on several libraries for animation, authentication, database access, and AI generation. You will install them all now so the project is ready to build on.
- Install the animation library, authentication packages, database ORM, and AI SDK by running this command:
npm install motion next-auth@beta @auth/drizzle-adapter drizzle-orm @neondatabase/serverless @google/genai
What are these packages?
- motion is the animation library (formerly Framer Motion) for smooth UI transitions and gesture support.
- next-auth@beta is NextAuth v5, which handles OAuth sign-in with GitHub and Google.
- @auth/drizzle-adapter connects NextAuth to your database through Drizzle ORM.
- drizzle-orm is a type-safe ORM that lets you query your database with TypeScript.
- @neondatabase/serverless is the serverless driver for Neon PostgreSQL.
- @google/genai is the Google Gemini AI SDK for generating roasts.
- Install the Drizzle development tool by running:
npm install -D drizzle-kit
What is drizzle-kit?
drizzle-kit is a CLI tool that generates and runs database migrations during development. It reads your TypeScript schema and pushes changes to your database.
- Initialize shadcn/ui in your project by running:
npx shadcn@latest init
What prompts will shadcn show?
The shadcn CLI detects your Next.js project and configures itself. If prompted to select a template, choose Next.js. If asked about a style or base color, accept the defaults.
This adds a components.json file and a utility function that you will use later to add pre-built UI components like buttons, cards, and dialogs.
Seeing install errors?
- If you see peer dependency warnings, try running the install command again with --force appended. Some packages may have peer dependency mismatches that are safe to ignore.
- Make sure you are inside the roastmytaste folder before running install commands. Run pwd to verify your current directory.
Why aren't my packages installing?
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the roastmytaste folder on your Desktop, then click Open.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, usually above Tab).
- Start the development server by running this command:
npm run dev
- Open your browser and navigate to http://localhost:3000.
You should see the default Next.js welcome page with the Next.js logo and some starter links.
Not seeing the welcome page?
- Check your terminal for error messages. If you see a port conflict, another process may already be using port 3000. Stop that process or try a different port.
- Make sure you ran npm run dev from inside the roastmytaste folder, not from the Desktop.
Why isn't my Next.js dev server starting?
Your development environment is fully set up. Next up, you will create the Neon database and define the schema that stores user accounts, lists, and roasts.
Set Up Neon Database and Drizzle ORM
Your Next.js app can render pages, but it has nowhere to store user data, lists, or roasts. Every piece of persistent information needs a database behind it.
In this step, you will set up a serverless PostgreSQL database on Neon and define your data model using Drizzle ORM. This gives you type-safe database queries and a schema that lives in your codebase alongside your app.
In this step, get ready to:
- Create a free Neon database project and save your connection string.
- Define the Drizzle schema for authentication and app-specific tables.
- Push the schema to Neon and verify your tables exist.
Create a Neon project and save your connection string
Neon is a serverless Postgres provider that offers a generous free tier. It automatically scales to zero when idle, so you pay nothing while your project sits unused.
- Open https://neon.tech in your browser.
- Click Sign Up and create a free account (you can use GitHub or Google to sign in).
- Once signed in, click New Project.
- Name the project roastmytaste.
- Leave the default region and Postgres version as-is, then click Create Project.
- On the project dashboard, find the Connection string section.
- Copy the full connection string (it starts with postgresql://).
Now save this connection string in your project as an environment variable.
- In VS Code, create a new file called .env.local in the root of your roastmytaste project folder.
- Add your connection string by pasting the following:
DATABASE_URL=your-neon-connection-string-here
- Replace your-neon-connection-string-here with the actual connection string you copied from the Neon dashboard.
- Save .env.local.
Why .env.local?
Next.js automatically reads .env.local and loads the values as environment variables. This file is already in your .gitignore by default, so your database credentials stay out of version control.
Can't find the connection string?
In the Neon dashboard, navigate to your project, then click Connection Details in the sidebar. Make sure Connection string is selected (not individual host/user/password fields).
help me find my Neon connection string
Define the Drizzle schema
Drizzle ORM lets you define your database tables as TypeScript code. The schema becomes the single source of truth for your database structure, and Drizzle uses it to give you type-safe queries.
- Create a new folder at src/db inside your project.
- Inside src/db, create a file called schema.ts.
- Add the NextAuth adapter tables and app-specific tables by pasting this code:
import {
timestamp,
pgTable,
text,
primaryKey,
integer,
uuid,
jsonb,
} from "drizzle-orm/pg-core";
import type { AdapterAccount } from "@auth/core/adapters";
// ============================================
// NextAuth adapter tables
// ============================================
export const users = pgTable("user", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text("name"),
email: text("email").unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: text("image"),
});
export const accounts = pgTable(
"account",
{
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => ({
compositePk: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
})
);
export const sessions = pgTable("session", {
sessionToken: text("sessionToken").primaryKey(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
});
export const verificationTokens = pgTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
},
(vt) => ({
compositePk: primaryKey({
columns: [vt.identifier, vt.token],
}),
})
);
// ============================================
// App-specific tables
// ============================================
export const lists = pgTable("list", {
id: uuid("id").primaryKey().defaultRandom(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name"),
tag: text("tag"),
items: jsonb("items").default("[]"),
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
});
export const roasts = pgTable("roast", {
id: uuid("id").primaryKey().defaultRandom(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
listId: uuid("listId").references(() => lists.id, { onDelete: "cascade" }),
roastText: text("roastText"),
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
});
What does this schema define?
The first four tables (users, accounts, sessions, verificationTokens) match the schema that NextAuth's Drizzle adapter expects. They store user profiles, OAuth account links, active sessions, and email verification tokens.
The lists table stores each user's curated media lists. The items column uses JSONB to hold an array of media objects (title, image URL, category) so you can display thumbnails without calling the search APIs again.
The roasts table stores generated AI roast text, linked to both the user and the list that was roasted.
- Save src/db/schema.ts.
Next, create the database client file that connects Drizzle to your Neon database.
- Create a file called index.ts inside the same src/db folder.
- Set up the database client by pasting this code:
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
// Create a Neon serverless SQL client
const sql = neon(process.env.DATABASE_URL!);
// Initialize Drizzle with the Neon client
export const db = drizzle({ client: sql });
What does this file do?
This creates a lightweight, serverless-friendly database connection using Neon's HTTP driver. Each request creates a fresh connection over HTTP instead of maintaining a persistent pool, which is ideal for serverless environments like Vercel.
- Save src/db/index.ts.
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/db/schema.ts and src/db/index.ts.
ⓧ I'd like to double check the full code
Here is the full src/db/schema.ts:
import {
timestamp,
pgTable,
text,
primaryKey,
integer,
uuid,
jsonb,
} from "drizzle-orm/pg-core";
import type { AdapterAccount } from "@auth/core/adapters";
// ============================================
// NextAuth adapter tables
// ============================================
export const users = pgTable("user", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text("name"),
email: text("email").unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: text("image"),
});
export const accounts = pgTable(
"account",
{
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => ({
compositePk: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
})
);
export const sessions = pgTable("session", {
sessionToken: text("sessionToken").primaryKey(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
});
export const verificationTokens = pgTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
},
(vt) => ({
compositePk: primaryKey({
columns: [vt.identifier, vt.token],
}),
})
);
// ============================================
// App-specific tables
// ============================================
export const lists = pgTable("list", {
id: uuid("id").primaryKey().defaultRandom(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name"),
tag: text("tag"),
items: jsonb("items").default("[]"),
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
});
export const roasts = pgTable("roast", {
id: uuid("id").primaryKey().defaultRandom(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
listId: uuid("listId").references(() => lists.id, { onDelete: "cascade" }),
roastText: text("roastText"),
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
});
And here is src/db/index.ts:
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
// Create a Neon serverless SQL client
const sql = neon(process.env.DATABASE_URL!);
// Initialize Drizzle with the Neon client
export const db = drizzle({ client: sql });
Create the Drizzle config and push your schema to Neon
Drizzle Kit uses a config file to know where your schema lives and how to connect to your database. You will use the drizzle-kit push command to apply your schema directly to Neon without generating migration files.
- Create a file called drizzle.config.ts in the root of your project (next to package.json).
- Add the Drizzle Kit configuration by pasting this code:
import { defineConfig } from "drizzle-kit";
export default defineConfig({
// Path to your schema file
schema: "./src/db/schema.ts",
// Output directory for generated migrations (if needed later)
out: "./drizzle",
// Database dialect
dialect: "postgresql",
// Connection credentials from your environment
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
What does this config do?
This tells Drizzle Kit where to find your schema (./src/db/schema.ts) and how to connect to your Neon database via the DATABASE_URL environment variable. When you run drizzle-kit push, it reads this config automatically.
- Save drizzle.config.ts.
- Push your schema to the Neon database by running this command in your terminal:
npx drizzle-kit push
Drizzle Kit will read your schema, connect to Neon, and create all six tables. You should see output confirming the tables were created.
Seeing a connection error?
Check that your DATABASE_URL in .env.local starts with postgresql:// and contains no trailing spaces or line breaks. If you copied the "pooled" connection string by mistake, switch to the regular one in the Neon dashboard.
help me debug my Neon database connection error with drizzle-kit push
Now verify the tables actually exist in your Neon database.
- Go back to the Neon dashboard in your browser.
- Click SQL Editor in the left sidebar.
- Run the following query to see your tables:
SELECT tablename FROM pg_tables WHERE schemaname = 'public';
You should see six tables listed: user, account, session, verificationToken, list, and roast.
Your database is ready and your schema is defined in code. Next up, you will wire NextAuth to this database so users can sign in and their accounts persist between sessions.
Set Up NextAuth v5 Authentication
Your database is ready. Now you need a way for users to sign in and have their identity persist across sessions.
In this step, you'll configure NextAuth v5 (also known as Auth.js) with GitHub and Google OAuth providers. The Drizzle adapter connects NextAuth to your Neon database so user accounts, sessions, and linked OAuth accounts are stored automatically.
In this step, get ready to:
- Register OAuth apps in GitHub and Google, and configure environment variables.
- Create the NextAuth configuration file with providers and the Drizzle adapter.
- Wire up sign-in and sign-out buttons in the app layout.
Register OAuth apps and add environment variables
OAuth lets users sign in with an existing account (like GitHub or Google) instead of creating a new username and password. You need to register your app with each provider so they know where to redirect users after authentication.
What is OAuth?
OAuth is a protocol that lets a third-party app (yours) access a user's identity from a provider (GitHub, Google) without ever handling their password. The provider confirms the user's identity and sends your app a token.
NextAuth v5 auto-detects credentials from environment variables named AUTH_{PROVIDER}_{ID|SECRET}. You don't need to explicitly pass them to each provider in your config.
First, generate an AUTH_SECRET value. This secret encrypts session tokens.
- Generate a random secret by running this command in your terminal:
openssl rand -base64 32
- Copy the output string. You'll paste it into your .env.local file shortly.
Register a GitHub OAuth App
- Go to GitHub Developer Settings in your browser.
- Click OAuth Apps in the left sidebar, then click New OAuth App.
- Fill in the registration form:
- Set Application name to RoastMyTaste.
- Set Homepage URL to http://localhost:3000.
- Set Authorization callback URL to http://localhost:3000/api/auth/callback/github.
- Click Register application.
- On the next screen, copy the Client ID value.
- Click Generate a new client secret and copy the secret value immediately.
Save your secret right away
GitHub only shows the client secret once. If you navigate away before copying it, you'll need to generate a new one.
Register Google OAuth credentials
- Go to the Google Cloud Console.
- Create a new project (or select an existing one).
- Navigate to APIs & Services, then Credentials.
- Click Create Credentials, then select OAuth client ID.
- If prompted to configure the OAuth consent screen, select External user type and fill in the required fields (app name, support email).
- Set the Application type to Web application.
- Under Authorized redirect URIs, add http://localhost:3000/api/auth/callback/google.
- Click Create.
- Copy the Client ID and Client secret from the dialog that appears.
Add all auth environment variables
Now add all the credentials to your environment file.
- Open .env.local and add these variables below your existing DATABASE_URL:
DATABASE_URL=<neon-connection-string>
# NextAuth secret for encrypting session tokens
AUTH_SECRET=your-generated-secret-here
# GitHub OAuth
AUTH_GITHUB_ID=your-github-client-id
AUTH_GITHUB_SECRET=your-github-client-secret
# Google OAuth
AUTH_GOOGLE_ID=your-google-client-id
AUTH_GOOGLE_SECRET=your-google-client-secret
What do these variables do?
- AUTH_SECRET encrypts the session cookie. It must be at least 32 characters.
- AUTH_GITHUB_ID and AUTH_GITHUB_SECRET identify your app to GitHub during the OAuth flow.
- AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET do the same for Google.
- Replace each placeholder with the actual values you copied from GitHub and Google.
- Save .env.local.
OAuth app not appearing?
Make sure you registered the app under your personal GitHub account (not an organization) for the quickest setup. For Google, ensure you completed the consent screen configuration before creating credentials.
Help me troubleshoot my OAuth app registration.
Configure NextAuth with providers and the Drizzle adapter
NextAuth v5 uses a single configuration file at the root of your project that exports helper functions you'll use throughout the app.
- Install the Drizzle adapter package by running:
npm install @auth/drizzle-adapter
- Create a new file called auth.ts in the root of your project (next to package.json).
- Add the NextAuth configuration by pasting this code:
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
import Google from "next-auth/providers/google"
import { DrizzleAdapter } from "@auth/drizzle-adapter"
import { db } from "@/db"
import { users, accounts, sessions, verificationTokens } from "@/db/schema"
// Configure NextAuth with OAuth providers and database adapter
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
providers: [GitHub, Google],
})
What does this code do?
- NextAuth is initialized with GitHub and Google as providers. Since your env vars follow the AUTH_{PROVIDER}_{ID|SECRET} naming convention, credentials are auto-detected.
- The DrizzleAdapter connects NextAuth to your Neon database. It passes your custom schema tables so NextAuth knows exactly where to store users, accounts, and sessions.
- The exported handlers, auth, signIn, and signOut are helper functions you'll use across your app for route handling, session checking, and triggering auth flows.
- Save auth.ts.
Create the catch-all API route
NextAuth needs a dynamic route handler to process all auth-related requests (sign in, callback, sign out, session checks).
- Create the folder structure src/app/api/auth/[...nextauth]/ by running:
mkdir -p src/app/api/auth/\[...nextauth\]
- Create a file called route.ts inside that folder and add this code:
import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers
What does this route do?
This catch-all route forwards all requests to /api/auth/* (sign in, callback, sign out, session) to NextAuth's internal handler. The @/auth import path resolves to your root auth.ts file.
- Save route.ts.
Import error on @/auth?
Make sure your tsconfig.json has a path alias for @/* pointing to ./src/* (which create-next-app sets up by default). Since auth.ts lives in the project root, you may need to adjust the import to a relative path like "../../../../auth" or add a separate alias. Check that the import resolves correctly.
Help me fix the import path for auth.ts in my Next.js project.
Add sign-in and sign-out to the app layout
To make session state available throughout your app, wrap it in a SessionProvider. Then add buttons so users can actually sign in and out.
- Open src/app/layout.tsx and replace its contents with this code:
import type { Metadata } from "next"
import { Inter } from "next/font/google"
import "./globals.css"
import { SessionProvider } from "next-auth/react"
import { AuthButtons } from "@/components/auth-buttons"
const inter = Inter({ subsets: ["latin"] })
export const metadata: Metadata = {
title: "RoastMyTaste",
description: "Get roasted by AI based on your media taste",
}
// Root layout wraps the app in SessionProvider for auth state
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<SessionProvider>
<header className="flex items-center justify-between p-4">
<h1 className="text-xl font-bold">RoastMyTaste</h1>
<AuthButtons />
</header>
<main>{children}</main>
</SessionProvider>
</body>
</html>
)
}
Why SessionProvider?
SessionProvider is a React Context provider from next-auth/react. It makes session data available to any client component in your app via the useSession hook.
It automatically keeps the session synced across browser tabs and refetches when the window regains focus.
- Create a new file at src/components/auth-buttons.tsx and add this code:
"use client"
import { useSession, signIn, signOut } from "next-auth/react"
// Client component for sign-in/sign-out UI
export function AuthButtons() {
const { data: session, status } = useSession()
if (status === "loading") {
return <div className="h-8 w-8 rounded-full bg-gray-700 animate-pulse" />
}
if (session) {
return (
<div className="flex items-center gap-3">
{session.user?.image && (
<img
src={session.user.image}
alt="Avatar"
className="h-8 w-8 rounded-full"
/>
)}
<span className="text-sm">{session.user?.name}</span>
<button
onClick={() => signOut()}
className="text-sm px-3 py-1 rounded bg-gray-700 hover:bg-gray-600"
>
Sign out
</button>
</div>
)
}
return (
<button
onClick={() => signIn()}
className="text-sm px-3 py-1 rounded bg-blue-600 hover:bg-blue-500"
>
Sign in
</button>
)
}
What does this component do?
- The "use client" directive marks this as a client component (required for hooks like useSession).
- useSession() returns the current session data and a loading status.
- When signed in, it shows the user's avatar, name, and a sign-out button.
- When signed out, it shows a sign-in button that triggers the NextAuth OAuth flow.
- Save both files.
Test the authentication flow
- Start your development server by running:
npm run dev
- Open http://localhost:3000 in your browser.
- Click Sign in in the header.
- You'll see the default NextAuth sign-in page with options for GitHub and Google.
- Click Sign in with GitHub. Authorize the app when GitHub asks for permission.
- After redirect, you should see your GitHub avatar and name in the header, plus a Sign out button.
- Verify the user was stored in your database by returning to the Neon dashboard.
- Open the SQL Editor and run:
SELECT * FROM users;
- You should see a row with your GitHub name and email. This confirms NextAuth is writing user data through the Drizzle adapter to Neon.
Sign-in redirects to an error page?
Double-check that your callback URL in GitHub matches exactly: http://localhost:3000/api/auth/callback/github. A trailing slash or typo will cause a mismatch error.
Also verify that AUTH_SECRET is set in .env.local. NextAuth requires this in all environments.
Help me debug my NextAuth sign-in error.
✔️ Awesome, I've got everything!
Great. Make sure you've saved all files and that signing in works with at least one provider.
ⓧ I'd like to double check the full code
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
import Google from "next-auth/providers/google"
import { DrizzleAdapter } from "@auth/drizzle-adapter"
import { db } from "@/db"
import { users, accounts, sessions, verificationTokens } from "@/db/schema"
// Configure NextAuth with OAuth providers and database adapter
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
providers: [GitHub, Google],
})
import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers
import type { Metadata } from "next"
import { Inter } from "next/font/google"
import "./globals.css"
import { SessionProvider } from "next-auth/react"
import { AuthButtons } from "@/components/auth-buttons"
const inter = Inter({ subsets: ["latin"] })
export const metadata: Metadata = {
title: "RoastMyTaste",
description: "Get roasted by AI based on your media taste",
}
// Root layout wraps the app in SessionProvider for auth state
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<SessionProvider>
<header className="flex items-center justify-between p-4">
<h1 className="text-xl font-bold">RoastMyTaste</h1>
<AuthButtons />
</header>
<main>{children}</main>
</SessionProvider>
</body>
</html>
)
}
"use client"
import { useSession, signIn, signOut } from "next-auth/react"
// Client component for sign-in/sign-out UI
export function AuthButtons() {
const { data: session, status } = useSession()
if (status === "loading") {
return <div className="h-8 w-8 rounded-full bg-gray-700 animate-pulse" />
}
if (session) {
return (
<div className="flex items-center gap-3">
{session.user?.image && (
<img
src={session.user.image}
alt="Avatar"
className="h-8 w-8 rounded-full"
/>
)}
<span className="text-sm">{session.user?.name}</span>
<button
onClick={() => signOut()}
className="text-sm px-3 py-1 rounded bg-gray-700 hover:bg-gray-600"
>
Sign out
</button>
</div>
)
}
return (
<button
onClick={() => signIn()}
className="text-sm px-3 py-1 rounded bg-blue-600 hover:bg-blue-500"
>
Sign in
</button>
)
}
Your auth system is wired up. Users can now sign in with GitHub or Google, and their accounts are persisted in your Neon database. Next up, you'll grab the remaining API keys for media search and AI roasting.
Get Your API Keys
Your authentication system is working and users can sign in. Now you need to connect the external services that power the app's core features.
RoastMyTaste uses two APIs that require keys: TMDB for movie poster images, and Google Gemini for AI-generated roasts. The other APIs (AniList, Open Library, MusicBrainz) are completely free with no key required.
In this step, get ready to:
- Create a TMDB account and get your API Read Access Token.
- Generate a Gemini API key from Google AI Studio.
- Add both keys to your environment variables file.
Create a TMDB account and get your API Read Access Token
TMDB (The Movie Database) provides movie and TV show data along with poster images. You need a free account and an API key to query their search endpoints.
- Open https://www.themoviedb.org in your browser.
- Click Join TMDB in the top navigation bar and create a free account.
- Confirm your email address when prompted.
- Once signed in, click your profile avatar in the top right corner.
- Select Settings from the dropdown menu.
- In the left-hand sidebar, click the API link.
- Follow the prompts to request an API key. Select Developer when asked about usage type.
- Accept the terms of use and fill in the application details. For the application URL, you can enter http://localhost:3000 for now.
- After your key is generated, look for the API Read Access Token section on the API page. This is the long Bearer token you need.
- Copy the API Read Access Token and keep it handy.
What is the API Read Access Token?
TMDB offers two authentication methods: a short API key (used as a query parameter) and a longer API Read Access Token (used as a Bearer token in the Authorization header). The Read Access Token works across both v3 and v4 of their API.
This project uses the Bearer token approach for cleaner, more secure API calls.
Not seeing the API link in settings?
The API registration page is not optimized for mobile devices. Make sure you access it from a desktop browser. If you just created your account, try signing out and signing back in.
Help me find my TMDB API key
Generate a Gemini API key from Google AI Studio
Google Gemini is the AI model that will analyze users' media taste and generate roasts. The free tier includes free input and output tokens, which is more than enough for this project.
- Open https://aistudio.google.com in your browser and sign in with your Google account.
- For new users, Google AI Studio creates a default project and API key after you accept the Terms of Service. If you already see a key listed, you can use it.
- If you need to create a new key, open the Dashboard from the left side panel.
- Navigate to the API Keys page.
- Click Create API key and follow the dialog to generate a key.
- Copy the generated API key and keep it handy.
Why Gemini?
This project uses the gemini-3.5-flash model, which is the latest GA model available on the free tier. The @google/genai SDK automatically picks up the API key from the GEMINI_API_KEY environment variable, so you won't need to hardcode it anywhere in your app.
Seeing a permission error when creating your key?
If you see a message about not having permission to create a key, try creating a new Google Cloud project that is not associated with an organization. You can also generate a key directly from the Google Cloud Console under APIs & Services > Credentials as a workaround.
Help me resolve this permission error
Add the API keys to your environment variables
Your .env.local file already has six environment variables from the previous steps. Now you need to add the two API keys.
- Switch back to your code editor and open .env.local in your project root.
- Add the following two lines at the end of the file:
GEMINI_API_KEY=your-gemini-api-key-here
TMDB_API_KEY=your-tmdb-read-access-token-here
- Replace your-gemini-api-key-here with the API key you copied from Google AI Studio.
- Replace your-tmdb-read-access-token-here with the API Read Access Token you copied from TMDB.
- Save .env.local.
What about the other APIs?
This project integrates four media search APIs, but only TMDB requires a key. The other three are completely free to use without authentication:
- AniList has a public GraphQL API at https://graphql.anilist.co that accepts unauthenticated queries.
- Open Library provides a free search API and cover images with no key needed.
- MusicBrainz is free to query. It only requires a custom User-Agent header (which you will set up in a later step).
Your .env.local file should now contain all eight environment variables needed for the project.
Verify your variable count
Open .env.local and count the entries. You should have exactly eight: DATABASE_URL, AUTH_SECRET, AUTH_GITHUB_ID, AUTH_GITHUB_SECRET, AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET, GEMINI_API_KEY, and TMDB_API_KEY.
All your API keys are in place. Next up, you will use them to build the landing page and app shell with a dark theme and category selection.
Build the Landing Page and Layout
Your API keys are locked and loaded. Now it's time to give RoastMyTaste a face.
A great app needs a great first impression. In this step, you'll set up the dark theme, custom fonts, and global styles that define the visual identity. Then you'll build an animated landing page with category cards that invite users to curate their media lists.
In this step, get ready to:
- Configure a dark theme with custom accent colors using Tailwind CSS variables.
- Update the app layout with custom fonts and a polished header.
- Build an animated landing page with Motion entrance effects and category cards.
Set up the dark theme and global styles
RoastMyTaste uses a dark theme with vibrant accent colors to create a bold, modern look. The theming system relies on CSS variables that Tailwind CSS maps to utility classes.
- Create a new file called tailwind.config.ts in the root of your project (the same level as package.json) and paste the following:
import type { Config } from "tailwindcss";
// Tailwind config with dark mode, custom colors, and shadcn/ui theme integration
const config: Config = {
darkMode: "class",
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
// Map CSS variables to Tailwind utility classes
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
border: "hsl(var(--border))",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};
export default config;
What does this config do?
The darkMode: "class" setting tells Tailwind to activate dark styles whenever the dark class is present on a parent element (like <html>).
Each color references a CSS variable (like --background) wrapped in hsl(). This lets you define the actual color values in your CSS file and swap them for different themes without touching this config.
- Now open src/app/globals.css and replace its entire contents with the following dark theme styles:
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Dark theme color palette using HSL values */
--background: 240 10% 4%;
--foreground: 0 0% 98%;
--card: 240 10% 8%;
--card-foreground: 0 0% 98%;
--accent: 280 100% 65%;
--accent-foreground: 0 0% 98%;
--muted: 240 5% 16%;
--muted-foreground: 240 5% 65%;
--border: 240 5% 18%;
--radius: 0.75rem;
}
/* Apply background and text colors globally */
body {
@apply bg-background text-foreground;
}
}
What are these CSS variables?
Each variable stores an HSL color value without the hsl() wrapper. The Tailwind config wraps them in hsl() when used as utilities.
The --accent: 280 100% 65% is a vibrant purple that gives the app its signature pop. This approach is how shadcn/ui components consume theme colors too.
- Save both tailwind.config.ts and src/app/globals.css.
Seeing unstyled white content?
Make sure the @tailwind base directive is present at the top of globals.css. Without it, Tailwind's base layer won't load and the CSS variables won't apply.
Also confirm that your layout file imports ./globals.css at the top.
Why isn't my dark theme applying?
Update the app layout with custom fonts
The layout file is the shell that wraps every page. You already have session handling in place from the auth step. Now you'll add a custom font, the dark class on the HTML element, and a polished header with the app logo.
- Open src/app/layout.tsx and replace its entire contents with the following:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { SessionProvider } from "next-auth/react";
import { auth } from "@/auth";
import "./globals.css";
// Load Inter font with Latin subset for clean typography
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "RoastMyTaste",
description: "Get your media taste absolutely destroyed by AI",
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
// Fetch the session server-side so child components can access it
const session = await auth();
return (
<html lang="en" className="dark">
<body
className={`${inter.className} min-h-screen bg-background text-foreground`}
>
<SessionProvider session={session}>
{/* Header with logo and auth controls */}
<header className="flex items-center justify-between px-6 py-4 border-b border-border">
<h1 className="text-xl font-bold bg-gradient-to-r from-accent to-purple-400 bg-clip-text text-transparent">
RoastMyTaste
</h1>
</header>
<main>{children}</main>
</SessionProvider>
</body>
</html>
);
}
What's happening here?
The Inter font is loaded from Google Fonts via Next.js's built-in next/font system. This automatically self-hosts the font for performance and applies it to the body.
The className="dark" on the <html> element activates the dark theme defined in your Tailwind config. The gradient text on the logo uses the accent color variable.
- Save src/app/layout.tsx.
- Refresh your browser. You should see a nearly-black page with a gradient "RoastMyTaste" logo in the header and a subtle bottom border.
Page is blank or showing errors?
Check that the import path @/auth resolves correctly. This should point to the auth.ts file you created in the earlier NextAuth step.
If you see a module not found error for next-auth/react, confirm that you installed NextAuth in Step 1.
Help me fix my layout import errors.
Build the animated landing page
The landing page is where users first interact with the app. You'll use Motion (formerly Framer Motion) to add smooth entrance animations and hover effects that make the page feel alive.
- Open src/app/page.tsx and replace its entire contents with the following:
"use client";
import { motion } from "motion/react";
import { useSession, signIn } from "next-auth/react";
import Link from "next/link";
// Define the five media categories users can choose from
const categories = [
{ name: "Movies", emoji: "\uD83C\uDFAC", description: "Your film taste, judged" },
{ name: "Anime", emoji: "\u26E9\uFE0F", description: "Your weeb credentials, evaluated" },
{ name: "Music", emoji: "\uD83C\uDFB5", description: "Your playlist, roasted" },
{ name: "Books", emoji: "\uD83D\uDCDA", description: "Your reading list, critiqued" },
{ name: "Resume", emoji: "\uD83D\uDCC4", description: "Your career, destroyed" },
];
export default function Home() {
const { data: session } = useSession();
return (
<div className="flex flex-col items-center justify-center px-4 py-20">
{/* Hero Section with entrance animation */}
<motion.div
className="text-center max-w-3xl"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<h1 className="text-5xl md:text-7xl font-bold mb-6 bg-gradient-to-r from-accent to-purple-400 bg-clip-text text-transparent">
RoastMyTaste
</h1>
<p className="text-xl md:text-2xl text-muted-foreground mb-10">
Curate your favorites. Let AI destroy your taste.
</p>
{/* Show different CTA based on auth state */}
{session ? (
<Link href="/create">
<motion.button
className="px-8 py-4 bg-accent text-accent-foreground rounded-lg text-lg font-semibold"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Start a New List
</motion.button>
</Link>
) : (
<motion.button
onClick={() => signIn()}
className="px-8 py-4 bg-accent text-accent-foreground rounded-lg text-lg font-semibold"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Sign In to Get Roasted
</motion.button>
)}
</motion.div>
{/* Category Cards with staggered entrance */}
<motion.div
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mt-20 max-w-5xl w-full"
initial="hidden"
animate="visible"
variants={{
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1 },
},
}}
>
{categories.map((category) => (
<motion.div
key={category.name}
className="p-6 rounded-xl bg-card border border-border hover:border-accent/50 transition-colors cursor-pointer"
variants={{
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
}}
whileHover={{ scale: 1.03, y: -4 }}
transition={{ type: "spring", stiffness: 300 }}
>
<span className="text-4xl mb-4 block">{category.emoji}</span>
<h3 className="text-xl font-semibold mb-2">{category.name}</h3>
<p className="text-muted-foreground">{category.description}</p>
</motion.div>
))}
</motion.div>
</div>
);
}
How do the Motion animations work?
The hero section uses initial and animate props to fade in and slide up when the page loads. Motion automatically transitions between the two states.
The category cards use a variants pattern with staggerChildren: 0.1. This means each card animates 100ms after the previous one, creating a cascading entrance effect.
The whileHover and whileTap props add interactive micro-animations. Cards lift slightly on hover, and buttons compress on click for tactile feedback.
Why Motion over CSS-only animations?
Motion provides spring physics, staggered children, exit animations, and gesture support that CSS alone cannot cleanly achieve. It is the industry standard React animation library, used by Framer and Figma.
For simple hover color changes, CSS is fine. For orchestrated entrance sequences, interactive gestures, and layout animations, Motion gives you a single declarative API that scales.
- Save src/app/page.tsx.
- Refresh your browser. You should see the "RoastMyTaste" headline fade in with a gradient, a subtitle beneath it, and five category cards that stagger in one after another with a slight upward slide.
- Hover over a category card. It should scale up slightly and lift.
- Click the "Sign In to Get Roasted" button. It should compress briefly on tap and then trigger the NextAuth sign-in flow.
Cards not animating or page looks broken?
Check that you have "use client" at the very top of page.tsx. Motion components require client-side rendering. Without this directive, Next.js treats the file as a server component and the animations will fail.
If you see a module error mentioning motion/react, confirm that you installed Motion in Step 1. The correct import path is motion/react (not framer-motion).
Help me fix my Motion animations.
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files.
ⓧ I'd like to double check the full code
Here is tailwind.config.ts:
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
border: "hsl(var(--border))",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};
export default config;
Here is src/app/globals.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 240 10% 4%;
--foreground: 0 0% 98%;
--card: 240 10% 8%;
--card-foreground: 0 0% 98%;
--accent: 280 100% 65%;
--accent-foreground: 0 0% 98%;
--muted: 240 5% 16%;
--muted-foreground: 240 5% 65%;
--border: 240 5% 18%;
--radius: 0.75rem;
}
body {
@apply bg-background text-foreground;
}
}
Here is src/app/layout.tsx:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { SessionProvider } from "next-auth/react";
import { auth } from "@/auth";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "RoastMyTaste",
description: "Get your media taste absolutely destroyed by AI",
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
return (
<html lang="en" className="dark">
<body
className={`${inter.className} min-h-screen bg-background text-foreground`}
>
<SessionProvider session={session}>
<header className="flex items-center justify-between px-6 py-4 border-b border-border">
<h1 className="text-xl font-bold bg-gradient-to-r from-accent to-purple-400 bg-clip-text text-transparent">
RoastMyTaste
</h1>
</header>
<main>{children}</main>
</SessionProvider>
</body>
</html>
);
}
Here is src/app/page.tsx:
"use client";
import { motion } from "motion/react";
import { useSession, signIn } from "next-auth/react";
import Link from "next/link";
const categories = [
{ name: "Movies", emoji: "\uD83C\uDFAC", description: "Your film taste, judged" },
{ name: "Anime", emoji: "\u26E9\uFE0F", description: "Your weeb credentials, evaluated" },
{ name: "Music", emoji: "\uD83C\uDFB5", description: "Your playlist, roasted" },
{ name: "Books", emoji: "\uD83D\uDCDA", description: "Your reading list, critiqued" },
{ name: "Resume", emoji: "\uD83D\uDCC4", description: "Your career, destroyed" },
];
export default function Home() {
const { data: session } = useSession();
return (
<div className="flex flex-col items-center justify-center px-4 py-20">
<motion.div
className="text-center max-w-3xl"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<h1 className="text-5xl md:text-7xl font-bold mb-6 bg-gradient-to-r from-accent to-purple-400 bg-clip-text text-transparent">
RoastMyTaste
</h1>
<p className="text-xl md:text-2xl text-muted-foreground mb-10">
Curate your favorites. Let AI destroy your taste.
</p>
{session ? (
<Link href="/create">
<motion.button
className="px-8 py-4 bg-accent text-accent-foreground rounded-lg text-lg font-semibold"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Start a New List
</motion.button>
</Link>
) : (
<motion.button
onClick={() => signIn()}
className="px-8 py-4 bg-accent text-accent-foreground rounded-lg text-lg font-semibold"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Sign In to Get Roasted
</motion.button>
)}
</motion.div>
<motion.div
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mt-20 max-w-5xl w-full"
initial="hidden"
animate="visible"
variants={{
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1 },
},
}}
>
{categories.map((category) => (
<motion.div
key={category.name}
className="p-6 rounded-xl bg-card border border-border hover:border-accent/50 transition-colors cursor-pointer"
variants={{
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
}}
whileHover={{ scale: 1.03, y: -4 }}
transition={{ type: "spring", stiffness: 300 }}
>
<span className="text-4xl mb-4 block">{category.emoji}</span>
<h3 className="text-xl font-semibold mb-2">{category.name}</h3>
<p className="text-muted-foreground">{category.description}</p>
</motion.div>
))}
</motion.div>
</div>
);
}
Your app now has a polished dark theme with smooth animations and a clear path for users to sign in. Next up, you'll build the search component that lets users find and add media to their lists.
Build the Media Search APIs and Component
Your landing page looks great, but those category cards don't do anything yet. To bring them to life, you need a way to search for movies, anime, music, and books and display visual results with cover art.
In this step, you'll create four API route handlers that talk to external search services. Then you'll build a reusable SearchModal component that calls them and displays image-rich results.
In this step, get ready to:
- Create API route handlers for movies, anime, music, and books search.
- Build a reusable SearchModal component with debounced search and image card results.
- Wire add-to-list interactions with animation feedback.
Create the movie and anime search API routes
Next.js API route handlers let you make server-side requests to external APIs without exposing your keys to the browser. You'll create one route per media category.
- Create the folder structure for the movies search route by running this command from your project root:
mkdir -p src/app/api/search/movies
- Create a new file called route.ts inside src/app/api/search/movies/ and add the following code:
import { NextRequest, NextResponse } from 'next/server';
// Search TMDB for movies by title
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q');
// Return empty results if no query provided
if (!query) {
return NextResponse.json([]);
}
try {
// Call the TMDB movie search endpoint
const response = await fetch(
`https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(query)}&language=en-US&page=1`,
{
headers: {
Authorization: `Bearer ${process.env.TMDB_API_KEY}`,
'Content-Type': 'application/json',
},
}
);
const data = await response.json();
// Transform TMDB results into our standard format
const results = data.results.slice(0, 10).map((movie: any) => ({
title: movie.title,
imageUrl: movie.poster_path
? `https://image.tmdb.org/t/p/w342${movie.poster_path}`
: null,
category: 'movies' as const,
metadata: {
tmdbId: movie.id,
overview: movie.overview,
},
}));
return NextResponse.json(results);
} catch (error) {
console.error('TMDB search error:', error);
return NextResponse.json({ error: 'Failed to search movies' }, { status: 500 });
}
}
What does this code do?
This route reads the q query parameter from the URL, sends it to the TMDB API with your read access token in the Authorization header, and transforms the response into a standard shape.
Each result gets a title, imageUrl, category, and metadata field. The poster image URL follows TMDB's pattern: https://image.tmdb.org/t/p/w342/{poster_path} where w342 is a 342px-wide thumbnail size.
- Now create the anime search route folder by running:
mkdir -p src/app/api/search/anime
- Create a new file called route.ts inside src/app/api/search/anime/ and add this code:
import { NextRequest, NextResponse } from 'next/server';
// GraphQL query for searching anime by title on AniList
const ANILIST_QUERY = `
query ($search: String) {
Page(perPage: 10) {
media(search: $search, type: ANIME) {
id
title {
romaji
}
coverImage {
large
}
format
}
}
}
`;
// Search AniList for anime by title
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q');
if (!query) {
return NextResponse.json([]);
}
try {
// AniList uses a public GraphQL API - no auth needed
const response = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: ANILIST_QUERY,
variables: { search: query },
}),
});
const data = await response.json();
// Transform AniList results into our standard format
const results = data.data.Page.media.map((anime: any) => ({
title: anime.title.romaji,
imageUrl: anime.coverImage.large,
category: 'anime' as const,
metadata: {
anilistId: anime.id,
format: anime.format,
},
}));
return NextResponse.json(results);
} catch (error) {
console.error('AniList search error:', error);
return NextResponse.json({ error: 'Failed to search anime' }, { status: 500 });
}
}
What does this code do?
AniList provides a public GraphQL API at https://graphql.anilist.co that requires no authentication. You send a POST request with a GraphQL query that searches for anime by title and returns cover images directly from the media object.
- Save both files.
- Verify the routes by starting your dev server (npm run dev) and visiting http://localhost:3000/api/search/movies?q=inception in your browser.
- You should see a JSON array with movie objects containing title, imageUrl, and metadata fields.
Seeing a 500 error or empty results?
Double-check that your TMDB_API_KEY in .env.local is the Read Access Token (the long one), not the shorter API Key. Restart your dev server after making changes to .env.local.
help me debug my TMDB API route returning errors
Create the music and books search API routes
The music route queries MusicBrainz for releases and fetches cover art from the Cover Art Archive. The books route uses Open Library's free search API.
- Create the music search route folder by running:
mkdir -p src/app/api/search/music
- Create route.ts inside src/app/api/search/music/ with the following code:
import { NextRequest, NextResponse } from 'next/server';
// Search MusicBrainz for releases and fetch cover art from Cover Art Archive
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q');
if (!query) {
return NextResponse.json([]);
}
try {
// MusicBrainz requires a custom User-Agent header
const response = await fetch(
`https://musicbrainz.org/ws/2/release?query=${encodeURIComponent(query)}&fmt=json&limit=10`,
{
headers: {
'User-Agent': 'RoastMyTaste/1.0.0 (contact@example.com)',
},
}
);
const data = await response.json();
// Transform results and build cover art URLs from Cover Art Archive
const results = data.releases.slice(0, 10).map((release: any) => ({
title: release.title,
imageUrl: `https://coverartarchive.org/release/${release.id}/front-500`,
category: 'music' as const,
metadata: {
mbid: release.id,
artist: release['artist-credit']?.[0]?.name || 'Unknown Artist',
},
}));
return NextResponse.json(results);
} catch (error) {
console.error('MusicBrainz search error:', error);
return NextResponse.json({ error: 'Failed to search music' }, { status: 500 });
}
}
What does this code do?
MusicBrainz requires a custom User-Agent header on every request. The release search endpoint at https://musicbrainz.org/ws/2/release returns JSON when you pass fmt=json.
Cover art comes from the Cover Art Archive. The URL pattern https://coverartarchive.org/release/{mbid}/front-500 returns a 500px front cover image for any release that has one. Not all releases have cover art, so some images may return a 404. You will handle that gracefully in the component later.
- Create the books search route folder by running:
mkdir -p src/app/api/search/books
- Create route.ts inside src/app/api/search/books/ with this code:
import { NextRequest, NextResponse } from 'next/server';
// Search Open Library for books by title
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q');
if (!query) {
return NextResponse.json([]);
}
try {
// Open Library's search API requires no authentication
const response = await fetch(
`https://openlibrary.org/search.json?q=${encodeURIComponent(query)}&limit=10&fields=title,author_name,cover_i,key`
);
const data = await response.json();
// Transform results using Open Library's cover image URL pattern
const results = data.docs.map((book: any) => ({
title: book.title,
imageUrl: book.cover_i
? `https://covers.openlibrary.org/b/id/${book.cover_i}-M.jpg`
: null,
category: 'books' as const,
metadata: {
key: book.key,
author: book.author_name?.[0] || 'Unknown Author',
},
}));
return NextResponse.json(results);
} catch (error) {
console.error('Open Library search error:', error);
return NextResponse.json({ error: 'Failed to search books' }, { status: 500 });
}
}
What does this code do?
Open Library provides a free search endpoint at https://openlibrary.org/search.json that needs no API key. Each result includes a cover_i field that maps to a cover image via the pattern https://covers.openlibrary.org/b/id/{cover_id}-M.jpg.
The fields parameter limits the response to only the data you need, which keeps the API response fast and small.
- Save both files.
- Test the books route by visiting http://localhost:3000/api/search/books?q=dune in your browser.
- You should see JSON results with title, imageUrl, and metadata.author fields.
Music search returning errors?
MusicBrainz has a rate limit of 1 request per second. If you refresh too quickly, you may get a 503 response. Wait a moment and try again. Also make sure your User-Agent header is set. MusicBrainz blocks requests without one.
help me debug my MusicBrainz or Open Library API routes
Build the SearchModal component
Now that all four API routes are working, you need a component that lets users type a search term, see image-rich results, and add items to their list. This reusable modal accepts a category prop and calls the matching route.
- Create a new file called SearchModal.tsx inside src/components/ and add the following code:
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion } from 'motion/react';
// Type for media items returned by our search API routes
interface MediaItem {
title: string;
imageUrl: string | null;
category: string;
metadata: Record<string, any>;
}
interface SearchModalProps {
category: string;
onAdd: (item: MediaItem) => void;
isOpen: boolean;
onClose: () => void;
}
export default function SearchModal({ category, onAdd, isOpen, onClose }: SearchModalProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<MediaItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [addedIds, setAddedIds] = useState<Set<string>>(new Set());
// Debounced search - waits 300ms after the user stops typing
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const timer = setTimeout(async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/search/${category}?q=${encodeURIComponent(query)}`);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsLoading(false);
}
}, 300);
return () => clearTimeout(timer);
}, [query, category]);
// Handle adding an item to the list with animation feedback
const handleAdd = useCallback((item: MediaItem) => {
onAdd(item);
setAddedIds((prev) => new Set(prev).add(item.title));
}, [onAdd]);
// Reset state when modal closes
useEffect(() => {
if (!isOpen) {
setQuery('');
setResults([]);
setAddedIds(new Set());
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="w-full max-w-2xl mx-4 bg-zinc-900 rounded-2xl border border-zinc-800 p-6 max-h-[80vh] flex flex-col"
>
{/* Header with search input */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold capitalize">Search {category}</h2>
<button onClick={onClose} className="text-zinc-400 hover:text-white">
X
</button>
</div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={`Search for ${category}...`}
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-accent mb-4"
autoFocus
/>
{/* Results grid */}
<div className="flex-1 overflow-y-auto">
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white" />
</div>
)}
{!isLoading && results.length === 0 && query && (
<p className="text-center text-zinc-500 py-12">No results found.</p>
)}
{!isLoading && results.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{results.map((item, index) => (
<motion.div
key={`${item.title}-${index}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="relative group"
>
{/* Cover art image */}
<div className="aspect-[2/3] rounded-lg overflow-hidden bg-zinc-800">
{item.imageUrl ? (
<img
src={item.imageUrl}
alt={item.title}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-zinc-600">
No Image
</div>
)}
</div>
{/* Title and add button */}
<p className="mt-2 text-sm text-zinc-300 truncate">{item.title}</p>
<motion.button
whileTap={{ scale: 0.9 }}
whileHover={{ scale: 1.05 }}
onClick={() => handleAdd(item)}
disabled={addedIds.has(item.title)}
className="mt-1 w-full py-1.5 text-xs font-medium rounded-md bg-accent text-white hover:bg-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{addedIds.has(item.title) ? 'Added' : 'Add to List'}
</motion.button>
</motion.div>
))}
</div>
)}
</div>
</motion.div>
</div>
);
}
What does this component do?
The SearchModal implements a debounced search pattern. When the user types, it waits 300ms after they stop before firing a request. This prevents flooding the APIs with one request per keystroke.
Results appear in a responsive grid of image cards. Each card shows the cover art and title. The "Add to List" button uses Motion's whileTap for a satisfying scale animation on press. Once added, the button disables so users cannot add duplicates.
- Save SearchModal.tsx.
- You will not see the modal in action yet because no page is importing it. You will wire it up to the category cards in the next step.
TypeScript errors in the component?
Make sure the file starts with 'use client'. The useState and useEffect hooks require client components in Next.js App Router. Also confirm you installed Motion earlier by checking package.json for the motion package.
help me fix TypeScript or import errors in my SearchModal component
✔️ Awesome, I've got everything!
Great. Double check you have saved all five new files.
ⓧ I'd like to double check the full code
Here are all the files you created in this step:
import { NextRequest, NextResponse } from 'next/server';
// Search TMDB for movies by title
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q');
// Return empty results if no query provided
if (!query) {
return NextResponse.json([]);
}
try {
// Call the TMDB movie search endpoint
const response = await fetch(
`https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(query)}&language=en-US&page=1`,
{
headers: {
Authorization: `Bearer ${process.env.TMDB_API_KEY}`,
'Content-Type': 'application/json',
},
}
);
const data = await response.json();
// Transform TMDB results into our standard format
const results = data.results.slice(0, 10).map((movie: any) => ({
title: movie.title,
imageUrl: movie.poster_path
? `https://image.tmdb.org/t/p/w342${movie.poster_path}`
: null,
category: 'movies' as const,
metadata: {
tmdbId: movie.id,
overview: movie.overview,
},
}));
return NextResponse.json(results);
} catch (error) {
console.error('TMDB search error:', error);
return NextResponse.json({ error: 'Failed to search movies' }, { status: 500 });
}
}
import { NextRequest, NextResponse } from 'next/server';
// GraphQL query for searching anime by title on AniList
const ANILIST_QUERY = `
query ($search: String) {
Page(perPage: 10) {
media(search: $search, type: ANIME) {
id
title {
romaji
}
coverImage {
large
}
format
}
}
}
`;
// Search AniList for anime by title
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q');
if (!query) {
return NextResponse.json([]);
}
try {
// AniList uses a public GraphQL API - no auth needed
const response = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: ANILIST_QUERY,
variables: { search: query },
}),
});
const data = await response.json();
// Transform AniList results into our standard format
const results = data.data.Page.media.map((anime: any) => ({
title: anime.title.romaji,
imageUrl: anime.coverImage.large,
category: 'anime' as const,
metadata: {
anilistId: anime.id,
format: anime.format,
},
}));
return NextResponse.json(results);
} catch (error) {
console.error('AniList search error:', error);
return NextResponse.json({ error: 'Failed to search anime' }, { status: 500 });
}
}
import { NextRequest, NextResponse } from 'next/server';
// Search MusicBrainz for releases and fetch cover art from Cover Art Archive
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q');
if (!query) {
return NextResponse.json([]);
}
try {
// MusicBrainz requires a custom User-Agent header
const response = await fetch(
`https://musicbrainz.org/ws/2/release?query=${encodeURIComponent(query)}&fmt=json&limit=10`,
{
headers: {
'User-Agent': 'RoastMyTaste/1.0.0 (contact@example.com)',
},
}
);
const data = await response.json();
// Transform results and build cover art URLs from Cover Art Archive
const results = data.releases.slice(0, 10).map((release: any) => ({
title: release.title,
imageUrl: `https://coverartarchive.org/release/${release.id}/front-500`,
category: 'music' as const,
metadata: {
mbid: release.id,
artist: release['artist-credit']?.[0]?.name || 'Unknown Artist',
},
}));
return NextResponse.json(results);
} catch (error) {
console.error('MusicBrainz search error:', error);
return NextResponse.json({ error: 'Failed to search music' }, { status: 500 });
}
}
import { NextRequest, NextResponse } from 'next/server';
// Search Open Library for books by title
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q');
if (!query) {
return NextResponse.json([]);
}
try {
// Open Library's search API requires no authentication
const response = await fetch(
`https://openlibrary.org/search.json?q=${encodeURIComponent(query)}&limit=10&fields=title,author_name,cover_i,key`
);
const data = await response.json();
// Transform results using Open Library's cover image URL pattern
const results = data.docs.map((book: any) => ({
title: book.title,
imageUrl: book.cover_i
? `https://covers.openlibrary.org/b/id/${book.cover_i}-M.jpg`
: null,
category: 'books' as const,
metadata: {
key: book.key,
author: book.author_name?.[0] || 'Unknown Author',
},
}));
return NextResponse.json(results);
} catch (error) {
console.error('Open Library search error:', error);
return NextResponse.json({ error: 'Failed to search books' }, { status: 500 });
}
}
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion } from 'motion/react';
// Type for media items returned by our search API routes
interface MediaItem {
title: string;
imageUrl: string | null;
category: string;
metadata: Record<string, any>;
}
interface SearchModalProps {
category: string;
onAdd: (item: MediaItem) => void;
isOpen: boolean;
onClose: () => void;
}
export default function SearchModal({ category, onAdd, isOpen, onClose }: SearchModalProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<MediaItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [addedIds, setAddedIds] = useState<Set<string>>(new Set());
// Debounced search - waits 300ms after the user stops typing
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const timer = setTimeout(async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/search/${category}?q=${encodeURIComponent(query)}`);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search failed:', error);
setResults([]);
} finally {
setIsLoading(false);
}
}, 300);
return () => clearTimeout(timer);
}, [query, category]);
// Handle adding an item to the list with animation feedback
const handleAdd = useCallback((item: MediaItem) => {
onAdd(item);
setAddedIds((prev) => new Set(prev).add(item.title));
}, [onAdd]);
// Reset state when modal closes
useEffect(() => {
if (!isOpen) {
setQuery('');
setResults([]);
setAddedIds(new Set());
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="w-full max-w-2xl mx-4 bg-zinc-900 rounded-2xl border border-zinc-800 p-6 max-h-[80vh] flex flex-col"
>
{/* Header with search input */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold capitalize">Search {category}</h2>
<button onClick={onClose} className="text-zinc-400 hover:text-white">
X
</button>
</div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={`Search for ${category}...`}
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-accent mb-4"
autoFocus
/>
{/* Results grid */}
<div className="flex-1 overflow-y-auto">
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white" />
</div>
)}
{!isLoading && results.length === 0 && query && (
<p className="text-center text-zinc-500 py-12">No results found.</p>
)}
{!isLoading && results.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{results.map((item, index) => (
<motion.div
key={`${item.title}-${index}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="relative group"
>
{/* Cover art image */}
<div className="aspect-[2/3] rounded-lg overflow-hidden bg-zinc-800">
{item.imageUrl ? (
<img
src={item.imageUrl}
alt={item.title}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-zinc-600">
No Image
</div>
)}
</div>
{/* Title and add button */}
<p className="mt-2 text-sm text-zinc-300 truncate">{item.title}</p>
<motion.button
whileTap={{ scale: 0.9 }}
whileHover={{ scale: 1.05 }}
onClick={() => handleAdd(item)}
disabled={addedIds.has(item.title)}
className="mt-1 w-full py-1.5 text-xs font-medium rounded-md bg-accent text-white hover:bg-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{addedIds.has(item.title) ? 'Added' : 'Add to List'}
</motion.button>
</motion.div>
))}
</div>
)}
</div>
</motion.div>
</div>
);
}
Your search infrastructure is ready. You have four API routes that talk to TMDB, AniList, MusicBrainz, and Open Library, plus a polished SearchModal that ties them together with debounced search and smooth animations. Next up, you will build the list management UI that uses this modal to let users curate their media picks before getting roasted.
Build the List Management UI
You have four working search APIs that return image-rich media results. Now you need a page where users actually build their list of favorites before getting roasted.
In this step, you'll create the curation page. This page gives users per-category sections, visual item cards with remove animations, a resume input area, and the all-important "Roast Me" button.
In this step, get ready to:
- Build a ListItem component with cover art, category badges, and animated removal.
- Create the curate page with per-category sections, SearchModal integration, resume input, tags, and a conditional Roast Me button.
Create the ListItem component
Each media item a user adds to their list needs a visual card showing the cover art, title, a category badge, and a remove button. When removed, the card should animate out smoothly using AnimatePresence from Motion.
- Create a new file at src/components/ListItem.tsx and add the following component:
"use client";
import { motion } from "motion/react";
// Props for each media item card
interface ListItemProps {
title: string;
imageUrl: string;
category: string;
onRemove: () => void;
}
export default function ListItem({ title, imageUrl, category, onRemove }: ListItemProps) {
return (
<motion.div
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8, transition: { duration: 0.2 } }}
className="relative group rounded-lg overflow-hidden bg-zinc-800 border border-zinc-700"
>
{/* Cover art */}
<div className="aspect-[2/3] relative">
<img
src={imageUrl}
alt={title}
className="w-full h-full object-cover"
/>
</div>
{/* Item details */}
<div className="p-2">
<p className="text-sm text-white truncate">{title}</p>
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/20 text-purple-300 capitalize">
{category}
</span>
</div>
{/* Remove button - visible on hover */}
<button
onClick={onRemove}
className="absolute top-1 right-1 w-6 h-6 rounded-full bg-red-500/80 text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`Remove ${title}`}
>
✕
</button>
</motion.div>
);
}
What does this code do?
The motion.div wrapper gives every card enter and exit animations. The layout prop tells Motion to animate position changes when siblings are removed.
The exit prop defines what happens when the card is removed from the DOM. It shrinks and fades out over 0.2 seconds. The remove button only appears on hover using Tailwind's group-hover utility.
- Save src/components/ListItem.tsx.
You won't see this component in action until the curate page renders it in the next chunk.
Build the curate page
The curate page is the core list-building experience. It manages all client-side state and brings together the SearchModal, ListItem cards, resume input, and the Roast Me button.
- Create a new file at src/app/curate/page.tsx and add the following code:
"use client";
import { useState } from "react";
import { AnimatePresence } from "motion/react";
import SearchModal from "@/components/SearchModal";
import ListItem from "@/components/ListItem";
// Shape of each media item in the list
interface MediaItem {
title: string;
imageUrl: string;
category: string;
metadata: Record<string, unknown>;
}
// Available media categories
const CATEGORIES = ["movies", "anime", "music", "books"] as const;
export default function CuratePage() {
// Core list state
const [items, setItems] = useState<MediaItem[]>([]);
const [listName, setListName] = useState("");
const [tag, setTag] = useState("");
const [resumeText, setResumeText] = useState("");
// Controls which category's SearchModal is open
const [activeCategory, setActiveCategory] = useState<string | null>(null);
// Add a new item from the SearchModal
const handleAddItem = (item: MediaItem) => {
setItems((prev) => [...prev, item]);
};
// Remove an item by its index in the array
const handleRemoveItem = (index: number) => {
setItems((prev) => prev.filter((_, i) => i !== index));
};
// Read an uploaded resume file as text
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
setResumeText(event.target?.result as string);
};
reader.readAsText(file);
}
};
// Roast Me is enabled when any category has items or resume is filled
const hasItems = items.length > 0 || resumeText.trim().length > 0;
// Filter items belonging to a specific category
const getItemsByCategory = (category: string) => {
return items.filter((item) => item.category === category);
};
return (
<main className="min-h-screen bg-zinc-950 text-white p-6 max-w-5xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Curate Your List</h1>
{/* List name input */}
<div className="mb-6">
<label htmlFor="list-name" className="block text-sm text-zinc-400 mb-1">
List Name
</label>
<input
id="list-name"
type="text"
value={listName}
onChange={(e) => setListName(e.target.value)}
placeholder="e.g. My All-Time Favorites"
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-2 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
{/* Tag input */}
<div className="mb-8">
<label htmlFor="tag-input" className="block text-sm text-zinc-400 mb-1">
Tag
</label>
<input
id="tag-input"
type="text"
value={tag}
onChange={(e) => setTag(e.target.value)}
placeholder="e.g. guilty pleasures, peak fiction"
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-2 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
{/* Per-category sections */}
{CATEGORIES.map((category) => (
<section key={category} className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold capitalize">{category}</h2>
<button
onClick={() => setActiveCategory(category)}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition-colors"
>
+ Add {category}
</button>
</div>
{/* Items grid with AnimatePresence for exit animations */}
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
<AnimatePresence>
{getItemsByCategory(category).map((item, idx) => {
const globalIndex = items.indexOf(item);
return (
<ListItem
key={`${item.title}-${globalIndex}`}
title={item.title}
imageUrl={item.imageUrl}
category={item.category}
onRemove={() => handleRemoveItem(globalIndex)}
/>
);
})}
</AnimatePresence>
</div>
{getItemsByCategory(category).length === 0 && (
<p className="text-zinc-500 text-sm">No {category} added yet.</p>
)}
</section>
))}
{/* Resume section */}
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4">Resume</h2>
<p className="text-sm text-zinc-400 mb-2">
Paste your resume text or upload a file for a career roast.
</p>
<textarea
value={resumeText}
onChange={(e) => setResumeText(e.target.value)}
placeholder="Paste your resume here..."
className="w-full h-40 bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-3 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-purple-500 resize-none"
/>
<input
type="file"
accept=".txt,.pdf,.doc,.docx"
onChange={handleFileUpload}
className="mt-2 text-sm text-zinc-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-zinc-800 file:text-zinc-300 file:cursor-pointer hover:file:bg-zinc-700"
/>
</section>
{/* Roast Me button - disabled until items are added */}
<div className="text-center mt-12">
<button
disabled={!hasItems}
className="px-8 py-4 bg-gradient-to-r from-purple-600 to-pink-600 rounded-xl text-lg font-bold transition-all disabled:opacity-40 disabled:cursor-not-allowed hover:scale-105 disabled:hover:scale-100"
>
Roast Me
</button>
{!hasItems && (
<p className="text-zinc-500 text-sm mt-2">
Add at least one item to get roasted.
</p>
)}
</div>
{/* SearchModal opens when a category Add button is clicked */}
{activeCategory && (
<SearchModal
category={activeCategory}
onAdd={handleAddItem}
onClose={() => setActiveCategory(null)}
/>
)}
</main>
);
}
What does this code do?
The page is a client component because it manages interactive state with useState. The items array stores every media item the user has added across all categories. Each item holds a title, imageUrl, category, and metadata object.
The CATEGORIES array drives the per-category sections. Each one renders an Add button that opens the SearchModal for that category. The AnimatePresence wrapper around the items grid enables smooth exit animations when items are removed.
The Roast Me button checks hasItems to decide whether it should be clickable. If no media items exist and the resume is empty, the button stays disabled with reduced opacity.
- Save src/app/curate/page.tsx.
- In your browser, navigate to http://localhost:3000/curate. You should see the page heading, list name and tag inputs, four empty category sections with Add buttons, the resume textarea, and a disabled Roast Me button at the bottom.
- Click any category's Add button. The SearchModal from the previous step should open. Search for something and add it. The item should appear as a visual card with cover art in the correct category section.
- Hover over the added card and click the red remove button. The card should animate out smoothly.
- Add at least one item to any category. The Roast Me button should change from disabled (faded) to fully visible and hoverable.
Cards not showing images?
If images appear broken, your browser may be blocking requests to external image domains. Check the browser console for errors. Since we're using plain <img> tags, there's no Next.js image optimization blocking. The issue is likely a CORS or mixed-content problem.
If you see nothing after clicking Add, make sure your SearchModal's onAdd callback is passing the full item object with title, imageUrl, category, and metadata fields.
help me debug why my media items aren't displaying in the curate page
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here's the full src/components/ListItem.tsx:
"use client";
import { motion } from "motion/react";
// Props for each media item card
interface ListItemProps {
title: string;
imageUrl: string;
category: string;
onRemove: () => void;
}
export default function ListItem({ title, imageUrl, category, onRemove }: ListItemProps) {
return (
<motion.div
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8, transition: { duration: 0.2 } }}
className="relative group rounded-lg overflow-hidden bg-zinc-800 border border-zinc-700"
>
{/* Cover art */}
<div className="aspect-[2/3] relative">
<img
src={imageUrl}
alt={title}
className="w-full h-full object-cover"
/>
</div>
{/* Item details */}
<div className="p-2">
<p className="text-sm text-white truncate">{title}</p>
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/20 text-purple-300 capitalize">
{category}
</span>
</div>
{/* Remove button - visible on hover */}
<button
onClick={onRemove}
className="absolute top-1 right-1 w-6 h-6 rounded-full bg-red-500/80 text-white text-xs flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`Remove ${title}`}
>
✕
</button>
</motion.div>
);
}
And here's the full src/app/curate/page.tsx:
"use client";
import { useState } from "react";
import { AnimatePresence } from "motion/react";
import SearchModal from "@/components/SearchModal";
import ListItem from "@/components/ListItem";
// Shape of each media item in the list
interface MediaItem {
title: string;
imageUrl: string;
category: string;
metadata: Record<string, unknown>;
}
// Available media categories
const CATEGORIES = ["movies", "anime", "music", "books"] as const;
export default function CuratePage() {
// Core list state
const [items, setItems] = useState<MediaItem[]>([]);
const [listName, setListName] = useState("");
const [tag, setTag] = useState("");
const [resumeText, setResumeText] = useState("");
// Controls which category's SearchModal is open
const [activeCategory, setActiveCategory] = useState<string | null>(null);
// Add a new item from the SearchModal
const handleAddItem = (item: MediaItem) => {
setItems((prev) => [...prev, item]);
};
// Remove an item by its index in the array
const handleRemoveItem = (index: number) => {
setItems((prev) => prev.filter((_, i) => i !== index));
};
// Read an uploaded resume file as text
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
setResumeText(event.target?.result as string);
};
reader.readAsText(file);
}
};
// Roast Me is enabled when any category has items or resume is filled
const hasItems = items.length > 0 || resumeText.trim().length > 0;
// Filter items belonging to a specific category
const getItemsByCategory = (category: string) => {
return items.filter((item) => item.category === category);
};
return (
<main className="min-h-screen bg-zinc-950 text-white p-6 max-w-5xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Curate Your List</h1>
{/* List name input */}
<div className="mb-6">
<label htmlFor="list-name" className="block text-sm text-zinc-400 mb-1">
List Name
</label>
<input
id="list-name"
type="text"
value={listName}
onChange={(e) => setListName(e.target.value)}
placeholder="e.g. My All-Time Favorites"
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-2 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
{/* Tag input */}
<div className="mb-8">
<label htmlFor="tag-input" className="block text-sm text-zinc-400 mb-1">
Tag
</label>
<input
id="tag-input"
type="text"
value={tag}
onChange={(e) => setTag(e.target.value)}
placeholder="e.g. guilty pleasures, peak fiction"
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-2 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
{/* Per-category sections */}
{CATEGORIES.map((category) => (
<section key={category} className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold capitalize">{category}</h2>
<button
onClick={() => setActiveCategory(category)}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm font-medium transition-colors"
>
+ Add {category}
</button>
</div>
{/* Items grid with AnimatePresence for exit animations */}
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
<AnimatePresence>
{getItemsByCategory(category).map((item, idx) => {
const globalIndex = items.indexOf(item);
return (
<ListItem
key={`${item.title}-${globalIndex}`}
title={item.title}
imageUrl={item.imageUrl}
category={item.category}
onRemove={() => handleRemoveItem(globalIndex)}
/>
);
})}
</AnimatePresence>
</div>
{getItemsByCategory(category).length === 0 && (
<p className="text-zinc-500 text-sm">No {category} added yet.</p>
)}
</section>
))}
{/* Resume section */}
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4">Resume</h2>
<p className="text-sm text-zinc-400 mb-2">
Paste your resume text or upload a file for a career roast.
</p>
<textarea
value={resumeText}
onChange={(e) => setResumeText(e.target.value)}
placeholder="Paste your resume here..."
className="w-full h-40 bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-3 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-purple-500 resize-none"
/>
<input
type="file"
accept=".txt,.pdf,.doc,.docx"
onChange={handleFileUpload}
className="mt-2 text-sm text-zinc-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-zinc-800 file:text-zinc-300 file:cursor-pointer hover:file:bg-zinc-700"
/>
</section>
{/* Roast Me button - disabled until items are added */}
<div className="text-center mt-12">
<button
disabled={!hasItems}
className="px-8 py-4 bg-gradient-to-r from-purple-600 to-pink-600 rounded-xl text-lg font-bold transition-all disabled:opacity-40 disabled:cursor-not-allowed hover:scale-105 disabled:hover:scale-100"
>
Roast Me
</button>
{!hasItems && (
<p className="text-zinc-500 text-sm mt-2">
Add at least one item to get roasted.
</p>
)}
</div>
{/* SearchModal opens when a category Add button is clicked */}
{activeCategory && (
<SearchModal
category={activeCategory}
onAdd={handleAddItem}
onClose={() => setActiveCategory(null)}
/>
)}
</main>
);
}
Your list management UI is ready. Users can search, add, remove, tag, and name their lists. Next up, you'll wire the Roast Me button to Google Gemini and generate some savage AI commentary.
Integrate Google Gemini for Roast Generation
Your curate page collects all the media items a user loves. Now it is time to put that list to work.
In this step you will send the user's selections to Google Gemini and stream back a personalized roast in real time. The result page will reveal the roast character by character while showing cover thumbnails for context.
In this step, get ready to:
- Create the roast API route that builds a prompt and streams Gemini's response.
- Build the roast results page with a typewriter animation, loading spinner, and error state.
- Connect the curate page's Roast Me button to the new results page.
Create the roast API route
The roast route receives the user's media list, constructs a detailed prompt, and streams the AI-generated text back to the client. Streaming means the user sees words appear as they are generated instead of waiting for the full response.
- Create a new file at src/app/api/roast/route.ts by running:
mkdir -p src/app/api/roast && touch src/app/api/roast/route.ts
- Confirm the file exists by running:
ls src/app/api/roast/
You should see route.ts in the output.
- Open src/app/api/roast/route.ts and add the import and type definition by pasting this code:
import { GoogleGenAI } from "@google/genai";
import { NextRequest } from "next/server";
// Shape of each media item sent from the client
interface MediaItem {
title: string;
category: string;
metadata?: Record<string, string>;
}
// Shape of the incoming request body
interface RoastRequestBody {
items: MediaItem[];
tag?: string;
resumeText?: string;
}
What does this code do?
You import GoogleGenAI from the official @google/genai SDK. The two interfaces describe the shape of the POST body your route expects: an array of media items plus an optional tag and resume text.
- Below the interfaces, add the prompt builder function:
// Build a detailed prompt that references the user's specific titles
function buildRoastPrompt(items: MediaItem[], tag?: string, resumeText?: string): string {
const grouped: Record<string, string[]> = {};
for (const item of items) {
if (!grouped[item.category]) {
grouped[item.category] = [];
}
grouped[item.category].push(item.title);
}
let listSection = Object.entries(grouped)
.map(([category, titles]) => `${category}: ${titles.join(", ")}`)
.join("\n");
if (resumeText) {
listSection += `\nResume/Career: ${resumeText}`;
}
const tagLine = tag ? `They tagged this list as "${tag}".` : "";
return `You are a brutally funny roast comedian. A user has shared their media taste for you to roast.
Here is their list:
${listSection}
${tagLine}
Write a savage, hilarious, personalized roast of their taste. Reference specific titles they picked. Be creative, witty, and merciless but keep it playful. Around 150-200 words. Do not use bullet points or numbered lists. Write in flowing paragraphs.`;
}
What does this code do?
The buildRoastPrompt function groups the user's items by category, formats them into a readable list, and wraps everything in a system prompt that instructs Gemini to write a personalized roast. The tag and resume text are included when present.
- Now add the POST handler that calls Gemini and streams the response:
// POST handler: stream the roast from Gemini
export async function POST(request: NextRequest) {
try {
const body: RoastRequestBody = await request.json();
if (!body.items || body.items.length === 0) {
return new Response(JSON.stringify({ error: "No items provided" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Initialize the Google GenAI client (reads GEMINI_API_KEY from env)
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
const prompt = buildRoastPrompt(body.items, body.tag, body.resumeText);
// Stream the response from Gemini
const responseStream = await ai.models.generateContentStream({
model: "gemini-3.5-flash",
contents: prompt,
});
// Create a ReadableStream to pipe chunks to the client
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of responseStream) {
if (chunk.text) {
controller.enqueue(new TextEncoder().encode(chunk.text));
}
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
},
});
} catch (error) {
console.error("Roast generation failed:", error);
return new Response(
JSON.stringify({ error: "Failed to generate roast" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
}
What does this code do?
The POST handler validates the request body, initializes the Gemini SDK using your GEMINI_API_KEY environment variable, and calls generateContentStream with the gemini-3.5-flash model.
The response is wrapped in a Web ReadableStream that encodes each text chunk and sends it to the client as it arrives. This is what makes the typewriter effect possible on the frontend.
- Save src/app/api/roast/route.ts.
Seeing a module not found error?
Make sure you installed the @google/genai package in Step 1. If not, run npm install @google/genai from your project root.
Also confirm that GEMINI_API_KEY is set in your .env.local file.
help me debug a module not found error for @google/genai in my Next.js API route
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
import { GoogleGenAI } from "@google/genai";
import { NextRequest } from "next/server";
// Shape of each media item sent from the client
interface MediaItem {
title: string;
category: string;
metadata?: Record<string, string>;
}
// Shape of the incoming request body
interface RoastRequestBody {
items: MediaItem[];
tag?: string;
resumeText?: string;
}
// Build a detailed prompt that references the user's specific titles
function buildRoastPrompt(items: MediaItem[], tag?: string, resumeText?: string): string {
const grouped: Record<string, string[]> = {};
for (const item of items) {
if (!grouped[item.category]) {
grouped[item.category] = [];
}
grouped[item.category].push(item.title);
}
let listSection = Object.entries(grouped)
.map(([category, titles]) => `${category}: ${titles.join(", ")}`)
.join("\n");
if (resumeText) {
listSection += `\nResume/Career: ${resumeText}`;
}
const tagLine = tag ? `They tagged this list as "${tag}".` : "";
return `You are a brutally funny roast comedian. A user has shared their media taste for you to roast.
Here is their list:
${listSection}
${tagLine}
Write a savage, hilarious, personalized roast of their taste. Reference specific titles they picked. Be creative, witty, and merciless but keep it playful. Around 150-200 words. Do not use bullet points or numbered lists. Write in flowing paragraphs.`;
}
// POST handler: stream the roast from Gemini
export async function POST(request: NextRequest) {
try {
const body: RoastRequestBody = await request.json();
if (!body.items || body.items.length === 0) {
return new Response(JSON.stringify({ error: "No items provided" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Initialize the Google GenAI client (reads GEMINI_API_KEY from env)
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
const prompt = buildRoastPrompt(body.items, body.tag, body.resumeText);
// Stream the response from Gemini
const responseStream = await ai.models.generateContentStream({
model: "gemini-3.5-flash",
contents: prompt,
});
// Create a ReadableStream to pipe chunks to the client
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of responseStream) {
if (chunk.text) {
controller.enqueue(new TextEncoder().encode(chunk.text));
}
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
},
});
} catch (error) {
console.error("Roast generation failed:", error);
return new Response(
JSON.stringify({ error: "Failed to generate roast" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
}
Build the roast results page
The results page reads the user's list from a query parameter or client-side state, calls the roast API, and reveals the response one chunk at a time using Motion animations.
- Create the roast page file by running:
mkdir -p src/app/roast && touch src/app/roast/page.tsx
- Open src/app/roast/page.tsx and add the imports and state setup:
"use client";
import { useEffect, useState, useRef } from "react";
import { motion } from "motion/react";
import { useSearchParams, useRouter } from "next/navigation";
// Represents a media item for display
interface MediaItem {
title: string;
imageUrl: string;
category: string;
}
export default function RoastPage() {
const searchParams = useSearchParams();
const router = useRouter();
// Core state for the roast flow
const [roastText, setRoastText] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [items, setItems] = useState<MediaItem[]>([]);
const hasFetched = useRef(false);
What does this code do?
This is a client component that manages three states: roastText accumulates the streamed response, isLoading controls the spinner, and error captures failures. The hasFetched ref prevents the API from being called twice in React strict mode.
- Below the state declarations, add the useEffect that triggers the roast API call and streams the response:
// Fetch the roast on mount
useEffect(() => {
if (hasFetched.current) return;
hasFetched.current = true;
const storedItems = sessionStorage.getItem("roastItems");
const storedTag = sessionStorage.getItem("roastTag");
const storedResume = sessionStorage.getItem("roastResume");
if (!storedItems) {
setError("No items found. Go back and add some picks.");
setIsLoading(false);
return;
}
const parsedItems = JSON.parse(storedItems);
setItems(parsedItems);
// Call the roast API and stream the response
async function fetchRoast() {
try {
const response = await fetch("/api/roast", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: parsedItems,
tag: storedTag || undefined,
resumeText: storedResume || undefined,
}),
});
if (!response.ok) {
throw new Error("Failed to generate roast");
}
// Read the stream chunk by chunk
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
setRoastText((prev) => prev + text);
}
} catch (err) {
setError("Something went wrong generating your roast. Try again.");
} finally {
setIsLoading(false);
}
}
fetchRoast();
}, []);
What does this code do?
On mount, the effect retrieves the user's list from sessionStorage (set by the curate page before navigating here). It then fetches the /api/roast endpoint and reads the streaming response using a ReadableStream reader.
Each decoded chunk is appended to roastText via the state setter, which triggers a re-render and creates the typewriter effect naturally as text accumulates.
- Finally, add the JSX that renders the loading, error, and result states:
// Loading state
if (isLoading && !roastText) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-950">
<motion.div
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 1, ease: "linear" }}
className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full"
/>
</div>
);
}
// Error state
if (error) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-950 p-6">
<p className="text-red-400 text-lg mb-4">{error}</p>
<button
onClick={() => router.push("/curate")}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
Go Back
</button>
</div>
);
}
// Results state
return (
<div className="min-h-screen bg-gray-950 p-6 md:p-12">
<div className="max-w-3xl mx-auto">
{/* Horizontal scroll of cover thumbnails */}
<div className="flex gap-3 overflow-x-auto pb-4 mb-8">
{items.map((item, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="flex-shrink-0 w-20 h-28 rounded-lg overflow-hidden"
>
<img
src={item.imageUrl}
alt={item.title}
className="w-full h-full object-cover"
/>
</motion.div>
))}
</div>
{/* Roast text with fade-in animation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
className="bg-gray-900 rounded-2xl p-8 border border-gray-800"
>
<h2 className="text-2xl font-bold text-white mb-4">Your Roast</h2>
<p className="text-gray-200 text-lg leading-relaxed whitespace-pre-wrap">
{roastText}
{isLoading && (
<motion.span
animate={{ opacity: [1, 0] }}
transition={{ repeat: Infinity, duration: 0.8 }}
className="inline-block w-2 h-5 bg-purple-400 ml-1"
/>
)}
</p>
</motion.div>
{/* Action buttons */}
{!isLoading && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="flex flex-wrap gap-4 mt-8 justify-center"
>
<button
onClick={() => {
setRoastText("");
setIsLoading(true);
hasFetched.current = false;
window.location.reload();
}}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
Roast Again
</button>
<button
onClick={() => router.push("/curate")}
className="px-6 py-3 bg-gray-800 text-white rounded-lg hover:bg-gray-700 transition"
>
New List
</button>
<button
onClick={() => {
// Save to Profile placeholder - wired in next step
alert("Save to Profile coming in the next step!");
}}
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
Save to Profile
</button>
</motion.div>
)}
</div>
</div>
);
}
What does this code do?
The component renders three possible states. The loading state shows an animated spinning circle. The error state shows a message and a button to go back. The results state shows a horizontal scroll of cover thumbnails at the top, the roast text with a blinking cursor while streaming, and three action buttons once the roast is complete.
The blinking cursor is a Motion-animated span that fades in and out, giving the feel of a live typewriter. Once isLoading becomes false, the cursor disappears and the action buttons animate in.
- Save src/app/roast/page.tsx.
- Refresh your browser and navigate to the curate page. Add at least one media item, then click Roast Me.
You should see a loading spinner, then the roast text appearing character by character with a blinking purple cursor.
Roast not appearing?
Check that your curate page stores items in sessionStorage under the key roastItems before navigating to /roast. Your Roast Me button handler should call sessionStorage.setItem("roastItems", JSON.stringify(items)) before router.push("/roast").
Also verify your GEMINI_API_KEY is correctly set in .env.local and that your dev server has been restarted since adding it.
help me debug why my roast page shows an error instead of streaming text from Gemini
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
"use client";
import { useEffect, useState, useRef } from "react";
import { motion } from "motion/react";
import { useSearchParams, useRouter } from "next/navigation";
// Represents a media item for display
interface MediaItem {
title: string;
imageUrl: string;
category: string;
}
export default function RoastPage() {
const searchParams = useSearchParams();
const router = useRouter();
// Core state for the roast flow
const [roastText, setRoastText] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [items, setItems] = useState<MediaItem[]>([]);
const hasFetched = useRef(false);
// Fetch the roast on mount
useEffect(() => {
if (hasFetched.current) return;
hasFetched.current = true;
const storedItems = sessionStorage.getItem("roastItems");
const storedTag = sessionStorage.getItem("roastTag");
const storedResume = sessionStorage.getItem("roastResume");
if (!storedItems) {
setError("No items found. Go back and add some picks.");
setIsLoading(false);
return;
}
const parsedItems = JSON.parse(storedItems);
setItems(parsedItems);
// Call the roast API and stream the response
async function fetchRoast() {
try {
const response = await fetch("/api/roast", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: parsedItems,
tag: storedTag || undefined,
resumeText: storedResume || undefined,
}),
});
if (!response.ok) {
throw new Error("Failed to generate roast");
}
// Read the stream chunk by chunk
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
setRoastText((prev) => prev + text);
}
} catch (err) {
setError("Something went wrong generating your roast. Try again.");
} finally {
setIsLoading(false);
}
}
fetchRoast();
}, []);
// Loading state
if (isLoading && !roastText) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-950">
<motion.div
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 1, ease: "linear" }}
className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full"
/>
</div>
);
}
// Error state
if (error) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-950 p-6">
<p className="text-red-400 text-lg mb-4">{error}</p>
<button
onClick={() => router.push("/curate")}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
Go Back
</button>
</div>
);
}
// Results state
return (
<div className="min-h-screen bg-gray-950 p-6 md:p-12">
<div className="max-w-3xl mx-auto">
{/* Horizontal scroll of cover thumbnails */}
<div className="flex gap-3 overflow-x-auto pb-4 mb-8">
{items.map((item, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="flex-shrink-0 w-20 h-28 rounded-lg overflow-hidden"
>
<img
src={item.imageUrl}
alt={item.title}
className="w-full h-full object-cover"
/>
</motion.div>
))}
</div>
{/* Roast text with fade-in animation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
className="bg-gray-900 rounded-2xl p-8 border border-gray-800"
>
<h2 className="text-2xl font-bold text-white mb-4">Your Roast</h2>
<p className="text-gray-200 text-lg leading-relaxed whitespace-pre-wrap">
{roastText}
{isLoading && (
<motion.span
animate={{ opacity: [1, 0] }}
transition={{ repeat: Infinity, duration: 0.8 }}
className="inline-block w-2 h-5 bg-purple-400 ml-1"
/>
)}
</p>
</motion.div>
{/* Action buttons */}
{!isLoading && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="flex flex-wrap gap-4 mt-8 justify-center"
>
<button
onClick={() => {
setRoastText("");
setIsLoading(true);
hasFetched.current = false;
window.location.reload();
}}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
Roast Again
</button>
<button
onClick={() => router.push("/curate")}
className="px-6 py-3 bg-gray-800 text-white rounded-lg hover:bg-gray-700 transition"
>
New List
</button>
<button
onClick={() => {
// Save to Profile placeholder - wired in next step
alert("Save to Profile coming in the next step!");
}}
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
Save to Profile
</button>
</motion.div>
)}
</div>
</div>
);
}
Wire the curate page to the roast page
The last piece is updating the Roast Me button on your curate page so it stores the current list in session storage and navigates to the results page.
- In src/app/curate/page.tsx, find your Roast Me button's onClick handler.
- Update the handler so it stores items, tag, and resume text in session storage before navigating:
// Inside your Roast Me button onClick handler
sessionStorage.setItem("roastItems", JSON.stringify(items));
sessionStorage.setItem("roastTag", tag);
sessionStorage.setItem("roastResume", resumeText);
router.push("/roast");
Why sessionStorage?
Session storage keeps the data available across page navigations within the same tab but clears it when the tab closes. This avoids cluttering the URL with a long query string and avoids a server round-trip to persist temporary data.
- Save src/app/curate/page.tsx.
- In your browser, go to the curate page, add a few media items, and click Roast Me.
You should be redirected to /roast where the spinner appears briefly, then your roast streams in with a blinking purple cursor.
Page redirects but shows 'No items found'?
This means sessionStorage.getItem("roastItems") returned null. Verify that your onClick handler calls sessionStorage.setItem before router.push. Open DevTools, go to the Application tab, and check Session Storage to confirm the data is there.
help me figure out why sessionStorage is empty when my roast page loads
Your app can now roast anyone's media taste in real time. Next up, you will save these roasts to the user's profile so they can revisit their greatest burns.
Save Lists and Roasts to Your Profile
Your app generates a roast, but it vanishes the moment the user leaves the page. To make lists and roasts permanent, you need to wire up the database so they persist across sessions.
In this step, you'll create API route handlers that let authenticated users save, retrieve, and delete their lists and roasts. Every route will verify the user's session before touching the database.
In this step, get ready to:
- Create the lists API route for fetching and creating lists.
- Create the single list API route for fetching and deleting individual lists.
- Create the roasts API route to save roast text linked to a list.
Create the lists API route
Your app needs two main operations for lists: fetching all of a user's saved lists, and creating a new list. Both live in a single route file that exports a GET and a POST handler.
- Create a new folder at src/app/api/lists/ inside your project.
- Inside that folder, create a file called route.ts and add the following code:
import { auth } from "@/auth";
import { db } from "@/db";
import { lists } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { NextResponse } from "next/server";
// GET: return all lists for the authenticated user, newest first
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
// Fetch all lists owned by this user, ordered by creation date
const userLists = await db
.select()
.from(lists)
.where(eq(lists.userId, session.user.id))
.orderBy(desc(lists.createdAt));
return NextResponse.json(userLists);
}
// POST: create a new list for the authenticated user
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
const body = await request.json();
const { name, tag, items } = body;
// Insert the new list with JSONB items containing title, imageUrl, category
const [newList] = await db
.insert(lists)
.values({
userId: session.user.id,
name,
tag,
items,
})
.returning();
return NextResponse.json({ id: newList.id }, { status: 201 });
}
What does this code do?
The GET handler calls auth() to check for a valid session. If the user is not signed in, it returns a 401 immediately. Otherwise it queries the lists table filtered by userId and sorted newest-first.
The POST handler reads name, tag, and items from the request body, inserts a row into the lists table, and returns the new list's ID. The items field is stored as JSONB so each media item's title, image URL, and category are preserved without needing separate tables.
- Save src/app/api/lists/route.ts.
Seeing a module not found error?
Make sure the import path @/auth resolves to your auth.ts file at the project root. Check that @/db points to src/db/index.ts and @/db/schema points to src/db/schema.ts. Your tsconfig.json paths must include these aliases.
help me fix my import paths for the lists API route
Create the single list API route
Users need to view a single saved list with its roast, and delete lists they no longer want. This route uses a dynamic segment [id] to target a specific list.
- Inside src/app/api/lists/, create a subfolder called [id].
- Create a file called route.ts inside src/app/api/lists/[id]/ and add the following code:
import { auth } from "@/auth";
import { db } from "@/db";
import { lists, roasts } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { NextResponse } from "next/server";
// GET: return a single list with its associated roast
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
const { id } = params;
// Fetch the list owned by this user
const [list] = await db
.select()
.from(lists)
.where(and(eq(lists.id, id), eq(lists.userId, session.user.id)));
if (!list) {
return NextResponse.json({ error: "List not found" }, { status: 404 });
}
// Fetch the roast associated with this list
const [roast] = await db
.select()
.from(roasts)
.where(eq(roasts.listId, id));
return NextResponse.json({ ...list, roast: roast || null });
}
// DELETE: remove a list and its associated roasts
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
const { id } = params;
// Delete any roasts linked to this list first
await db.delete(roasts).where(eq(roasts.listId, id));
// Delete the list itself, scoped to the authenticated user
await db
.delete(lists)
.where(and(eq(lists.id, id), eq(lists.userId, session.user.id)));
return NextResponse.json({ success: true });
}
What does this code do?
The GET handler fetches a single list by its ID but only if the authenticated user owns it. It then queries the roasts table for any roast linked to that list and returns both together.
The DELETE handler removes the associated roasts first to avoid orphaned records, then deletes the list itself. Both operations are scoped to the authenticated user's ID for security.
- Save src/app/api/lists/[id]/route.ts.
Getting a 404 when calling the route?
Double-check that the folder is named exactly [id] with square brackets. Next.js uses the brackets to create a dynamic route segment. Without them, the route will not match requests like /api/lists/abc123.
help me debug why my dynamic list route isn't matching
Create the roasts API route and wire up saving
After the AI generates a roast, you need a separate route to persist the roast text linked to the list and the user. This keeps roast storage decoupled from list creation.
- Create a new folder at src/app/api/roasts/ (note the plural, separate from the existing src/app/api/roast/ folder that handles generation).
- Create route.ts inside it and add the following code:
import { auth } from "@/auth";
import { db } from "@/db";
import { roasts } from "@/db/schema";
import { NextResponse } from "next/server";
// POST: save a roast linked to a list and user
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
const body = await request.json();
const { listId, roastText } = body;
// Insert the roast record into the database
const [newRoast] = await db
.insert(roasts)
.values({
userId: session.user.id,
listId,
roastText,
})
.returning();
return NextResponse.json({ id: newRoast.id }, { status: 201 });
}
What does this code do?
This POST handler receives listId and roastText from the client, verifies the user is authenticated, then inserts a new row into the roasts table. The userId comes from the server-side session, not the client, so users cannot forge roasts under someone else's account.
- Save src/app/api/roasts/route.ts.
Now wire the save flow into your roast results page. The Save to Profile button on src/app/roast/page.tsx should call these two routes in sequence.
- Return to src/app/roast/page.tsx and locate the Save to Profile button's click handler.
- Update the handler to POST to /api/lists first (sending name, tag, and items), then POST to /api/roasts with the returned listId and the generated roastText.
Why two separate API calls?
Splitting list creation from roast saving keeps each route focused on one responsibility. If a user generates multiple roasts from the same list later, the list already exists and you only need to call the roasts route again.
- Save src/app/roast/page.tsx.
- In the browser, sign in, add some media to a list, generate a roast, and click Save to Profile. Open your Neon dashboard's SQL editor and run SELECT * FROM lists; to confirm the row was saved.
Save button does nothing?
Open your browser's Network tab and check the response from /api/lists. If you see a 401 status, your session is not being sent with the request. Make sure your fetch call does not override credentials.
If you see a 500 error, check your terminal for a Drizzle error. A common cause is the items field being sent as a string instead of a JSON object.
help me debug why my Save to Profile button isn't working
Your lists and roasts now persist in the database, linked to the signed-in user. Next up, you'll build a profile page that displays these saved lists as a visual gallery.
Build the Profile and List Gallery
Your saved lists are sitting in the database, but users have no way to browse them. A profile page gives users a visual home base for all their curated lists.
In this step, you'll build a gallery-style profile page that displays each saved list as a rich card with thumbnail mosaics. You'll also build a detail view where users can see all their items, read the full roast, and take actions like re-roasting or deleting.
In this step, get ready to:
- Create a reusable ListCard component with thumbnail mosaics and metadata.
- Build the profile gallery page with staggered animations and an empty state.
- Build the list detail page with re-roast, edit, and delete functionality.
Create the ListCard component
Each list in the gallery needs a visual card that gives users a quick preview of what's inside. The card shows a 2x2 mosaic of cover art thumbnails, the list name, a tag badge, the creation date, and a short roast snippet.
- Create a new file at src/components/ListCard.tsx and add the following code:
'use client';
import { motion } from 'motion/react';
import Link from 'next/link';
import Image from 'next/image';
// Type for a single media item stored in the list's JSONB column
interface MediaItem {
title: string;
imageUrl: string;
category: string;
metadata?: Record<string, string>;
}
// Props for the ListCard component
interface ListCardProps {
id: string;
name: string;
tag: string | null;
items: MediaItem[];
createdAt: string;
roastSnippet: string | null;
index: number; // Used for staggered animation delay
}
export default function ListCard({
id,
name,
tag,
items,
createdAt,
roastSnippet,
index,
}: ListCardProps) {
// Grab the first 4 items for the 2x2 thumbnail mosaic
const thumbnails = items.slice(0, 4);
// Format the creation date to a readable string
const formattedDate = new Date(createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: index * 0.1 }}
>
<Link
href={`/profile/${id}`}
className="block rounded-xl border border-white/10 bg-white/5 p-4 hover:bg-white/10 transition-colors"
>
{/* 2x2 Thumbnail Mosaic */}
<div className="grid grid-cols-2 gap-1 rounded-lg overflow-hidden aspect-square mb-3">
{thumbnails.map((item, i) => (
<div key={i} className="relative w-full h-full bg-white/5">
{item.imageUrl ? (
<Image
src={item.imageUrl}
alt={item.title}
fill
className="object-cover"
sizes="(max-width: 768px) 40vw, 150px"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-white/40">
{item.title.slice(0, 2)}
</div>
)}
</div>
))}
{/* Fill empty slots if fewer than 4 items */}
{Array.from({ length: Math.max(0, 4 - thumbnails.length) }).map(
(_, i) => (
<div
key={`empty-${i}`}
className="w-full h-full bg-white/5"
/>
)
)}
</div>
{/* List name */}
<h3 className="text-white font-semibold text-sm truncate">{name}</h3>
{/* Tag badge and date */}
<div className="flex items-center gap-2 mt-1">
{tag && (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/20 text-purple-300">
{tag}
</span>
)}
<span className="text-xs text-white/40">{formattedDate}</span>
</div>
{/* Roast snippet */}
{roastSnippet && (
<p className="text-xs text-white/50 mt-2 line-clamp-2">
{roastSnippet.slice(0, 100)}...
</p>
)}
</Link>
</motion.div>
);
}
What does this code do?
The ListCard component renders a clickable card that links to the list detail page. It uses Motion for entrance animations, with each card delayed by its index * 0.1 seconds to create a stagger effect.
The 2x2 mosaic grid displays the first 4 items' cover images. If a list has fewer than 4 items, empty placeholder slots fill the remaining space. The line-clamp-2 Tailwind class truncates the roast snippet to 2 lines.
- Save src/components/ListCard.tsx.
Seeing a TypeScript error on the Image component?
If Next.js complains about external image domains, you need to add the image hostnames to your next.config file's images.remotePatterns array. You should have already configured this in the search API step.
Help me fix my Next.js Image domain configuration
Build the profile gallery page
The profile page fetches all of a user's saved lists and displays them in a responsive grid of ListCard components. When a user has no lists yet, it shows a friendly empty state that encourages them to start curating.
- Create the directory src/app/profile/ and add a new file called page.tsx with the following code:
'use client';
import { useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { motion } from 'motion/react';
import Link from 'next/link';
import ListCard from '@/components/ListCard';
// Shape of a list returned from the API
interface SavedList {
id: string;
name: string;
tag: string | null;
items: { title: string; imageUrl: string; category: string; metadata?: Record<string, string> }[];
createdAt: string;
roastText: string | null;
}
export default function ProfilePage() {
const { data: session, status } = useSession();
const router = useRouter();
const [lists, setLists] = useState<SavedList[]>([]);
const [loading, setLoading] = useState(true);
// Redirect unauthenticated users to the home page
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/');
}
}, [status, router]);
// Fetch the user's saved lists from the API
useEffect(() => {
async function fetchLists() {
try {
const res = await fetch('/api/lists');
if (res.ok) {
const data = await res.json();
setLists(data);
}
} catch (error) {
console.error('Failed to fetch lists:', error);
} finally {
setLoading(false);
}
}
if (status === 'authenticated') {
fetchLists();
}
}, [status]);
// Show loading state while session or data loads
if (status === 'loading' || loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-white/50">Loading your profile...</p>
</div>
);
}
return (
<div className="min-h-screen px-4 py-12 max-w-5xl mx-auto">
{/* Page header with New List button */}
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold text-white">Your Lists</h1>
<Link
href="/curate"
className="px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-500 text-white text-sm font-medium transition-colors"
>
New List
</Link>
</div>
{/* Empty state when no lists exist */}
{lists.length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center py-20"
>
<p className="text-white/60 text-lg mb-4">
No lists yet. Start curating your taste!
</p>
<Link
href="/curate"
className="inline-block px-6 py-3 rounded-lg bg-purple-600 hover:bg-purple-500 text-white font-medium transition-colors"
>
Create Your First List
</Link>
</motion.div>
) : (
// Gallery grid of list cards with staggered entrance
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{lists.map((list, index) => (
<ListCard
key={list.id}
id={list.id}
name={list.name}
tag={list.tag}
items={list.items}
createdAt={list.createdAt}
roastSnippet={list.roastText}
index={index}
/>
))}
</div>
)}
</div>
);
}
What does this code do?
The profile page is a client component that uses useSession from NextAuth to check authentication. If the user is not signed in, it redirects them home.
Once authenticated, it fetches from /api/lists (the GET route you built earlier) and maps each list to a ListCard. The responsive grid uses grid-cols-2 md:grid-cols-3 lg:grid-cols-4 to adapt from 2 columns on mobile to 4 on desktop.
- Save src/app/profile/page.tsx.
- In your browser, sign in and navigate to /profile. If you have saved lists from earlier, you should see them displayed as cards with thumbnail mosaics. If not, you should see the empty state with the "Create Your First List" button.
Profile page shows loading forever?
Check that you are signed in. The page only fetches lists when status === 'authenticated'. Open the browser console to see if the /api/lists request is returning a 401, which means your session is not active.
Help me debug my profile page loading state
Build the list detail page
When a user clicks on a list card, they need a detail view that shows every media item as a full image card, the complete roast text, and action buttons for managing the list.
- Create the directory src/app/profile/[listId]/ and add a new file called page.tsx with the following code:
'use client';
import { use, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { motion } from 'motion/react';
import Image from 'next/image';
// Shape of the list detail returned from the API
interface ListDetail {
id: string;
name: string;
tag: string | null;
items: { title: string; imageUrl: string; category: string; metadata?: Record<string, string> }[];
createdAt: string;
roastText: string | null;
}
export default function ListDetailPage({
params,
}: {
params: Promise<{ listId: string }>;
}) {
// Unwrap the dynamic route params using React's use() API
const { listId } = use(params);
const router = useRouter();
const [list, setList] = useState<ListDetail | null>(null);
const [loading, setLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState('');
const [editTag, setEditTag] = useState('');
// Fetch the single list's data including its roast
useEffect(() => {
async function fetchList() {
try {
const res = await fetch(`/api/lists/${listId}`);
if (res.ok) {
const data = await res.json();
setList(data);
setEditName(data.name);
setEditTag(data.tag || '');
}
} catch (error) {
console.error('Failed to fetch list:', error);
} finally {
setLoading(false);
}
}
fetchList();
}, [listId]);
// Delete the list and navigate back to profile
async function handleDelete() {
if (!confirm('Are you sure you want to delete this list?')) return;
const res = await fetch(`/api/lists/${listId}`, { method: 'DELETE' });
if (res.ok) {
router.push('/profile');
}
}
// Re-roast the existing list items
async function handleReroast() {
if (!list) return;
// Navigate to roast page with the list items as query state
const itemsParam = encodeURIComponent(JSON.stringify(list.items));
router.push(`/roast?items=${itemsParam}&listId=${list.id}`);
}
// Save inline edits to name and tag
async function handleSaveEdit() {
if (!list) return;
const res = await fetch(`/api/lists/${listId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editName, tag: editTag }),
});
if (res.ok) {
setList({ ...list, name: editName, tag: editTag });
setIsEditing(false);
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-white/50">Loading list...</p>
</div>
);
}
if (!list) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-white/50">List not found.</p>
</div>
);
}
return (
<div className="min-h-screen px-4 py-12 max-w-4xl mx-auto">
{/* Header with list name and action buttons */}
<div className="mb-8">
{isEditing ? (
<div className="space-y-3">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white"
/>
<input
type="text"
value={editTag}
onChange={(e) => setEditTag(e.target.value)}
placeholder="Tag (optional)"
className="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white"
/>
<div className="flex gap-2">
<button
onClick={handleSaveEdit}
className="px-4 py-2 rounded-lg bg-green-600 hover:bg-green-500 text-white text-sm"
>
Save
</button>
<button
onClick={() => setIsEditing(false)}
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm"
>
Cancel
</button>
</div>
</div>
) : (
<>
<h1 className="text-2xl font-bold text-white">{list.name}</h1>
{list.tag && (
<span className="inline-block mt-2 text-xs px-2 py-0.5 rounded-full bg-purple-500/20 text-purple-300">
{list.tag}
</span>
)}
</>
)}
{/* Action buttons */}
<div className="flex gap-3 mt-4">
<button
onClick={handleReroast}
className="px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-500 text-white text-sm font-medium"
>
Re-roast
</button>
<button
onClick={() => setIsEditing(true)}
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm font-medium"
>
Edit
</button>
<button
onClick={handleDelete}
className="px-4 py-2 rounded-lg bg-red-600/20 hover:bg-red-600/40 text-red-300 text-sm font-medium"
>
Delete
</button>
</div>
</div>
{/* Items grid showing all media in the list */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 mb-10">
{list.items.map((item, index) => (
<motion.div
key={index}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className="rounded-lg overflow-hidden border border-white/10 bg-white/5"
>
{/* Cover image */}
<div className="relative aspect-[2/3] bg-white/5">
{item.imageUrl ? (
<Image
src={item.imageUrl}
alt={item.title}
fill
className="object-cover"
sizes="(max-width: 768px) 45vw, 200px"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-white/30 text-sm">
No image
</div>
)}
</div>
{/* Title and category label */}
<div className="p-2">
<p className="text-white text-xs font-medium truncate">
{item.title}
</p>
<p className="text-white/40 text-xs capitalize">
{item.category}
</p>
</div>
</motion.div>
))}
</div>
{/* Full roast text */}
{list.roastText && (
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
<h2 className="text-lg font-semibold text-white mb-3">The Roast</h2>
<p className="text-white/70 whitespace-pre-wrap leading-relaxed">
{list.roastText}
</p>
</div>
)}
</div>
);
}
What does this code do?
This page uses React's use() function to unwrap the params promise from the dynamic route segment [listId]. In Next.js 15+, route params are asynchronous. Client components cannot be async, so use() is the correct way to read them.
The three action buttons handle distinct workflows. Re-roast sends the existing items to the roast page. Edit toggles inline input fields for name and tag. Delete calls DELETE /api/lists/[id] and redirects back to the profile.
- Save src/app/profile/[listId]/page.tsx.
- In your browser, navigate to /profile and click on any list card. You should see all the media items displayed as image cards in a grid, with the full roast text below them and the Re-roast, Edit, and Delete buttons at the top.
Clicking a card gives a 404?
Make sure you created the folder structure exactly as src/app/profile/[listId]/page.tsx with square brackets around listId. The brackets tell Next.js this is a dynamic route segment. Also confirm the id value in your ListCard link matches what your API returns.
Help me debug my dynamic route setup
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file created in this step.
src/components/ListCard.tsx
'use client';
import { motion } from 'motion/react';
import Link from 'next/link';
import Image from 'next/image';
// Type for a single media item stored in the list's JSONB column
interface MediaItem {
title: string;
imageUrl: string;
category: string;
metadata?: Record<string, string>;
}
// Props for the ListCard component
interface ListCardProps {
id: string;
name: string;
tag: string | null;
items: MediaItem[];
createdAt: string;
roastSnippet: string | null;
index: number; // Used for staggered animation delay
}
export default function ListCard({
id,
name,
tag,
items,
createdAt,
roastSnippet,
index,
}: ListCardProps) {
// Grab the first 4 items for the 2x2 thumbnail mosaic
const thumbnails = items.slice(0, 4);
// Format the creation date to a readable string
const formattedDate = new Date(createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: index * 0.1 }}
>
<Link
href={`/profile/${id}`}
className="block rounded-xl border border-white/10 bg-white/5 p-4 hover:bg-white/10 transition-colors"
>
{/* 2x2 Thumbnail Mosaic */}
<div className="grid grid-cols-2 gap-1 rounded-lg overflow-hidden aspect-square mb-3">
{thumbnails.map((item, i) => (
<div key={i} className="relative w-full h-full bg-white/5">
{item.imageUrl ? (
<Image
src={item.imageUrl}
alt={item.title}
fill
className="object-cover"
sizes="(max-width: 768px) 40vw, 150px"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-white/40">
{item.title.slice(0, 2)}
</div>
)}
</div>
))}
{/* Fill empty slots if fewer than 4 items */}
{Array.from({ length: Math.max(0, 4 - thumbnails.length) }).map(
(_, i) => (
<div
key={`empty-${i}`}
className="w-full h-full bg-white/5"
/>
)
)}
</div>
{/* List name */}
<h3 className="text-white font-semibold text-sm truncate">{name}</h3>
{/* Tag badge and date */}
<div className="flex items-center gap-2 mt-1">
{tag && (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/20 text-purple-300">
{tag}
</span>
)}
<span className="text-xs text-white/40">{formattedDate}</span>
</div>
{/* Roast snippet */}
{roastSnippet && (
<p className="text-xs text-white/50 mt-2 line-clamp-2">
{roastSnippet.slice(0, 100)}...
</p>
)}
</Link>
</motion.div>
);
}
src/app/profile/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { motion } from 'motion/react';
import Link from 'next/link';
import ListCard from '@/components/ListCard';
// Shape of a list returned from the API
interface SavedList {
id: string;
name: string;
tag: string | null;
items: { title: string; imageUrl: string; category: string; metadata?: Record<string, string> }[];
createdAt: string;
roastText: string | null;
}
export default function ProfilePage() {
const { data: session, status } = useSession();
const router = useRouter();
const [lists, setLists] = useState<SavedList[]>([]);
const [loading, setLoading] = useState(true);
// Redirect unauthenticated users to the home page
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/');
}
}, [status, router]);
// Fetch the user's saved lists from the API
useEffect(() => {
async function fetchLists() {
try {
const res = await fetch('/api/lists');
if (res.ok) {
const data = await res.json();
setLists(data);
}
} catch (error) {
console.error('Failed to fetch lists:', error);
} finally {
setLoading(false);
}
}
if (status === 'authenticated') {
fetchLists();
}
}, [status]);
// Show loading state while session or data loads
if (status === 'loading' || loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-white/50">Loading your profile...</p>
</div>
);
}
return (
<div className="min-h-screen px-4 py-12 max-w-5xl mx-auto">
{/* Page header with New List button */}
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold text-white">Your Lists</h1>
<Link
href="/curate"
className="px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-500 text-white text-sm font-medium transition-colors"
>
New List
</Link>
</div>
{/* Empty state when no lists exist */}
{lists.length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center py-20"
>
<p className="text-white/60 text-lg mb-4">
No lists yet. Start curating your taste!
</p>
<Link
href="/curate"
className="inline-block px-6 py-3 rounded-lg bg-purple-600 hover:bg-purple-500 text-white font-medium transition-colors"
>
Create Your First List
</Link>
</motion.div>
) : (
// Gallery grid of list cards with staggered entrance
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{lists.map((list, index) => (
<ListCard
key={list.id}
id={list.id}
name={list.name}
tag={list.tag}
items={list.items}
createdAt={list.createdAt}
roastSnippet={list.roastText}
index={index}
/>
))}
</div>
)}
</div>
);
}
src/app/profile/[listId]/page.tsx
'use client';
import { use, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { motion } from 'motion/react';
import Image from 'next/image';
// Shape of the list detail returned from the API
interface ListDetail {
id: string;
name: string;
tag: string | null;
items: { title: string; imageUrl: string; category: string; metadata?: Record<string, string> }[];
createdAt: string;
roastText: string | null;
}
export default function ListDetailPage({
params,
}: {
params: Promise<{ listId: string }>;
}) {
// Unwrap the dynamic route params using React's use() API
const { listId } = use(params);
const router = useRouter();
const [list, setList] = useState<ListDetail | null>(null);
const [loading, setLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState('');
const [editTag, setEditTag] = useState('');
// Fetch the single list's data including its roast
useEffect(() => {
async function fetchList() {
try {
const res = await fetch(`/api/lists/${listId}`);
if (res.ok) {
const data = await res.json();
setList(data);
setEditName(data.name);
setEditTag(data.tag || '');
}
} catch (error) {
console.error('Failed to fetch list:', error);
} finally {
setLoading(false);
}
}
fetchList();
}, [listId]);
// Delete the list and navigate back to profile
async function handleDelete() {
if (!confirm('Are you sure you want to delete this list?')) return;
const res = await fetch(`/api/lists/${listId}`, { method: 'DELETE' });
if (res.ok) {
router.push('/profile');
}
}
// Re-roast the existing list items
async function handleReroast() {
if (!list) return;
// Navigate to roast page with the list items as query state
const itemsParam = encodeURIComponent(JSON.stringify(list.items));
router.push(`/roast?items=${itemsParam}&listId=${list.id}`);
}
// Save inline edits to name and tag
async function handleSaveEdit() {
if (!list) return;
const res = await fetch(`/api/lists/${listId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editName, tag: editTag }),
});
if (res.ok) {
setList({ ...list, name: editName, tag: editTag });
setIsEditing(false);
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-white/50">Loading list...</p>
</div>
);
}
if (!list) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-white/50">List not found.</p>
</div>
);
}
return (
<div className="min-h-screen px-4 py-12 max-w-4xl mx-auto">
{/* Header with list name and action buttons */}
<div className="mb-8">
{isEditing ? (
<div className="space-y-3">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white"
/>
<input
type="text"
value={editTag}
onChange={(e) => setEditTag(e.target.value)}
placeholder="Tag (optional)"
className="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white"
/>
<div className="flex gap-2">
<button
onClick={handleSaveEdit}
className="px-4 py-2 rounded-lg bg-green-600 hover:bg-green-500 text-white text-sm"
>
Save
</button>
<button
onClick={() => setIsEditing(false)}
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm"
>
Cancel
</button>
</div>
</div>
) : (
<>
<h1 className="text-2xl font-bold text-white">{list.name}</h1>
{list.tag && (
<span className="inline-block mt-2 text-xs px-2 py-0.5 rounded-full bg-purple-500/20 text-purple-300">
{list.tag}
</span>
)}
</>
)}
{/* Action buttons */}
<div className="flex gap-3 mt-4">
<button
onClick={handleReroast}
className="px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-500 text-white text-sm font-medium"
>
Re-roast
</button>
<button
onClick={() => setIsEditing(true)}
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm font-medium"
>
Edit
</button>
<button
onClick={handleDelete}
className="px-4 py-2 rounded-lg bg-red-600/20 hover:bg-red-600/40 text-red-300 text-sm font-medium"
>
Delete
</button>
</div>
</div>
{/* Items grid showing all media in the list */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 mb-10">
{list.items.map((item, index) => (
<motion.div
key={index}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className="rounded-lg overflow-hidden border border-white/10 bg-white/5"
>
{/* Cover image */}
<div className="relative aspect-[2/3] bg-white/5">
{item.imageUrl ? (
<Image
src={item.imageUrl}
alt={item.title}
fill
className="object-cover"
sizes="(max-width: 768px) 45vw, 200px"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-white/30 text-sm">
No image
</div>
)}
</div>
{/* Title and category label */}
<div className="p-2">
<p className="text-white text-xs font-medium truncate">
{item.title}
</p>
<p className="text-white/40 text-xs capitalize">
{item.category}
</p>
</div>
</motion.div>
))}
</div>
{/* Full roast text */}
{list.roastText && (
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
<h2 className="text-lg font-semibold text-white mb-3">The Roast</h2>
<p className="text-white/70 whitespace-pre-wrap leading-relaxed">
{list.roastText}
</p>
</div>
)}
</div>
);
}
Your profile is alive. Users can now browse their list collection, drill into any list to see every item with full artwork, re-roast old lists, or clean up ones they don't want. Next up, you'll build the roast results page that ties everything together with animated text reveal and a social-card feel.
Polish, Optimize, and Deploy to Vercel
You have a fully functional app with auth, search, roasting, profiles, and list management. The final push is to make it production-ready.
In this step, you will optimize image loading for performance, ensure responsive layouts work on every screen size, and deploy the whole thing live on Vercel.
In this step, get ready to:
- Configure Next.js Image optimization for all external image sources.
- Add responsive layouts and page transition animations.
- Deploy the app live on Vercel with all environment variables and OAuth callbacks configured.
Configure Next.js Image optimization
Your app loads images from four different external domains (TMDB, Open Library, Cover Art Archive, and AniList). The Next.js Image component optimizes and lazy-loads images automatically, but it needs to know which remote hosts to trust.
- Open next.config.ts in the root of your project.
- Replace the file contents with the following configuration to allow images from all four external APIs:
import type { NextConfig } from 'next'
const config: NextConfig = {
images: {
remotePatterns: [
{
// TMDB movie posters
protocol: 'https',
hostname: 'image.tmdb.org',
pathname: '/t/p/**',
},
{
// Open Library book covers
protocol: 'https',
hostname: 'covers.openlibrary.org',
pathname: '/b/**',
},
{
// MusicBrainz Cover Art Archive
protocol: 'https',
hostname: 'coverartarchive.org',
pathname: '/**',
},
{
// AniList anime cover images
protocol: 'https',
hostname: 'cdn.anilist.co',
pathname: '/**',
},
],
},
}
export default config
What does this configuration do?
The remotePatterns array tells Next.js which external domains are safe to optimize. Each entry specifies a protocol, hostname, and pathname pattern. Without these entries, the Image component would reject external URLs and show a 400 error.
The /** wildcard in pathname allows any path under that hostname, which is necessary because each API uses different image path structures.
- Save next.config.ts.
- Restart your dev server (stop with Ctrl+C, then start again). Config changes require a restart.
Images still showing as broken?
Make sure you stopped and restarted the dev server. Changes to next.config.ts are only picked up on server startup. Also check that the hostname in your config matches the actual image URL exactly (no www. prefix, correct subdomain).
Why aren't my remote images loading after configuring remotePatterns?
Now update your components to use the Next.js Image component. Wherever you currently use <img> tags for media thumbnails (in ListCard.tsx, ListItem.tsx, and SearchModal.tsx), replace them with the optimized Image component.
- At the top of each component file that displays media images, add the import:
import Image from 'next/image'
- Replace each <img> element with the Image component. Use the fill prop for images inside a sized container, or provide explicit width and height values.
- Add sizes attributes to help the browser pick the right resolution. For thumbnail grids, use something like sizes="(max-width: 768px) 50vw, 25vw".
- Save all modified component files.
Why use the Image component?
The Next.js Image component automatically generates optimized formats (WebP/AVIF), lazy-loads images below the fold, and prevents layout shift. For media-heavy apps like RoastMyTaste, this makes a significant difference in page load speed and user experience.
Add responsive layouts and page transitions
A production app needs to look great on every screen. This means your profile gallery should stack into a single column on mobile, your curate page should arrange controls vertically on smaller screens, and all interactive elements need to be easy to tap.
- Open src/app/profile/page.tsx and update the gallery grid to use responsive Tailwind classes:
// Responsive grid: 1 column on mobile, 2 on tablet, 3 on desktop
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
- In src/app/curate/page.tsx, ensure the layout stacks vertically on mobile by using flex-col as the base and md:flex-row for larger screens.
- In src/app/roast/page.tsx, ensure the roast text and media thumbnails scroll cleanly on mobile. Use overflow-x-auto for the horizontal thumbnail strip.
- Check that all buttons and interactive elements have a minimum size of 44px for touch targets. Add min-h-[44px] min-w-[44px] classes where needed.
Now add page transition animations using Motion's AnimatePresence component.
- Open src/app/layout.tsx and wrap the children content with AnimatePresence:
import { AnimatePresence } from 'motion/react'
// Inside the layout's return, wrap {children}:
<AnimatePresence mode="wait">
{children}
</AnimatePresence>
What does AnimatePresence do?
AnimatePresence detects when child components are removed from the React tree and lets them animate out before being unmounted. With mode="wait", the exiting page finishes its exit animation before the new page animates in. This creates smooth page-to-page transitions.
- Save all modified files.
- Test the app on different screen widths. In your browser, open DevTools and toggle the responsive device toolbar to check mobile (375px), tablet (768px), and desktop views.
- Verify that the profile gallery shows one column on mobile. Buttons should be easy to tap without accidentally hitting adjacent elements.
Layout looks wrong on mobile?
Make sure you are using Tailwind's mobile-first approach. Unprefixed classes (like grid-cols-1) apply at all sizes. Prefixed classes (like md:grid-cols-2) apply at that breakpoint and above.
Help me debug responsive layout issues in my Next.js Tailwind app
Deploy to Vercel
Your app is polished and ready for the world. Vercel is built by the same team behind Next.js, so deployment is seamless. You will push your code to GitHub and connect the repository to Vercel.
- Initialize a Git repository and push to GitHub by running these commands:
git init
git add .
git commit -m "Initial commit - RoastMyTaste app"
git branch -M main
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/roast-my-taste.git
git push -u origin main
What if I already have a Git repo?
If you initialized Git when you ran create-next-app, skip the git init command. Just add your remote, commit, and push.
- Go to https://vercel.com/new and sign in with your GitHub account.
- Click Import next to your roast-my-taste repository.
- Vercel auto-detects that this is a Next.js project. Leave the framework and build settings as defaults.
- Before clicking Deploy, expand the Environment Variables section.
- Add each of the following environment variables with the same values from your local .env.local file:
- DATABASE_URL (your Neon connection string).
- AUTH_SECRET (your NextAuth secret).
- AUTH_GITHUB_ID and AUTH_GITHUB_SECRET (your GitHub OAuth app credentials).
- AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET (your Google OAuth credentials).
- GEMINI_API_KEY (your Google AI Studio key).
- TMDB_API_KEY (your TMDB Read Access Token).
- Click Deploy.
Vercel builds and deploys your app. This usually takes 1-2 minutes. Once complete, you will see a live URL like https://roast-my-taste.vercel.app.
What does Vercel do during deployment?
Vercel installs your dependencies, runs next build to compile your app, and deploys the output to its global edge network. Your API routes become serverless functions. Every future push to main will trigger an automatic redeployment.
The last piece is updating your OAuth callback URLs. Your GitHub and Google OAuth apps still point to localhost:3000 for callbacks. You need to add your production URL.
- Go to GitHub Developer Settings and open your OAuth app.
- Add your Vercel production URL to the Authorization callback URL field. The format is:
https://[[VERCEL_DOMAIN="your-project.vercel.app"]]/api/auth/callback/github
- Click Update application.
- Go to the Google Cloud Console and open your OAuth 2.0 credentials.
- Under Authorized redirect URIs, add:
https://[[VERCEL_DOMAIN="your-project.vercel.app"]]/api/auth/callback/google
- Click Save.
Keep localhost URLs too
Do not remove the localhost:3000 callback URLs from your OAuth apps. You still need them for local development. OAuth apps can have multiple callback URLs.
Now verify everything works in production.
- Open your live Vercel URL in the browser.
- Test the sign-in flow with both GitHub and Google.
- Search for a movie and add it to a list.
- Generate a roast and save it to your profile.
- Check your profile page to confirm the saved list appears with thumbnail images.
Auth redirects failing in production?
Double-check that you added the production callback URL to both GitHub and Google OAuth settings. The URL must include /api/auth/callback/github (or /google) at the end. Also confirm AUTH_SECRET is set in your Vercel environment variables.
Help me fix OAuth redirect errors on my Vercel deployment
Your app is live. You have a production-ready, deployed web application with authentication, database persistence, multiple API integrations, and AI-powered features. That is a serious portfolio piece.
Secret mission
Add Shareable Roast Cards
Your roasts are too good to keep to yourself. Add a 'Share' button that generates a beautifully styled PNG card of the roast result, complete with media thumbnails and app branding. Users can download it and post it anywhere on social media.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on free-tier services. Neon's free tier costs $0 and scales to zero when idle. Vercel's Hobby plan is $0 for non-commercial personal use. There are no ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Neon Postgres database (free tier, scales to zero when idle).
- Vercel deployment (free Hobby plan).
- GitHub OAuth App (free).
- Google Cloud OAuth credentials (free).
- TMDB API key (free, no charges).
- Google AI Studio API key (free tier).
- Local project files on your Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or want to show off your app.
- Neon's free tier includes 100 compute-hours per month and scales to zero after inactivity. You won't be charged.
- Vercel's Hobby plan is free for non-commercial personal use. Your deployment stays live at no cost.
- All API keys and OAuth apps remain active but incur no charges.
- Your local project files stay on your Desktop.
Pause - I'll come back to this later
Shut down running processes to free up memory. Both Neon and Vercel auto-scale to zero when unused, so pausing requires no manual action on the cloud side.
- Stop your local development server if it's still running by pressing Ctrl+C in the terminal.
- Neon automatically suspends your compute after a few minutes of inactivity. The first request after suspension wakes it in about 1 second.
- Vercel keeps your deployment live but only runs serverless functions on demand. No cost while idle.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
Delete the Neon project
- Open the Neon Console at console.neon.tech.
- Select the project you created for RoastMyTaste.
- Click Settings in the sidebar.
- Click Delete and confirm. This permanently removes the database, branches, and roles.
Delete the Vercel project
- Open the Vercel dashboard at vercel.com and select your RoastMyTaste project.
- Click Settings in the sidebar.
- Scroll to the bottom of the General page and find the Delete Project section.
- Click Delete. In the confirmation dialog, enter the project name and the confirmation phrase, then click Continue.
Delete the GitHub OAuth App
- On GitHub, click your profile picture and go to Settings.
- In the left sidebar, click Developer settings.
- Click OAuth Apps.
- Select the OAuth app you created for this project.
- In the left sidebar, click Advanced.
- Click Delete application, then confirm by clicking Delete this OAuth Application.
Delete the Google OAuth credentials
- Open the Google Cloud Console and navigate to APIs & Services then Credentials.
- In the OAuth 2.0 Client IDs section, find the credential you created for this project.
- Click the three-dot menu on the right and select Delete.
- Confirm the deletion when prompted.
Delete your TMDB API key
- Log in to your TMDB account at themoviedb.org.
- Go to your account settings and navigate to the API section.
- Delete or revoke the API key you created for this project.
Delete your Google AI Studio API key
- Open Google AI Studio at aistudio.google.com.
- Navigate to the API keys section.
- Find and delete the API key you generated for this project.
Delete local project files
Remove your project folder from your Desktop by running the appropriate command for your operating system.
macOS
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your-project-folder-name"]]
Windows
cd ~\Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your-project-folder-name"]]
Nice Work!
Nice Work!
You just built something people will actually want to use. That's the real flex.
RoastMyTaste is a fully deployed, full-stack web app with user accounts, persistent data, multi-API integrations, AI-powered roast generation, and a polished animated UI.
You've learned how to:
- Build a full-stack Next.js application with NextAuth v5 OAuth authentication, a Neon serverless Postgres database, and Drizzle ORM for type-safe queries.
- Integrate 4 different media APIs (TMDB, AniList, MusicBrainz, Open Library) with image-rich search results, plus Google Gemini AI for personalized roast generation.
- Design and deploy a commercial-quality UI with smooth animations using Motion, a visual profile gallery with thumbnail mosaics, and live deployment on Vercel.
- Added shareable roast cards that users can download as styled PNG images for social media.
Ready to quiz yourself?