Build a Music Catalog SaaS
Build a full-stack SaaS app with auth, database, and CRUD using Next.js.
Introduction
30 Second Summary
You know that feeling when your music library is scattered across five different streaming apps, playlists, and forgotten downloads? Imagine having one clean catalog where everything lives together, organized exactly how you want it.
In this project, you will build a full-stack music catalog app where users sign in, add songs to their personal collection, and search through their library with instant filtering.
What You'll Build
You sign in, click "Add Song", fill in the details, and watch your collection grow in a responsive grid of album-art cards that you can search and filter in real time.
By the end of this project, you'll have:
- A sign-in flow powered by Clerk where each user only ever sees their own private music collection.
- A protected dashboard that displays your songs in a responsive grid with cover art, and lets you add or delete songs using Next.js Server Actions with no separate API layer needed.
- A live search bar and album filter that narrows your collection as you type, deployed to Vercel so you can share it with anyone.
- Secret Mission: Add playlist support so you can group songs into named playlists and view them on dedicated pages.
Are there any prerequisites?
This project assumes medium-level experience with React and Next.js (components, props, basic routing). You also need an existing Neon console account. You will create free accounts for Clerk and Vercel during the project.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Project with Authentication
Every feature in your music catalog needs to know who is using it. Without authentication, there is no way to keep one user's songs separate from another's.
In this step, you will scaffold a Next.js 16 project and wire in Clerk for authentication. By the end, you will have a running app with a sign-in button that works.
In this step, get ready to:
- Scaffold a Next.js 16 project with TypeScript and Tailwind CSS.
- Create a Clerk account and initialize authentication with the Clerk CLI.
- Verify sign-in and sign-up work in the browser.
Scaffold the Next.js project
You need a fresh Next.js app to build on. The create-next-app CLI scaffolds a project with all the tooling pre-configured.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project by running this command:
npx create-next-app@latest soundshelf
What will this command ask?
The CLI will ask: Would you like to use the recommended Next.js defaults? Select Yes, use recommended defaults. This gives you TypeScript, Tailwind CSS, ESLint, and the App Router. These are exactly the tools this project uses.
- Select Yes, use recommended defaults when prompted.
- Once scaffolding finishes, move into the project folder by running:
cd soundshelf
- Confirm the project was created by running:
ls
You should see files like package.json, app/, next.config.ts, and tsconfig.json in the output.
Command not found?
If you see an error about npx not being found, Node.js may not be on your PATH. Close and reopen your terminal, then try again.
help me fix npx command not found
Create a Clerk account and initialize authentication
Clerk handles user registration, login, and session management so you do not have to build any of that from scratch. You will create a free account, then use the Clerk CLI to wire everything into your project automatically.
- Open your browser and go to clerk.com.
- Click Sign Up and create a free account.
- Once signed in to the Clerk dashboard, click Create application.
- Name your application SoundShelf and click Create.
Why Clerk?
Building authentication from scratch requires password hashing, session tokens, email verification, and security patches. Clerk gives you pre-built sign-in and sign-up components that handle all of this. The Hobby tier is free for up to 50,000 monthly active users.
- Back in your terminal (still inside the soundshelf folder), install the Clerk CLI globally by running:
npm install -g clerk
- Authenticate the CLI with your Clerk account by running:
clerk auth login
What happens when you run this?
This command opens your browser to complete the login flow. After you approve access, the terminal confirms you are authenticated. Switch back to your terminal once the browser shows success.
- Initialize Clerk in your project by running:
clerk init
What does clerk init do?
The CLI detects your Next.js project, installs the @clerk/nextjs package, creates proxy.ts (the middleware file for Next.js 16), wraps your layout with ClerkProvider, and scaffolds sign-in and sign-up routes.
If it offers to install agent skills, you can decline. If it asks to link a Clerk application, select the SoundShelf application you just created.
- Confirm that proxy.ts was created in the project root by running:
ls proxy.ts
You should see proxy.ts listed. This file protects your routes and enables Clerk to manage sessions.
Don't see proxy.ts?
If clerk init did not finish successfully, make sure you ran clerk auth login first and that you selected the SoundShelf application when prompted.
help me troubleshoot clerk init not creating proxy.ts
Pull environment variables and customize the layout
Clerk needs API keys to connect your app to your Clerk application. The CLI can pull these directly into your project. You also need to customize the layout header to show sign-in and sign-out buttons.
- Pull your Clerk keys into the project by running:
clerk env pull
You should see output confirming that .env.local was written with your publishable key and secret key.
What is .env.local?
This file stores environment variables that Next.js reads at startup. Your Clerk keys live here so the app can authenticate against Clerk's servers. Never commit this file to version control.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the soundshelf folder on your Desktop, then click Open.
- In the Explorer sidebar, open app/layout.tsx.
The clerk init command already wrapped your layout with ClerkProvider. Now you will replace the file contents with a custom header that shows authentication buttons.
- Select all the code in app/layout.tsx and replace it with the following imports and component wrapper:
import { ClerkProvider, SignInButton, SignUpButton, UserButton, Show } from "@clerk/nextjs";
import "./globals.css";
export const metadata = {
title: "SoundShelf - Your Music Catalog",
description: "Organize and browse your music collection",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body className="min-h-screen bg-gray-950 text-white">
<main className="px-6 py-8 max-w-6xl mx-auto">{children}</main>
</body>
</html>
</ClerkProvider>
);
}
What does this code do?
ClerkProvider wraps the entire app so any component can access the user's authentication state. The Show component lets you conditionally render UI based on whether a user is signed in or out.
The imports at the top pull in the auth components you will use in the header next.
- Now add the header inside the <body> tag, directly above the <main> element. Paste this code above the <main> line:
<header className="border-b border-gray-800 px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold">SoundShelf</h1>
<div className="flex items-center gap-4">
<Show when="signed-out">
<SignInButton mode="modal">
<button className="px-4 py-2 bg-indigo-600 rounded-lg hover:bg-indigo-500 text-sm">
Sign In
</button>
</SignInButton>
<SignUpButton mode="modal">
<button className="px-4 py-2 border border-gray-700 rounded-lg hover:bg-gray-800 text-sm">
Sign Up
</button>
</SignUpButton>
</Show>
<Show when="signed-in">
<UserButton />
</Show>
</div>
</header>
What does this header do?
When the user is signed out, Show when="signed-out" renders Sign In and Sign Up buttons. Both open modal dialogs so the user never leaves the page.
When signed in, Show when="signed-in" renders a UserButton avatar that gives access to account settings and sign-out.
- Save app/layout.tsx.
Next, update the home page so signed-in users get redirected to the dashboard (which you will build in a later step).
- Open app/page.tsx in VS Code and replace its entire contents with:
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
export default async function Home() {
const { userId } = await auth();
if (userId) {
redirect("/dashboard");
}
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
<h2 className="text-4xl font-bold mb-4">Your Music, Organized</h2>
<p className="text-gray-400 text-lg max-w-md">
Catalog your song collection with titles, artists, albums, and cover art.
Sign in to get started.
</p>
</div>
);
}
What does this page do?
The auth() function checks if a user is signed in. If they are, it redirects them to /dashboard (you will create that page in Step 4). If not, it shows a welcome message with a prompt to sign in.
- Save app/page.tsx.
✔️ Awesome, I've got everything!
Great. Double check you have saved both app/layout.tsx and app/page.tsx.
ⓧ I'd like to double check the full code
Your app/layout.tsx should have these two sections, in order:
- The imports, metadata, and RootLayout function wrapper (with ClerkProvider, <html>, <body>).
- The <header> block with Show, SignInButton, SignUpButton, and UserButton placed directly above the <main> tag.
Scroll up and confirm both chunks are present in order. Your app/page.tsx should match the code block above exactly.
Verify authentication works
Everything is wired up. Time to see it in action.
- Start the development server by running this in your terminal:
npm run dev
Before you open the browser, think about this: what do you expect to see on the page given that you are not signed in yet?
- Open your browser and go to http://localhost:3000.
You should see the "Your Music, Organized" heading with Sign In and Sign Up buttons in the top-right corner of the header.
- Click Sign Up.
- Create an account using your email address.
- Complete the email verification if prompted.
After signing up, the header now shows your avatar (the UserButton component) instead of the sign-in buttons. Authentication is working.
Why do I see a 404 page?
After signing in, the home page redirects you to /dashboard which does not exist yet. That is expected. The important thing is that your UserButton avatar appears in the header, confirming auth works. You will build the dashboard page in Step 4.
Sign-in modal not appearing?
Make sure your .env.local file contains both NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY. If either is missing, re-run clerk env pull.
Also check that you restarted the dev server after the .env.local file was created. Press Ctrl+C to stop it, then run npm run dev again.
help me debug why the Clerk sign-in modal is not appearing
Your app has a working sign-in flow. Next up, you will connect a database so you can start storing songs for each user.
Connect Neon Postgres with Prisma
Your app authenticates users, but it has nowhere to store their data. Without a database, you can only show hardcoded content.
In this step, you will connect a Neon Postgres database using Prisma ORM. Prisma gives you a type-safe way to define your data models and query them from your Next.js app.
In this step, get ready to:
- Install Prisma and the Neon serverless adapter.
- Define a Song model and push the schema to your Neon database.
- Create a Prisma client helper and verify the database connection.
Install Prisma and configure the database connection
Prisma has two parts: a CLI for managing your schema and migrations, and a client library your app uses at runtime. You also need the Neon adapter so Prisma connects over HTTP instead of TCP, which is required for serverless deployments on Vercel.
- Install the Prisma client and Neon adapter as production dependencies by running:
npm install @prisma/client @prisma/adapter-neon
- Install the Prisma CLI as a dev dependency by running:
npm install prisma --save-dev
Why two separate installs?
The prisma package is the CLI you use during development to manage schemas and run migrations. The @prisma/client and @prisma/adapter-neon packages run in your app at runtime to query the database.
- Scaffold the Prisma directory by running:
npx prisma init --datasource-provider postgresql
You will see output confirming Prisma created a prisma/ directory with a schema.prisma file, a prisma.config.ts configuration file, and a .env file for your database URL.
What did prisma init create?
- prisma/schema.prisma is where you define your data models.
- prisma.config.ts tells the Prisma CLI where to find your schema and database URL.
- .env holds a placeholder DATABASE_URL that the Prisma CLI reads when running commands.
Now you need your Neon connection string to replace that placeholder.
- Open the Neon console at console.neon.tech and select your project.
- Click the Connect button on the Project Dashboard.
- In the connection modal, confirm Connection pooling is enabled (it should be on by default).
- Copy the full connection string. It looks like postgresql://user:pass@ep-xxx-pooler.region.aws.neon.tech/neondb?sslmode=require with -pooler in the hostname.
- Open .env.local in VS Code and add your Neon connection string as a new line at the bottom:
DATABASE_URL=your-neon-pooled-connection-string
- Also open the .env file that Prisma just created and replace the placeholder DATABASE_URL value with the same Neon connection string.
Why set DATABASE_URL in two files?
Next.js loads .env.local automatically when your app runs. The Prisma CLI reads .env when you run commands like prisma db push. Both need access to the same connection string.
Don't see a Connect button?
If you do not have a Neon project yet, click New Project on the Neon dashboard first, give it any name, and pick a region close to you. The Connect button appears on the project dashboard after creation.
help me find my Neon connection string
Define the Song model and push the schema to Neon
The generated schema file has a bare-bones structure. You will replace it entirely with a Song model that stores each user's music data.
- Open prisma/schema.prisma and replace its entire contents with the following:
generator client {
provider = "prisma-client-js"
output = "../app/generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Song {
id String @id @default(cuid())
title String
artist String
album String @default("")
coverUrl String @default("")
userId String
createdAt DateTime @default(now())
}
What does this schema do?
- The generator block tells Prisma to generate a TypeScript client into app/generated/prisma so you can import it in your app.
- The datasource block reads your connection string from the DATABASE_URL environment variable.
- The Song model defines the table structure. Each song is tied to a userId so users only see their own collection.
- Fields like album and coverUrl default to empty strings, making them optional when adding a song.
- Save prisma/schema.prisma.
- Push the schema to your Neon database by running:
npx prisma db push
Before you run this, what do you expect to happen? The command should sync your schema to Neon and create the Song table remotely.
You should see output confirming the database is in sync, including a line like "Your database is now in sync with your Prisma schema."
Seeing a connection error?
Double-check that your DATABASE_URL in the .env file matches your Neon pooled connection string exactly. Make sure the string includes -pooler in the hostname and ends with ?sslmode=require.
help me fix my Prisma database connection error
✔️ Awesome, I've got everything!
Great job. Double check that you saved prisma/schema.prisma and your Song table is live in Neon.
ⓧ I'd like to double check the full code
generator client {
provider = "prisma-client-js"
output = "../app/generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Song {
id String @id @default(cuid())
title String
artist String
album String @default("")
coverUrl String @default("")
userId String
createdAt DateTime @default(now())
}
Create the Prisma client and verify the connection
Your database table exists, but your app has no way to talk to it yet. You need a Prisma client instance that your app can import from anywhere.
- Create a new folder called lib at the root of your project.
- Inside lib/, create a file called prisma.ts and add the following:
import { PrismaClient } from "../app/generated/prisma";
import { PrismaNeon } from "@prisma/adapter-neon";
// Cache the client on globalThis to avoid creating new connections on every hot reload
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient;
};
// Create the Neon adapter for serverless HTTP connections
const adapter = new PrismaNeon({
connectionString: process.env.DATABASE_URL!,
});
// Reuse the existing client if one was cached, otherwise create a new one
const prisma =
globalForPrisma.prisma ||
new PrismaClient({ adapter });
// In development, cache the client so hot reloads don't exhaust connections
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export default prisma;
What does this code do?
- PrismaNeon connects to Neon over HTTP instead of a persistent TCP socket. This is required for serverless environments like Vercel.
- The globalForPrisma pattern caches the client on the global object. Without it, every Next.js hot reload in development creates a new database connection until the pool runs out.
- In production, a fresh client is created once per cold start (no caching needed).
- Save lib/prisma.ts.
Now verify the connection actually works by creating a quick test API route.
- Create the folder path app/api/test-db/ inside your project.
- Create a file called route.ts inside app/api/test-db/ and add:
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
// A simple GET route that queries all songs to test the database connection
export async function GET() {
const songs = await prisma.song.findMany();
return NextResponse.json(songs);
}
What does this route do?
It queries all rows from the Song table and returns them as JSON. Since the table is empty, you should get back an empty array []. This is a temporary route just to confirm the connection works.
- Save app/api/test-db/route.ts.
- Start the dev server by running:
npm run dev
Before you visit the test route, do you expect it to succeed or fail? If your connection string is correct and the schema was pushed, it should return data.
- Open your browser and visit http://localhost:3000/api/test-db.
You should see an empty JSON array [] in the browser. This confirms Prisma is connected to your Neon database and can query the Song table.
Seeing an error instead of []?
If you see a module not found error for @/lib/prisma, make sure your lib/ folder is at the project root (same level as app/), not inside app/.
If you see a Prisma client generation error, stop the server and run npx prisma generate to regenerate the client, then restart with npm run dev.
help me fix my Prisma connection test route
✔️ Awesome, I've got everything!
Great. Double check you saved both lib/prisma.ts and app/api/test-db/route.ts.
ⓧ I'd like to double check the full code
import { PrismaClient } from "../app/generated/prisma";
import { PrismaNeon } from "@prisma/adapter-neon";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient;
};
const adapter = new PrismaNeon({
connectionString: process.env.DATABASE_URL!,
});
const prisma =
globalForPrisma.prisma ||
new PrismaClient({ adapter });
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export default prisma;
Your database is connected and ready to store songs. Next up, you will build the form that lets users add music to their collection.
Build the Add Song Form
Your database is connected to Neon and the Song table exists, but it's completely empty. Without a way to insert songs, there's nothing to browse or search.
In this step, you'll build a form that sends data directly to the server using a Server Action. Server Actions let you handle form submissions without creating a separate API route.
In this step, get ready to:
- Create a Server Action that inserts songs into your database.
- Build a modal form component for adding song details.
- Wire the form to a protected dashboard page and add songs to your collection.
Create the addSong Server Action
A Server Action is an async function marked with "use server" that runs exclusively on the server. When you pass it to a form's action prop, Next.js automatically calls it on submission and passes the form data.
- Create a new folder app/actions inside your project.
- Inside that folder, create a file called songs.ts and paste the following code:
"use server";
import { auth } from "@clerk/nextjs/server";
import { revalidatePath } from "next/cache";
import prisma from "@/lib/prisma";
export async function addSong(formData: FormData) {
const { userId } = await auth();
if (!userId) {
throw new Error("Unauthorized");
}
const title = formData.get("title") as string;
const artist = formData.get("artist") as string;
const album = (formData.get("album") as string) || "";
const coverUrl = (formData.get("coverUrl") as string) || "";
await prisma.song.create({
data: {
title,
artist,
album,
coverUrl,
userId,
},
});
revalidatePath("/dashboard");
}
What does this code do?
This file exports a single Server Action that handles inserting a song into the database:
- "use server" at the top marks every export in this file as a Server Action.
- auth() from Clerk retrieves the current user's ID on the server. If nobody is signed in, the action throws an error.
- prisma.song.create() inserts a new row into the Song table with the form values and the authenticated user's ID.
- revalidatePath("/dashboard") tells Next.js to re-fetch data for the dashboard so new songs appear immediately.
- Save app/actions/songs.ts.
- Check your terminal where npm run dev is running. You should see no compilation errors.
Seeing a module not found error?
- Make sure the file path is exactly app/actions/songs.ts. The @/lib/prisma import relies on the lib/prisma.ts file you created in the previous step.
- Confirm that lib/prisma.ts exists at the root of your project (not inside app/).
Help me debug a module not found error in my Server Action file.
Build the AddSongForm component
The form lives in a client component because it needs interactive state (opening and closing a modal). It calls the Server Action you just created when the user submits.
- Create a new folder app/dashboard inside your project.
- Inside that folder, create a file called add-song-form.tsx and paste the following code to set up the component shell with the modal structure:
"use client";
import { useState } from "react";
import { addSong } from "@/app/actions/songs";
export function AddSongForm() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button
onClick={() => setIsOpen(true)}
className="px-4 py-2 bg-indigo-600 rounded-lg hover:bg-indigo-500 text-sm font-medium"
>
+ Add Song
</button>
{isOpen && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md">
<h3 className="text-lg font-semibold mb-4">Add a Song</h3>
<form
action={async (formData) => {
await addSong(formData);
setIsOpen(false);
}}
className="space-y-4"
>
What's happening here?
- "use client" marks this as a client component so it can use useState for the modal toggle.
- The + Add Song button sets isOpen to true, which renders the dark overlay and modal.
- The form's action prop calls addSong with the form data, then closes the modal on success.
- Directly below the opening <form> tag (after the line className="space-y-4"), add the three song detail inputs:
<div>
<label className="block text-sm text-gray-400 mb-1">Title</label>
<input
name="title"
required
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Artist</label>
<input
name="artist"
required
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Album</label>
<input
name="album"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white"
/>
</div>
What do these inputs do?
- Title and Artist have the required attribute. The browser won't submit the form without them.
- Album is optional. The Server Action defaults it to an empty string if left blank.
- Each input's name attribute matches what the Server Action reads via formData.get().
- Below the Album input, add the Cover Art URL field and the form action buttons to complete the component:
<div>
<label className="block text-sm text-gray-400 mb-1">Cover Art URL</label>
<input
name="coverUrl"
type="url"
placeholder="https://example.com/cover.jpg"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white"
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
className="flex-1 px-4 py-2 bg-indigo-600 rounded-lg hover:bg-indigo-500 font-medium text-sm"
>
Add Song
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
className="flex-1 px-4 py-2 border border-gray-700 rounded-lg hover:bg-gray-800 text-sm"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</>
);
}
What does this section do?
- The Cover Art URL input uses type="url" for browser validation and a placeholder showing the expected format.
- The Cancel button has type="button" so it doesn't accidentally submit the form.
- All closing tags (</form>, the modal </div> wrappers, and the fragment </>) close out the component.
- Save app/dashboard/add-song-form.tsx.
- Check your terminal. You should see no compilation errors for this file.
Seeing an import error for addSong?
- The import path @/app/actions/songs must match the file you created in the previous substep. Confirm the folder is app/actions/ (not app/action/ without the s).
- Make sure songs.ts exports the function as addSong (not a default export).
Help me fix an import error in my AddSongForm component.
Place the form on the dashboard page and add songs
The form component exists but has no page to live on. You need a protected dashboard page that only signed-in users can access.
- Inside the app/dashboard folder, create a file called page.tsx and paste the following code:
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import { AddSongForm } from "./add-song-form";
export default async function Dashboard() {
const { userId } = await auth();
if (!userId) {
redirect("/");
}
return (
<div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-8">
<h2 className="text-2xl font-bold">My Collection</h2>
<AddSongForm />
</div>
</div>
);
}
What does this page do?
- auth() checks if the visitor is signed in. If not, they get redirected to the home page.
- This is a Server Component (no "use client" directive), so the auth check happens on the server before any HTML is sent.
- The AddSongForm client component renders inside the server page, giving you interactive state within a protected route.
- Save app/dashboard/page.tsx.
Before you refresh the browser, what do you expect to see on the dashboard?
- Open your browser at localhost:3000. Since you're signed in, it should automatically redirect you to /dashboard.
- You should see the heading My Collection and a purple + Add Song button in the top right.
- Click the + Add Song button. A modal form appears over a dark overlay.
- Add three songs to your collection. For each, fill in a title and artist (album and cover URL are optional), then click Add Song.
The modal closes after each submission, confirming the Server Action ran successfully.
Now verify the songs actually landed in your database.
- Open your Neon console in the browser.
- Navigate to the SQL Editor tab.
- Run this query:
SELECT * FROM "Song";
You should see three rows, each with the title, artist, and your Clerk userId attached.
Don't see any rows in Neon?
- Make sure you filled in the Title and Artist fields before clicking Add Song. Both are required.
- Check the terminal for errors. A common issue is a missing DATABASE_URL in .env.local or a connection string that isn't the pooled version.
- Confirm the Song table exists by running npx prisma db push again if needed.
Help me debug why songs aren't appearing in my Neon database.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files: app/actions/songs.ts, app/dashboard/add-song-form.tsx, and app/dashboard/page.tsx.
ⓧ I'd like to double check the full code
Your project should have these three new files. Scroll up and confirm each code block is present in order:
- app/actions/songs.ts contains the addSong Server Action (the first code block in this step).
- app/dashboard/add-song-form.tsx contains the full AddSongForm component (the three consecutive code blocks that form the component shell, input fields, and buttons).
- app/dashboard/page.tsx contains the protected Dashboard page that renders AddSongForm.
Your app now has a working way to add songs. Next up, you'll display your collection as a browsable grid of song cards.
Display Your Music Collection
You have three songs stored in your Neon database, but your dashboard shows nothing below the header. Right now the app is like a filing cabinet with documents inside but the drawer sealed shut.
In this step, you will query those songs from the database and render them as visual cards. This is the moment the catalog comes alive and you see the product you are building.
In this step, get ready to:
- Query your songs from Neon and render them in a responsive card grid.
- Create a delete action so you can remove songs from your collection.
- Add hover effects and an empty state message for when the collection is blank.
Configure image support and create the song card
Song cards will display cover art from external URLs. Next.js blocks remote images by default for security, so you need to tell it which hosts are allowed.
- Create a new file called next.config.ts in the root of your project (the same level as package.json) and paste the following:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Allow cover art images from any HTTPS source
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**",
},
],
},
};
export default nextConfig;
What does this configuration do?
The remotePatterns array tells Next.js which external image URLs are safe to optimize. Using hostname: "**" allows any HTTPS host, which is fine for a learning project where cover art comes from various sources.
In a production app, you would restrict this to specific domains like your CDN.
Now you will build the component that renders each song as a card. The card shows cover art, title, artist, and album name.
- Create a new file at app/dashboard/song-card.tsx and paste the imports and type definition:
"use client";
import Image from "next/image";
type Song = {
id: string;
title: string;
artist: string;
album: string;
coverUrl: string;
};
Why is this a client component?
The "use client" directive marks this as a Client Component because it will include interactive elements (a delete button with form submission). The Song type defines the shape of each song object your card receives as a prop.
- Below the type definition in the same file, add the SongCard component function:
export function SongCard({ song }: { song: Song }) {
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 hover:border-gray-700 transition-colors group">
<div className="aspect-square bg-gray-800 rounded-lg mb-3 overflow-hidden relative">
{song.coverUrl ? (
<Image
src={song.coverUrl}
alt={`${song.title} cover`}
fill
className="object-cover"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-600">
<svg className="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg>
</div>
)}
</div>
<h3 className="font-semibold truncate">{song.title}</h3>
<p className="text-sm text-gray-400 truncate">{song.artist}</p>
{song.album && (
<p className="text-xs text-gray-500 truncate mt-1">{song.album}</p>
)}
</div>
);
}
What does this component render?
- If the song has a coverUrl, it renders the image using the Next.js Image component with fill layout (like a background image that covers the container).
- If there is no cover URL, a music note SVG placeholder appears instead.
- The group class on the outer div enables group-hover effects you will use in the next substep for a delete button.
- Save both next.config.ts and song-card.tsx.
- Confirm you see no red underlines in VS Code for either file.
Seeing a TypeScript error in song-card.tsx?
Make sure the file starts with "use client"; on its own line. Also confirm the Image import is from "next/image" (not "next/legacy/image").
help me fix TypeScript errors in my SongCard component
Update the dashboard to render your collection
The song card component is ready but not used anywhere yet. Now you will update the dashboard page to query your songs from Neon and display them in a grid.
- Open app/dashboard/page.tsx and replace the entire file contents with the following imports and query logic:
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import prisma from "@/lib/prisma";
import { AddSongForm } from "./add-song-form";
import { SongCard } from "./song-card";
export default async function Dashboard() {
const { userId } = await auth();
if (!userId) {
redirect("/");
}
// Fetch only songs belonging to the current user
const songs = await prisma.song.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
});
- Directly below the query (still inside the same function), add the return statement that renders the grid:
return (
<div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-8">
<h2 className="text-2xl font-bold">My Collection</h2>
<AddSongForm />
</div>
{songs.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p className="text-lg">No songs yet.</p>
<p className="text-sm mt-2">Add your first song to get started!</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{songs.map((song) => (
<SongCard key={song.id} song={song} />
))}
</div>
)}
</div>
);
}
What changed in this page?
- prisma.song.findMany({ where: { userId } }) queries only the current user's songs, ordered newest first.
- A conditional render shows an empty state message when the collection is blank, or a responsive grid of SongCard components when songs exist.
- The grid uses Tailwind CSS responsive columns: 1 on mobile, 2 on small screens, 3 on medium, 4 on large.
What do you think you will see when you refresh your browser?
- Save app/dashboard/page.tsx.
- Switch to your browser and refresh localhost:3000/dashboard.
You should see your three songs displayed as cards in a grid, each with a cover art area (or placeholder music note icon), title, and artist name.
Seeing an error about images?
If you see an error mentioning an unconfigured host, make sure you saved next.config.ts and restart the dev server with npm run dev. Changes to next.config.ts require a server restart to take effect.
help me fix the next/image unconfigured host error
Add delete functionality
You can see your songs, but you cannot remove them yet. A delete action needs to verify that the song belongs to the current user before removing it.
- Open app/actions/songs.ts and add the deleteSong function below the existing addSong function:
export async function deleteSong(formData: FormData) {
const { userId } = await auth();
if (!userId) {
throw new Error("Unauthorized");
}
const songId = formData.get("songId") as string;
// Verify the song belongs to the current user
const song = await prisma.song.findUnique({ where: { id: songId } });
if (!song || song.userId !== userId) {
throw new Error("Not found or unauthorized");
}
await prisma.song.delete({ where: { id: songId } });
revalidatePath("/dashboard");
}
Why check ownership before deleting?
Without the ownership check, a malicious user could craft a request with any song ID and delete another user's data. The findUnique call confirms the song exists and that song.userId matches the currently authenticated user before proceeding.
revalidatePath("/dashboard") tells Next.js to re-render the dashboard page so the deleted song disappears immediately.
Now you need to wire a delete button into each song card.
- Open app/dashboard/song-card.tsx and add this import at the top, below the existing Image import:
import { deleteSong } from "@/app/actions/songs";
- Inside the SongCard component, find the closing </div> that wraps the entire card.
- Add this form just above that closing </div>, right after the album paragraph:
<form action={deleteSong} className="mt-3 opacity-0 group-hover:opacity-100 transition-opacity">
<input type="hidden" name="songId" value={song.id} />
<button
type="submit"
className="text-xs text-red-400 hover:text-red-300"
>
Delete
</button>
</form>
How does the hidden delete button work?
- The form is invisible (opacity-0) until the user hovers over the card. The group-hover:opacity-100 class makes it appear because the parent div has the group class.
- A hidden input passes the songId to the Server Action when the form submits, so the server knows which song to delete.
Before you test, predict: what do you think happens to the page after you click Delete?
- Save app/actions/songs.ts and app/dashboard/song-card.tsx.
- Switch to your browser and refresh localhost:3000/dashboard.
- Hover over one of your song cards.
- Click the red Delete text that appears at the bottom of the card.
The song disappears from the grid immediately. The revalidatePath call triggers a fresh server render so the deleted card is gone without a manual page refresh.
Delete button not appearing on hover?
Check that the outermost <div> in your SongCard has the group class. The group-hover:opacity-100 on the form depends on a parent with group to detect the hover state.
help me fix the delete button not showing on hover in my SongCard
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files: next.config.ts, app/dashboard/song-card.tsx, app/dashboard/page.tsx, and app/actions/songs.ts.
ⓧ I'd like to double check the full code
Here are the files you created or modified in this step.
next.config.ts (new file):
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**",
},
],
},
};
export default nextConfig;
app/dashboard/song-card.tsx (new file). Confirm these sections appear in order:
- "use client" directive at the top.
- Imports for deleteSong and Image.
- Song type with id, title, artist, album, coverUrl fields.
- SongCard function with image/placeholder, text fields, and delete form.
app/dashboard/page.tsx (updated). Confirm these sections appear in order:
- Imports for auth, redirect, prisma, AddSongForm, and SongCard.
- Auth check and redirect.
- prisma.song.findMany query filtered by userId.
- Return statement with conditional empty state or grid of SongCard components.
app/actions/songs.ts (updated). Confirm the deleteSong function appears below addSong with ownership verification and revalidatePath.
Your music catalog now shows real data from Neon, and you can remove songs you no longer want. Next up, you will add search and filtering so you can find songs instantly as your collection grows.
Add Search and Filtering
In the last step you brought your collection to life with a visual grid of song cards and a delete button on each one.
The grid works, but the moment you have more than a handful of songs it becomes frustrating to find anything. A real music catalog needs a way to search and narrow results. In this step you will add a search bar that filters by title or artist, plus an album dropdown that narrows results even further.
In this step, get ready to:
- Create a client-side SearchBar component with debounced text search.
- Update the dashboard page to read URL search parameters and filter songs server-side.
- Add an album filter dropdown that combines with text search.
Create the SearchBar component
The search experience uses two layers working together. A client component updates the URL as the user types (with a short debounce so the page does not re-render on every keystroke). The Server Component on the dashboard reads those URL parameters and queries Prisma with the appropriate filter.
- Create a new file at app/dashboard/search-bar.tsx.
- Add the following code to set up the component, its hooks, and the debounced search effect:
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
export function SearchBar({ albums }: { albums: string[] }) {
const router = useRouter();
const searchParams = useSearchParams();
// Initialize local state from the current URL param
const [query, setQuery] = useState(searchParams.get("q") || "");
// Debounce: wait 300ms after the user stops typing, then update the URL
useEffect(() => {
const timeout = setTimeout(() => {
const params = new URLSearchParams(searchParams.toString());
if (query) {
params.set("q", query);
} else {
params.delete("q");
}
router.push(`/dashboard?${params.toString()}`);
}, 300);
return () => clearTimeout(timeout);
}, [query]);
What does this code do?
useSearchParams reads the current URL query string on the client. The useEffect sets a 300ms timer every time query changes. If the user keeps typing, the previous timer is cleared and a new one starts.
Once the timer fires, the URL is pushed with a ?q= parameter. Because the dashboard is a Server Component, Next.js re-renders it with the new searchParams value and the filtered results appear.
- Below the code you just pasted, add the album change handler and the JSX that renders the text input:
// Update URL when the album dropdown changes
const handleAlbumChange = (album: string) => {
const params = new URLSearchParams(searchParams.toString());
if (album) {
params.set("album", album);
} else {
params.delete("album");
}
router.push(`/dashboard?${params.toString()}`);
};
return (
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<input
type="text"
placeholder="Search by title or artist..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1 px-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500"
/>
What does handleAlbumChange do?
It follows the same pattern as the text search: read the current URL params, set or delete the album key, then push the updated URL. The Server Component re-renders with the new filter applied.
- Finally, add the album dropdown and close the component:
{albums.length > 0 && (
<select
onChange={(e) => handleAlbumChange(e.target.value)}
defaultValue={searchParams.get("album") || ""}
className="px-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white"
>
<option value="">All Albums</option>
{albums.map((album) => (
<option key={album} value={album}>
{album}
</option>
))}
</select>
)}
</div>
);
}
What does this section do?
The <select> only renders when the user has at least one album in their collection. Each unique album name becomes an option. Choosing one immediately updates the URL with ?album=AlbumName and the dashboard re-queries.
- Save app/dashboard/search-bar.tsx.
Seeing a TypeScript error about the albums prop?
Make sure the function signature is SearchBar({ albums }: { albums: string[] }) with the correct type annotation. The page component will pass this prop once you wire it up next.
help me fix my SearchBar component TypeScript error
Update the dashboard page to filter songs
The SearchBar component pushes ?q= and ?album= into the URL. Now the dashboard Server Component needs to read those parameters and build a filtered Prisma query.
- Open app/dashboard/page.tsx and replace the entire file contents with the following.
- Start by pasting the imports, function signature, and auth check:
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import prisma from "@/lib/prisma";
import { AddSongForm } from "./add-song-form";
import { SongCard } from "./song-card";
import { SearchBar } from "./search-bar";
export default async function Dashboard({
searchParams,
}: {
searchParams: Promise<{ q?: string; album?: string }>;
}) {
const { userId } = await auth();
if (!userId) {
redirect("/");
}
Why is searchParams a Promise?
In Next.js 16, searchParams passed to page components is a Promise you must await. This allows the framework to optimize static rendering for routes that do not depend on URL parameters.
- Directly below, add the parameter parsing and the dynamic where clause that filters by text and album:
// Await the searchParams promise (Next.js 16 async params)
const params = await searchParams;
const query = params.q || "";
const albumFilter = params.album || "";
// Build a dynamic where clause starting with the user's ID
const where: any = { userId };
if (query) {
where.OR = [
{ title: { contains: query, mode: "insensitive" } },
{ artist: { contains: query, mode: "insensitive" } },
];
}
if (albumFilter) {
where.album = albumFilter;
}
What does the where clause do?
The where.OR pattern tells Prisma to match songs where either the title or the artist contains the search text, case-insensitively. The album filter adds an exact-match condition on top. Both conditions combine so only songs matching everything appear.
- Next, add the two database queries and the albums array:
// Fetch filtered songs for the current user
const songs = await prisma.song.findMany({
where,
orderBy: { createdAt: "desc" },
});
// Fetch distinct album names for the dropdown
const allSongs = await prisma.song.findMany({
where: { userId },
select: { album: true },
distinct: ["album"],
});
const albums = allSongs
.map((s) => s.album)
.filter((a) => a.length > 0);
Why two separate queries?
The first query fetches songs matching the current filters. The second query fetches all distinct album names from the user's entire collection (unfiltered), so the dropdown always shows every album regardless of the current search text.
- Finally, add the JSX return that renders the header, SearchBar, and song grid:
return (
<div>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-8">
<h2 className="text-2xl font-bold">My Collection</h2>
<AddSongForm />
</div>
<SearchBar albums={albums} />
{songs.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p className="text-lg">No songs yet.</p>
<p className="text-sm mt-2">Add your first song to get started!</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{songs.map((song) => (
<SongCard key={song.id} song={song} />
))}
</div>
)}
</div>
);
}
- Save app/dashboard/page.tsx.
Module not found error for ./search-bar?
Make sure you saved app/dashboard/search-bar.tsx in the previous substep. The filename must be exactly search-bar.tsx (with a hyphen) inside the app/dashboard/ directory.
help me fix my search-bar import error
Test search and album filtering together
Both files are saved and the dev server should pick up the changes automatically. Before you test, think about what you expect to see.
- Return to your browser at localhost:3000/dashboard.
- You should see a text input and (if you have songs with album names) a dropdown above your song grid.
What do you think will happen if you type an artist name that matches one of your songs?
- Type an artist name from your collection into the search bar.
After a brief pause (300ms debounce), the grid updates to show only songs by that artist. The URL bar changes to include ?q=yourquery.
- Select an album from the dropdown.
The results narrow further. Both filters work together because the dashboard page combines both where conditions in a single Prisma query.
- Clear the search text and set the dropdown back to All Albums to confirm all songs reappear.
Search bar not filtering results?
Check that your dashboard page.tsx function accepts searchParams as a prop and awaits it with await searchParams. If you forget the await, the params object will be a Promise and params.q will always be undefined.
Also confirm your songs have album values set. The dropdown only appears when at least one song has a non-empty album field.
help me debug why my search filter is not working
✔️ Awesome, I've got everything!
Great. Double check you have saved both app/dashboard/search-bar.tsx and app/dashboard/page.tsx.
ⓧ I'd like to double check the full code
Both files exceed 30 lines, so confirm yours matches by checking each section you built above.
For app/dashboard/search-bar.tsx, confirm you have these sections in order:
- The "use client" directive, imports, function signature, hooks, and the useEffect debounce block.
- The handleAlbumChange function and the opening JSX with the text input.
- The conditional album <select> dropdown and the closing braces.
For app/dashboard/page.tsx, confirm you have these sections in order:
- Imports including SearchBar, function signature accepting searchParams as a Promise, and the auth redirect.
- The await searchParams call, the where clause with OR text filter, and the albumFilter check.
- The two findMany queries (filtered songs and distinct albums) plus the albums array.
- The JSX return rendering <SearchBar albums={albums} /> above the song grid.
Your music catalog now has real-time search and album filtering. The app is fully functional with authentication, CRUD operations, and a polished browsing experience. Next up, you will deploy it live so anyone can access your catalog.
Secret mission
Add Playlist Support
Your music catalog is searchable and filterable, but a flat list of songs only goes so far. Real music apps let you group songs into playlists. Can you add a one-to-many database relationship, Server Actions for playlist management, and a dynamic route to view each playlist?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses Clerk, Neon, and Vercel, all on free tiers with no ongoing charges.
Resources you used:
- Clerk application (free Hobby tier, no charges).
- Neon Postgres database (free tier, 0.5 GB storage).
- Vercel deployment (free Hobby tier).
- Local project folder on your Desktop.
- Clerk CLI (globally installed npm package).
Keep everything running
No action needed. Choose this if you are still actively building or want your app to stay live at your Vercel URL.
- All three services (Clerk, Neon, Vercel) are on free tiers with no charges.
- Your app stays live and accessible at your Vercel deployment URL.
- The Clerk CLI remains installed globally on your machine.
Pause - I'll come back to this later
Free up local resources while keeping your cloud services intact. No data is lost.
- Stop the dev server by pressing Ctrl+C in your terminal.
- Neon auto-suspends compute after 5 minutes of inactivity. No action needed.
- Vercel deployments persist indefinitely on the free tier. No action needed.
- Your Clerk application remains active with no charges on the Hobby plan.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Delete your Clerk application: open the Clerk Dashboard, select your application, navigate to Settings, scroll to the Danger zone section, and delete the application.
- Delete your Neon project: open the Neon Console, select your project, click Settings, then click Delete. This permanently removes all databases, branches, and roles.
- Delete your Vercel project: open the Vercel dashboard, select your project, open Settings in the sidebar, scroll to the Delete Project section at the bottom of the General page, click Delete, then confirm by entering the project name.
- Uninstall the Clerk CLI by running:
npm uninstall -g clerk
- Delete your local project folder by running:
cd ~/Desktop
rm -rf soundshelf
Windows users
Replace the commands above with:
cd ~\Desktop
Remove-Item -Recurse -Force soundshelf
That's a Wrap!
That's a Wrap!
Nice work! You just built a full-stack SaaS application from scratch using Next.js 16, complete with authentication, a database, CRUD operations, and a searchable UI.
You've learned how to:
- Implemented authentication with Clerk including sign-up, sign-in, protected routes, and user-scoped data so each user only sees their own collection.
- Designed a Prisma ORM database schema connected to Neon Postgres and built full CRUD operations using Server Actions with no separate API layer needed.
- Created a responsive, searchable music catalog UI with Tailwind CSS featuring real-time filtering by title, artist, and album.
- (Secret Mission) Added playlist support with database relations and dynamic routes so users can organize songs into named playlists.
Ready to quiz yourself?