Build an Edu-Creator Marketplace
Build a two-sided marketplace connecting edu-creators with brands.
Introduction
30 Second Summary
Educational content creators are thriving on YouTube, Instagram, and TikTok. But education brands still struggle to find and connect with the right creators for partnerships.
In this project, you will build a two-sided marketplace where edu-creators build profiles and education brands discover and contact them. You will use Next.js for the frontend, Supabase for the backend, and deploy it live on Vercel.
What You'll Build
A full-stack edu-creator marketplace with role-based authentication, a relational database secured by Row-Level Security, creator profiles, a brand discovery dashboard with filtering, and a collaboration request flow.
By the end of this project, you'll have:
- A Supabase-powered backend with role-based authentication, a relational Postgres database, and Row-Level Security policies.
- A creator profile system where educational influencers showcase their niche, platforms, audience size, and collaboration rate.
- A brand discovery dashboard with real-time filtering by teaching niche and follower count.
- A Featured Creators section on the landing page that spotlights top edu-creators (Secret Mission).
Are there any prerequisites?
This project assumes basic familiarity with React and Next.js. You will need a GitLab account, a Supabase account (free tier), and a Vercel account (free tier).
Before We Start
Before We Start...
Set Up Your Project and Accounts
You are building a two-sided marketplace that connects educational content creators with brands. Before you write any application code, you need to set up the three services that power this project: Supabase for the database and authentication, Next.js for the frontend framework, and GitLab for version control.
In this step, you will create a Supabase project in the cloud, scaffold a Next.js app on your machine, and push everything to a GitLab repository.
In this step, get ready to:
- Create a Supabase project and grab your connection credentials.
- Scaffold a Next.js app with TypeScript and Tailwind CSS.
- Push the project to GitLab.
Create a Supabase project
Supabase is a hosted Postgres database that also provides built-in authentication and security policies. You need a Supabase project to store your marketplace data.
- Open your browser and go to database.new in the address bar.
- Sign up for a free Supabase account if you do not have one, or log in to your existing account.
- Click New Project.
- Enter edu-creator-marketplace as the project name.
- Enter a strong database password and save it somewhere safe (you will not need it in code, but you will need it if you connect to the database directly later).
- Select a region close to you.
- Click Create new project.
Supabase takes about a minute to finish provisioning. Wait until the dashboard loads.
Save your database password
Store this password in a password manager or a secure note. You cannot retrieve it later from the Supabase dashboard.
Once the project is ready, you need to copy two credentials that your Next.js app will use to talk to Supabase.
- In your Supabase project dashboard, click the Connect button in the top navigation bar.
- Copy the Project URL (it looks like https://abcdefg.supabase.co).
- Copy the Publishable Key (it starts with sb_publishable_).
- Paste both values into a temporary note. You will add them to your project shortly.
What are the Project URL and Publishable Key?
The Project URL is your Supabase project's unique address on the internet. The Publishable Key is a public key that identifies your app to Supabase. It is safe to include in client-side code because Row-Level Security policies control what data each user can access.
Don't see a Publishable Key?
If the Connect dialog only shows legacy anon and service_role keys, go to Settings, then API Keys. Open the API Keys tab, click Create new API Keys, and copy the Publishable key value. You can also use the legacy anon key in its place during the transition period.
help me find my Supabase publishable key or anon key
Scaffold the Next.js app
Next.js is the React framework you will use for the frontend. The create-next-app CLI scaffolds a project with TypeScript, Tailwind CSS, and the App Router already configured.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold the Next.js project by running this command:
npx create-next-app@latest edu-creator-marketplace --ts --use-npm
What will this command ask me?
The create-next-app wizard will ask you a series of configuration questions. Since the command already sets TypeScript (--ts) and npm (--use-npm), you will be prompted to choose between recommended defaults or custom settings. Choose No, customize settings so you can enable the src/ directory.
- When asked "Would you like to use the recommended Next.js defaults?", select No, customize settings.
- Enter Yes for the TypeScript prompt.
- Select ESLint for the linter prompt.
- Enter No for the React Compiler prompt.
- Enter Yes for the Tailwind CSS prompt.
- Enter Yes for the src/ directory prompt.
- Enter Yes for the App Router prompt.
- Enter No for the custom import alias prompt.
- Enter Yes for the AGENTS.md prompt.
The wizard creates the project folder and installs all dependencies. This takes about a minute.
- Move into the new project folder by running this command:
cd edu-creator-marketplace
- Install the Supabase client packages by running this command:
npm install @supabase/supabase-js @supabase/ssr
What do these packages do?
- @supabase/supabase-js is the main Supabase client library for querying your database and calling auth methods.
- @supabase/ssr provides server-side rendering helpers that handle cookies for authentication in Next.js.
- Confirm the packages installed successfully by running:
ls node_modules/@supabase
You should see ssr and supabase-js listed in the output.
Installation failed?
If npm install fails with a network error, check your internet connection and try again. If you see a permissions error, try running the command with sudo on macOS/Linux or running your terminal as Administrator on Windows.
help me fix an npm install error for @supabase/supabase-js and @supabase/ssr
Now you need to create an environment file so your app can connect to Supabase.
- Create the .env.local file in the root of your edu-creator-marketplace folder by running this command:
touch .env.local
- Open the edu-creator-marketplace folder in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the edu-creator-marketplace folder on your Desktop, then click Open.
- In the Explorer sidebar on the left, click on .env.local to open it.
- Paste the following into .env.local, replacing the placeholder values with the Project URL and Publishable Key you copied from Supabase:
NEXT_PUBLIC_SUPABASE_URL=[[SUPABASE_URL="your-supabase-project-url"]]
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=[[SUPABASE_KEY="your-supabase-publishable-key"]]
What do these environment variables do?
- NEXT_PUBLIC_SUPABASE_URL tells your app where your Supabase project lives on the internet.
- NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY authenticates your app with Supabase. The NEXT_PUBLIC_ prefix makes these variables available in the browser, which is safe because the publishable key is designed for client-side use.
- Save .env.local by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Now start the development server to make sure everything is working.
- Back in your terminal (inside the edu-creator-marketplace folder), start the dev server by running:
npm run dev
- Open your browser and go to http://localhost:3000.
You should see the default Next.js welcome page with the Next.js logo and links to documentation.
Not seeing the Next.js page?
Check your terminal for error messages. If you see EADDRINUSE, port 3000 is already in use by another app. Stop that app first, or the terminal output will show a different port number you can use instead.
If you see a TypeScript or module error, make sure you ran npm install @supabase/supabase-js @supabase/ssr from inside the edu-creator-marketplace folder.
help me fix my Next.js dev server not starting
Push to GitLab
With your project running locally, the next step is to push it to GitLab so you have version control and can deploy later from a remote repository.
- Open gitlab.com in your browser and log in.
- Click New project.
- Select Create blank project.
- Enter edu-creator-marketplace as the project name.
- Uncheck Initialize repository with a README (your local project already has files).
- Click Create project.
- Copy the HTTPS URL for your new GitLab repo (it looks like https://gitlab.com/your-username/edu-creator-marketplace.git).
- Stop the dev server in your terminal by pressing Ctrl+C.
- Initialize git and push your code to GitLab by running these commands:
git init
git remote add origin [[YOUR_GITLAB_URL="https://gitlab.com/your-username/edu-creator-marketplace.git"]]
git add .
git commit -m "Initial Next.js + Supabase scaffold"
git push -u origin main
What do these commands do?
- git init initializes a git repository in your project folder (create-next-app may have already done this, but running it again is harmless).
- git remote add origin connects your local repo to the GitLab repo you just created.
- git add . stages all your files for the first commit.
- git push -u origin main pushes your code to GitLab and sets main as the default upstream branch.
What if git asks for my credentials?
GitLab may prompt you for your username and password. If you have two-factor authentication enabled, you need to use a Personal Access Token instead of your password. Go to GitLab > Preferences > Access Tokens to create one with write_repository scope.
- Refresh your GitLab project page in the browser. You should see your project files listed in the repository.
Push rejected or failed?
If you see error: src refspec main does not match any, make sure you ran git add . and git commit before pushing. If the push is rejected because the remote already has a README, delete the GitLab project and recreate it without the README checkbox.
help me push my Next.js project to GitLab
Your environment is ready. Next up, you will create the Supabase client utilities and auth middleware that power authentication across the entire app.
Set Up Supabase Client Utilities and Auth Middleware
In Step 1, you scaffolded your Next.js project, installed the Supabase packages, and connected your app to Supabase using environment variables. Your app runs, but it has no way to actually talk to your Supabase backend yet.
Every page in your marketplace needs to interact with your Supabase database and authentication system. How does a Next.js app create a Supabase connection that works in both the browser and on the server? In this step, you will create the utility files and middleware that make this possible.
In this step, get ready to:
- Create browser-side and server-side Supabase client utilities.
- Set up auth middleware that refreshes user sessions on every request.
- Verify the entire setup compiles without errors.
Create the Supabase client utilities
Your app needs two different Supabase clients. The browser client runs inside React components on the user's machine. The server client runs in Server Components and API routes, where it reads cookies to figure out who is logged in.
Why two clients?
The @supabase/ssr package provides createBrowserClient and createServerClient as separate factories. The browser client is a singleton that lives for the page's lifetime. The server client is created fresh on every request because it needs to read cookies from the incoming HTTP request.
- In VS Code, right-click the src folder in the Explorer sidebar.
- Select New Folder and name it lib.
- Right-click the new lib folder, select New Folder, and name it supabase.
- Right-click the supabase folder, select New File, and name it client.ts.
- Add the following code to client.ts:
import { createBrowserClient } from '@supabase/ssr'
// Creates a Supabase client that runs in the browser
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
)
}
What does this code do?
- createBrowserClient creates a Supabase client designed for React components running in the user's browser.
- It reads your project URL and publishable key from the environment variables you set in .env.local during Step 1.
- The ! after each variable tells TypeScript that these values will definitely exist at runtime.
- Save client.ts.
You will not see a visible change yet. The next file (server.ts) creates the server-side counterpart.
- Right-click the supabase folder again, select New File, and name it server.ts.
- Add the following code to server.ts:
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
// Creates a Supabase client that runs on the server
// Needs cookie access to track the logged-in user
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
// Read all cookies from the incoming request
getAll() {
return cookieStore.getAll()
},
// Write updated cookies back to the response
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method is called from a Server Component.
// This can be ignored if you have middleware refreshing sessions.
}
},
},
}
)
}
What does this code do?
- createServerClient creates a Supabase client designed for server-side code. It takes a cookies configuration so Supabase can read and write auth tokens stored in the user's cookies.
- getAll() reads the cookies from the current request. setAll() writes updated cookies (like refreshed tokens) back to the response.
- The catch block handles a known edge case where Server Components cannot write cookies. The middleware you create next handles this instead.
- Save server.ts.
You still will not see a visible change. These utility files are used by other parts of the app you will build in later steps.
Awesome, I've got everything!
Double check you have saved both client.ts and server.ts inside src/lib/supabase/.
I'd like to double check the full code
Here is the full code for both files. Compare yours against these:
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
)
}
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method is called from a Server Component.
// This can be ignored if you have middleware refreshing sessions.
}
},
},
}
)
}
Create the auth middleware
Auth tokens expire. If a user's token expires mid-session, they get silently logged out the next time the server tries to check who they are. Middleware solves this by refreshing the auth token on every request before any page code runs.
You will create two files for this. First, a helper that contains the session-refresh logic. Second, a root-level middleware file that Next.js automatically detects and runs on every request.
- Right-click the supabase folder in the Explorer sidebar, select New File, and name it middleware.ts.
- Add the following code to src/lib/supabase/middleware.ts:
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
// Refreshes the user's auth session on every request
export async function updateSession(request: NextRequest) {
// Start with a "pass-through" response
let supabaseResponse = NextResponse.next({
request,
})
// Create a Supabase client that can read/write cookies on the request and response
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet, headers) {
// Update cookies on the request (for downstream server code)
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({
request,
})
// Update cookies on the response (sent back to the browser)
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
Object.entries(headers).forEach(([key, value]) =>
supabaseResponse.headers.set(key, value)
)
},
},
}
)
// Refresh the auth token so the session stays alive
await supabase.auth.getClaims()
return supabaseResponse
}
What does this code do?
- updateSession() is the core session-refresh function. It creates a Supabase client that can read cookies from the incoming request and write updated cookies onto the outgoing response.
- supabase.auth.getClaims() checks the user's auth token. If the token is expired, Supabase refreshes it behind the scenes and writes the new token into the response cookies.
- The setAll callback updates cookies on both the request (so downstream server code sees the fresh token) and the response (so the browser receives the refreshed cookie).
- Save middleware.ts inside src/lib/supabase/.
This helper file is not active on its own. Next.js does not know about it until you create the root middleware file that imports it.
- Right-click the src folder in the Explorer sidebar, select New File, and name it middleware.ts.
- Add the following code to src/middleware.ts:
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
// Runs on every matching request to keep auth sessions fresh
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
// Only run middleware on app routes, skip static files and images
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
What does this code do?
- This is the root middleware file that Next.js automatically detects. It runs the updateSession() function you just wrote on every incoming request.
- The matcher config is a regex that excludes static assets (_next/static, _next/image, favicon.ico, and image files). There is no reason to refresh auth tokens for a CSS file or a PNG.
- Save src/middleware.ts.
Next.js picks up this file automatically. If your dev server is still running, it will detect the new middleware on the next request.
Seeing a red underline on the import?
- Make sure middleware.ts (the root one) is directly inside the src folder, not inside src/lib/supabase/. You should have two files named middleware.ts in different locations.
- Check that the import path is @/lib/supabase/middleware. The @/ alias maps to your src folder.
- Still stuck? Help me debug this middleware import error.
Awesome, I've got everything!
Double check you have saved both src/lib/supabase/middleware.ts and src/middleware.ts.
I'd like to double check the full code
Here is the full code for both middleware files. Compare yours against these:
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
Object.entries(headers).forEach(([key, value]) =>
supabaseResponse.headers.set(key, value)
)
},
},
}
)
await supabase.auth.getClaims()
return supabaseResponse
}
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
Verify the setup compiles
You have created four files across two substeps. Before moving on, confirm that everything compiles cleanly.
- Check the terminal where npm run dev is running. If your dev server is not running, start it by running this command:
npm run dev
- Open http://localhost:3000 in your browser.
The default Next.js welcome page should load with no errors. Check your terminal output for any red TypeScript errors. If you see none, your Supabase client utilities and middleware are wired up correctly.
Seeing TypeScript errors in the terminal?
- Verify that you installed @supabase/ssr in Step 1. Run npm list @supabase/ssr to confirm it is in your dependencies.
- Check your file locations. You should have client.ts, server.ts, and middleware.ts inside src/lib/supabase/. You should also have a separate middleware.ts directly inside src/.
- Make sure your .env.local file is in the project root (the edu-creator-marketplace folder), not inside src/.
- Still stuck? Help me debug these TypeScript errors in my Supabase setup.
Your Supabase plumbing is in place. Every page and API route in your app can now create a Supabase client, and your middleware keeps user sessions alive automatically. Next up, you will design the database schema that powers the entire marketplace.
Design the Database Schema
Your Supabase client utilities and auth middleware are ready to go. Now it's time to design the database that powers your marketplace.
A two-sided marketplace needs structured data. Creators need profiles with their niche and follower count. Brands need a way to send collaboration requests. You will build both of these tables in Supabase's SQL Editor, then lock them down with Row-Level Security so users can only modify their own data.
In this step, get ready to:
- Create the profiles table to store creator and brand data.
- Create the collaboration requests table to connect brands with creators.
- Set up Row-Level Security policies and an auto-profile trigger.
Create the profiles table
Every user in the marketplace needs a profile. Creators use it to list their teaching niche, platforms, and follower count. Brands use it to display their company name. A single profiles table stores both roles, with a role column to distinguish between them.
- Go to your Supabase project dashboard.
- Click SQL Editor in the left sidebar.
- Create the profiles table by pasting this SQL into the editor:
-- Create profiles table
create table public.profiles (
id uuid references auth.users on delete cascade not null primary key,
updated_at timestamp with time zone,
role text check (role in ('creator', 'brand')),
display_name text,
bio text,
niche text,
platforms text,
follower_count integer default 0,
collab_rate text,
website text,
avatar_url text
);
alter table public.profiles enable row level security;
What does this SQL do?
- id uuid references auth.users links each profile to a Supabase Auth user. When a user is deleted from Auth, their profile is automatically removed too (on delete cascade).
- role is constrained to either 'creator' or 'brand'. This prevents invalid roles from being stored.
- Creator-specific columns like niche, platforms, and follower_count will stay empty for brand users. This keeps the schema simple with one table for both roles.
- alter table ... enable row level security turns on RLS. Without this line, the table's data would be fully exposed through the Data API.
- Click Run in the bottom right of the SQL Editor.
You should see a green Success. No rows returned message at the bottom of the editor.
- Click Table Editor in the left sidebar.
You should see profiles listed as a table. Click on it to see all eleven columns you just defined.
Seeing an error after clicking Run?
- Check that you pasted the entire SQL block, including the alter table line at the end. Missing the trailing semicolon is a common cause.
- If you see relation "profiles" already exists, the table was already created. You can continue to the next substep.
- Make sure you are in the SQL Editor for the correct Supabase project.
Help me debug my profiles table SQL in Supabase.
Create the collaboration requests table
Brands need a way to reach out to creators they want to work with. The collaboration_requests table stores each request, linking a brand to a creator with a message and a status.
- Go back to the SQL Editor in the left sidebar.
- Clear the previous SQL and paste this new query:
-- Create collaboration requests table
create table public.collaboration_requests (
id bigint primary key generated always as identity,
created_at timestamp with time zone default now(),
brand_id uuid references public.profiles(id) on delete cascade not null,
creator_id uuid references public.profiles(id) on delete cascade not null,
message text not null,
status text default 'pending' check (status in ('pending', 'accepted', 'declined'))
);
alter table public.collaboration_requests enable row level security;
What does this SQL do?
- id bigint primary key generated always as identity auto-generates a unique ID for each request. Unlike the profiles table, this ID is not tied to an auth user.
- brand_id and creator_id both reference the profiles table. This creates a relational link between the request and the two users involved.
- status defaults to 'pending' and is constrained to three values: pending, accepted, or declined.
- Click Run.
You should see another green Success. No rows returned message.
- Open Table Editor in the left sidebar.
You should now see both profiles and collaboration_requests listed as tables.
Seeing a foreign key error?
- The collaboration_requests table references profiles(id). If the profiles table does not exist yet, this query will fail. Make sure you ran the profiles SQL first.
- If you see relation "collaboration_requests" already exists, the table was already created. You can skip ahead.
Help me fix my collaboration_requests table SQL.
Set up Row-Level Security policies and the auto-profile trigger
Your tables exist, but they are currently locked down completely. RLS is enabled, which means no one can read or write data until you create policies that define who is allowed to do what.
What is Row-Level Security?
RLS lets you write rules directly in Postgres that control which rows each user can see or modify. Instead of checking permissions in your app code, the database enforces them automatically on every query.
- Go back to the SQL Editor.
- Add the RLS policies for the profiles table by pasting this SQL:
-- RLS policies for profiles
create policy "Public profiles are viewable by everyone"
on public.profiles for select
using (true);
create policy "Users can insert their own profile"
on public.profiles for insert
with check ((select auth.uid()) = id);
create policy "Users can update their own profile"
on public.profiles for update
using ((select auth.uid()) = id);
What do these policies do?
- The select policy uses using (true), which means anyone (logged in or not) can read profiles. This is needed for the discovery dashboard.
- The insert and update policies check that auth.uid() matches the row's id. This ensures users can only create or edit their own profile.
- Click Run.
You should see Success. No rows returned.
Next, you need a way to automatically create a profile row when a new user signs up. Without this, users would sign up through Supabase Auth but have no matching row in profiles to update during onboarding.
- Clear the editor and create the auto-profile trigger by pasting this SQL:
-- Auto-create profile on signup
create function public.handle_new_user()
returns trigger
set search_path = ''
as $$
begin
insert into public.profiles (id)
values (new.id);
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
What does this trigger do?
Every time a new row is inserted into auth.users (meaning a new user signs up), Postgres automatically runs handle_new_user(). This function inserts a matching row into profiles using the same user ID. The profile starts mostly empty. The user fills in their role and details during onboarding.
security definer lets this function bypass RLS. Without it, the trigger could not insert into profiles because the new user's session is not established yet.
- Click Run.
You should see Success. No rows returned.
Finally, you need RLS policies for the collaboration_requests table, plus grant statements that give the Supabase Data API access to your tables.
- Clear the editor and paste this final SQL block:
-- RLS policies for collaboration requests
create policy "Brands can insert their own requests"
on public.collaboration_requests for insert
with check ((select auth.uid()) = brand_id);
create policy "Brands can view their own requests"
on public.collaboration_requests for select
using ((select auth.uid()) = brand_id);
create policy "Creators can view requests sent to them"
on public.collaboration_requests for select
using ((select auth.uid()) = creator_id);
-- Grant access to the Data API
grant select on public.profiles to anon;
grant select, insert, update on public.profiles to authenticated;
grant select, insert on public.collaboration_requests to authenticated;
What do these policies and grants do?
- Brands can only insert requests where brand_id matches their own user ID. They cannot impersonate another brand.
- Both brands and creators have separate select policies. Brands see the requests they sent. Creators see the requests they received. Neither can see the other's data.
- The grant statements give the Supabase Data API permission to access these tables. anon (unauthenticated visitors) can read profiles. authenticated users can read, insert, and update profiles, and read or insert collaboration requests.
- Click Run.
You should see Success. No rows returned.
- Click Authentication in the left sidebar, then click Policies.
You should see both profiles and collaboration_requests listed with their RLS policies. The profiles table should have three policies (select, insert, update). The collaboration_requests table should have three policies (one insert, two select).
Policies not showing up?
- If you see fewer policies than expected, one of the SQL blocks may have failed silently. Go back to the SQL Editor and re-run the block that you suspect did not complete.
- If you see policy "..." already exists, that policy was already created successfully. You can skip it.
- Make sure RLS is enabled on both tables. Look for the RLS enabled badge next to each table name in the Policies view.
Help me verify my RLS policies are set up correctly.
Awesome, I've run everything
Double check that your Policies page shows all six policies across both tables. You are all set.
I'd like to double check the full SQL
Here is the complete SQL schema you should have run across all four blocks. You can paste this into a fresh SQL Editor tab to compare, but do not run it again if your tables already exist.
-- Create profiles table
create table public.profiles (
id uuid references auth.users on delete cascade not null primary key,
updated_at timestamp with time zone,
role text check (role in ('creator', 'brand')),
display_name text,
bio text,
niche text,
platforms text,
follower_count integer default 0,
collab_rate text,
website text,
avatar_url text
);
alter table public.profiles enable row level security;
-- RLS policies for profiles
create policy "Public profiles are viewable by everyone"
on public.profiles for select
using (true);
create policy "Users can insert their own profile"
on public.profiles for insert
with check ((select auth.uid()) = id);
create policy "Users can update their own profile"
on public.profiles for update
using ((select auth.uid()) = id);
-- Auto-create profile on signup
create function public.handle_new_user()
returns trigger
set search_path = ''
as $$
begin
insert into public.profiles (id)
values (new.id);
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
-- Create collaboration requests table
create table public.collaboration_requests (
id bigint primary key generated always as identity,
created_at timestamp with time zone default now(),
brand_id uuid references public.profiles(id) on delete cascade not null,
creator_id uuid references public.profiles(id) on delete cascade not null,
message text not null,
status text default 'pending' check (status in ('pending', 'accepted', 'declined'))
);
alter table public.collaboration_requests enable row level security;
-- RLS policies for collaboration requests
create policy "Brands can insert their own requests"
on public.collaboration_requests for insert
with check ((select auth.uid()) = brand_id);
create policy "Brands can view their own requests"
on public.collaboration_requests for select
using ((select auth.uid()) = brand_id);
create policy "Creators can view requests sent to them"
on public.collaboration_requests for select
using ((select auth.uid()) = creator_id);
-- Grant access to the Data API
grant select on public.profiles to anon;
grant select, insert, update on public.profiles to authenticated;
grant select, insert on public.collaboration_requests to authenticated;
Your database schema is fully set up. Two tables, six security policies, and an auto-profile trigger are all in place. Next up, you will build the signup, login, and role selection pages that let users create accounts and start filling in their profiles.
Build Signup, Login, and Role Selection
Your database schema is designed and secured with Row-Level Security policies. Now you need a way for users to actually create accounts, log in, and tell the app whether they are a creator or a brand.
In this step, you will build the full authentication flow using Supabase Auth. This includes a login page, a signup flow, an onboarding screen where users pick their role, and an email confirmation endpoint that verifies new accounts.
In this step, get ready to:
- Build the login and signup pages with server actions.
- Create an onboarding page where users select their role and display name.
- Set up the email confirmation endpoint for verifying new accounts.
Build the signup and login pages
The login page is the front door to your marketplace. It needs two inputs (email and password) and two actions (log in or sign up).
You will split this into two files. An actions file handles the server-side logic for authenticating with Supabase. A page file renders the form the user sees.
- Create a new folder called login inside your app directory.
- Inside app/login/, create a file called actions.ts and paste the following code:
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
// Log in an existing user with email and password
export async function login(formData: FormData) {
const supabase = await createClient()
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signInWithPassword(data)
if (error) {
redirect('/login?error=' + encodeURIComponent(error.message))
}
revalidatePath('/', 'layout')
redirect('/dashboard')
}
// Sign up a new user with email and password
export async function signup(formData: FormData) {
const supabase = await createClient()
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signUp(data)
if (error) {
redirect('/login?error=' + encodeURIComponent(error.message))
}
revalidatePath('/', 'layout')
redirect('/onboarding')
}
What does this code do?
- 'use server' marks this file as a Next.js Server Actions module. The functions run on the server, not in the browser.
- login() calls supabase.auth.signInWithPassword() to authenticate an existing user. On success, it redirects to /dashboard.
- signup() calls supabase.auth.signUp() to register a new user. On success, it redirects to /onboarding so the new user can choose their role.
- Both functions redirect to /login?error=... if something goes wrong, passing the error message as a query parameter.
- Save actions.ts.
- You will not see a visible change yet. The next file creates the page that uses these actions.
- In the same app/login/ folder, create a file called page.tsx and paste the following code:
import { login, signup } from './actions'
export default function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>
}) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-6 text-center text-2xl font-bold text-gray-900">
EduConnect
</h1>
<p className="mb-6 text-center text-gray-600">
The marketplace for educational creators and brands
</p>
{/* Login form with email and password fields */}
<form className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{/* Two buttons: Log In triggers the login action, Sign Up triggers the signup action */}
<div className="flex gap-3">
<button
formAction={login}
className="flex-1 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Log In
</button>
<button
formAction={signup}
className="flex-1 rounded-md border border-blue-600 px-4 py-2 text-blue-600 hover:bg-blue-50"
>
Sign Up
</button>
</div>
</form>
</div>
</div>
)
}
What does this code do?
- The page renders a centered card with the app name, a tagline, and a form with email and password fields.
- Each button uses formAction to bind directly to the server actions you just created. Clicking Log In calls login() and clicking Sign Up calls signup().
- The searchParams prop is available for reading error messages from the URL. The form itself is styled with Tailwind CSS utility classes.
- Save page.tsx.
- Make sure your dev server is running with npm run dev.
- Visit http://localhost:3000/login in your browser. You should see a centered white card with the title "EduConnect", email and password fields, and Log In and Sign Up buttons.
Don't see the login page?
- Check that you created page.tsx inside app/login/, not in the project root. Next.js App Router uses the folder path to determine the URL.
- Look at your terminal for TypeScript errors. Make sure the import path './actions' in page.tsx matches the actions.ts filename exactly.
- Confirm your lib/supabase/server.ts file exists from Step 2. The actions file imports from @/lib/supabase/server.
- Still stuck? Help me debug why my login page is not rendering.
Awesome, I've got everything!
Great, double check you have saved both actions.ts and page.tsx.
I'd like to double check the full code
Here is the full app/login/actions.ts:
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
export async function login(formData: FormData) {
const supabase = await createClient()
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signInWithPassword(data)
if (error) {
redirect('/login?error=' + encodeURIComponent(error.message))
}
revalidatePath('/', 'layout')
redirect('/dashboard')
}
export async function signup(formData: FormData) {
const supabase = await createClient()
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signUp(data)
if (error) {
redirect('/login?error=' + encodeURIComponent(error.message))
}
revalidatePath('/', 'layout')
redirect('/onboarding')
}
Here is the full app/login/page.tsx:
import { login, signup } from './actions'
export default function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>
}) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-6 text-center text-2xl font-bold text-gray-900">
EduConnect
</h1>
<p className="mb-6 text-center text-gray-600">
The marketplace for educational creators and brands
</p>
<form className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="flex gap-3">
<button
formAction={login}
className="flex-1 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Log In
</button>
<button
formAction={signup}
className="flex-1 rounded-md border border-blue-600 px-4 py-2 text-blue-600 hover:bg-blue-50"
>
Sign Up
</button>
</div>
</form>
</div>
</div>
)
}
Build the onboarding page with role selection
After a new user signs up, they land on the onboarding page. This is where they tell your app whether they are a creator or a brand, and enter their display name.
The onboarding page uses a Server Component to check authentication and a Client Component to handle the interactive form. You will create both files now.
- Create a new folder called onboarding inside your app directory.
- Inside app/onboarding/, create a file called OnboardingForm.tsx and paste the following code:
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
export default function OnboardingForm({ userId }: { userId: string }) {
const [role, setRole] = useState<'creator' | 'brand' | ''>('')
const [displayName, setDisplayName] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!role || !displayName.trim()) return
setLoading(true)
// Update the user's profile row with their chosen role and display name
const { error } = await supabase
.from('profiles')
.update({ role, display_name: displayName.trim() })
.eq('id', userId)
if (error) {
alert('Error saving profile: ' + error.message)
setLoading(false)
return
}
// Send creators to fill in their full profile, brands to the dashboard
if (role === 'creator') {
router.push('/profile/edit')
} else {
router.push('/dashboard')
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
I am a...
</label>
{/* Role selection buttons */}
<div className="flex gap-3">
<button
type="button"
onClick={() => setRole('creator')}
className={`flex-1 rounded-md border-2 px-4 py-3 text-center font-medium transition ${
role === 'creator'
? 'border-blue-600 bg-blue-50 text-blue-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300'
}`}
>
🎓 Creator
</button>
<button
type="button"
onClick={() => setRole('brand')}
className={`flex-1 rounded-md border-2 px-4 py-3 text-center font-medium transition ${
role === 'brand'
? 'border-blue-600 bg-blue-50 text-blue-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300'
}`}
>
🏢 Brand
</button>
</div>
</div>
<div>
<label
htmlFor="displayName"
className="block text-sm font-medium text-gray-700"
>
Display Name
</label>
<input
id="displayName"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Your name or company name"
/>
</div>
<button
type="submit"
disabled={!role || !displayName.trim() || loading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Saving...' : 'Continue'}
</button>
</form>
)
}
What does this code do?
- 'use client' marks this as a Client Component because it uses React state and event handlers.
- Two toggle buttons let the user pick either "Creator" or "Brand". The selected button highlights with a blue border.
- On submit, the form calls supabase.from('profiles').update() to save the role and display name to the user's existing profile row. The handle_new_user() trigger you created in Step 3 already created this row when the user signed up.
- Creators are redirected to /profile/edit to fill in their full profile. Brands go straight to /dashboard.
- Save OnboardingForm.tsx. You will not see a change yet. The next file creates the page that renders this form.
- In the same app/onboarding/ folder, create a file called page.tsx and paste the following code:
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import OnboardingForm from './OnboardingForm'
export default async function OnboardingPage() {
// Check if the user is authenticated
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// Redirect unauthenticated visitors to the login page
if (!user) {
redirect('/login')
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-2 text-2xl font-bold text-gray-900">
Welcome to EduConnect
</h1>
<p className="mb-6 text-gray-600">Tell us a bit about yourself</p>
<OnboardingForm userId={user.id} />
</div>
</div>
)
}
What does this code do?
- This is an async Server Component. It runs on the server and can await data before rendering.
- supabase.auth.getUser() checks whether the visitor is logged in. If not, they are redirected to /login.
- The authenticated user's id is passed to the Client Component as a prop, so the form knows which profile row to update.
- Save page.tsx.
- Visit http://localhost:3000/onboarding in your browser. Since you are not logged in, you should be redirected to /login. This confirms the auth guard is working.
Not being redirected?
- Make sure page.tsx is inside app/onboarding/, not app/login/.
- Check that your middleware from Step 2 is still in place at middleware.ts in the project root. The middleware refreshes auth sessions on every request.
- Need more help? Help me debug why my onboarding page is not redirecting unauthenticated users.
Awesome, I've got everything!
Great, double check you have saved both OnboardingForm.tsx and page.tsx in app/onboarding/.
I'd like to double check the full code
Here is the full app/onboarding/OnboardingForm.tsx:
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
export default function OnboardingForm({ userId }: { userId: string }) {
const [role, setRole] = useState<'creator' | 'brand' | ''>('')
const [displayName, setDisplayName] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!role || !displayName.trim()) return
setLoading(true)
const { error } = await supabase
.from('profiles')
.update({ role, display_name: displayName.trim() })
.eq('id', userId)
if (error) {
alert('Error saving profile: ' + error.message)
setLoading(false)
return
}
if (role === 'creator') {
router.push('/profile/edit')
} else {
router.push('/dashboard')
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
I am a...
</label>
<div className="flex gap-3">
<button
type="button"
onClick={() => setRole('creator')}
className={`flex-1 rounded-md border-2 px-4 py-3 text-center font-medium transition ${
role === 'creator'
? 'border-blue-600 bg-blue-50 text-blue-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300'
}`}
>
🎓 Creator
</button>
<button
type="button"
onClick={() => setRole('brand')}
className={`flex-1 rounded-md border-2 px-4 py-3 text-center font-medium transition ${
role === 'brand'
? 'border-blue-600 bg-blue-50 text-blue-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300'
}`}
>
🏢 Brand
</button>
</div>
</div>
<div>
<label
htmlFor="displayName"
className="block text-sm font-medium text-gray-700"
>
Display Name
</label>
<input
id="displayName"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Your name or company name"
/>
</div>
<button
type="submit"
disabled={!role || !displayName.trim() || loading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Saving...' : 'Continue'}
</button>
</form>
)
}
Here is the full app/onboarding/page.tsx:
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import OnboardingForm from './OnboardingForm'
export default async function OnboardingPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-2 text-2xl font-bold text-gray-900">
Welcome to EduConnect
</h1>
<p className="mb-6 text-gray-600">Tell us a bit about yourself</p>
<OnboardingForm userId={user.id} />
</div>
</div>
)
}
Create the auth confirmation endpoint
When a user signs up, Supabase sends them a confirmation email with a special link. Your app needs a route to receive that link, verify the token, and start the user's session.
- Create the folder path app/auth/confirm/ inside your app directory.
- Inside app/auth/confirm/, create a file called route.ts and paste the following code:
import { type EmailOtpType } from '@supabase/supabase-js'
import { redirect } from 'next/navigation'
import { type NextRequest } from 'next/server'
import { createClient } from '@/lib/supabase/server'
// Handle the email confirmation callback from Supabase
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const token_hash = searchParams.get('token_hash')
const type = searchParams.get('type') as EmailOtpType | null
// Verify the OTP token to confirm the user's email
if (token_hash && type) {
const supabase = await createClient()
const { error } = await supabase.auth.verifyOtp({ type, token_hash })
if (!error) {
redirect('/onboarding')
}
}
// If verification fails, redirect back to login with an error
redirect('/login?error=Could+not+verify+email')
}
What does this code do?
- This is a Next.js Route Handler. It responds to GET requests at /auth/confirm.
- When a user clicks the confirmation link in their email, the browser sends a GET request with token_hash and type query parameters.
- supabase.auth.verifyOtp() exchanges the token for a valid session. On success, the user is redirected to /onboarding to pick their role.
- If verification fails (expired or invalid token), the user is sent back to the login page with an error message.
- Save route.ts.
Now you need to update the Supabase email template so the confirmation link points to your app's /auth/confirm route instead of the default Supabase URL.
- Open your Supabase Dashboard at https://supabase.com/dashboard and select your project.
- Click Authentication in the left sidebar.
- Click Email Templates.
- Find the Confirm signup template.
- Replace the default confirmation link in the template body with the following:
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Confirm your email</a>
What is this template doing?
{{ .SiteURL }} is a Supabase template variable that resolves to your app's URL (e.g. http://localhost:3000 during development). {{ .TokenHash }} is the hashed verification token. Together, they build a link that hits your /auth/confirm route handler.
- Click Save in the Supabase Dashboard.
Pro tip: set your Site URL for local development
For the {{ .SiteURL }} variable to work locally, go to Authentication > URL Configuration in your Supabase Dashboard. Set the Site URL to http://localhost:3000. You will update this to your Vercel URL when you deploy later.
To test the full signup flow, try signing up with a real email address on your login page. Check your inbox for the confirmation email and click the link. You should land on the onboarding page where you can pick a role.
Not receiving the confirmation email?
- Supabase free-tier projects have a rate limit on auth emails. Wait a minute and try again.
- Check your spam folder. Supabase sends from a generic email address that some providers flag.
- Double check the email template in Authentication > Email Templates. Make sure you edited the Confirm signup template (not Invite or Magic link).
- Verify the Site URL under Authentication > URL Configuration is set to http://localhost:3000.
- Still stuck? Help me debug why Supabase email confirmation is not working.
Awesome, I've got everything!
Great, double check you have saved route.ts in app/auth/confirm/.
I'd like to double check the full code
Here is the full app/auth/confirm/route.ts:
import { type EmailOtpType } from '@supabase/supabase-js'
import { redirect } from 'next/navigation'
import { type NextRequest } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const token_hash = searchParams.get('token_hash')
const type = searchParams.get('type') as EmailOtpType | null
if (token_hash && type) {
const supabase = await createClient()
const { error } = await supabase.auth.verifyOtp({ type, token_hash })
if (!error) {
redirect('/onboarding')
}
}
redirect('/login?error=Could+not+verify+email')
}
Your auth flow is wired up. Users can sign up, confirm their email, choose a role, and get redirected to the right place. Next up, you will build the creator profile page where creators fill in their full profile details.
Build the Creator Profile Page
In the last step, you built signup, login, and onboarding. Users can now create accounts, pick a role, and land in the right place. But creators who just signed up have empty profiles with nothing to show brands.
In this step, you will build two pages that bring creator profiles to life. First, an edit form where creators fill out their bio, niche, platforms, and rates. Second, a public profile page where anyone (especially brands) can view a creator and send a collaboration request.
In this step, get ready to:
- Build a protected profile edit form that writes to Supabase.
- Build a public creator profile page with a dynamic route.
- Style both pages with Tailwind CSS and verify the visual output.
Build the profile edit form
The profile edit page needs two parts. A Server Component handles authentication and data fetching on the server. A Client Component renders the interactive form in the browser.
- Create the folder app/profile/edit/ inside your edu-creator-marketplace project.
- Create app/profile/edit/page.tsx and add this server component:
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import ProfileEditForm from './ProfileEditForm'
export default async function ProfileEditPage() {
// Create a server-side Supabase client and check if the user is logged in
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// Redirect to login if not authenticated
if (!user) {
redirect('/login')
}
// Fetch the current user's profile from the profiles table
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single()
// Redirect to onboarding if the profile doesn't exist yet
if (!profile) {
redirect('/onboarding')
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="mx-auto max-w-2xl px-4">
<h1 className="mb-6 text-2xl font-bold text-gray-900">
Edit Your Creator Profile
</h1>
<ProfileEditForm profile={profile} />
</div>
</div>
)
}
What does this code do?
- This is a server component that runs on the server before any HTML reaches the browser.
- supabase.auth.getUser() checks if the user is logged in. If not, they get redirected to /login.
- It fetches the user's profile row from the profiles table and passes it to the ProfileEditForm client component as a prop.
- Save page.tsx. You won't see a change yet because the ProfileEditForm component doesn't exist. That's what you'll build next.
- Create app/profile/edit/ProfileEditForm.tsx and add this client component:
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
// The six teaching niches available for creators
const NICHES = ['Math', 'Coding', 'Languages', 'Science', 'Arts', 'Business']
// Shape of the profile data passed in from the server component
interface Profile {
id: string
display_name: string | null
bio: string | null
niche: string | null
platforms: string | null
follower_count: number | null
collab_rate: string | null
website: string | null
}
export default function ProfileEditForm({ profile }: { profile: Profile }) {
// Initialize form state with existing profile data (or empty defaults)
const [form, setForm] = useState({
display_name: profile.display_name || '',
bio: profile.bio || '',
niche: profile.niche || '',
platforms: profile.platforms || '',
follower_count: profile.follower_count || 0,
collab_rate: profile.collab_rate || '',
website: profile.website || '',
})
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
// Generic change handler that works for all input types
function handleChange(
e: React.ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>
) {
const { name, value } = e.target
setForm((prev) => ({
...prev,
[name]: name === 'follower_count' ? parseInt(value) || 0 : value,
}))
}
// Submit the updated profile to Supabase
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
const { error } = await supabase
.from('profiles')
.update({
display_name: form.display_name,
bio: form.bio,
niche: form.niche,
platforms: form.platforms,
follower_count: form.follower_count,
collab_rate: form.collab_rate,
website: form.website,
updated_at: new Date().toISOString(),
})
.eq('id', profile.id)
if (error) {
alert('Error updating profile: ' + error.message)
} else {
router.push('/dashboard')
}
setLoading(false)
}
return (
<form
onSubmit={handleSubmit}
className="space-y-4 rounded-lg bg-white p-6 shadow-md"
>
<div>
<label className="block text-sm font-medium text-gray-700">
Display Name
</label>
<input
name="display_name"
value={form.display_name}
onChange={handleChange}
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Bio</label>
<textarea
name="bio"
value={form.bio}
onChange={handleChange}
rows={3}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
placeholder="Tell brands about your teaching style and audience"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Teaching Niche
</label>
<select
name="niche"
value={form.niche}
onChange={handleChange}
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
>
<option value="">Select a niche</option>
{NICHES.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Platforms
</label>
<input
name="platforms"
value={form.platforms}
onChange={handleChange}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
placeholder="e.g. YouTube, Instagram, TikTok"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Follower Count
</label>
<input
name="follower_count"
type="number"
value={form.follower_count}
onChange={handleChange}
min={0}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Collaboration Rate
</label>
<input
name="collab_rate"
value={form.collab_rate}
onChange={handleChange}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
placeholder="e.g. $500-$1000"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Website
</label>
<input
name="website"
value={form.website}
onChange={handleChange}
type="url"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
placeholder="https://yoursite.com"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Saving...' : 'Save Profile'}
</button>
</form>
)
}
What does this code do?
- The 'use client' directive makes this a client component. Forms need interactivity (state, event handlers) that only works in the browser.
- The form state is initialized with existing profile data so returning creators see their current values pre-filled.
- handleChange uses the input's name attribute to update the correct field. For follower_count, it converts the string value to an integer.
- handleSubmit calls supabase.from('profiles').update() to save the form data to the database. On success, it redirects to the dashboard.
- Save ProfileEditForm.tsx.
- Make sure your dev server is running (npm run dev). Log in as your test creator account and navigate to http://localhost:3000/profile/edit.
- You should see a form with fields for Display Name, Bio, Teaching Niche, Platforms, Follower Count, Collaboration Rate, and Website.
Page redirects to /login or shows an error?
- Make sure you are logged in with a valid account. If your session expired, log in again at /login.
- If the page redirects to /onboarding, your user does not have a profile row yet. Complete onboarding first by selecting a role and display name.
- Check the terminal for TypeScript errors. A common one is a mismatched import path for ProfileEditForm.
- Why is my profile edit page not loading?
Awesome, I've got everything!
Double check you have saved both page.tsx and ProfileEditForm.tsx inside app/profile/edit/.
I'd like to double check the full code
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import ProfileEditForm from './ProfileEditForm'
export default async function ProfileEditPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single()
if (!profile) {
redirect('/onboarding')
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="mx-auto max-w-2xl px-4">
<h1 className="mb-6 text-2xl font-bold text-gray-900">
Edit Your Creator Profile
</h1>
<ProfileEditForm profile={profile} />
</div>
</div>
)
}
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
const NICHES = ['Math', 'Coding', 'Languages', 'Science', 'Arts', 'Business']
interface Profile {
id: string
display_name: string | null
bio: string | null
niche: string | null
platforms: string | null
follower_count: number | null
collab_rate: string | null
website: string | null
}
export default function ProfileEditForm({ profile }: { profile: Profile }) {
const [form, setForm] = useState({
display_name: profile.display_name || '',
bio: profile.bio || '',
niche: profile.niche || '',
platforms: profile.platforms || '',
follower_count: profile.follower_count || 0,
collab_rate: profile.collab_rate || '',
website: profile.website || '',
})
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
function handleChange(
e: React.ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>
) {
const { name, value } = e.target
setForm((prev) => ({
...prev,
[name]: name === 'follower_count' ? parseInt(value) || 0 : value,
}))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
const { error } = await supabase
.from('profiles')
.update({
display_name: form.display_name,
bio: form.bio,
niche: form.niche,
platforms: form.platforms,
follower_count: form.follower_count,
collab_rate: form.collab_rate,
website: form.website,
updated_at: new Date().toISOString(),
})
.eq('id', profile.id)
if (error) {
alert('Error updating profile: ' + error.message)
} else {
router.push('/dashboard')
}
setLoading(false)
}
return (
<form
onSubmit={handleSubmit}
className="space-y-4 rounded-lg bg-white p-6 shadow-md"
>
<div>
<label className="block text-sm font-medium text-gray-700">
Display Name
</label>
<input
name="display_name"
value={form.display_name}
onChange={handleChange}
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Bio</label>
<textarea
name="bio"
value={form.bio}
onChange={handleChange}
rows={3}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
placeholder="Tell brands about your teaching style and audience"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Teaching Niche
</label>
<select
name="niche"
value={form.niche}
onChange={handleChange}
required
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
>
<option value="">Select a niche</option>
{NICHES.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Platforms
</label>
<input
name="platforms"
value={form.platforms}
onChange={handleChange}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
placeholder="e.g. YouTube, Instagram, TikTok"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Follower Count
</label>
<input
name="follower_count"
type="number"
value={form.follower_count}
onChange={handleChange}
min={0}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Collaboration Rate
</label>
<input
name="collab_rate"
value={form.collab_rate}
onChange={handleChange}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
placeholder="e.g. $500-$1000"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Website
</label>
<input
name="website"
value={form.website}
onChange={handleChange}
type="url"
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
placeholder="https://yoursite.com"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Saving...' : 'Save Profile'}
</button>
</form>
)
}
Build the public profile view
Brands need a way to view a creator's full profile before deciding to collaborate. This page uses a dynamic route that loads any creator's profile by their unique ID.
- Create the folder app/creator/[id]/ inside your project. The square brackets tell Next.js this is a dynamic route segment.
- Create app/creator/[id]/page.tsx and add this server component:
import { createClient } from '@/lib/supabase/server'
import { notFound } from 'next/navigation'
import CollabRequestButton from './CollabRequestButton'
// Map each niche to a distinct badge color
const NICHE_COLORS: Record<string, string> = {
Math: 'bg-purple-100 text-purple-800',
Coding: 'bg-green-100 text-green-800',
Languages: 'bg-yellow-100 text-yellow-800',
Science: 'bg-blue-100 text-blue-800',
Arts: 'bg-pink-100 text-pink-800',
Business: 'bg-orange-100 text-orange-800',
}
export default async function CreatorProfilePage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const supabase = await createClient()
// Fetch the creator's profile by ID, filtering to role='creator'
const { data: creator } = await supabase
.from('profiles')
.select('*')
.eq('id', id)
.eq('role', 'creator')
.single()
// Show a 404 page if the creator doesn't exist
if (!creator) {
notFound()
}
// Check if the current visitor is logged in
const {
data: { user },
} = await supabase.auth.getUser()
let currentUserRole: string | null = null
let existingRequest = false
if (user) {
// Look up the visitor's role to determine if they are a brand
const { data: currentProfile } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
currentUserRole = currentProfile?.role || null
// Check if this brand already sent a request to this creator
if (currentUserRole === 'brand') {
const { data: request } = await supabase
.from('collaboration_requests')
.select('id')
.eq('brand_id', user.id)
.eq('creator_id', id)
.limit(1)
.maybeSingle()
existingRequest = !!request
}
}
const nicheColor =
NICHE_COLORS[creator.niche || ''] || 'bg-gray-100 text-gray-800'
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="mx-auto max-w-2xl px-4">
<div className="rounded-lg bg-white p-8 shadow-md">
<div className="mb-4 flex items-center gap-3">
<h1 className="text-3xl font-bold text-gray-900">
{creator.display_name}
</h1>
{creator.niche && (
<span
className={`rounded-full px-3 py-1 text-sm font-medium ${nicheColor}`}
>
{creator.niche}
</span>
)}
</div>
{creator.bio && (
<p className="mb-6 text-gray-600">{creator.bio}</p>
)}
<div className="mb-6 grid grid-cols-2 gap-4">
{creator.platforms && (
<div>
<p className="text-sm font-medium text-gray-500">Platforms</p>
<p className="text-gray-900">{creator.platforms}</p>
</div>
)}
{creator.follower_count !== null && (
<div>
<p className="text-sm font-medium text-gray-500">Followers</p>
<p className="text-gray-900">
{creator.follower_count.toLocaleString()}
</p>
</div>
)}
{creator.collab_rate && (
<div>
<p className="text-sm font-medium text-gray-500">
Collaboration Rate
</p>
<p className="text-gray-900">{creator.collab_rate}</p>
</div>
)}
{creator.website && (
<div>
<p className="text-sm font-medium text-gray-500">Website</p>
<a
href={creator.website}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{creator.website}
</a>
</div>
)}
</div>
{currentUserRole === 'brand' && user && (
<CollabRequestButton
brandId={user.id}
creatorId={id}
alreadySent={existingRequest}
/>
)}
</div>
</div>
</div>
)
}
What does this code do?
- The params prop contains the id from the URL. Visiting /creator/abc-123 sets id to abc-123.
- NICHE_COLORS maps each niche name to a pair of Tailwind classes. This gives each niche (Math, Coding, etc.) its own colored badge.
- The page checks if the current visitor is a brand. If so, it queries collaboration_requests to see if a request already exists. This prevents duplicate requests.
- The CollabRequestButton only renders for brand users, keeping the page clean for other visitors.
- Save page.tsx. The page won't load yet because the CollabRequestButton component is missing. You will create it next.
- Create app/creator/[id]/CollabRequestButton.tsx and add this client component:
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export default function CollabRequestButton({
brandId,
creatorId,
alreadySent,
}: {
brandId: string
creatorId: string
alreadySent: boolean
}) {
const [showForm, setShowForm] = useState(false)
const [message, setMessage] = useState('')
const [sent, setSent] = useState(alreadySent)
const [loading, setLoading] = useState(false)
const supabase = createClient()
// Insert a new collaboration request into the database
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!message.trim()) return
setLoading(true)
const { error } = await supabase.from('collaboration_requests').insert({
brand_id: brandId,
creator_id: creatorId,
message: message.trim(),
})
if (error) {
alert('Error sending request: ' + error.message)
} else {
setSent(true)
setShowForm(false)
}
setLoading(false)
}
// Show a success message if the request has already been sent
if (sent) {
return (
<div className="rounded-md bg-green-50 px-4 py-3 text-green-700">
Collaboration request sent!
</div>
)
}
// Show the message form when the brand clicks the button
if (showForm) {
return (
<form onSubmit={handleSubmit} className="space-y-3">
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
required
className="w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
placeholder="Introduce your brand and describe the collaboration you have in mind..."
/>
<div className="flex gap-2">
<button
type="submit"
disabled={loading}
className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Sending...' : 'Send Request'}
</button>
<button
type="button"
onClick={() => setShowForm(false)}
className="rounded-md border border-gray-300 px-4 py-2 text-gray-600 hover:bg-gray-50"
>
Cancel
</button>
</div>
</form>
)
}
// Default state: show the Send Collaboration Request button
return (
<button
onClick={() => setShowForm(true)}
className="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
Send Collaboration Request
</button>
)
}
What does this code do?
- This component has three visual states: the initial button, the message form, and a success confirmation.
- When a brand clicks Send Collaboration Request, the button is replaced by a text area where the brand writes a message.
- On submit, it inserts a row into the collaboration_requests table with the brand's ID, the creator's ID, and the message.
- If alreadySent is true (checked by the server component), the button is replaced by a green confirmation message right away.
- Save CollabRequestButton.tsx.
- You won't be able to test this page in the browser right now because you need a real creator profile ID in the URL. You will test it after styling is complete.
Seeing a TypeScript error about params?
- In Next.js 16, the params prop is a Promise. Make sure you are using params: Promise<{ id: string }> in the type annotation and await params inside the function body.
- Check that the folder name is exactly [id] with square brackets. Without them, Next.js treats it as a static route.
- Why am I getting a TypeScript error on my dynamic route?
Awesome, I've got everything!
Double check you have saved both page.tsx and CollabRequestButton.tsx inside app/creator/[id]/.
I'd like to double check the full code
import { createClient } from '@/lib/supabase/server'
import { notFound } from 'next/navigation'
import CollabRequestButton from './CollabRequestButton'
const NICHE_COLORS: Record<string, string> = {
Math: 'bg-purple-100 text-purple-800',
Coding: 'bg-green-100 text-green-800',
Languages: 'bg-yellow-100 text-yellow-800',
Science: 'bg-blue-100 text-blue-800',
Arts: 'bg-pink-100 text-pink-800',
Business: 'bg-orange-100 text-orange-800',
}
export default async function CreatorProfilePage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const supabase = await createClient()
const { data: creator } = await supabase
.from('profiles')
.select('*')
.eq('id', id)
.eq('role', 'creator')
.single()
if (!creator) {
notFound()
}
const {
data: { user },
} = await supabase.auth.getUser()
let currentUserRole: string | null = null
let existingRequest = false
if (user) {
const { data: currentProfile } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
currentUserRole = currentProfile?.role || null
if (currentUserRole === 'brand') {
const { data: request } = await supabase
.from('collaboration_requests')
.select('id')
.eq('brand_id', user.id)
.eq('creator_id', id)
.limit(1)
.maybeSingle()
existingRequest = !!request
}
}
const nicheColor =
NICHE_COLORS[creator.niche || ''] || 'bg-gray-100 text-gray-800'
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="mx-auto max-w-2xl px-4">
<div className="rounded-lg bg-white p-8 shadow-md">
<div className="mb-4 flex items-center gap-3">
<h1 className="text-3xl font-bold text-gray-900">
{creator.display_name}
</h1>
{creator.niche && (
<span
className={`rounded-full px-3 py-1 text-sm font-medium ${nicheColor}`}
>
{creator.niche}
</span>
)}
</div>
{creator.bio && (
<p className="mb-6 text-gray-600">{creator.bio}</p>
)}
<div className="mb-6 grid grid-cols-2 gap-4">
{creator.platforms && (
<div>
<p className="text-sm font-medium text-gray-500">Platforms</p>
<p className="text-gray-900">{creator.platforms}</p>
</div>
)}
{creator.follower_count !== null && (
<div>
<p className="text-sm font-medium text-gray-500">Followers</p>
<p className="text-gray-900">
{creator.follower_count.toLocaleString()}
</p>
</div>
)}
{creator.collab_rate && (
<div>
<p className="text-sm font-medium text-gray-500">
Collaboration Rate
</p>
<p className="text-gray-900">{creator.collab_rate}</p>
</div>
)}
{creator.website && (
<div>
<p className="text-sm font-medium text-gray-500">Website</p>
<a
href={creator.website}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{creator.website}
</a>
</div>
)}
</div>
{currentUserRole === 'brand' && user && (
<CollabRequestButton
brandId={user.id}
creatorId={id}
alreadySent={existingRequest}
/>
)}
</div>
</div>
</div>
)
}
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export default function CollabRequestButton({
brandId,
creatorId,
alreadySent,
}: {
brandId: string
creatorId: string
alreadySent: boolean
}) {
const [showForm, setShowForm] = useState(false)
const [message, setMessage] = useState('')
const [sent, setSent] = useState(alreadySent)
const [loading, setLoading] = useState(false)
const supabase = createClient()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!message.trim()) return
setLoading(true)
const { error } = await supabase.from('collaboration_requests').insert({
brand_id: brandId,
creator_id: creatorId,
message: message.trim(),
})
if (error) {
alert('Error sending request: ' + error.message)
} else {
setSent(true)
setShowForm(false)
}
setLoading(false)
}
if (sent) {
return (
<div className="rounded-md bg-green-50 px-4 py-3 text-green-700">
Collaboration request sent!
</div>
)
}
if (showForm) {
return (
<form onSubmit={handleSubmit} className="space-y-3">
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
required
className="w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
placeholder="Introduce your brand and describe the collaboration you have in mind..."
/>
<div className="flex gap-2">
<button
type="submit"
disabled={loading}
className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Sending...' : 'Send Request'}
</button>
<button
type="button"
onClick={() => setShowForm(false)}
className="rounded-md border border-gray-300 px-4 py-2 text-gray-600 hover:bg-gray-50"
>
Cancel
</button>
</div>
</form>
)
}
return (
<button
onClick={() => setShowForm(true)}
className="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
Send Collaboration Request
</button>
)
}
Test and verify the profile pages
All four files are in place. The styling is already built into the components you just created using Tailwind CSS utility classes. Time to test the full flow.
How does the niche badge styling work?
The NICHE_COLORS object maps each niche string (like Math or Coding) to a pair of Tailwind classes for background and text color. When the page renders, it looks up the creator's niche in this map and applies the matching classes to a <span> element. This turns the niche into a colored pill badge next to the creator's name.
- Make sure your dev server is running (npm run dev).
- Log in as your test creator account.
- Navigate to http://localhost:3000/profile/edit.
- Fill in the form fields: enter a bio, select a niche from the dropdown, add platforms like YouTube, Instagram, set a follower count like 25000, and enter a collaboration rate like $500-$1000.
- Click Save Profile. You should be redirected to /dashboard.
Now test the public profile page.
- Open your Supabase Dashboard and go to the Table Editor.
- Select the profiles table and find your creator's row. Copy the value in the id column.
- Navigate to http://localhost:3000/creator/ followed by the ID you just copied.
You should see a card-style layout with the creator's display name, a colored niche badge, their bio, platforms, follower count, collaboration rate, and website link.
Profile page shows a 404 or blank data?
- Make sure you copied the full UUID from the id column in the Supabase Table Editor. A partial ID returns a 404.
- Check that your profile row has role set to creator. The query filters by .eq('role', 'creator'), so a profile with no role returns a 404.
- Verify that RLS policies are in place. Your "Public profiles are viewable by everyone" policy must be active for the SELECT operation on the profiles table.
- Why is my creator profile page showing a 404?
Your creator profile pages are complete. Creators can edit their profiles, and anyone can view them publicly. Next up, you will build the brand discovery dashboard where brands browse all creators and filter by niche and follower count.
Build the Brand Discovery Dashboard
In the previous step, you built the creator profile pages. Creators can now edit their details and display them publicly. The next question is: how do brands actually find these creators?
In this step, you will build the dashboard page. The dashboard serves two audiences. Brands see a discovery view where they can browse and filter creators by niche and follower count. Creators see their incoming collaboration requests.
In this step, get ready to:
- Build a dashboard routing page that directs brands and creators to different views.
- Create the brand discovery view with real-time filtering by niche and audience size.
- Build the creator's incoming collaboration requests view.
Build the dashboard routing page
The /dashboard URL needs to serve two completely different views depending on who is logged in. Brands see a creator discovery grid. Creators see their incoming requests. A single routing page handles this decision on the server before anything renders.
- Create a new folder called dashboard inside your app directory.
- Inside app/dashboard/, create a new file called page.tsx and add the following code:
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import BrandDashboard from './BrandDashboard'
import CreatorDashboard from './CreatorDashboard'
export default async function DashboardPage() {
const supabase = await createClient()
// Get the currently logged-in user
const {
data: { user },
} = await supabase.auth.getUser()
// Redirect to login if not authenticated
if (!user) {
redirect('/login')
}
// Fetch the user's role from their profile
const { data: profile } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
// If no role set yet, send them to onboarding
if (!profile?.role) {
redirect('/onboarding')
}
// Render the appropriate dashboard based on role
if (profile.role === 'brand') {
return <BrandDashboard />
}
return <CreatorDashboard userId={user.id} />
}
What does this code do?
- This is a Server Component that runs on every request to /dashboard.
- It checks if the user is authenticated. If not, it redirects to /login.
- It fetches the user's role from the profiles table, then renders either BrandDashboard or CreatorDashboard based on that role.
- If the user has no role yet (they skipped onboarding), they get redirected to /onboarding.
- Save page.tsx.
You won't see the dashboard render yet. The two components you are importing (BrandDashboard and CreatorDashboard) don't exist yet, so your terminal will show import errors. The next two substeps create them.
Seeing import errors in your terminal?
This is expected. The page.tsx file imports two components that do not exist yet. These errors will disappear after you create BrandDashboard.tsx and CreatorDashboard.tsx in the next substeps.
Create the brand discovery view with filters
Brands need a way to browse creators and narrow down results. This component fetches all creator profiles from Supabase and displays them as a filterable grid of cards. The filters use React state to re-query the database whenever the user changes a dropdown.
- Inside app/dashboard/, create a new file called BrandDashboard.tsx and add the following code:
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
// Niche options that match the creator profile form
const NICHES = ['Math', 'Coding', 'Languages', 'Science', 'Arts', 'Business']
// Follower count ranges for the filter dropdown
const FOLLOWER_RANGES = [
{ label: 'Any', min: 0, max: Infinity },
{ label: '1K - 10K', min: 1000, max: 10000 },
{ label: '10K - 50K', min: 10000, max: 50000 },
{ label: '50K - 100K', min: 50000, max: 100000 },
{ label: '100K+', min: 100000, max: Infinity },
]
// Shape of each creator returned from Supabase
interface Creator {
id: string
display_name: string | null
niche: string | null
follower_count: number | null
platforms: string | null
collab_rate: string | null
}
// Color map for niche badges on each card
const NICHE_COLORS: Record<string, string> = {
Math: 'bg-purple-100 text-purple-800',
Coding: 'bg-green-100 text-green-800',
Languages: 'bg-yellow-100 text-yellow-800',
Science: 'bg-blue-100 text-blue-800',
Arts: 'bg-pink-100 text-pink-800',
Business: 'bg-orange-100 text-orange-800',
}
export default function BrandDashboard() {
const [creators, setCreators] = useState<Creator[]>([])
const [selectedNiche, setSelectedNiche] = useState('')
const [selectedRange, setSelectedRange] = useState(0)
const [loading, setLoading] = useState(true)
const supabase = createClient()
// Re-fetch creators whenever filters change
useEffect(() => {
async function fetchCreators() {
setLoading(true)
let query = supabase
.from('profiles')
.select('id, display_name, niche, follower_count, platforms, collab_rate')
.eq('role', 'creator')
.order('follower_count', { ascending: false })
// Apply niche filter if selected
if (selectedNiche) {
query = query.eq('niche', selectedNiche)
}
// Apply follower count range filter
const range = FOLLOWER_RANGES[selectedRange]
if (range.min > 0) {
query = query.gte('follower_count', range.min)
}
if (range.max !== Infinity) {
query = query.lte('follower_count', range.max)
}
const { data } = await query
setCreators(data || [])
setLoading(false)
}
fetchCreators()
}, [selectedNiche, selectedRange])
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="mx-auto max-w-6xl px-4">
<h1 className="mb-6 text-2xl font-bold text-gray-900">
Discover Edu-Creators
</h1>
{/* Filter bar */}
<div className="mb-6 flex flex-wrap gap-4 rounded-lg bg-white p-4 shadow-sm">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Niche
</label>
<select
value={selectedNiche}
onChange={(e) => setSelectedNiche(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
>
<option value="">All Niches</option>
{NICHES.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Followers
</label>
<select
value={selectedRange}
onChange={(e) => setSelectedRange(Number(e.target.value))}
className="rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
>
{FOLLOWER_RANGES.map((r, i) => (
<option key={r.label} value={i}>
{r.label}
</option>
))}
</select>
</div>
</div>
{/* Creator card grid */}
{loading ? (
<p className="text-gray-500">Loading creators...</p>
) : creators.length === 0 ? (
<p className="text-gray-500">No creators found matching your filters.</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{creators.map((creator) => {
const nicheColor =
NICHE_COLORS[creator.niche || ''] || 'bg-gray-100 text-gray-800'
return (
<Link
key={creator.id}
href={`/creator/${creator.id}`}
className="block rounded-lg bg-white p-5 shadow-sm transition hover:shadow-md"
>
<div className="mb-2 flex items-center justify-between">
<h3 className="font-semibold text-gray-900">
{creator.display_name}
</h3>
{creator.niche && (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${nicheColor}`}
>
{creator.niche}
</span>
)}
</div>
{creator.follower_count !== null && (
<p className="text-sm text-gray-500">
{creator.follower_count.toLocaleString()} followers
</p>
)}
{creator.platforms && (
<p className="text-sm text-gray-500">{creator.platforms}</p>
)}
{creator.collab_rate && (
<p className="mt-2 text-sm font-medium text-gray-700">
{creator.collab_rate}
</p>
)}
</Link>
)
})}
</div>
)}
</div>
</div>
)
}
What does this code do?
- This is a 'use client' component because it uses React state and useEffect to handle interactive filtering.
- The NICHES and FOLLOWER_RANGES constants define the filter options. These match the values creators chose when editing their profiles.
- The useEffect hook fires whenever selectedNiche or selectedRange changes. It builds a Supabase query using .eq() for niche and .gte() / .lte() for follower count ranges.
- Each creator renders as a clickable card that links to their public profile page at /creator/[id].
- The NICHE_COLORS map gives each niche a distinct colored badge so brands can scan the grid quickly.
- Save BrandDashboard.tsx.
You still won't see the dashboard load because the CreatorDashboard component is still missing. The next substep creates it.
Build the creator's dashboard view
When a creator visits /dashboard, they should see their incoming collaboration requests from brands. This component queries the collaboration_requests table and joins it with profiles to show each brand's display name.
- Inside app/dashboard/, create a new file called CreatorDashboard.tsx and add the following code:
import { createClient } from '@/lib/supabase/server'
export default async function CreatorDashboard({
userId,
}: {
userId: string
}) {
const supabase = await createClient()
// Fetch collaboration requests sent to this creator, with the brand's name
const { data: requests } = await supabase
.from('collaboration_requests')
.select('id, created_at, message, status, brand_id, profiles!collaboration_requests_brand_id_fkey(display_name)')
.eq('creator_id', userId)
.order('created_at', { ascending: false })
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="mx-auto max-w-4xl px-4">
<h1 className="mb-6 text-2xl font-bold text-gray-900">
Collaboration Requests
</h1>
{/* Empty state when no requests exist yet */}
{!requests || requests.length === 0 ? (
<div className="rounded-lg bg-white p-8 text-center shadow-sm">
<p className="text-gray-500">
No collaboration requests yet. As brands discover your profile,
their requests will appear here.
</p>
</div>
) : (
<div className="space-y-4">
{requests.map((req) => (
<div
key={req.id}
className="rounded-lg bg-white p-5 shadow-sm"
>
<div className="mb-2 flex items-center justify-between">
{/* Brand name from the joined profiles table */}
<h3 className="font-semibold text-gray-900">
{(req.profiles as { display_name: string | null })?.display_name || 'Unknown Brand'}
</h3>
{/* Request status badge */}
<span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">
{req.status}
</span>
</div>
<p className="mb-2 text-gray-600">{req.message}</p>
<p className="text-xs text-gray-400">
{new Date(req.created_at).toLocaleDateString()}
</p>
</div>
))}
</div>
)}
</div>
</div>
)
}
What does this code do?
- This is a Server Component that receives the creator's userId as a prop from the routing page.
- The Supabase query uses a foreign key join (profiles!collaboration_requests_brand_id_fkey) to pull the brand's display_name alongside each request. This avoids a separate query for each brand name.
- Each request card shows the brand's name, message, status (e.g. "pending"), and the date it was sent.
- If no requests exist yet, the creator sees a friendly empty state message.
Why a foreign key join instead of a separate query?
The collaboration_requests table stores a brand_id that references profiles.id. Supabase lets you follow this foreign key relationship directly in the .select() call. Instead of making one query for requests and then a second query for each brand's name, you get everything in a single database round trip.
- Save CreatorDashboard.tsx.
- Check your terminal. The import errors from earlier should now be resolved.
- In your browser, log in as a brand user and navigate to /dashboard. You should see the "Discover Edu-Creators" heading with a filter bar showing Niche and Followers dropdowns.
- If you have any creator profiles in your database, they appear as cards below the filter bar. Try changing the filters to see the grid update.
Dashboard not loading or showing a blank page?
- Check your terminal for TypeScript errors. A missing import or typo in a file name is the most common cause.
- Make sure all three files (page.tsx, BrandDashboard.tsx, CreatorDashboard.tsx) are inside the app/dashboard/ folder.
- If you are redirected to /login, your session may have expired. Log in again.
- If you see "No creators found matching your filters" but expect to see results, make sure you have signed up as a creator in a separate account and filled in a profile with a niche and follower count.
Why isn't my dashboard showing any creators even though I created a profile?
Awesome, I've got everything!
Great. Double check you have saved all three files in app/dashboard/.
I'd like to double check the full code
Here is the complete code for all three dashboard files.
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import BrandDashboard from './BrandDashboard'
import CreatorDashboard from './CreatorDashboard'
export default async function DashboardPage() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
const { data: profile } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
if (!profile?.role) {
redirect('/onboarding')
}
if (profile.role === 'brand') {
return <BrandDashboard />
}
return <CreatorDashboard userId={user.id} />
}
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
const NICHES = ['Math', 'Coding', 'Languages', 'Science', 'Arts', 'Business']
const FOLLOWER_RANGES = [
{ label: 'Any', min: 0, max: Infinity },
{ label: '1K - 10K', min: 1000, max: 10000 },
{ label: '10K - 50K', min: 10000, max: 50000 },
{ label: '50K - 100K', min: 50000, max: 100000 },
{ label: '100K+', min: 100000, max: Infinity },
]
interface Creator {
id: string
display_name: string | null
niche: string | null
follower_count: number | null
platforms: string | null
collab_rate: string | null
}
const NICHE_COLORS: Record<string, string> = {
Math: 'bg-purple-100 text-purple-800',
Coding: 'bg-green-100 text-green-800',
Languages: 'bg-yellow-100 text-yellow-800',
Science: 'bg-blue-100 text-blue-800',
Arts: 'bg-pink-100 text-pink-800',
Business: 'bg-orange-100 text-orange-800',
}
export default function BrandDashboard() {
const [creators, setCreators] = useState<Creator[]>([])
const [selectedNiche, setSelectedNiche] = useState('')
const [selectedRange, setSelectedRange] = useState(0)
const [loading, setLoading] = useState(true)
const supabase = createClient()
useEffect(() => {
async function fetchCreators() {
setLoading(true)
let query = supabase
.from('profiles')
.select('id, display_name, niche, follower_count, platforms, collab_rate')
.eq('role', 'creator')
.order('follower_count', { ascending: false })
if (selectedNiche) {
query = query.eq('niche', selectedNiche)
}
const range = FOLLOWER_RANGES[selectedRange]
if (range.min > 0) {
query = query.gte('follower_count', range.min)
}
if (range.max !== Infinity) {
query = query.lte('follower_count', range.max)
}
const { data } = await query
setCreators(data || [])
setLoading(false)
}
fetchCreators()
}, [selectedNiche, selectedRange])
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="mx-auto max-w-6xl px-4">
<h1 className="mb-6 text-2xl font-bold text-gray-900">
Discover Edu-Creators
</h1>
<div className="mb-6 flex flex-wrap gap-4 rounded-lg bg-white p-4 shadow-sm">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Niche
</label>
<select
value={selectedNiche}
onChange={(e) => setSelectedNiche(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
>
<option value="">All Niches</option>
{NICHES.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Followers
</label>
<select
value={selectedRange}
onChange={(e) => setSelectedRange(Number(e.target.value))}
className="rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
>
{FOLLOWER_RANGES.map((r, i) => (
<option key={r.label} value={i}>
{r.label}
</option>
))}
</select>
</div>
</div>
{loading ? (
<p className="text-gray-500">Loading creators...</p>
) : creators.length === 0 ? (
<p className="text-gray-500">No creators found matching your filters.</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{creators.map((creator) => {
const nicheColor =
NICHE_COLORS[creator.niche || ''] || 'bg-gray-100 text-gray-800'
return (
<Link
key={creator.id}
href={`/creator/${creator.id}`}
className="block rounded-lg bg-white p-5 shadow-sm transition hover:shadow-md"
>
<div className="mb-2 flex items-center justify-between">
<h3 className="font-semibold text-gray-900">
{creator.display_name}
</h3>
{creator.niche && (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${nicheColor}`}
>
{creator.niche}
</span>
)}
</div>
{creator.follower_count !== null && (
<p className="text-sm text-gray-500">
{creator.follower_count.toLocaleString()} followers
</p>
)}
{creator.platforms && (
<p className="text-sm text-gray-500">{creator.platforms}</p>
)}
{creator.collab_rate && (
<p className="mt-2 text-sm font-medium text-gray-700">
{creator.collab_rate}
</p>
)}
</Link>
)
})}
</div>
)}
</div>
</div>
)
}
import { createClient } from '@/lib/supabase/server'
export default async function CreatorDashboard({
userId,
}: {
userId: string
}) {
const supabase = await createClient()
const { data: requests } = await supabase
.from('collaboration_requests')
.select('id, created_at, message, status, brand_id, profiles!collaboration_requests_brand_id_fkey(display_name)')
.eq('creator_id', userId)
.order('created_at', { ascending: false })
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="mx-auto max-w-4xl px-4">
<h1 className="mb-6 text-2xl font-bold text-gray-900">
Collaboration Requests
</h1>
{!requests || requests.length === 0 ? (
<div className="rounded-lg bg-white p-8 text-center shadow-sm">
<p className="text-gray-500">
No collaboration requests yet. As brands discover your profile,
their requests will appear here.
</p>
</div>
) : (
<div className="space-y-4">
{requests.map((req) => (
<div
key={req.id}
className="rounded-lg bg-white p-5 shadow-sm"
>
<div className="mb-2 flex items-center justify-between">
<h3 className="font-semibold text-gray-900">
{(req.profiles as { display_name: string | null })?.display_name || 'Unknown Brand'}
</h3>
<span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">
{req.status}
</span>
</div>
<p className="mb-2 text-gray-600">{req.message}</p>
<p className="text-xs text-gray-400">
{new Date(req.created_at).toLocaleDateString()}
</p>
</div>
))}
</div>
)}
</div>
</div>
)
}
Your marketplace now has a working discovery engine. Brands can browse creators, filter by niche and audience size, and click through to full profiles. Next up, you will wire up the collaboration request flow so brands can actually reach out to creators.
Build the Collaboration Request Flow
Brands can now discover creators on the dashboard and view their profiles. But there is no way to actually reach out. The marketplace is missing its core interaction.
In this step, you will build the collaboration request flow that lets brands send a message to a creator directly from their profile. You will also add a shared navigation bar so users can move around the app.
In this step, get ready to:
- Create the collaboration request form component.
- Add duplicate request prevention and success feedback.
- Build a shared navigation bar for the entire app.
Create the request form component
The creator profile page has a "Send Collaboration Request" button, but clicking it does nothing yet. You need a client component that shows a message form when the brand clicks the button and inserts a new row into the collaboration_requests table on submit.
- Create a new file at app/creator/[id]/CollabRequestButton.tsx and paste the following code:
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export default function CollabRequestButton({
brandId,
creatorId,
alreadySent,
}: {
brandId: string
creatorId: string
alreadySent: boolean
}) {
const [showForm, setShowForm] = useState(false)
const [message, setMessage] = useState('')
const [sent, setSent] = useState(alreadySent)
const [loading, setLoading] = useState(false)
const supabase = createClient()
// Insert a new collaboration request into the database
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!message.trim()) return
setLoading(true)
const { error } = await supabase.from('collaboration_requests').insert({
brand_id: brandId,
creator_id: creatorId,
message: message.trim(),
})
if (error) {
alert('Error sending request: ' + error.message)
} else {
setSent(true)
setShowForm(false)
}
setLoading(false)
}
// Show a success banner if the request has already been sent
if (sent) {
return (
<div className="rounded-md bg-green-50 px-4 py-3 text-green-700">
Collaboration request sent!
</div>
)
}
// Show the message form when the brand clicks the button
if (showForm) {
return (
<form onSubmit={handleSubmit} className="space-y-3">
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
required
className="w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 focus:border-blue-500 focus:outline-none"
placeholder="Introduce your brand and describe the collaboration you have in mind..."
/>
<div className="flex gap-2">
<button
type="submit"
disabled={loading}
className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Sending...' : 'Send Request'}
</button>
<button
type="button"
onClick={() => setShowForm(false)}
className="rounded-md border border-gray-300 px-4 py-2 text-gray-600 hover:bg-gray-50"
>
Cancel
</button>
</div>
</form>
)
}
// Default: show the "Send Collaboration Request" button
return (
<button
onClick={() => setShowForm(true)}
className="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
Send Collaboration Request
</button>
)
}
What does this component do?
- The component has three render states. By default it shows a button. Clicking the button reveals a form with a text area. After submitting, it shows a green success banner.
- handleSubmit inserts a new row into collaboration_requests with the brand's ID, creator's ID, and the message text.
- The alreadySent prop initializes the sent state so that returning visitors immediately see "Request sent" instead of the form.
- Save CollabRequestButton.tsx.
You will not see a change yet. The next substep wires this component into the creator profile page.
Add request status and duplicate prevention
Now you need to connect the new component to the creator profile page. The page must also check whether a request from this brand already exists so it can pass that info to the component.
- Open app/creator/[id]/page.tsx.
- At the top of the file, add this import below the existing imports:
import CollabRequestButton from './CollabRequestButton'
- Inside the component function, find the line that declares let currentUserRole. Add a new variable right after it:
let currentUserRole: string | null = null
let existingRequest = false
- Find the block where you check if currentUserRole === 'brand'. Add the existing request check inside that block, so it looks like this:
if (currentUserRole === 'brand') {
// Check if this brand already sent a request to this creator
const { data: request } = await supabase
.from('collaboration_requests')
.select('id')
.eq('brand_id', user.id)
.eq('creator_id', id)
.limit(1)
.maybeSingle()
existingRequest = !!request
}
What does this query do?
This queries the collaboration_requests table for any row where the brand_id matches the logged-in user and the creator_id matches the profile being viewed. If a row exists, existingRequest becomes true and the button will show "Request sent" instead of the form.
- Scroll down to the JSX at the bottom of the component. Find the block that renders the button when currentUserRole === 'brand'. Replace the plain <button> with the new CollabRequestButton component:
{currentUserRole === 'brand' && user && (
<CollabRequestButton
brandId={user.id}
creatorId={id}
alreadySent={existingRequest}
/>
)}
- Save page.tsx.
Awesome, I've got everything!
Great. Double check you have saved both CollabRequestButton.tsx and page.tsx.
I'd like to double check the full code
import { createClient } from '@/lib/supabase/server'
import { notFound } from 'next/navigation'
import CollabRequestButton from './CollabRequestButton'
const NICHE_COLORS: Record<string, string> = {
Math: 'bg-purple-100 text-purple-800',
Coding: 'bg-green-100 text-green-800',
Languages: 'bg-yellow-100 text-yellow-800',
Science: 'bg-blue-100 text-blue-800',
Arts: 'bg-pink-100 text-pink-800',
Business: 'bg-orange-100 text-orange-800',
}
export default async function CreatorProfilePage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const supabase = await createClient()
const { data: creator } = await supabase
.from('profiles')
.select('*')
.eq('id', id)
.eq('role', 'creator')
.single()
if (!creator) {
notFound()
}
const {
data: { user },
} = await supabase.auth.getUser()
let currentUserRole: string | null = null
let existingRequest = false
if (user) {
const { data: currentProfile } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
currentUserRole = currentProfile?.role || null
if (currentUserRole === 'brand') {
const { data: request } = await supabase
.from('collaboration_requests')
.select('id')
.eq('brand_id', user.id)
.eq('creator_id', id)
.limit(1)
.maybeSingle()
existingRequest = !!request
}
}
const nicheColor =
NICHE_COLORS[creator.niche || ''] || 'bg-gray-100 text-gray-800'
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="mx-auto max-w-2xl px-4">
<div className="rounded-lg bg-white p-8 shadow-md">
<div className="mb-4 flex items-center gap-3">
<h1 className="text-3xl font-bold text-gray-900">
{creator.display_name}
</h1>
{creator.niche && (
<span
className={`rounded-full px-3 py-1 text-sm font-medium ${nicheColor}`}
>
{creator.niche}
</span>
)}
</div>
{creator.bio && (
<p className="mb-6 text-gray-600">{creator.bio}</p>
)}
<div className="mb-6 grid grid-cols-2 gap-4">
{creator.platforms && (
<div>
<p className="text-sm font-medium text-gray-500">Platforms</p>
<p className="text-gray-900">{creator.platforms}</p>
</div>
)}
{creator.follower_count !== null && (
<div>
<p className="text-sm font-medium text-gray-500">Followers</p>
<p className="text-gray-900">
{creator.follower_count.toLocaleString()}
</p>
</div>
)}
{creator.collab_rate && (
<div>
<p className="text-sm font-medium text-gray-500">
Collaboration Rate
</p>
<p className="text-gray-900">{creator.collab_rate}</p>
</div>
)}
{creator.website && (
<div>
<p className="text-sm font-medium text-gray-500">Website</p>
<a
href={creator.website}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{creator.website}
</a>
</div>
)}
</div>
{currentUserRole === 'brand' && user && (
<CollabRequestButton
brandId={user.id}
creatorId={id}
alreadySent={existingRequest}
/>
)}
</div>
</div>
</div>
)
}
- To test this, you need two accounts. Open a second browser or an incognito window.
- Log in as your brand account and navigate to a creator's profile page.
- Click Send Collaboration Request. A text area should appear.
- Type a test message and click Send Request. You should see a green "Collaboration request sent" banner replace the form.
- Refresh the page. The banner should still appear, proving the duplicate check works.
Seeing an error when you click Send Request?
- Check your browser console for a Row Level Security error. The RLS policy you created in Step 3 only lets brands insert requests where brand_id matches their own user ID. Make sure you are logged in as a brand account, not a creator.
- If you see a relation "collaboration_requests" does not exist error, go back to the Supabase SQL Editor and re-run the SQL from Step 3 that creates the collaboration_requests table.
- Still stuck? Help me debug why my collaboration request insert is failing.
Add a navigation bar
Users currently have no way to navigate between pages without manually typing URLs. A shared navigation bar solves this by giving every page consistent links to the dashboard, profile, and login/logout.
You will create three things: a sign-out route handler, the Navbar component, and an updated root layout that includes the navbar on every page.
- Create a new file at app/auth/signout/route.ts and paste the following code:
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
// Handle POST requests to sign the user out
export async function POST() {
const supabase = await createClient()
await supabase.auth.signOut()
redirect('/login')
}
What does this route do?
This is a Next.js Route Handler that responds to POST requests. When the Navbar's logout form submits to /auth/signout, this handler calls supabase.auth.signOut() to clear the session, then redirects the user to the login page.
- Save route.ts.
You will not see a change yet. The Navbar component you create next will submit to this route.
- Create the folder app/components/ inside your edu-creator-marketplace project.
- Create a new file at app/components/Navbar.tsx and paste the following code:
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
export default async function Navbar() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
// Fetch the user's role so we can conditionally show the Profile link
let role: string | null = null
if (user) {
const { data: profile } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
role = profile?.role || null
}
return (
<nav className="border-b border-gray-200 bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<Link href="/" className="text-xl font-bold text-blue-600">
EduConnect
</Link>
<div className="flex items-center gap-4">
{user ? (
<>
<Link
href="/dashboard"
className="text-sm text-gray-600 hover:text-gray-900"
>
Dashboard
</Link>
{role === 'creator' && (
<Link
href="/profile/edit"
className="text-sm text-gray-600 hover:text-gray-900"
>
My Profile
</Link>
)}
<form action="/auth/signout" method="post">
<button
type="submit"
className="text-sm text-gray-600 hover:text-gray-900"
>
Log Out
</button>
</form>
</>
) : (
<Link
href="/login"
className="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
>
Log In
</Link>
)}
</div>
</div>
</nav>
)
}
What does the Navbar do?
- This is an async Server Component. It calls supabase.auth.getUser() to check if the visitor is logged in.
- If logged in, it shows links to Dashboard and My Profile (creators only), plus a Log Out button.
- If logged out, it shows a single Log In button.
- The Log Out button is inside a <form> that posts to the /auth/signout route you just created.
- Save Navbar.tsx.
The Navbar component exists, but no page renders it yet. You need to add it to the root layout so it appears on every page.
- Open app/layout.tsx and replace its entire contents with the following:
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import Navbar from './components/Navbar'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'EduConnect - Edu-Creator Marketplace',
description:
'Connect educational content creators with brands in the teaching and learning space',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<body className={inter.className}>
<Navbar />
{children}
</body>
</html>
)
}
What changed in the layout?
- The Navbar import was added at the top of the file.
- <Navbar /> is rendered above {children} inside the <body>. This means every page in your app now gets the navigation bar at the top.
- The metadata was also updated to reflect the app name "EduConnect" and its purpose.
- Save layout.tsx.
- Go to http://localhost:3000 in your browser. You should see a white navigation bar at the top of the page with "EduConnect" on the left and a blue Log In button on the right.
- Log in with your test account. The navbar should now show Dashboard, My Profile (if you are a creator), and Log Out.
- Click Log Out. You should be redirected to the login page.
Navbar not showing up?
- Make sure you saved layout.tsx. The import path must be './components/Navbar' (relative to app/).
- Check your terminal for TypeScript errors. A missing file or mismatched export name will prevent the page from rendering.
- If the Log Out button does nothing, confirm that app/auth/signout/route.ts exists and exports a POST function.
- Still stuck? Help me debug why my Navbar is not rendering in my Next.js layout.
Your marketplace now has a complete collaboration flow. Brands can discover creators, view their profiles, and send a collaboration request with a personalized message. Creators see those requests on their dashboard. The navigation bar ties everything together. Next up, you will deploy the whole app to Vercel and make it live.
Deploy to Vercel
Your marketplace is fully functional. Creators can sign up, build profiles, and receive collaboration requests from brands who discover them through the filtered dashboard.
Now it is time to take this app live. You will deploy to Vercel, a hosting platform designed for Next.js apps. You will also update your Supabase project so authentication emails point to your production URL instead of localhost.
In this step, get ready to:
- Connect your GitLab repository to Vercel.
- Configure environment variables and deploy your app.
- Update Supabase authentication settings for your production URL.
Connect your GitLab repository to Vercel
Vercel reads your repository, detects the framework, and builds your app automatically. By signing in with GitLab, every future push triggers a fresh deploy.
- Open your browser and go to vercel.com.
- Click Sign Up if you do not have an account, then choose Continue with GitLab.
- Authorize Vercel to access your GitLab account when prompted.
Why sign in with GitLab?
Connecting your GitLab account lets Vercel pull your code directly. It also enables automatic redeployments every time you push new commits to your repository.
- Click Add New... in the top navigation bar.
- Select Project.
- Find edu-creator-marketplace in the repository list and click Import.
Vercel detects that this is a Next.js project. You should see the Framework Preset set to Next.js on the configuration screen.
Add your environment variables and deploy
Your app needs the same Supabase credentials in production that you used locally. Without them, the app cannot connect to your database or authenticate users.
- On the project configuration screen, expand the Environment Variables section.
- In the Key field, enter NEXT_PUBLIC_SUPABASE_URL.
- In the Value field, paste your Supabase project URL (the same value from your .env.local file).
- Click Add.
- Add a second variable with the key NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY and paste your Supabase publishable key as the value.
- Click Add.
- Click Deploy.
Vercel builds your app and assigns it a live URL. The build takes about 1-2 minutes.
- Once the build succeeds, click the preview URL to open your live app.
- Copy this URL. You will need it in the next substep.
Build failing?
- Make sure both environment variable names match exactly: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY. A typo in the key name causes the app to crash on load.
- Check the build logs in Vercel for specific error messages. TypeScript errors that did not appear locally can surface during the production build.
- Make sure you pushed all your code to GitLab before importing. Vercel builds from your repository, not your local files.
- Help me debug my failing Vercel build.
Update Supabase authentication settings for production
Supabase uses your Site URL to build email confirmation links. Right now it still points to localhost:3000. You need to update it to your live Vercel URL so that signup confirmation emails redirect users to the correct place.
- Go to your Supabase Dashboard.
- Open your edu-creator-marketplace project.
- In the left sidebar, click Authentication.
- Click URL Configuration.
- In the Site URL field, replace the existing value with your Vercel deployment URL (e.g. https://edu-creator-marketplace.vercel.app).
- Click Save.
- Scroll down to the Redirect URLs section.
- Click Add URL.
- Enter your Vercel deployment URL (the same URL you set as the Site URL).
- Click Add URL to save it.
What are redirect URLs?
Redirect URLs tell Supabase which domains are allowed to receive authentication callbacks. Without your Vercel URL in this list, login and signup redirects fail with an "invalid redirect" error.
Time to test the full flow on your live app.
- Open your Vercel URL in a browser.
- Click Get Started to go to the login page.
- Sign up with an email and password.
- Check your email for the confirmation link and click it.
- Choose the Creator role during onboarding.
- Fill in your creator profile with a display name, niche, and follower count.
- Open a different browser (or an incognito window) and sign up as a second user.
- Choose the Brand role for this second account.
- As the brand, visit the Dashboard to see the creator you just made.
- Click on the creator card to view their profile.
- Click Send Collaboration Request and type a message.
You should see a green "Collaboration request sent" confirmation. Switch back to the creator's browser and check the Dashboard. The brand's request should appear with the message and a "pending" status badge.
Signup confirmation email not arriving?
- Check your spam folder first.
- Verify the Site URL in Supabase matches your Vercel URL exactly, including https://.
- Confirm the email template in Authentication > Templates > Confirm Signup uses the format: {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email.
- Help me troubleshoot my Supabase confirmation email.
Secret mission
Featured Creators on the Landing Page
Your landing page tells visitors what EduConnect does, but it doesn't show them who's already on the platform. What if brands could see top creators before they even sign up? Build a Featured Creators section that queries the database and displays a polished grid of your highest-follower creators right on the homepage.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the Supabase free tier and the Vercel free tier, so there are no ongoing costs. However, the Supabase free plan limits you to 2 projects, so cleaning up frees a slot for future projects.
Resources you used:
- Supabase project (edu-creator-marketplace) with Postgres database, auth, and RLS policies.
- Database tables: profiles and collaboration_requests.
- Next.js local project files (edu-creator-marketplace directory).
- GitLab repository.
- Vercel deployment.
- .env.local file with Supabase credentials.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the marketplace with new features.
- Your Supabase project stays active on the free tier with no charges.
- Your Vercel deployment remains live at your deployment URL.
- Your local project files stay on your machine, ready for development.
Pause - I'll come back to this later
Shut down running processes to free up resources while keeping your files and accounts intact.
- Stop the local development server by pressing Ctrl + C in your terminal.
- Your Supabase project will automatically pause after 7 days of inactivity on the free tier. You can also manually pause it from the Supabase Dashboard under Settings > General.
- Your Vercel deployment will remain live but costs nothing on the free tier.
- Keep your .env.local file and local project folder. You will need them when you come back.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Delete your Supabase project. Go to the Supabase Dashboard, open your project, then navigate to Settings > General. Scroll to the bottom and click Delete project. This removes your database, auth users, and all stored data.
- Delete your Vercel deployment. Go to the Vercel Dashboard, select your project, then navigate to Settings. Scroll to the bottom and click Delete.
- Delete your GitLab repository. Open the repo in GitLab, go to Settings > General, expand Advanced, and click Delete project.
- Delete the local project folder from your machine.
Mission accomplished
That's a Wrap!
Nice work! You just built and deployed a production-grade two-sided marketplace using Next.js and Supabase.
You've learned how to:
- Design a relational Postgres database with role-based profiles, collaboration requests, and Row-Level Security policies that protect user data at the database level.
- Implement cookie-based authentication with Supabase Auth and the @supabase/ssr package, including signup, login, role selection, and session-refreshing middleware.
- Build a full discovery dashboard with real-time filtering by niche and follower count, a collaboration request flow connecting brands with creators, and deploy it all live on Vercel.
- Add a Featured Creators section to the landing page that showcases top edu-creators to entice brands to sign up.
Ready to quiz yourself?