Build a Spotify Plan Splitting App

Build a full-stack app to create groups and track Spotify Family plan payments.

Introduction

30 Second Summary

Spotify Family costs $21.99/month for up to 6 people. That's less than $4 each, but coordinating who's paid and who hasn't is the hard part.

In this project, you will build SpotiFam, a full-stack web app where users sign in with GitHub, create or join a Spotify Family group, and track monthly payment contributions from every member.

What You'll Build

A Next.js 15 application with GitHub OAuth authentication, a Neon serverless Postgres database managed by Drizzle ORM, and a polished dark-themed dashboard for managing group subscriptions and payments.

By the end of this project, you'll have:

  • A full-stack Next.js 15 app with GitHub authentication, a serverless database, and a dark-themed UI styled with Tailwind CSS.
  • A group management system where users create Spotify Family groups, browse open slots, and request to join.
  • A payment tracking dashboard that shows who owes what each billing cycle, with manager controls to confirm payments.
  • An in-app notification system for payment reminders and join requests.
  • Secret Mission: Add real-time group chat so members can communicate within their family group.

Are there any prerequisites?

You'll need Node.js 18+ installed, a free Neon account for your database, and a GitHub account for OAuth authentication.

Basic familiarity with React and Next.js helps, but every step walks you through the code.

Before We Start

Before We Start...

Set Up the Project and Database

Every full-stack app starts with a solid foundation. Before you write any application logic, you need a project scaffold, a database, and the right packages wired together.

In this step, you'll create your Next.js 15 app, connect it to a Neon serverless Postgres database, and install the packages that power authentication and database access.

In this step, get ready to:
  • Scaffold a Next.js 15 app with TypeScript and Tailwind CSS.
  • Install Drizzle ORM, Neon serverless driver, and Auth.js packages.
  • Create a Neon database and configure environment variables.
Scaffold the Next.js 15 app

Next.js provides a scaffolding tool called create-next-app that generates your project structure, installs React, and configures TypeScript and Tailwind CSS in one command.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your SpotiFam project by running this command:
npx create-next-app@latest spotifam

What will this command ask me?

The create-next-app wizard will ask you a series of questions about your project setup. Answer each one individually as listed below.

  • Enter Yes for the TypeScript prompt.
  • Enter Yes for the ESLint prompt.
  • Enter Yes for the Tailwind CSS prompt.
  • Enter No for the src/ directory prompt.
  • Enter Yes for the App Router prompt.
  • Enter Yes for the Turbopack prompt.
  • Enter No for the custom import alias prompt (keep the default).

The installer downloads React, Next.js, and your chosen tools. This takes about 30 seconds.

  • Move into your new project folder by running:
cd spotifam
  • Confirm the project was created by running:
ls

You should see files like package.json, tsconfig.json, an app/ folder, and a node_modules/ folder in the output.

Seeing 'command not found' for npx?

This means Node.js is not installed or not in your PATH. Run node --version to check. You need Node.js 18 or higher for this project.

help me install Node.js on my machine

  • Open your project in VS Code by running:
code .

The code command not working?

If code . does not open VS Code, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter. Then click File, Open Folder, and select the spotifam folder on your Desktop.

Install project dependencies

Your scaffolded app has React and Next.js, but it still needs the packages for database access, schema management, and authentication.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, both macOS and Windows).
  • Install the runtime dependencies by running:
npm install drizzle-orm @neondatabase/serverless next-auth@beta @auth/drizzle-adapter

What are these packages?

drizzle-orm is a lightweight TypeScript ORM that lets you write database queries with full type safety. @neondatabase/serverless is the driver that connects Drizzle to your Neon Postgres database.

next-auth@beta is Auth.js v5, the authentication library for Next.js. @auth/drizzle-adapter connects Auth.js to your Drizzle-managed database so user sessions and accounts are stored in Neon.

  • Install the development dependency by running:
npm install -D drizzle-kit

What is drizzle-kit?

drizzle-kit is a CLI tool that pushes your TypeScript schema definition directly to the database. You'll use its push command to create tables without writing SQL migration files.

You should see output confirming packages were added. No errors means everything installed correctly.

Seeing npm errors during install?

Check that you are inside the spotifam folder (run pwd to confirm). If you see peer dependency warnings, those are usually safe to ignore.

help me fix npm install errors

Now add two helper scripts to your package.json so you can push schema changes and inspect your database with short commands.

  • In VS Code, open package.json from the Explorer sidebar on the left.
  • Find the "scripts" section. It looks like this:
"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "next lint"
}
  • Add two new lines after "lint": "next lint" so the scripts section looks like this:
"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "next lint",
  "db:push": "drizzle-kit push",
  "db:studio": "drizzle-kit studio"
}

What do these scripts do?

db:push sends your Drizzle schema to Neon and creates or updates tables. db:studio opens a visual database browser so you can inspect your data.

  • Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Create a Neon project and configure environment variables

Your app needs a database to store users, groups, and payments. Neon gives you a serverless Postgres database with a generous free tier.

  • Open https://console.neon.tech in your browser and sign in to your Neon account.
  • Click New Project.
  • Enter spotifam as the project name.
  • Enter spotifam as the database name.
  • Leave the Postgres version and region as their defaults.
  • Click Create Project.

After creation, Neon displays a connection string. This is the URL your app uses to talk to the database.

  • Copy the connection string from the Neon dashboard. It looks like: postgresql://user:password@ep-cool-name-123456.us-east-2.aws.neon.tech/spotifam?sslmode=require.

Keep this tab open

You'll paste the connection string in the next step. Keep the Neon console tab open so you can copy it again if needed.

Now you'll create an environment file that stores your database connection string and authentication secrets. These values stay local and never get committed to version control.

  • In VS Code, create a new file called .env.local in the root of your spotifam folder. Right-click in the Explorer sidebar and select New File.
  • Name the file .env.local.
  • Paste the following content into the file:
# Neon database connection string
DATABASE_URL=your-neon-connection-string-here

# Auth.js secret for encrypting tokens
AUTH_SECRET=your-generated-secret

# GitHub OAuth credentials (you'll set these up in Step 3)
AUTH_GITHUB_ID=your-github-client-id
AUTH_GITHUB_SECRET=your-github-client-secret

What do these variables do?

DATABASE_URL tells Drizzle and Neon where your database lives. AUTH_SECRET encrypts session tokens for Auth.js.

AUTH_GITHUB_ID and AUTH_GITHUB_SECRET are placeholders for now. You'll fill these in when you configure GitHub OAuth in Step 3.

  • Replace your-neon-connection-string-here with the connection string you copied from the Neon dashboard.

Next, generate a secure value for AUTH_SECRET. Auth.js provides a CLI command that generates a random secret and adds it to your env file.

  • In your VS Code terminal, generate the secret by running:
npx auth secret

This command generates a random string and writes it to your .env.local file as AUTH_SECRET. If it creates a duplicate line, remove the old placeholder line so you only have one AUTH_SECRET entry.

  • Save .env.local.

Now verify that your Next.js app runs correctly with the new packages installed.

  • Start the development server by running:
npm run dev
  • Open http://localhost:3000 in your browser.

You should see the default Next.js welcome page with the Vercel logo and "Get started by editing app/page.tsx" text.

Not seeing the welcome page?

Check your terminal for error messages. Make sure you are inside the spotifam directory when you run npm run dev. If port 3000 is already in use, Next.js will try port 3001 instead.

help me get my Next.js dev server running

✔️ Awesome, I've got everything!

Great. Double check you've saved both package.json and .env.local before moving on.

ⓧ I'd like to double check the full code

Here is what your .env.local should look like (with your own values):

DATABASE_URL=postgresql://user:password@host/spotifam?sslmode=require
AUTH_SECRET=your-generated-secret
AUTH_GITHUB_ID=your-github-client-id
AUTH_GITHUB_SECRET=your-github-client-secret

And your package.json scripts section:

{
  "name": "spotifam",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "db:push": "drizzle-kit push",
    "db:studio": "drizzle-kit studio"
  },
  "dependencies": {
    "@auth/drizzle-adapter": "latest",
    "@neondatabase/serverless": "latest",
    "drizzle-orm": "latest",
    "next": "15.0.0",
    "next-auth": "beta",
    "react": "^18",
    "react-dom": "^18"
  },
  "devDependencies": {
    "drizzle-kit": "latest",
    "typescript": "^5",
    "tailwindcss": "^3",
    "@types/node": "^20",
    "@types/react": "^18"
  }
}

Your project foundation is solid. You have a running Next.js app, all the packages you need, and a live Neon database ready to accept your schema. Next up, you'll design the database tables that power groups, memberships, and payments.

Design the Database Schema

Your app needs a structured database to track who belongs to which group, who has paid their share, and when to send reminders. Without a well-designed schema, all of that logic falls apart.

In this step, you'll define your entire database schema using Drizzle ORM and push it to your Neon Postgres database. Drizzle lets you write schemas in TypeScript, so you get type safety from the database layer all the way to your frontend components.

In this step, get ready to:
  • Define the database schema with tables for authentication, groups, memberships, payments, and notifications.
  • Configure Drizzle Kit to connect to your Neon database.
  • Push the schema to Neon and verify the tables exist.
Define the Drizzle schema tables

SpotiFam needs seven tables to handle its core features: four for authentication (required by Auth.js) and three for the app's business logic (groups, payments, notifications).

  • Create a new folder called db inside your src folder.
  • Inside src/db, create a file called index.ts. This file sets up the database connection.
  • Add the following code to src/db/index.ts:
import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';
import * as schema from './schema';

// Create a serverless SQL connection using your Neon connection string
const sql = neon(process.env.DATABASE_URL!);

// Export the Drizzle client with your schema for type-safe queries
export const db = drizzle(sql, { schema });

What does this code do?

The neon() function creates a serverless HTTP connection to your Neon database using the connection string from your .env.local file.

Passing { schema } to drizzle() gives the client full knowledge of your table structure. This enables type-safe queries throughout your app.

  • Save src/db/index.ts.

You won't see a visible change yet. This file will be used once you create the schema it references.

  • Now create a second file in the same folder called schema.ts.
  • Add the following code to src/db/schema.ts to define the enums and authentication tables:
import { pgTable, text, timestamp, integer, boolean, pgEnum, decimal } from 'drizzle-orm/pg-core';

// Define enums for membership and payment status tracking
export const membershipStatusEnum = pgEnum('membership_status', ['pending', 'active', 'rejected']);
export const paymentStatusEnum = pgEnum('payment_status', ['pending', 'paid']);

// Users table - stores basic profile info from OAuth
export const users = pgTable('users', {
  id: text('id').primaryKey(),
  name: text('name'),
  email: text('email').notNull().unique(),
  emailVerified: timestamp('email_verified', { mode: 'date' }),
  image: text('image'),
});

// Accounts table - stores OAuth provider details (required by Auth.js)
export const accounts = pgTable('accounts', {
  userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  type: text('type').notNull(),
  provider: text('provider').notNull(),
  providerAccountId: text('provider_account_id').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'),
});

// Sessions table - tracks active user sessions (required by Auth.js)
export const sessions = pgTable('sessions', {
  sessionToken: text('session_token').primaryKey(),
  userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  expires: timestamp('expires', { mode: 'date' }).notNull(),
});

// Verification tokens - for email verification (required by Auth.js)
export const verificationTokens = pgTable('verification_tokens', {
  identifier: text('identifier').notNull(),
  token: text('token').notNull(),
  expires: timestamp('expires', { mode: 'date' }).notNull(),
});

What does this code do?

The two pgEnum definitions create PostgreSQL enum types for membership status (pending, active, rejected) and payment status (pending, paid). Enums enforce that only valid values can be stored.

The four tables (users, accounts, sessions, verificationTokens) are required by the Auth.js Drizzle adapter. They store user profiles, OAuth credentials, and session data.

  • Save src/db/schema.ts.

You won't see a change yet. The next chunk adds the app-specific tables that power groups and payments.

  • In src/db/schema.ts, add the following code below the verificationTokens table:
// Groups table - each Spotify Family plan group
export const groups = pgTable('groups', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  ownerId: text('owner_id').notNull().references(() => users.id),
  monthlyCost: decimal('monthly_cost', { precision: 10, scale: 2 }).notNull(),
  maxMembers: integer('max_members').notNull().default(6),
  description: text('description'),
  createdAt: timestamp('created_at', { mode: 'date' }).defaultNow(),
});

// Memberships table - tracks who belongs to which group
export const memberships = pgTable('memberships', {
  id: text('id').primaryKey(),
  groupId: text('group_id').notNull().references(() => groups.id, { onDelete: 'cascade' }),
  userId: text('user_id').notNull().references(() => users.id),
  status: membershipStatusEnum('status').notNull().default('pending'),
  joinedAt: timestamp('joined_at', { mode: 'date' }).defaultNow(),
});

// Payments table - records monthly payment status per member
export const payments = pgTable('payments', {
  id: text('id').primaryKey(),
  groupId: text('group_id').notNull().references(() => groups.id, { onDelete: 'cascade' }),
  userId: text('user_id').notNull().references(() => users.id),
  amount: decimal('amount', { precision: 10, scale: 2 }).notNull(),
  month: integer('month').notNull(),
  year: integer('year').notNull(),
  status: paymentStatusEnum('status').notNull().default('pending'),
  paidAt: timestamp('paid_at', { mode: 'date' }),
});

// Notifications table - in-app alerts for payment reminders and join requests
export const notifications = pgTable('notifications', {
  id: text('id').primaryKey(),
  userId: text('user_id').notNull().references(() => users.id),
  type: text('type').notNull(),
  message: text('message').notNull(),
  read: boolean('read').notNull().default(false),
  createdAt: timestamp('created_at', { mode: 'date' }).defaultNow(),
});

What does this code do?

  • groups stores each Spotify Family plan group. The maxMembers defaults to 6 because Spotify Family supports up to 6 accounts.
  • memberships links users to groups with a status field. New join requests start as pending until the group owner approves them.
  • payments tracks each member's payment for a specific month and year. The decimal type ensures accurate currency math.
  • notifications stores in-app alerts. The read boolean defaults to false so new notifications always appear as unread.
  • Save src/db/schema.ts.

Your schema file is now complete. You won't see a visible output yet because these tables only exist in code until you push them to Neon.

Seeing a red squiggly line in your editor?

If TypeScript shows errors on the references() calls, make sure the users table is defined above the tables that reference it. Drizzle resolves references in order.

help me fix TypeScript errors in my Drizzle schema file

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here's the complete src/db/index.ts:

import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';
import * as schema from './schema';

const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });

And here's the complete src/db/schema.ts:

import { pgTable, text, timestamp, integer, boolean, pgEnum, decimal } from 'drizzle-orm/pg-core';

export const membershipStatusEnum = pgEnum('membership_status', ['pending', 'active', 'rejected']);
export const paymentStatusEnum = pgEnum('payment_status', ['pending', 'paid']);

export const users = pgTable('users', {
  id: text('id').primaryKey(),
  name: text('name'),
  email: text('email').notNull().unique(),
  emailVerified: timestamp('email_verified', { mode: 'date' }),
  image: text('image'),
});

export const accounts = pgTable('accounts', {
  userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  type: text('type').notNull(),
  provider: text('provider').notNull(),
  providerAccountId: text('provider_account_id').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'),
});

export const sessions = pgTable('sessions', {
  sessionToken: text('session_token').primaryKey(),
  userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  expires: timestamp('expires', { mode: 'date' }).notNull(),
});

export const verificationTokens = pgTable('verification_tokens', {
  identifier: text('identifier').notNull(),
  token: text('token').notNull(),
  expires: timestamp('expires', { mode: 'date' }).notNull(),
});

export const groups = pgTable('groups', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  ownerId: text('owner_id').notNull().references(() => users.id),
  monthlyCost: decimal('monthly_cost', { precision: 10, scale: 2 }).notNull(),
  maxMembers: integer('max_members').notNull().default(6),
  description: text('description'),
  createdAt: timestamp('created_at', { mode: 'date' }).defaultNow(),
});

export const memberships = pgTable('memberships', {
  id: text('id').primaryKey(),
  groupId: text('group_id').notNull().references(() => groups.id, { onDelete: 'cascade' }),
  userId: text('user_id').notNull().references(() => users.id),
  status: membershipStatusEnum('status').notNull().default('pending'),
  joinedAt: timestamp('joined_at', { mode: 'date' }).defaultNow(),
});

export const payments = pgTable('payments', {
  id: text('id').primaryKey(),
  groupId: text('group_id').notNull().references(() => groups.id, { onDelete: 'cascade' }),
  userId: text('user_id').notNull().references(() => users.id),
  amount: decimal('amount', { precision: 10, scale: 2 }).notNull(),
  month: integer('month').notNull(),
  year: integer('year').notNull(),
  status: paymentStatusEnum('status').notNull().default('pending'),
  paidAt: timestamp('paid_at', { mode: 'date' }),
});

export const notifications = pgTable('notifications', {
  id: text('id').primaryKey(),
  userId: text('user_id').notNull().references(() => users.id),
  type: text('type').notNull(),
  message: text('message').notNull(),
  read: boolean('read').notNull().default(false),
  createdAt: timestamp('created_at', { mode: 'date' }).defaultNow(),
});
Configure Drizzle Kit and push the schema

Drizzle Kit is the CLI tool that reads your schema and applies it to your database. You need a config file to tell it where your schema lives and how to connect.

  • Create a file called drizzle.config.ts in the root of your spotifam folder (the same level as package.json).
  • Add the following code to drizzle.config.ts:
import type { Config } from 'drizzle-kit';

export default {
  // Point to your schema file
  schema: './src/db/schema.ts',
  // Output folder for generated migration files
  out: './drizzle',
  // Use PostgreSQL dialect for Neon
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
} satisfies Config;

What does this code do?

This config tells Drizzle Kit where to find your schema (./src/db/schema.ts) and how to connect to your database using the DATABASE_URL from your environment variables.

The satisfies Config at the end gives you TypeScript autocompletion without changing the runtime type of the export.

  • Save drizzle.config.ts.
  • Push your schema to Neon by running this command in your terminal:
npm run db:push

You should see output confirming that the tables and enums were created. Drizzle Kit reads your schema, generates the SQL, and applies it directly to your Neon database.

Why db:push instead of migrations?

The drizzle-kit push command pushes your schema directly to the database without generating migration files. This is simpler during development. For production apps, you'd use drizzle-kit generate to create versioned migration files instead.

Seeing a connection error?

Make sure your DATABASE_URL in .env.local matches the connection string from your Neon dashboard exactly. The format should be postgresql://user:password@endpoint_hostname.neon.tech:port/dbname?sslmode=require.

help me fix my Neon database connection error when running drizzle-kit push

Verify tables are created in the Neon console
  • Open the Neon console in your browser at console.neon.tech.
  • Select your spotifam project.
  • Click Tables in the left sidebar.

You should see all seven tables listed: users, accounts, sessions, verification_tokens, groups, memberships, payments, and notifications.

Click into a table to explore

Click on any table name in the Neon console to see its columns, types, and constraints. This is a great way to confirm your schema looks exactly how you defined it in code.

Your database is designed and live. Next up, you'll wire up GitHub authentication so users can actually sign in and interact with these tables.

Set Up Authentication

Your database schema is live on Neon with tables ready to store users, groups, and payments.

Now you need a way to identify who is using SpotiFam. Without authentication, anyone could access dashboards or join groups anonymously. In this step, you will configure Auth.js (NextAuth v5) with GitHub OAuth so users can sign in securely and get linked to a database record.

In this step, get ready to:
  • Configure Auth.js with a GitHub OAuth provider and the Drizzle adapter.
  • Create the auth API route handler and middleware for protected routes.
  • Build a sign-in page with the dark theme and test the login flow.
Register a GitHub OAuth app

Auth.js needs credentials from a GitHub OAuth app to handle the sign-in handshake. You will register a new OAuth app in GitHub's Developer Settings, then copy its Client ID and Client Secret into your environment variables.

  • Open GitHub in your browser and click your profile picture in the top-right corner.
  • Click Settings.
  • In the left sidebar, scroll down and click Developer settings.
  • In the left sidebar, click OAuth Apps.
  • Click New OAuth App (or Register a new application if this is your first app).
  • In the Application name field, type SpotiFam.
  • In the Homepage URL field, type http://localhost:3000.
  • In the Authorization callback URL field, type http://localhost:3000/api/auth/callback/github.
  • Click Register application.

GitHub now shows your new app's details page with a Client ID at the top.

  • Copy the Client ID value.
  • Click Generate a new client secret.
  • Copy the generated secret immediately. You will not be able to see it again after you leave this page.

What is an OAuth app?

An OAuth app is a registration that tells GitHub your application is allowed to request user sign-ins. When a user clicks "Sign in with GitHub," GitHub checks the Client ID and callback URL to verify the request is legitimate.

After the user approves, GitHub redirects them back to your callback URL with a temporary code. Auth.js exchanges that code for an access token behind the scenes.

Now update your environment variables with the real credentials.

  • Open .env.local in VS Code.
  • Find this line:
AUTH_GITHUB_ID=your-github-client-id
  • Replace your-github-client-id with the Client ID you copied from GitHub.
  • Find this line:
AUTH_GITHUB_SECRET=your-github-client-secret
  • Replace your-github-client-secret with the Client Secret you generated.
  • Save .env.local.

Lost your client secret?

GitHub only shows the secret once. If you navigated away before copying it, go back to your OAuth app settings and click Generate a new client secret to create a fresh one.

Help me set up my GitHub OAuth credentials.

Next, create the Auth.js configuration file that ties everything together.

  • Create a new file at src/auth.ts and paste the following code:
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/db';

// Configure NextAuth with GitHub OAuth and Drizzle for database sessions
export const { auth, handlers, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  providers: [GitHub],
  pages: {
    signIn: '/signin',
  },
});

What does this code do?

NextAuth initializes the authentication system. The DrizzleAdapter(db) tells Auth.js to store user accounts and sessions directly in your Neon database using the tables you created in Step 2.

The providers: [GitHub] array registers GitHub as a sign-in option. Auth.js automatically reads AUTH_GITHUB_ID and AUTH_GITHUB_SECRET from your environment variables.

The pages object points to a custom sign-in page instead of the default Auth.js one.

  • Save src/auth.ts.

Seeing a module not found error?

Make sure you have next-auth and @auth/drizzle-adapter installed from Step 1. Check that the import path @/db resolves to your src/db/index.ts file.

Help me debug my Auth.js configuration.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/auth.ts.

ⓧ I'd like to double check the full code

import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/db';

export const { auth, handlers, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  providers: [GitHub],
  pages: {
    signIn: '/signin',
  },
});
Create the API route and middleware

Auth.js needs an API route to handle OAuth callbacks from GitHub. You also need middleware to protect certain routes so only signed-in users can access them.

  • Create the folder path src/app/api/auth/[...nextauth]/ inside your project.
  • Create a new file at src/app/api/auth/[...nextauth]/route.ts and paste the following code:
import { handlers } from '@/auth';

// Export the GET and POST handlers for the Auth.js API route
export const { GET, POST } = handlers;

What does this route do?

The [...nextauth] folder is a Next.js catch-all route. It handles all requests to /api/auth/* including the GitHub callback, sign-in, sign-out, and session endpoints.

  • Save route.ts.

Now create the middleware that redirects unauthenticated users away from protected pages.

  • Create a new file at middleware.ts in the project root (the same level as package.json) and paste the following code:
import { auth } from '@/auth';
import { NextResponse } from 'next/server';

// Wrap the auth helper to check login status on protected routes
export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isProtected = req.nextUrl.pathname.startsWith('/dashboard') || req.nextUrl.pathname.startsWith('/groups');

  // Redirect unauthenticated users to the sign-in page
  if (isProtected && !isLoggedIn) {
    return NextResponse.redirect(new URL('/signin', req.nextUrl));
  }
  return NextResponse.next();
});

// Only run middleware on dashboard and group routes
export const config = {
  matcher: ['/dashboard/:path*', '/groups/:path*'],
};

What does this middleware do?

The auth() wrapper from Auth.js attaches the session to the request object. If a user visits /dashboard or /groups without being logged in, the middleware redirects them to /signin.

The matcher config limits which routes trigger the middleware. This prevents unnecessary checks on public pages.

  • Save middleware.ts.

Middleware not working?

The middleware.ts file must be in the project root (next to package.json). If you placed it inside src/, Next.js will not detect it.

Help me fix my Next.js middleware.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

import { handlers } from '@/auth';
export const { GET, POST } = handlers;
import { auth } from '@/auth';
import { NextResponse } from 'next/server';

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isProtected = req.nextUrl.pathname.startsWith('/dashboard') || req.nextUrl.pathname.startsWith('/groups');
  if (isProtected && !isLoggedIn) {
    return NextResponse.redirect(new URL('/signin', req.nextUrl));
  }
  return NextResponse.next();
});

export const config = {
  matcher: ['/dashboard/:path*', '/groups/:path*'],
};
Build the sign-in page and test the login flow

You need a custom sign-in page that matches SpotiFam's dark theme. You will also update the root layout to load the project's fonts before testing the full OAuth flow.

  • Open src/app/layout.tsx and replace its entire contents with the following code:
import type { Metadata } from 'next';
import { Plus_Jakarta_Sans, Inter } from 'next/font/google';
import './globals.css';

// Load Plus Jakarta Sans for headings
const plusJakarta = Plus_Jakarta_Sans({ subsets: ['latin'], variable: '--font-plus-jakarta' });
// Load Inter for body text and UI elements
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });

export const metadata: Metadata = {
  title: 'SpotiFam',
  description: 'Split your Spotify Family plan',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${plusJakarta.variable} ${inter.variable}`}>
      <body className="bg-[#0a0a0f] text-white font-inter antialiased">{children}</body>
    </html>
  );
}

What does this layout do?

The layout loads two Google Fonts and makes them available as CSS variables. Plus Jakarta Sans is used for headings. Inter is used for body text and numeric displays like payment amounts.

The bg-[#0a0a0f] class sets the dark background color across all pages. The antialiased class smooths font rendering.

  • Save src/app/layout.tsx.

Now create the sign-in page itself.

  • Create the folder src/app/signin/ inside your project.
  • Create a new file at src/app/signin/page.tsx and paste the following code:
import { signIn } from '@/auth';

export default function SignInPage() {
  return (
    <div className="min-h-screen bg-[#0a0a0f] flex items-center justify-center">
      <div className="bg-[#13131a] border border-[#2a2a3a] rounded-2xl p-10 flex flex-col items-center gap-6 w-full max-w-sm">
        {/* App branding */}
        <div className="flex flex-col items-center gap-2">
          <span className="text-4xl">🎵</span>
          <h1 className="text-2xl font-bold text-white font-plus-jakarta">SpotiFam</h1>
          <p className="text-[#8888aa] text-sm text-center">Split your Spotify Family plan with friends</p>
        </div>
        {/* Server action form triggers GitHub OAuth */}
        <form action={async () => { 'use server'; await signIn('github', { redirectTo: '/dashboard' }); }} className="w-full">
          <button type="submit" className="w-full bg-[#7c3aed] hover:bg-[#6d28d9] text-white font-semibold py-3 px-6 rounded-xl transition-colors flex items-center justify-center gap-2">
            Sign in with GitHub
          </button>
        </form>
      </div>
    </div>
  );
}

What does this page do?

The sign-in page displays a centered card with SpotiFam branding and a purple "Sign in with GitHub" button. The button triggers a server action that calls signIn('github'), which redirects the user to GitHub's OAuth consent screen.

After the user authorizes, GitHub sends them back to your callback URL. Auth.js creates a user record in the database and redirects to /dashboard.

  • Save src/app/signin/page.tsx.

Seeing a TypeScript error about server actions?

Make sure the 'use server' directive is the first statement inside the async arrow function. It must appear before await signIn(...).

Help me fix my server action in the sign-in page.

✔️ Awesome, I've got everything!

Great. Make sure both layout.tsx and signin/page.tsx are saved.

ⓧ I'd like to double check the full code

import type { Metadata } from 'next';
import { Plus_Jakarta_Sans, Inter } from 'next/font/google';
import './globals.css';

const plusJakarta = Plus_Jakarta_Sans({ subsets: ['latin'], variable: '--font-plus-jakarta' });
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });

export const metadata: Metadata = {
  title: 'SpotiFam',
  description: 'Split your Spotify Family plan',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${plusJakarta.variable} ${inter.variable}`}>
      <body className="bg-[#0a0a0f] text-white font-inter antialiased">{children}</body>
    </html>
  );
}
import { signIn } from '@/auth';

export default function SignInPage() {
  return (
    <div className="min-h-screen bg-[#0a0a0f] flex items-center justify-center">
      <div className="bg-[#13131a] border border-[#2a2a3a] rounded-2xl p-10 flex flex-col items-center gap-6 w-full max-w-sm">
        <div className="flex flex-col items-center gap-2">
          <span className="text-4xl">🎵</span>
          <h1 className="text-2xl font-bold text-white font-plus-jakarta">SpotiFam</h1>
          <p className="text-[#8888aa] text-sm text-center">Split your Spotify Family plan with friends</p>
        </div>
        <form action={async () => { 'use server'; await signIn('github', { redirectTo: '/dashboard' }); }} className="w-full">
          <button type="submit" className="w-full bg-[#7c3aed] hover:bg-[#6d28d9] text-white font-semibold py-3 px-6 rounded-xl transition-colors flex items-center justify-center gap-2">
            Sign in with GitHub
          </button>
        </form>
      </div>
    </div>
  );
}

Time to test the full authentication flow.

  • Start your development server by running:
npm run dev
  • Open your browser and navigate to http://localhost:3000/signin.

You should see a dark page with a centered card showing the SpotiFam logo, title, and a purple "Sign in with GitHub" button.

  • Click Sign in with GitHub.

GitHub will show an authorization screen asking you to grant SpotiFam access to your account.

  • Click Authorize on the GitHub consent screen.

After authorization, you will be redirected back to your app. Since the /dashboard page does not exist yet, you will see a 404 page. That is expected.

  • Open the Neon console, navigate to your spotifam database, and check the users table.

You should see a new row with your GitHub name, email, and profile image. Auth.js created this record automatically during the OAuth flow.

Getting an OAuth error or redirect mismatch?

Double check that the Authorization callback URL in your GitHub OAuth app settings is exactly http://localhost:3000/api/auth/callback/github. Any trailing slash or typo will cause a mismatch error.

Also verify that AUTH_GITHUB_ID and AUTH_GITHUB_SECRET in .env.local match the values on your GitHub app page. Restart the dev server after changing environment variables.

Help me debug my OAuth redirect error.

Authentication is wired up and verified. Next, you will build the group management pages where users can create and join Spotify Family groups.

Build the Group Management Pages

Authentication is working and your database schema is in place. Now you need a way for users to actually interact with the app.

In this step, you will build the core group management experience. Users will browse available Spotify Family groups, create their own group as a plan manager, and request to join existing groups.

In this step, get ready to:
  • Create a Browse Groups page showing available groups with open slots.
  • Build a Create Group form where the plan manager sets group details.
  • Implement join request logic so users can request to join a group.
Create the Browse Groups page

The Browse Groups page is where users discover available Spotify Family groups to join. Each group card shows the name, cost per member, and how many slots remain.

Before building the pages, you need to install a small utility called nanoid that generates unique IDs for new database records.

  • Install nanoid by running this command in your terminal (from the spotifam folder):
npm install nanoid

What is nanoid?

Nanoid generates short, URL-friendly unique identifiers. You will use these as primary keys for groups, memberships, and other records you insert into Neon.

Next, you will create a shared navigation bar that appears on every page of the app. This keeps the UI consistent and gives users quick access to Browse Groups, Dashboard, and Sign Out.

  • Create a new folder called components inside the src folder.
  • Create a new file called Navbar.tsx inside src/components.
  • Paste the following code into Navbar.tsx:
import Link from 'next/link';
import { signOut } from '@/auth';

export default function Navbar() {
  return (
    <nav className="bg-[#13131a] border-b border-[#2a2a3a] px-6 py-4 flex items-center justify-between">
      {/* Logo links back to the dashboard */}
      <Link href="/dashboard" className="text-xl font-bold text-white font-plus-jakarta flex items-center gap-2">🎵 SpotiFam</Link>
      <div className="flex items-center gap-6">
        {/* Main navigation links */}
        <Link href="/groups" className="text-[#8888aa] hover:text-white transition-colors">Browse Groups</Link>
        <Link href="/dashboard" className="text-[#8888aa] hover:text-white transition-colors">Dashboard</Link>
        {/* Server action for sign out */}
        <form action={async () => { 'use server'; await signOut({ redirectTo: '/signin' }); }}>
          <button type="submit" className="text-[#8888aa] hover:text-white transition-colors">Sign Out</button>
        </form>
      </div>
    </nav>
  );
}

What does this code do?

  • The Navbar is a server component that renders a dark navigation bar matching your app's theme.
  • It uses Next.js Link components for client-side navigation between Browse Groups and Dashboard.
  • The Sign Out button uses a server action that calls signOut() from Auth.js and redirects back to the sign-in page.
  • Save Navbar.tsx.

Now create the Browse Groups page itself. This is a server component that queries your Neon database directly and renders group cards.

  • Create a new folder called groups inside src/app.
  • Create a new file called page.tsx inside src/app/groups.
  • Paste the following code into src/app/groups/page.tsx:
import { db } from '@/db';
import { groups, memberships } from '@/db/schema';
import { eq, sql } from 'drizzle-orm';
import Link from 'next/link';
import Navbar from '@/components/Navbar';

export default async function GroupsPage() {
  // Query all groups and count their active members
  const allGroups = await db
    .select({
      id: groups.id,
      name: groups.name,
      description: groups.description,
      monthlyCost: groups.monthlyCost,
      maxMembers: groups.maxMembers,
      memberCount: sql<number>`count(${memberships.id}) filter (where ${memberships.status} = 'active')`,
    })
    .from(groups)
    .leftJoin(memberships, eq(groups.id, memberships.groupId))
    .groupBy(groups.id);

  return (
    <div className="min-h-screen bg-[#0a0a0f]">
      <Navbar />
      <main className="max-w-5xl mx-auto px-4 py-10">
        <div className="flex items-center justify-between mb-8">
          <h1 className="text-3xl font-bold text-white font-plus-jakarta">Browse Groups</h1>
          <Link href="/groups/create" className="bg-[#7c3aed] hover:bg-[#6d28d9] text-white font-semibold py-2 px-5 rounded-xl transition-colors">+ Create Group</Link>
        </div>
        {/* Grid of group cards */}
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
          {allGroups.map((group) => (
            <div key={group.id} className="bg-[#13131a] border border-[#2a2a3a] rounded-2xl p-6 flex flex-col gap-3">
              <h2 className="text-lg font-bold text-white">{group.name}</h2>
              <p className="text-[#8888aa] text-sm">{group.description}</p>
              <div className="flex items-center justify-between mt-2">
                <span className="text-[#22c55e] font-semibold">${(Number(group.monthlyCost) / group.maxMembers).toFixed(2)}/mo per member</span>
                <span className="text-[#8888aa] text-sm">{group.memberCount}/{group.maxMembers} members</span>
              </div>
              {/* Join request form posts to the join API route */}
              <form action={`/api/groups/${group.id}/join`} method="POST">
                <button type="submit" disabled={Number(group.memberCount) >= group.maxMembers} className="w-full mt-2 bg-[#7c3aed] hover:bg-[#6d28d9] disabled:opacity-50 text-white font-semibold py-2 rounded-xl transition-colors">Request to Join</button>
              </form>
            </div>
          ))}
        </div>
      </main>
    </div>
  );
}

What does this code do?

  • The GroupsPage is an async server component, which means it can query the database directly without needing a separate API call.
  • The Drizzle query uses a leftJoin to count active memberships per group, so each card shows how many slots are filled.
  • The cost per member is calculated by dividing monthlyCost by maxMembers, showing each person their share.
  • The "Request to Join" button is disabled when a group is already full.
  • Save src/app/groups/page.tsx.
  • Run your dev server with npm run dev and navigate to http://localhost:3000/groups in your browser.
  • You should see the dark-themed Browse Groups page with the Navbar at the top, a heading, and a purple "+ Create Group" button. The grid will be empty since no groups exist yet.

Page not loading or showing an error?

  • If you see a redirect to /signin, sign in with GitHub first. The middleware protects all /groups routes.
  • Check that your src/components/Navbar.tsx file path matches exactly. The import in the page uses @/components/Navbar.
  • Make sure your DATABASE_URL in .env.local is correct. The page queries Neon directly on load.

Help me debug my Browse Groups page

Build the Create Group form

Users need a way to create their own group. The Create Group page is a client component with a form that sends data to an API route.

You need the API route first so the form has somewhere to send its data. This route handles both listing groups (GET) and creating new ones (POST).

  • Create the folder path src/app/api/groups.
  • Create a new file called route.ts inside src/app/api/groups.
  • Paste the following code into src/app/api/groups/route.ts:
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { groups, memberships } from '@/db/schema';
import { auth } from '@/auth';
import { eq, sql } from 'drizzle-orm';
import { nanoid } from 'nanoid';

// GET: list all groups with their active member counts
export async function GET() {
  const allGroups = await db
    .select({
      id: groups.id,
      name: groups.name,
      description: groups.description,
      monthlyCost: groups.monthlyCost,
      maxMembers: groups.maxMembers,
      ownerId: groups.ownerId,
      createdAt: groups.createdAt,
      memberCount: sql<number>`count(${memberships.id}) filter (where ${memberships.status} = 'active')`,
    })
    .from(groups)
    .leftJoin(memberships, eq(groups.id, memberships.groupId))
    .groupBy(groups.id);
  return NextResponse.json(allGroups);
}

// POST: create a new group and auto-add the creator as an active member
export async function POST(req: Request) {
  const session = await auth();
  if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  const { name, description, monthlyCost } = await req.json();
  const groupId = nanoid();
  // Insert the group record
  const [group] = await db.insert(groups).values({
    id: groupId,
    name,
    description,
    monthlyCost: monthlyCost.toString(),
    ownerId: session.user.id,
  }).returning();
  // Auto-create an active membership for the group owner
  await db.insert(memberships).values({
    id: nanoid(),
    groupId,
    userId: session.user.id,
    status: 'active',
  });
  return NextResponse.json(group);
}

What does this code do?

  • The GET handler fetches all groups with a count of active members. This is the same query pattern as the Browse Groups page but exposed as an API endpoint.
  • The POST handler checks the user session, creates a new group with a nanoid, and automatically adds the creator as an active member.
  • The auto-membership ensures the plan manager is always counted as a member of their own group.
  • Save src/app/api/groups/route.ts.

Now build the Create Group page. This is a client component because it uses React state to manage form inputs.

  • Create a new folder called create inside src/app/groups.
  • Create a new file called page.tsx inside src/app/groups/create.
  • Paste the following code into src/app/groups/create/page.tsx:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Navbar from '@/components/Navbar';

export default function CreateGroupPage() {
  const router = useRouter();
  const [name, setName] = useState('');
  const [description, setDescription] = useState('');
  const [monthlyCost, setMonthlyCost] = useState('');

  // Handle form submission by posting to the groups API
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    await fetch('/api/groups', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, description, monthlyCost: parseFloat(monthlyCost) }),
    });
    // Redirect back to the browse page after creation
    router.push('/groups');
  }

  return (
    <div className="min-h-screen bg-[#0a0a0f]">
      <Navbar />
      <main className="max-w-lg mx-auto px-4 py-10">
        <h1 className="text-3xl font-bold text-white font-plus-jakarta mb-8">Create a Group</h1>
        <form onSubmit={handleSubmit} className="bg-[#13131a] border border-[#2a2a3a] rounded-2xl p-8 flex flex-col gap-5">
          <div className="flex flex-col gap-2">
            <label className="text-[#8888aa] text-sm">Group Name</label>
            <input value={name} onChange={e => setName(e.target.value)} required className="bg-[#0a0a0f] border border-[#2a2a3a] rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#7c3aed]" />
          </div>
          <div className="flex flex-col gap-2">
            <label className="text-[#8888aa] text-sm">Description</label>
            <textarea value={description} onChange={e => setDescription(e.target.value)} className="bg-[#0a0a0f] border border-[#2a2a3a] rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#7c3aed] resize-none" rows={3} />
          </div>
          <div className="flex flex-col gap-2">
            <label className="text-[#8888aa] text-sm">Total Monthly Cost ($)</label>
            <input type="number" step="0.01" value={monthlyCost} onChange={e => setMonthlyCost(e.target.value)} required className="bg-[#0a0a0f] border border-[#2a2a3a] rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#7c3aed]" />
          </div>
          <button type="submit" className="bg-[#7c3aed] hover:bg-[#6d28d9] text-white font-semibold py-3 rounded-xl transition-colors mt-2">Create Group</button>
        </form>
      </main>
    </div>
  );
}

What does this code do?

  • The 'use client' directive makes this a client component so it can use React hooks like useState for form state.
  • On submit, it sends a POST request to /api/groups with the group name, description, and monthly cost.
  • After successful creation, router.push('/groups') redirects the user back to the Browse Groups page where they can see their new group.
  • Save src/app/groups/create/page.tsx.
  • Navigate to http://localhost:3000/groups/create in your browser.
  • You should see a form with fields for Group Name, Description, and Total Monthly Cost, plus a purple "Create Group" button.
  • Try creating a group. Enter Test Family for the name, any description, and 21.99 for the cost.
  • Click Create Group. You should be redirected to the Browse Groups page and see your new group card with "$3.67/mo per member" and "1/6 members".

Group not appearing after creation?

  • Open your browser DevTools (F12) and check the Network tab. Look for a POST request to /api/groups. If you see a 401 error, your session may have expired. Sign out and sign back in.
  • If you see a 500 error, check your terminal for the full error message. A common cause is the nanoid import failing. Confirm you ran npm install nanoid earlier.

Help me debug my Create Group form

Implement join request logic

The Browse Groups page already has a "Request to Join" button on each card. That button posts to an API route that does not exist yet.

This route creates a pending membership for the requesting user and sends a notification to the group owner so they know someone wants to join.

  • Create the folder path src/app/api/groups/[groupId]/join. The [groupId] square brackets make it a dynamic route segment in Next.js.
  • Create a new file called route.ts inside src/app/api/groups/[groupId]/join.
  • Paste the following code into src/app/api/groups/[groupId]/join/route.ts:
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { memberships, notifications, groups } from '@/db/schema';
import { auth } from '@/auth';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';

export async function POST(req: Request, { params }: { params: { groupId: string } }) {
  // Verify the user is logged in
  const session = await auth();
  if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  const { groupId } = params;
  // Create a pending membership for the requesting user
  await db.insert(memberships).values({
    id: nanoid(),
    groupId,
    userId: session.user.id,
    status: 'pending',
  });
  // Look up the group to find the owner
  const [group] = await db.select().from(groups).where(eq(groups.id, groupId));
  // Notify the group owner about the join request
  await db.insert(notifications).values({
    id: nanoid(),
    userId: group.ownerId,
    type: 'join_request',
    message: `${session.user.name} has requested to join your group "${group.name}".`,
    read: false,
  });
  return NextResponse.json({ success: true });
}

What does this code do?

  • The route extracts groupId from the URL params (the dynamic [groupId] folder name).
  • It inserts a new membership with status 'pending'. The group owner will need to approve it before the user becomes an active member.
  • It also creates a notification record for the group owner. You will build the notification UI in a later step.
  • Save src/app/api/groups/[groupId]/join/route.ts.
  • Navigate to http://localhost:3000/groups and click Request to Join on the group card you created earlier.
  • You should see the browser navigate to the API response showing {"success":true}. This confirms the join request was stored in the database.

Why does the browser show JSON?

The "Request to Join" button uses a standard HTML form that posts directly to the API route. The route returns JSON, so that is what the browser displays.

In a production app, you would use a client-side fetch and show a toast notification instead. For this project, the JSON response confirms the logic works.

Seeing a 500 error on join?

  • Check that your folder is named exactly [groupId] with square brackets. This is how Next.js recognizes dynamic route segments.
  • Verify the group you are trying to join actually exists in the database. Check the Neon console SQL editor to confirm.

Help me debug my join request API route

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here are the complete files you created in this step. Compare your code against these.

import Link from 'next/link';
import { signOut } from '@/auth';

export default function Navbar() {
  return (
    <nav className="bg-[#13131a] border-b border-[#2a2a3a] px-6 py-4 flex items-center justify-between">
      <Link href="/dashboard" className="text-xl font-bold text-white font-plus-jakarta flex items-center gap-2">🎵 SpotiFam</Link>
      <div className="flex items-center gap-6">
        <Link href="/groups" className="text-[#8888aa] hover:text-white transition-colors">Browse Groups</Link>
        <Link href="/dashboard" className="text-[#8888aa] hover:text-white transition-colors">Dashboard</Link>
        <form action={async () => { 'use server'; await signOut({ redirectTo: '/signin' }); }}>
          <button type="submit" className="text-[#8888aa] hover:text-white transition-colors">Sign Out</button>
        </form>
      </div>
    </nav>
  );
}
import { db } from '@/db';
import { groups, memberships } from '@/db/schema';
import { eq, sql } from 'drizzle-orm';
import Link from 'next/link';
import Navbar from '@/components/Navbar';

export default async function GroupsPage() {
  const allGroups = await db
    .select({
      id: groups.id,
      name: groups.name,
      description: groups.description,
      monthlyCost: groups.monthlyCost,
      maxMembers: groups.maxMembers,
      memberCount: sql<number>`count(${memberships.id}) filter (where ${memberships.status} = 'active')`,
    })
    .from(groups)
    .leftJoin(memberships, eq(groups.id, memberships.groupId))
    .groupBy(groups.id);

  return (
    <div className="min-h-screen bg-[#0a0a0f]">
      <Navbar />
      <main className="max-w-5xl mx-auto px-4 py-10">
        <div className="flex items-center justify-between mb-8">
          <h1 className="text-3xl font-bold text-white font-plus-jakarta">Browse Groups</h1>
          <Link href="/groups/create" className="bg-[#7c3aed] hover:bg-[#6d28d9] text-white font-semibold py-2 px-5 rounded-xl transition-colors">+ Create Group</Link>
        </div>
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
          {allGroups.map((group) => (
            <div key={group.id} className="bg-[#13131a] border border-[#2a2a3a] rounded-2xl p-6 flex flex-col gap-3">
              <h2 className="text-lg font-bold text-white">{group.name}</h2>
              <p className="text-[#8888aa] text-sm">{group.description}</p>
              <div className="flex items-center justify-between mt-2">
                <span className="text-[#22c55e] font-semibold">${(Number(group.monthlyCost) / group.maxMembers).toFixed(2)}/mo per member</span>
                <span className="text-[#8888aa] text-sm">{group.memberCount}/{group.maxMembers} members</span>
              </div>
              <form action={`/api/groups/${group.id}/join`} method="POST">
                <button type="submit" disabled={Number(group.memberCount) >= group.maxMembers} className="w-full mt-2 bg-[#7c3aed] hover:bg-[#6d28d9] disabled:opacity-50 text-white font-semibold py-2 rounded-xl transition-colors">Request to Join</button>
              </form>
            </div>
          ))}
        </div>
      </main>
    </div>
  );
}
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Navbar from '@/components/Navbar';

export default function CreateGroupPage() {
  const router = useRouter();
  const [name, setName] = useState('');
  const [description, setDescription] = useState('');
  const [monthlyCost, setMonthlyCost] = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    await fetch('/api/groups', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, description, monthlyCost: parseFloat(monthlyCost) }),
    });
    router.push('/groups');
  }

  return (
    <div className="min-h-screen bg-[#0a0a0f]">
      <Navbar />
      <main className="max-w-lg mx-auto px-4 py-10">
        <h1 className="text-3xl font-bold text-white font-plus-jakarta mb-8">Create a Group</h1>
        <form onSubmit={handleSubmit} className="bg-[#13131a] border border-[#2a2a3a] rounded-2xl p-8 flex flex-col gap-5">
          <div className="flex flex-col gap-2">
            <label className="text-[#8888aa] text-sm">Group Name</label>
            <input value={name} onChange={e => setName(e.target.value)} required className="bg-[#0a0a0f] border border-[#2a2a3a] rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#7c3aed]" />
          </div>
          <div className="flex flex-col gap-2">
            <label className="text-[#8888aa] text-sm">Description</label>
            <textarea value={description} onChange={e => setDescription(e.target.value)} className="bg-[#0a0a0f] border border-[#2a2a3a] rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#7c3aed] resize-none" rows={3} />
          </div>
          <div className="flex flex-col gap-2">
            <label className="text-[#8888aa] text-sm">Total Monthly Cost ($)</label>
            <input type="number" step="0.01" value={monthlyCost} onChange={e => setMonthlyCost(e.target.value)} required className="bg-[#0a0a0f] border border-[#2a2a3a] rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#7c3aed]" />
          </div>
          <button type="submit" className="bg-[#7c3aed] hover:bg-[#6d28d9] text-white font-semibold py-3 rounded-xl transition-colors mt-2">Create Group</button>
        </form>
      </main>
    </div>
  );
}
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { groups, memberships } from '@/db/schema';
import { auth } from '@/auth';
import { eq, sql } from 'drizzle-orm';
import { nanoid } from 'nanoid';

export async function GET() {
  const allGroups = await db
    .select({
      id: groups.id,
      name: groups.name,
      description: groups.description,
      monthlyCost: groups.monthlyCost,
      maxMembers: groups.maxMembers,
      ownerId: groups.ownerId,
      createdAt: groups.createdAt,
      memberCount: sql<number>`count(${memberships.id}) filter (where ${memberships.status} = 'active')`,
    })
    .from(groups)
    .leftJoin(memberships, eq(groups.id, memberships.groupId))
    .groupBy(groups.id);
  return NextResponse.json(allGroups);
}

export async function POST(req: Request) {
  const session = await auth();
  if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  const { name, description, monthlyCost } = await req.json();
  const groupId = nanoid();
  const [group] = await db.insert(groups).values({
    id: groupId,
    name,
    description,
    monthlyCost: monthlyCost.toString(),
    ownerId: session.user.id,
  }).returning();
  await db.insert(memberships).values({
    id: nanoid(),
    groupId,
    userId: session.user.id,
    status: 'active',
  });
  return NextResponse.json(group);
}
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { memberships, notifications, groups } from '@/db/schema';
import { auth } from '@/auth';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';

export async function POST(req: Request, { params }: { params: { groupId: string } }) {
  const session = await auth();
  if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  const { groupId } = params;
  await db.insert(memberships).values({
    id: nanoid(),
    groupId,
    userId: session.user.id,
    status: 'pending',
  });
  const [group] = await db.select().from(groups).where(eq(groups.id, groupId));
  await db.insert(notifications).values({
    id: nanoid(),
    userId: group.ownerId,
    type: 'join_request',
    message: `${session.user.name} has requested to join your group "${group.name}".`,
    read: false,
  });
  return NextResponse.json({ success: true });
}

Your group management pages are live. Users can browse groups, create their own, and send join requests. Next up, you will build the payment tracking dashboard so group members can track who owes what each month.

Build the Payment Tracking Dashboard

Users can now browse groups and request to join them. The next question is: how does everyone know who has paid their share this month?

In this step, you will build a payment tracking dashboard that shows each group member's payment status for the current billing cycle. The plan manager will be able to confirm payments, and the system will automatically scope everything to the current month and year.

In this step, get ready to:
  • Create API routes that fetch and create payment records scoped by month and year.
  • Implement "Mark as Paid" functionality so the plan manager can confirm member payments.
  • Build the dashboard pages that display groups and per-member payment status.
Create the payments API routes

The dashboard needs a backend to read and write payment records. You will create two API route files: one for listing and creating payments within a billing cycle, and another for confirming individual payments.

  • Create the folder path src/app/api/groups/[groupId]/payments/ inside your project.
  • Create a new file called route.ts inside that folder and paste the following code:
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { payments } from '@/db/schema';
import { auth } from '@/auth';
import { and, eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';

export async function GET(req: Request, { params }: { params: { groupId: string } }) {
  // Get the current month and year for billing cycle scoping
  const now = new Date();
  const month = now.getMonth() + 1;
  const year = now.getFullYear();
  // Fetch only payments matching this group AND this billing cycle
  const result = await db.select().from(payments).where(and(eq(payments.groupId, params.groupId), eq(payments.month, month), eq(payments.year, year)));
  return NextResponse.json(result);
}

export async function POST(req: Request, { params }: { params: { groupId: string } }) {
  const session = await auth();
  if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  // Extract payment details from the request body
  const { userId, amount, month, year } = await req.json();
  // Insert a new payment record for this billing cycle
  const [payment] = await db.insert(payments).values({
    id: nanoid(),
    groupId: params.groupId,
    userId,
    amount: amount.toString(),
    month,
    year,
    status: 'pending',
  }).returning();
  return NextResponse.json(payment);
}

What does this code do?

The GET handler fetches payment records filtered to the current month and year. This means every time a new month starts, the dashboard shows a fresh slate because no payment records exist for the new billing cycle yet.

The POST handler creates a new payment record scoped to a specific month and year. The plan manager uses this to establish a payment entry before confirming it.

  • Save src/app/api/groups/[groupId]/payments/route.ts.

Now create the route for confirming an individual payment. This is what the plan manager triggers when they mark someone as paid.

  • Create a new folder [paymentId] inside src/app/api/groups/[groupId]/payments/.
  • Create a new file called route.ts inside [paymentId]/ and paste:
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { payments, notifications } from '@/db/schema';
import { auth } from '@/auth';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';

export async function PATCH(req: Request, { params }: { params: { groupId: string; paymentId: string } }) {
  const session = await auth();
  if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  // Update the payment record to 'paid' and set the timestamp
  const [payment] = await db.update(payments).set({ status: 'paid', paidAt: new Date() }).where(eq(payments.id, params.paymentId)).returning();
  // Send a notification to the member whose payment was confirmed
  await db.insert(notifications).values({
    id: nanoid(),
    userId: payment.userId,
    type: 'payment_confirmed',
    message: `Your payment of $${payment.amount} has been confirmed for this month.`,
    read: false,
  });
  return NextResponse.json(payment);
}

What does this code do?

The PATCH handler updates a payment record's status to paid and stamps it with the current date. It also inserts a notification for the member so they know their payment was confirmed.

  • Save src/app/api/groups/[groupId]/payments/[paymentId]/route.ts.

Seeing a TypeScript error on params?

Make sure your folder structure exactly matches the dynamic route pattern: [groupId] and [paymentId] must include the square brackets in the folder names.

Help me fix my Next.js dynamic route params

Implement the Mark as Paid button

The plan manager needs a way to trigger the payment confirmation from the UI. Since clicking a button requires client-side interactivity, you will build this as a client component.

  • Create a new file called MarkPaidButton.tsx inside src/components/ and paste the following code:
'use client';
import { useRouter } from 'next/navigation';

export default function MarkPaidButton({ groupId, userId, paymentId, amount, month, year }: { groupId: string; userId: string; paymentId?: string; amount: string; month: number; year: number }) {
  const router = useRouter();

  async function handleClick() {
    if (paymentId) {
      // Payment record already exists, just mark it as paid
      await fetch(`/api/groups/${groupId}/payments/${paymentId}`, { method: 'PATCH' });
    } else {
      // No payment record yet — create one first, then mark it as paid
      const res = await fetch(`/api/groups/${groupId}/payments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userId, amount, month, year }),
      });
      const newPayment = await res.json();
      await fetch(`/api/groups/${groupId}/payments/${newPayment.id}`, { method: 'PATCH' });
    }
    // Refresh the page to show updated payment status
    router.refresh();
  }

  return (
    <button onClick={handleClick} className="bg-[#22c55e] hover:bg-[#16a34a] text-black font-semibold px-3 py-1 rounded-lg text-sm transition-colors">Mark Paid</button>
  );
}

What does this code do?

This component handles two scenarios. If a payment record already exists for this member in the current billing cycle, it calls PATCH directly to mark it as paid. If no record exists yet, it first creates one via POST, then immediately patches it to paid.

After the API calls complete, router.refresh() re-fetches the server component data so the UI updates without a full page reload.

  • Save src/components/MarkPaidButton.tsx.

Button not doing anything when clicked?

Make sure 'use client' is the very first line of the file. Without it, Next.js treats the component as a server component and the onClick handler won't work.

Help me debug my client component

Build the dashboard pages with monthly billing cycle logic

The billing cycle logic works like this: the server derives the current month and year from new Date(). If no payment record exists for a member in that cycle, they are shown as pending. When a new month starts, there are no records for it yet, so everyone resets to pending automatically.

First, create the dashboard overview page that shows all groups the current user belongs to.

  • Create the folder src/app/dashboard/ inside your project.
  • Create a new file called page.tsx inside src/app/dashboard/ and paste the following code:
import { auth } from '@/auth';
import { db } from '@/db';
import { memberships, groups } from '@/db/schema';
import { eq } from 'drizzle-orm';
import Link from 'next/link';
import Navbar from '@/components/Navbar';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const session = await auth();
  if (!session?.user?.id) redirect('/signin');

  // Fetch all groups where the current user has an active membership
  const userMemberships = await db
    .select({ group: groups })
    .from(memberships)
    .innerJoin(groups, eq(memberships.groupId, groups.id))
    .where(eq(memberships.userId, session.user.id))
    .where(eq(memberships.status, 'active'));

  return (
    <div className="min-h-screen bg-[#0a0a0f]">
      <Navbar />
      <main className="max-w-5xl mx-auto px-4 py-10">
        <h1 className="text-3xl font-bold text-white font-plus-jakarta mb-8">My Groups</h1>
        {userMemberships.length === 0 ? (
          <div className="text-[#8888aa] text-center py-20">
            <p className="text-lg mb-4">You haven't joined any groups yet.</p>
            <Link href="/groups" className="bg-[#7c3aed] hover:bg-[#6d28d9] text-white font-semibold py-2 px-5 rounded-xl transition-colors">Browse Groups</Link>
          </div>
        ) : (
          <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
            {userMemberships.map(({ group }) => (
              <Link key={group.id} href={`/dashboard/${group.id}`} className="bg-[#13131a] border border-[#2a2a3a] rounded-2xl p-6 hover:border-[#7c3aed] transition-colors">
                <h2 className="text-lg font-bold text-white mb-1">{group.name}</h2>
                <p className="text-[#8888aa] text-sm mb-3">{group.description}</p>
                <span className="text-[#22c55e] font-semibold">${(Number(group.monthlyCost) / group.maxMembers).toFixed(2)}/mo</span>
              </Link>
            ))}
          </div>
        )}
      </main>
    </div>
  );
}

What does this code do?

This page queries for all groups where the current user has an active membership status. Each group card links to a detail dashboard at /dashboard/[groupId] where they can see payment statuses.

If the user has no groups yet, a friendly empty state with a link to browse groups is shown instead.

  • Save src/app/dashboard/page.tsx.

Now create the group detail page that shows every member's payment status for the current billing cycle.

  • Create a new folder [groupId] inside src/app/dashboard/.
  • Create a new file called page.tsx inside src/app/dashboard/[groupId]/ and paste the following code:
import { auth } from '@/auth';
import { db } from '@/db';
import { memberships, groups, payments, users } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import Navbar from '@/components/Navbar';
import { redirect } from 'next/navigation';
import MarkPaidButton from '@/components/MarkPaidButton';

export default async function GroupDashboardPage({ params }: { params: { groupId: string } }) {
  const session = await auth();
  if (!session?.user?.id) redirect('/signin');

  const { groupId } = params;
  // Derive the current billing cycle from the server clock
  const now = new Date();
  const month = now.getMonth() + 1;
  const year = now.getFullYear();

  const [group] = await db.select().from(groups).where(eq(groups.id, groupId));
  const isOwner = group.ownerId === session.user.id;

  // Fetch all active members with their user info
  const activeMembers = await db
    .select({ user: users, membership: memberships })
    .from(memberships)
    .innerJoin(users, eq(memberships.userId, users.id))
    .where(and(eq(memberships.groupId, groupId), eq(memberships.status, 'active')));

  // Fetch payment records for this group in the current billing cycle
  const monthPayments = await db
    .select()
    .from(payments)
    .where(and(eq(payments.groupId, groupId), eq(payments.month, month), eq(payments.year, year)));

  // Map payments by userId for quick lookup
  const paymentMap = Object.fromEntries(monthPayments.map(p => [p.userId, p]));
  const costPerMember = (Number(group.monthlyCost) / group.maxMembers).toFixed(2);

  return (
    <div className="min-h-screen bg-[#0a0a0f]">
      <Navbar />
      <main className="max-w-3xl mx-auto px-4 py-10">
        <h1 className="text-3xl font-bold text-white font-plus-jakarta mb-2">{group.name}</h1>
        <p className="text-[#8888aa] mb-8">{new Date(year, month - 1).toLocaleString('default', { month: 'long', year: 'numeric' })} billing cycle</p>
        <div className="bg-[#13131a] border border-[#2a2a3a] rounded-2xl overflow-hidden">
          {activeMembers.map(({ user, membership }) => {
            const payment = paymentMap[user.id];
            const isPaid = payment?.status === 'paid';
            return (
              <div key={user.id} className="flex items-center justify-between px-6 py-4 border-b border-[#2a2a3a] last:border-0">
                <div className="flex items-center gap-3">
                  {user.image && <img src={user.image} alt={user.name ?? ''} className="w-9 h-9 rounded-full" />}
                  <span className="text-white font-medium">{user.name}</span>
                </div>
                <div className="flex items-center gap-4">
                  <span className="text-[#8888aa]">${costPerMember}</span>
                  <span className={`px-3 py-1 rounded-full text-xs font-semibold ${isPaid ? 'bg-[#14532d] text-[#22c55e]' : 'bg-[#3b1f6e] text-[#a78bfa]'}`}>{isPaid ? 'Paid' : 'Pending'}</span>
                  {isOwner && !isPaid && <MarkPaidButton groupId={groupId} userId={user.id} paymentId={payment?.id} amount={costPerMember} month={month} year={year} />}
                </div>
              </div>
            );
          })}
        </div>
      </main>
    </div>
  );
}

How does the billing cycle reset work?

There is no cron job or scheduled task that resets payment statuses. The system derives month and year from new Date() on the server each time the page loads.

When a new month starts, the query for payments filtered by that month returns an empty set. Every member appears as "Pending" because no payment records exist for the new cycle yet. The old month's records stay in the database as a historical log.

  • Save src/app/dashboard/[groupId]/page.tsx.
  • Make sure your dev server is running, then navigate to http://localhost:3000/dashboard in your browser.
  • You should see the "My Groups" heading. If you created a group earlier, it appears as a clickable card showing the group name and per-member cost.
  • Click on a group card to open the detail dashboard. You should see a list of active members with their payment status showing "Pending" in purple and a green "Mark Paid" button (if you are the group owner).

Dashboard showing no groups even though you created one?

The dashboard only shows groups where your membership status is active. When you create a group, the API route in src/app/api/groups/route.ts automatically creates an active membership for the owner. If you joined via the "Request to Join" button, your status may still be pending.

Help me debug my dashboard query

✔️ Awesome, I've got everything!

Great. Double check you have saved all five new files before moving on.

ⓧ I'd like to double check the full code

Here are the complete files you created in this step. Compare them against your code.

import { NextResponse } from 'next/server';
import { db } from '@/db';
import { payments } from '@/db/schema';
import { auth } from '@/auth';
import { and, eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';

export async function GET(req: Request, { params }: { params: { groupId: string } }) {
  const now = new Date();
  const month = now.getMonth() + 1;
  const year = now.getFullYear();
  const result = await db.select().from(payments).where(and(eq(payments.groupId, params.groupId), eq(payments.month, month), eq(payments.year, year)));
  return NextResponse.json(result);
}

export async function POST(req: Request, { params }: { params: { groupId: string } }) {
  const session = await auth();
  if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  const { userId, amount, month, year } = await req.json();
  const [payment] = await db.insert(payments).values({
    id: nanoid(),
    groupId: params.groupId,
    userId,
    amount: amount.toString(),
    month,
    year,
    status: 'pending',
  }).returning();
  return NextResponse.json(payment);
}
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { payments, notifications } from '@/db/schema';
import { auth } from '@/auth';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';

export async function PATCH(req: Request, { params }: { params: { groupId: string; paymentId: string } }) {
  const session = await auth();
  if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  const [payment] = await db.update(payments).set({ status: 'paid', paidAt: new Date() }).where(eq(payments.id, params.paymentId)).returning();
  await db.insert(notifications).values({
    id: nanoid(),
    userId: payment.userId,
    type: 'payment_confirmed',
    message: `Your payment of $${payment.amount} has been confirmed for this month.`,
    read: false,
  });
  return NextResponse.json(payment);
}
'use client';
import { useRouter } from 'next/navigation';

export default function MarkPaidButton({ groupId, userId, paymentId, amount, month, year }: { groupId: string; userId: string; paymentId?: string; amount: string; month: number; year: number }) {
  const router = useRouter();

  async function handleClick() {
    if (paymentId) {
      await fetch(`/api/groups/${groupId}/payments/${paymentId}`, { method: 'PATCH' });
    } else {
      const res = await fetch(`/api/groups/${groupId}/payments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userId, amount, month, year }),
      });
      const newPayment = await res.json();
      await fetch(`/api/groups/${groupId}/payments/${newPayment.id}`, { method: 'PATCH' });
    }
    router.refresh();
  }

  return (
    <button onClick={handleClick} className="bg-[#22c55e] hover:bg-[#16a34a] text-black font-semibold px-3 py-1 rounded-lg text-sm transition-colors">Mark Paid</button>
  );
}
import { auth } from '@/auth';
import { db } from '@/db';
import { memberships, groups } from '@/db/schema';
import { eq } from 'drizzle-orm';
import Link from 'next/link';
import Navbar from '@/components/Navbar';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const session = await auth();
  if (!session?.user?.id) redirect('/signin');

  const userMemberships = await db
    .select({ group: groups })
    .from(memberships)
    .innerJoin(groups, eq(memberships.groupId, groups.id))
    .where(eq(memberships.userId, session.user.id))
    .where(eq(memberships.status, 'active'));

  return (
    <div className="min-h-screen bg-[#0a0a0f]">
      <Navbar />
      <main className="max-w-5xl mx-auto px-4 py-10">
        <h1 className="text-3xl font-bold text-white font-plus-jakarta mb-8">My Groups</h1>
        {userMemberships.length === 0 ? (
          <div className="text-[#8888aa] text-center py-20">
            <p className="text-lg mb-4">You haven't joined any groups yet.</p>
            <Link href="/groups" className="bg-[#7c3aed] hover:bg-[#6d28d9] text-white font-semibold py-2 px-5 rounded-xl transition-colors">Browse Groups</Link>
          </div>
        ) : (
          <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
            {userMemberships.map(({ group }) => (
              <Link key={group.id} href={`/dashboard/${group.id}`} className="bg-[#13131a] border border-[#2a2a3a] rounded-2xl p-6 hover:border-[#7c3aed] transition-colors">
                <h2 className="text-lg font-bold text-white mb-1">{group.name}</h2>
                <p className="text-[#8888aa] text-sm mb-3">{group.description}</p>
                <span className="text-[#22c55e] font-semibold">${(Number(group.monthlyCost) / group.maxMembers).toFixed(2)}/mo</span>
              </Link>
            ))}
          </div>
        )}
      </main>
    </div>
  );
}
import { auth } from '@/auth';
import { db } from '@/db';
import { memberships, groups, payments, users } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import Navbar from '@/components/Navbar';
import { redirect } from 'next/navigation';
import MarkPaidButton from '@/components/MarkPaidButton';

export default async function GroupDashboardPage({ params }: { params: { groupId: string } }) {
  const session = await auth();
  if (!session?.user?.id) redirect('/signin');

  const { groupId } = params;
  const now = new Date();
  const month = now.getMonth() + 1;
  const year = now.getFullYear();

  const [group] = await db.select().from(groups).where(eq(groups.id, groupId));
  const isOwner = group.ownerId === session.user.id;

  const activeMembers = await db
    .select({ user: users, membership: memberships })
    .from(memberships)
    .innerJoin(users, eq(memberships.userId, users.id))
    .where(and(eq(memberships.groupId, groupId), eq(memberships.status, 'active')));

  const monthPayments = await db
    .select()
    .from(payments)
    .where(and(eq(payments.groupId, groupId), eq(payments.month, month), eq(payments.year, year)));

  const paymentMap = Object.fromEntries(monthPayments.map(p => [p.userId, p]));
  const costPerMember = (Number(group.monthlyCost) / group.maxMembers).toFixed(2);

  return (
    <div className="min-h-screen bg-[#0a0a0f]">
      <Navbar />
      <main className="max-w-3xl mx-auto px-4 py-10">
        <h1 className="text-3xl font-bold text-white font-plus-jakarta mb-2">{group.name}</h1>
        <p className="text-[#8888aa] mb-8">{new Date(year, month - 1).toLocaleString('default', { month: 'long', year: 'numeric' })} billing cycle</p>
        <div className="bg-[#13131a] border border-[#2a2a3a] rounded-2xl overflow-hidden">
          {activeMembers.map(({ user, membership }) => {
            const payment = paymentMap[user.id];
            const isPaid = payment?.status === 'paid';
            return (
              <div key={user.id} className="flex items-center justify-between px-6 py-4 border-b border-[#2a2a3a] last:border-0">
                <div className="flex items-center gap-3">
                  {user.image && <img src={user.image} alt={user.name ?? ''} className="w-9 h-9 rounded-full" />}
                  <span className="text-white font-medium">{user.name}</span>
                </div>
                <div className="flex items-center gap-4">
                  <span className="text-[#8888aa]">${costPerMember}</span>
                  <span className={`px-3 py-1 rounded-full text-xs font-semibold ${isPaid ? 'bg-[#14532d] text-[#22c55e]' : 'bg-[#3b1f6e] text-[#a78bfa]'}`}>{isPaid ? 'Paid' : 'Pending'}</span>
                  {isOwner && !isPaid && <MarkPaidButton groupId={groupId} userId={user.id} paymentId={payment?.id} amount={costPerMember} month={month} year={year} />}
                </div>
              </div>
            );
          })}
        </div>
      </main>
    </div>
  );
}

Your payment tracking dashboard is live. Next up, you will add a notification system so members get alerts when payments are due or confirmed.

Add the Notification System

Your payment dashboard tracks who owes what, but members have no way of knowing when something happens in their group. A plan manager confirms a payment, or someone requests to join, and nobody finds out until they manually check.

In this step, you will build an in-app notification system that surfaces these events in a bell icon on the navbar. Your existing API routes already write notifications to the database. Now you will create endpoints to read them and a UI component to display them.

In this step, get ready to:
  • Create API routes to fetch and mark notifications as read.
  • Build a NotificationBell component with an unread badge and dropdown.
  • Wire the bell into the Navbar so notifications appear site-wide.
Create the notification API routes

Your join and payment routes already insert rows into the notifications table. You need two new endpoints: one to fetch all notifications for the current user, and one to mark a specific notification as read.

  • Create the folder path src/app/api/notifications/ inside your project.
  • Create a new file called route.ts inside that folder and add the following code:
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { notifications } from '@/db/schema';
import { auth } from '@/auth';
import { eq, desc } from 'drizzle-orm';

export async function GET() {
  // Check that a valid session exists
  const session = await auth();
  if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  // Fetch all notifications for this user, newest first
  const userNotifications = await db
    .select()
    .from(notifications)
    .where(eq(notifications.userId, session.user.id))
    .orderBy(desc(notifications.createdAt));

  return NextResponse.json(userNotifications);
}

What does this code do?

This GET handler authenticates the user, then queries the notifications table for all rows matching the current user's ID.

The desc(notifications.createdAt) orders results so the newest notification appears first in the dropdown.

  • Save src/app/api/notifications/route.ts.
  • Now create the nested folder path src/app/api/notifications/[notificationId]/read/.
  • Create a file called route.ts inside that folder and add the following code:
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { notifications } from '@/db/schema';
import { auth } from '@/auth';
import { eq } from 'drizzle-orm';

export async function PATCH(req: Request, { params }: { params: { notificationId: string } }) {
  // Verify the user is authenticated
  const session = await auth();
  if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  // Set read to true for the given notification
  const [notification] = await db
    .update(notifications)
    .set({ read: true })
    .where(eq(notifications.id, params.notificationId))
    .returning();

  return NextResponse.json(notification);
}

What does this code do?

This PATCH handler finds the notification by its dynamic notificationId param and flips the read column to true. The .returning() call sends the updated row back so the client can confirm the change.

  • Save src/app/api/notifications/[notificationId]/read/route.ts.

Seeing a folder structure error?

Make sure the square brackets are part of the actual folder name: [notificationId]. Next.js uses brackets to denote dynamic route segments. If you named it without brackets, the route will not capture the ID from the URL.

Help me fix my notification API route folder structure.

Build the NotificationBell component

The bell component is a client component that fetches notifications on mount, shows an unread count badge, and toggles a dropdown panel when clicked.

  • Create a new file called NotificationBell.tsx inside src/components/ and add the following code:
'use client';
import { useEffect, useState } from 'react';

type Notification = {
  id: string;
  type: string;
  message: string;
  read: boolean;
  createdAt: string;
};

export default function NotificationBell() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [open, setOpen] = useState(false);

  // Fetch all notifications for this user on component mount
  async function fetchNotifications() {
    const res = await fetch('/api/notifications');
    const data = await res.json();
    setNotifications(data);
  }

  useEffect(() => { fetchNotifications(); }, []);

  // Count how many notifications are still unread
  const unreadCount = notifications.filter(n => !n.read).length;

  // Mark a single notification as read via the API
  async function markRead(id: string) {
    await fetch(`/api/notifications/${id}/read`, { method: 'PATCH' });
    setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
  }

  return (
    <div className="relative">
      <button onClick={() => setOpen(!open)} className="relative text-[#8888aa] hover:text-white transition-colors">
        <span className="text-xl">🔔</span>
        {unreadCount > 0 && (
          <span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">{unreadCount}</span>
        )}
      </button>
      {open && (
        <div className="absolute right-0 mt-2 w-80 bg-[#13131a] border border-[#2a2a3a] rounded-2xl shadow-xl z-50 overflow-hidden">
          <div className="px-4 py-3 border-b border-[#2a2a3a]">
            <h3 className="text-white font-semibold">Notifications</h3>
          </div>
          <div className="max-h-80 overflow-y-auto">
            {notifications.length === 0 ? (
              <p className="text-[#8888aa] text-sm px-4 py-6 text-center">No notifications yet</p>
            ) : (
              notifications.map(n => (
                <div key={n.id} className={`px-4 py-3 border-b border-[#2a2a3a] last:border-0 ${!n.read ? 'bg-[#1a1a2e]' : ''}`}>
                  <p className="text-white text-sm">{n.message}</p>
                  <div className="flex items-center justify-between mt-1">
                    <span className="text-[#8888aa] text-xs">{new Date(n.createdAt).toLocaleDateString()}</span>
                    {!n.read && <button onClick={() => markRead(n.id)} className="text-[#7c3aed] text-xs hover:underline">Mark as read</button>}
                  </div>
                </div>
              ))
            )}
          </div>
        </div>
      )}
    </div>
  );
}

What does this component do?

  • On mount, it fetches /api/notifications and stores the results in state.
  • It calculates unreadCount by filtering for notifications where read is false, then renders a red badge if that count is above zero.
  • Clicking the bell toggles a dropdown panel. Unread items get a highlighted background (bg-[#1a1a2e]) so they stand out visually.
  • The markRead function calls the PATCH endpoint and optimistically updates local state so the badge count drops immediately.
  • Save src/components/NotificationBell.tsx.

Seeing a hydration error in the console?

Make sure the very first line of the file is 'use client'; with no blank lines above it. Without this directive, Next.js treats the file as a server component and hooks like useState will throw an error.

Help me debug my NotificationBell component.

Add the bell to the Navbar

The bell needs to appear on every authenticated page. Your Navbar component is already rendered across the dashboard and groups pages, so importing NotificationBell there makes it available site-wide.

  • Open src/components/Navbar.tsx.
  • Find the existing imports at the top of the file:
import Link from 'next/link';
import { signOut } from '@/auth';
  • Add a new import line directly below them:
import NotificationBell from './NotificationBell';
  • Now find the Dashboard link inside the <div className="flex items-center gap-6"> block:
<Link href="/dashboard" className="text-[#8888aa] hover:text-white transition-colors">Dashboard</Link>
  • Add <NotificationBell /> on the line directly below that Dashboard link, so it sits between the link and the sign-out form:
<Link href="/dashboard" className="text-[#8888aa] hover:text-white transition-colors">Dashboard</Link>
<NotificationBell />
<form action={async () => { 'use server'; await signOut({ redirectTo: '/signin' }); }}>
  • Save src/components/Navbar.tsx.
  • Refresh your browser on the dashboard page. You should see a bell icon in the navbar, between the Dashboard link and the Sign Out button.

Bell not appearing in the navbar?

Check that the import path is './NotificationBell' (relative from the same components folder). If you used '@/components/NotificationBell' that also works, but make sure the casing matches your filename exactly.

Help me figure out why my NotificationBell is not rendering.

✔️ 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 complete code for each file you created or modified in this step.

import { NextResponse } from 'next/server';
import { db } from '@/db';
import { notifications } from '@/db/schema';
import { auth } from '@/auth';
import { eq, desc } from 'drizzle-orm';

export async function GET() {
  const session = await auth();
  if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  const userNotifications = await db
    .select()
    .from(notifications)
    .where(eq(notifications.userId, session.user.id))
    .orderBy(desc(notifications.createdAt));
  return NextResponse.json(userNotifications);
}
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { notifications } from '@/db/schema';
import { auth } from '@/auth';
import { eq } from 'drizzle-orm';

export async function PATCH(req: Request, { params }: { params: { notificationId: string } }) {
  const session = await auth();
  if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  const [notification] = await db
    .update(notifications)
    .set({ read: true })
    .where(eq(notifications.id, params.notificationId))
    .returning();
  return NextResponse.json(notification);
}
'use client';
import { useEffect, useState } from 'react';

type Notification = {
  id: string;
  type: string;
  message: string;
  read: boolean;
  createdAt: string;
};

export default function NotificationBell() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [open, setOpen] = useState(false);

  async function fetchNotifications() {
    const res = await fetch('/api/notifications');
    const data = await res.json();
    setNotifications(data);
  }

  useEffect(() => { fetchNotifications(); }, []);

  const unreadCount = notifications.filter(n => !n.read).length;

  async function markRead(id: string) {
    await fetch(`/api/notifications/${id}/read`, { method: 'PATCH' });
    setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
  }

  return (
    <div className="relative">
      <button onClick={() => setOpen(!open)} className="relative text-[#8888aa] hover:text-white transition-colors">
        <span className="text-xl">🔔</span>
        {unreadCount > 0 && (
          <span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">{unreadCount}</span>
        )}
      </button>
      {open && (
        <div className="absolute right-0 mt-2 w-80 bg-[#13131a] border border-[#2a2a3a] rounded-2xl shadow-xl z-50 overflow-hidden">
          <div className="px-4 py-3 border-b border-[#2a2a3a]">
            <h3 className="text-white font-semibold">Notifications</h3>
          </div>
          <div className="max-h-80 overflow-y-auto">
            {notifications.length === 0 ? (
              <p className="text-[#8888aa] text-sm px-4 py-6 text-center">No notifications yet</p>
            ) : (
              notifications.map(n => (
                <div key={n.id} className={`px-4 py-3 border-b border-[#2a2a3a] last:border-0 ${!n.read ? 'bg-[#1a1a2e]' : ''}`}>
                  <p className="text-white text-sm">{n.message}</p>
                  <div className="flex items-center justify-between mt-1">
                    <span className="text-[#8888aa] text-xs">{new Date(n.createdAt).toLocaleDateString()}</span>
                    {!n.read && <button onClick={() => markRead(n.id)} className="text-[#7c3aed] text-xs hover:underline">Mark as read</button>}
                  </div>
                </div>
              ))
            )}
          </div>
        </div>
      )}
    </div>
  );
}
import Link from 'next/link';
import { signOut } from '@/auth';
import NotificationBell from './NotificationBell';

export default function Navbar() {
  return (
    <nav className="bg-[#13131a] border-b border-[#2a2a3a] px-6 py-4 flex items-center justify-between">
      <Link href="/dashboard" className="text-xl font-bold text-white font-plus-jakarta flex items-center gap-2">🎵 SpotiFam</Link>
      <div className="flex items-center gap-6">
        <Link href="/groups" className="text-[#8888aa] hover:text-white transition-colors">Browse Groups</Link>
        <Link href="/dashboard" className="text-[#8888aa] hover:text-white transition-colors">Dashboard</Link>
        <NotificationBell />
        <form action={async () => { 'use server'; await signOut({ redirectTo: '/signin' }); }}>
          <button type="submit" className="text-[#8888aa] hover:text-white transition-colors">Sign Out</button>
        </form>
      </div>
    </nav>
  );
}

To test the full flow, try requesting to join a group from a second browser session (or an incognito window with a different GitHub account). The group owner should see a red badge on the bell icon. Clicking the bell opens the dropdown with the join request message.

Your app now surfaces real-time activity to users without them having to hunt for changes. Next up, the Secret Mission challenges you to add group chat so members can communicate directly inside SpotiFam.

Secret mission

Add Real-Time Group Chat

Your SpotiFam group can track payments and send notifications, but members have no way to talk to each other directly. In this secret mission, you'll build a messaging system so group members can chat about payments, share proof, or coordinate within their family group.

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 Neon's free tier (0.5 GB storage, 100 CU-hours/month) and runs locally, so there are no ongoing costs unless you exceed the free tier limits.

Resources you used:

  • Next.js 15 app (local project files and node_modules).
  • Neon serverless Postgres database (free tier project with tables for users, groups, memberships, payments, and notifications).
  • GitHub OAuth application (registered under your GitHub account settings).
  • Environment variables in .env.local (database connection string, auth secrets, GitHub OAuth credentials).

Keep everything running

No action needed. Choose this if you are still actively building or want to extend SpotiFam with new features.

  • Your Neon database stays active on the free tier. It auto-suspends compute after 5 minutes of inactivity, so you will not consume CU-hours while idle.
  • Your local project files remain on your Desktop ready to develop.
  • Your GitHub OAuth app stays registered and functional.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and database stay intact for when you return.

  • Stop the Next.js dev server by pressing Ctrl+C in your terminal.
  • Your Neon database automatically suspends its compute when idle. No action needed there.
  • Keep your .env.local file intact so you can restart the project without reconfiguring credentials.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible.

  • Delete your Neon project by opening the Neon console and selecting your project.
  • Click Settings, then click Delete Project. This removes all database tables and data permanently.
  • Delete the GitHub OAuth app by going to your GitHub account Settings.
  • Select Developer settings, then OAuth Apps.
  • Select your SpotiFam app and click Delete application.
  • Delete the local project folder from your Desktop.

That's a Wrap!

That's a Wrap!

Nice work. You just built SpotiFam, a full subscription-splitting platform from scratch.

You've learned how to:

  • Build a full-stack Next.js 15 app with GitHub OAuth authentication, a serverless Postgres database on Neon, and type-safe queries through Drizzle ORM.
  • Design a multi-table relational schema for group management, membership tracking, and recurring monthly payments.
  • Create a payment tracking dashboard with monthly billing cycles and an in-app notification system for payment reminders and group activity.
  • Secret Mission: Added real-time group chat so members can communicate within their family group.

Ready to quiz yourself?