Build a Contact Pipeline CRM
Build a multi-tenant CRM with a Kanban pipeline using Next.js and Supabase.
Introduction
30 Second Summary
Keeping track of people you need to follow up with is hard. You meant to call that promising contact last Tuesday, but the sticky note disappeared and the spreadsheet got buried.
In this project, you will build a personal contact pipeline CRM using Next.js and Supabase. You will create a visual Kanban board where you drag prospects through your sales pipeline from first contact to enrollment.
What You'll Build
Open your finished app to see every prospect laid out across five pipeline columns, ready to be moved with a single drag as your relationships progress.
By the end of this project, you'll have:
- A sign-up and sign-in flow that creates your own isolated workspace. No other user can see your contacts.
- A Kanban pipeline board with five stages where you drag contacts between columns as relationships progress. Moving a contact from "New" to "Contacted" is one drag gesture.
- A contact management system for adding prospects with their name, phone, email, and notes. Click any card to view or edit the full details.
- Secret Mission: Add a follow-up reminder system that flags contacts sitting in the same stage for more than 3 days.
Are there any prerequisites?
This project assumes familiarity with React and TypeScript. You will also need Node.js installed on your machine and a free Supabase account, which you can create at database.new.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters for your workflow.
Scaffold the Next.js + Supabase Project
Every CRM needs two things: a database to store contacts and an authentication system to keep each user's data private. Supabase gives you both out of the box with a hosted PostgreSQL database and a built-in auth system.
In this step, you will create a Supabase project, scaffold a Next.js app from Supabase's official starter template, and connect the two. The template comes pre-configured with cookie-based authentication, TypeScript, and Tailwind CSS, so you can skip the boilerplate and jump straight into building CRM features.
In this step, get ready to:
- Create a Supabase project and copy your connection credentials.
- Scaffold the Next.js app using the official Supabase template.
- Start the dev server and confirm working auth pages.
Create a new Supabase project
Your CRM needs a database and an auth provider. A single Supabase project gives you both.
- Open database.new in your browser. This takes you straight to the Supabase new-project wizard.
- If you are not already signed in, sign in to your Supabase account.
- Enter network-crm as the project name.
- Choose a database password and save it somewhere safe.
- Select the region closest to you.
- Click Create new project.
Supabase takes a minute or two to provision your database. Once it is ready, you need to grab two values that connect your Next.js app to this project.
- Click the Connect button at the top of your project dashboard.
- In the Connect panel, copy the Project URL value. Paste it somewhere you can access in a moment (a text file or sticky note works).
- Copy the Publishable key value from the same panel and paste it alongside your URL.
What are these two values?
The Project URL is the unique address of your Supabase database. Every API call your app makes goes through this URL.
The publishable key is a safe-to-expose token that tells Supabase which project the request belongs to. It does not grant admin access. Row Level Security (which you will set up in Step 2) controls what data each user can actually read and write.
Dashboard shows an anon key?
If your Supabase dashboard displays an anon key instead of a publishable key, that is the same value. Supabase is transitioning to a new naming convention, and both work with the starter template during the transition period.
Scaffold the Next.js app with the Supabase template
Instead of setting up auth, TypeScript, and Tailwind by hand, you will use a starter template that wires everything together for you.
- 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 app by running this command:
npx create-next-app@latest network-crm -e with-supabase
What does this command do?
- npx create-next-app@latest downloads and runs the latest version of the Next.js project scaffolder.
- network-crm sets the project folder name.
- -e with-supabase tells it to use Supabase's official starter template instead of the default blank project.
If npm asks you to confirm installing a package, type y and press Enter.
Once the scaffold finishes, you should see a success message with the project name.
- Move into the new project folder by running:
cd network-crm
- Confirm the template files were created by running:
ls
You should see files like package.json, tsconfig.json, and folders like app/ and lib/.
Don't see these files?
- Make sure you ran cd network-crm after the scaffold finished. Without it, you are still in the Desktop directory.
- If the scaffold command failed, check that Node.js is installed by running node --version in your terminal.
- help me troubleshoot the create-next-app scaffold
What did the template set up for you?
The with-supabase template pre-installs @supabase/supabase-js and @supabase/ssr and creates two Supabase client files for you.
- lib/supabase/client.ts creates a client for browser-side code.
- lib/supabase/server.ts creates a client for Server Components that reads cookies.
- A proxy file handles auth token refreshing automatically on every request.
Connect the app to your Supabase project
The template created a .env.example file with placeholder environment variables. You need to rename it and fill in your real Supabase credentials.
- Rename the example file to .env.local by running:
mv .env.example .env.local
- Open the network-crm folder in your code editor.
- Open the .env.local file. You should see two lines with placeholder values.
- Replace the placeholder next to NEXT_PUBLIC_SUPABASE_URL with the Project URL you copied from the Connect panel.
- Replace the placeholder next to NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY with the publishable key (or anon key) you copied.
Your .env.local should now look like this (with your own values):
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key
Why .env.local?
Next.js automatically loads environment variables from .env.local and makes any variable prefixed with NEXT_PUBLIC_ available in both server and browser code. The .gitignore file already excludes .env.local from version control, so your keys stay out of Git.
- Save .env.local.
Not sure which values to paste?
- Your Project URL looks like https://abcdefg.supabase.co. If it does not start with https://, double-check you copied the full URL from the Connect panel.
- Your publishable key starts with sb_publishable_ (or eyJ if you are using a legacy anon key). Both formats work.
- help me find my Supabase URL and key
Start the dev server and confirm auth pages work
Everything is wired up. Time to see it running.
- Start the dev server by running:
npm run dev
Before you open the browser, what do you think you will see at http://localhost:3000? The template includes a full auth setup, so think about what pages it might show.
- Open http://localhost:3000 in your browser.
You should see the Supabase starter page with Sign up and Sign in links. These auth pages already work because the template configured cookie-based sessions for you.
Seeing an error instead of the starter page?
- Check that .env.local exists in the network-crm folder (not .env.example). The rename must have completed successfully.
- Verify that both NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY have values (not the original placeholders).
- If you changed .env.local after starting the server, stop it with Ctrl+C and run npm run dev again. Next.js only reads environment variables at startup.
- help me fix the Supabase starter page not loading
Your Next.js app is connected to Supabase and ready to go. Next up, you will create the contacts database table and lock it down with Row Level Security so every user's data stays private.
Build the Contact Database Schema with Row Level Security
Your Next.js app is running and connected to Supabase. But right now, the database behind it is completely empty. There is no place to store the contacts you will be tracking through your pipeline.
Without a database schema, contacts have nowhere to live. Without Row Level Security, any logged-in user could read everyone else's prospects. In this step, you will create the contacts table and lock it down so each user only sees their own data.
In this step, get ready to:
- Create a contacts table with fields for prospect details and pipeline stage.
- Enable Row Level Security with policies that isolate each user's data.
Create the contacts table in the SQL Editor
Every contact in your pipeline needs a home. The contacts table will store each prospect's name, phone, email, notes, where they came from, and which pipeline stage they are in.
- In your Supabase dashboard, click SQL Editor in the left sidebar.
- Click New query to open a blank editor pane.
- Create the contacts table by pasting this SQL and clicking Run:
-- Create the contacts table for storing prospect information
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) NOT NULL,
full_name TEXT NOT NULL,
email TEXT,
phone TEXT,
notes TEXT,
source TEXT,
stage TEXT DEFAULT 'new'
CHECK (stage IN ('new', 'contacted', 'presentation', 'follow_up', 'enrolled')),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
What does this SQL do?
This creates a table where each row is one contact in your pipeline.
- id generates a unique identifier for each contact automatically.
- user_id links each contact to the user who created it. This is how you will enforce data isolation.
- stage defaults to 'new' and only accepts the five valid pipeline values: new, contacted, presentation, follow_up, and enrolled. The CHECK constraint prevents invalid stages from sneaking in.
- created_at and updated_at track when each contact was added and last modified.
You should see a Success. No rows returned message in the results panel. That means the table was created.
- Confirm the table structure by clearing the editor and running this query:
SELECT * FROM contacts;
You should see an empty result set with all ten column headers: id, user_id, full_name, email, phone, notes, source, stage, created_at, and updated_at. No rows yet, because you have not added any contacts.
Seeing a syntax error?
- Make sure you copied the entire SQL block, including the closing ); at the bottom.
- If you see a "relation already exists" error, the table was already created from a previous attempt. You can drop it first with DROP TABLE contacts; and run the CREATE TABLE again.
- help me debug my CREATE TABLE SQL in Supabase
Lock down the table with Row Level Security
Your table exists, but it has a serious problem. Right now, any authenticated user who hits your Supabase API can read, edit, or delete every contact in the table. Row Level Security fixes this by adding a rule to every query: you can only touch rows where the user_id matches your login.
- Open a new query in the SQL Editor and enable RLS with four access policies by pasting this SQL and clicking Run:
-- Turn on Row Level Security for the contacts table
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
-- Allow users to read only their own contacts
CREATE POLICY "Users can view their own contacts"
ON contacts FOR SELECT
USING (auth.uid() = user_id);
-- Allow users to insert contacts linked to their own ID
CREATE POLICY "Users can insert their own contacts"
ON contacts FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Allow users to update only their own contacts
CREATE POLICY "Users can update their own contacts"
ON contacts FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Allow users to delete only their own contacts
CREATE POLICY "Users can delete their own contacts"
ON contacts FOR DELETE
USING (auth.uid() = user_id);
What do these policies do?
Each policy gates one type of database operation (SELECT, INSERT, UPDATE, DELETE) with the same check: auth.uid() = user_id.
auth.uid() is a Supabase helper that returns the ID of the currently logged-in user. PostgreSQL runs this check on every single row before allowing access. If User A tries to read User B's contacts, the check fails and those rows are invisible.
This is multi-tenant data isolation enforced at the database layer. Your application code never needs to filter by user_id manually. The database handles it.
You should see another Success. No rows returned message confirming all five statements ran.
- Verify that the policies are active by clicking Authentication in the left sidebar.
- Click Policies.
Before you look, predict: how many policies should you see listed under the contacts table?
You should see the contacts table listed with RLS enabled and four policies: one each for SELECT, INSERT, UPDATE, and DELETE.
Don't see any policies?
- Make sure you ran the entire RLS SQL block, not just the first ALTER TABLE line. All five statements need to run together.
- If the contacts table does not appear under Policies, go back to the SQL Editor and run just the ALTER TABLE contacts ENABLE ROW LEVEL SECURITY; line first, then the four CREATE POLICY statements.
- help me enable Row Level Security on my Supabase contacts table
Why enforce security at the database layer?
You might wonder why you do not just filter contacts by user_id in your application code. The problem is that application-level filters can be forgotten or bypassed. A new developer adding a query might skip the WHERE user_id = ... clause, and suddenly all contacts leak.
RLS makes this impossible. The database itself blocks cross-user access, no matter what query your code sends.
Your database is now secure. Every contact a user creates is invisible to everyone else. Next up, you will replace the default starter page with a Kanban pipeline board that pulls these contacts onto the screen.
Display Contacts on a Kanban Pipeline Board
Your database is ready with a contacts table and Row Level Security policies in place. But your app still shows the default Supabase starter page after you sign in.
In this step, you will replace that starter page with a pipeline board that fetches contacts from your database and displays them in five columns. This is the first visible piece of your CRM.
In this step, get ready to:
- Create a dashboard route that fetches contacts from Supabase and groups them by stage.
- Build pipeline board components that display five columns with contact cards.
- Redirect authenticated users to the dashboard automatically.
Create the dashboard route
Every page in a Next.js App Router project is a file inside the app/ directory. To create a new page at /dashboard, you create a folder called dashboard with a page.tsx file inside it.
- In your code editor, create a new folder called dashboard inside the app/ directory of your network-crm project.
- Inside app/dashboard/, create a new file called page.tsx and paste the following code:
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import { PipelineBoard } from "@/components/PipelineBoard";
// Valid pipeline stages in display order
const STAGES = ["new", "contacted", "presentation", "follow_up", "enrolled"];
export default async function DashboardPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/sign-in");
// Fetch all contacts for the current user
const { data: contacts } = await supabase
.from("contacts")
.select("*")
.order("created_at", { ascending: true });
// Group contacts by pipeline stage
const grouped: Record<string, any[]> = {};
for (const stage of STAGES) {
grouped[stage] = (contacts || []).filter((c) => c.stage === stage);
}
return (
<div className="min-h-screen bg-gray-50 p-8">
<h1 className="text-2xl font-bold mb-6">Contact Pipeline</h1>
<PipelineBoard groupedContacts={grouped} />
</div>
);
}
What does this code do?
This is a Server Component. It runs on the server, not in the browser. That means it can talk directly to Supabase without exposing your queries to the client.
- createClient() creates a Supabase server client that reads the user's session from cookies.
- getUser() checks if someone is signed in. If not, the page redirects to the sign-in page.
- The query fetches all contacts from the contacts table. RLS ensures only this user's rows come back.
- The for loop groups each contact into one of the five stage arrays, then passes them to PipelineBoard.
This page imports a PipelineBoard component that does not exist yet. If you check your browser, you will see a module-not-found error. That is expected. You will create the component next.
Build the ContactCard and PipelineBoard components
The pipeline board needs two components. ContactCard displays a single contact's name and source. PipelineBoard renders five columns and places a ContactCard in each one.
- In the root of your network-crm project, create a new folder called components if it does not already exist.
- Inside components/, create a new file called ContactCard.tsx and paste the following code:
"use client";
// Type definition for a contact row from Supabase
export type Contact = {
id: string;
full_name: string;
email: string | null;
phone: string | null;
notes: string | null;
source: string | null;
stage: string;
created_at: string;
updated_at: string;
};
// Renders a single contact card showing name and source
export function ContactCard({ contact }: { contact: Contact }) {
return (
<div className="bg-white rounded-lg p-3 shadow-sm border border-gray-200">
<p className="font-medium text-gray-900">{contact.full_name}</p>
{contact.source && (
<p className="text-sm text-gray-500 mt-1">{contact.source}</p>
)}
</div>
);
}
What does this code do?
The "use client" directive marks this as a Client Component. It runs in the browser, which is required for interactive features you will add later (like drag-and-drop).
The Contact type defines the shape of a contact row from your Supabase table. The component itself renders the contact's full_name and optionally shows the source if one exists.
- Now create a second file in components/ called PipelineBoard.tsx and paste the following code:
"use client";
import { ContactCard, Contact } from "./ContactCard";
// Pipeline stage definitions with display labels
const STAGES = [
{ key: "new", label: "New" }, { key: "contacted", label: "Contacted" },
{ key: "presentation", label: "Presentation" },
{ key: "follow_up", label: "Follow-Up" }, { key: "enrolled", label: "Enrolled" },
];
export function PipelineBoard({ groupedContacts }: { groupedContacts: Record<string, Contact[]> }) {
return (
<div className="flex gap-4 overflow-x-auto pb-4">
{STAGES.map(({ key, label }) => {
const contacts = groupedContacts[key] || [];
return (
<div key={key} className="flex-shrink-0 w-64 bg-gray-100 rounded-lg p-4">
<h2 className="font-semibold text-gray-700 mb-3">
{label} <span className="text-gray-400">({contacts.length})</span>
</h2>
{contacts.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No contacts yet</p>
) : (
<div className="space-y-2">
{contacts.map((c) => <ContactCard key={c.id} contact={c} />)}
</div>
)}
</div>
);
})}
</div>
);
}
What does this code do?
The STAGES array maps each database stage key (like follow_up) to a human-readable label (like "Follow-Up"). This keeps your column order consistent.
For each stage, the component renders a column showing the label, a contact count, and either a list of ContactCard components or a "No contacts yet" placeholder if the stage is empty.
- Save both components/ContactCard.tsx and components/PipelineBoard.tsx.
- Make sure your dev server is still running. If it stopped, start it again by running:
npm run dev
- Sign in to your app at http://localhost:3000 if you are not already signed in.
- Navigate to http://localhost:3000/dashboard in your browser.
You should see a Kanban board with five empty columns labeled "New", "Contacted", "Presentation", "Follow-Up", and "Enrolled". Each column shows a count of (0) and the text "No contacts yet".
Seeing a module-not-found error?
- Make sure the file components/PipelineBoard.tsx exists and that the filename matches exactly (capital P, capital B).
- Check that components/ContactCard.tsx exists with the same casing.
- Confirm both files are saved and the dev server has reloaded.
Still stuck? Why can't Next.js find my PipelineBoard component?
Redirect authenticated users to the dashboard
Right now you have to manually type /dashboard in the URL bar to see your board. Authenticated users should land on the dashboard automatically when they visit the root URL.
The with-supabase template includes a proxy file at lib/supabase/proxy.ts that runs on every request. It already redirects unauthenticated users to the login page. You will add a redirect for authenticated users visiting / so they go straight to /dashboard.
- Open lib/supabase/proxy.ts in your code editor.
- Find the block near the bottom that starts with the return supabaseResponse line. Look for the comment that says IMPORTANT: You *must* return the supabaseResponse object as it is.
- Just above that comment block, find the existing redirect logic. It looks like this:
if (
request.nextUrl.pathname !== "/" &&
!user &&
!request.nextUrl.pathname.startsWith("/login") &&
!request.nextUrl.pathname.startsWith("/auth")
) {
// no user, potentially respond by redirecting the user to the login page
const url = request.nextUrl.clone();
url.pathname = "/auth/login";
return NextResponse.redirect(url);
}
- Add the following redirect block directly after the closing } of that if statement, and before the // IMPORTANT: comment:
// Redirect authenticated users from the landing page to the dashboard
if (request.nextUrl.pathname === "/" && user) {
const url = request.nextUrl.clone();
url.pathname = "/dashboard";
return NextResponse.redirect(url);
}
What does this redirect do?
This checks two things: is the user visiting the root URL (/), and are they signed in? If both are true, the proxy redirects them to /dashboard before the page even starts rendering.
The proxy runs on every request, so this redirect happens instantly. Unauthenticated visitors still see the default landing page.
- Save lib/supabase/proxy.ts.
- Before you test, what do you expect to see when you visit http://localhost:3000 while signed in?
- Navigate to http://localhost:3000 in your browser.
You should be redirected automatically to /dashboard and see your five-column Kanban board. The URL bar updates to http://localhost:3000/dashboard.
Not being redirected?
- Make sure you are signed in. Try visiting http://localhost:3000/dashboard directly to confirm your session is active.
- Check that your new if block is placed after the unauthenticated redirect and before the return supabaseResponse line.
- Confirm the condition checks request.nextUrl.pathname === "/" with a strict equality, not startsWith.
Still stuck? Why is my proxy.ts redirect not working for authenticated users?
✔️ Awesome, I've got everything!
Great. Double check that you have saved all three files: app/dashboard/page.tsx, components/ContactCard.tsx, and components/PipelineBoard.tsx.
ⓧ I'd like to double check the full code
Scroll up and confirm each file is present and matches. Here is the checklist:
- app/dashboard/page.tsx imports createClient, redirect, and PipelineBoard. It defines STAGES, checks auth, fetches contacts, groups them, and renders the board.
- components/ContactCard.tsx exports the Contact type and a ContactCard component that shows full_name and source.
- components/PipelineBoard.tsx imports ContactCard and Contact, defines STAGES with labels, and renders five columns with counts, cards, or a "No contacts yet" placeholder.
- lib/supabase/proxy.ts has a new if block that redirects authenticated users from / to /dashboard.
Your CRM now has a real pipeline view. Next up, you will add the ability to create contacts and drag them between stages.
Add Contacts and Drag Them Between Stages
In Step 3, you built a Kanban pipeline board that fetches contacts from Supabase and displays five stage columns. The board is ready, but every column is still empty.
A pipeline you cannot add to or rearrange is just a static display. In this step, you will create a form that inserts new contacts, wire up drag-and-drop so you can move prospects between stages, and build a detail panel for viewing and editing each contact.
In this step, get ready to:
- Build an add-contact dialog that inserts prospects into Supabase.
- Implement drag-and-drop so you can move contacts between pipeline stages.
- Add a contact detail panel for viewing and editing prospect information.
Create the add-contact dialog
Every prospect in your pipeline starts as a form submission. You need a dialog component that collects a contact's name, phone, email, notes, and source, then inserts it into Supabase with the stage set to new.
- Create a new file at components/AddContactDialog.tsx and add the component logic by pasting the following code:
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
export default function AddContactDialog(
{ onContactAdded }: { onContactAdded: () => void }
) {
const [isOpen, setIsOpen] = useState(false);
const [form, setForm] = useState({
full_name: "", phone: "", email: "", notes: "", source: "",
});
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const supabase = createClient();
// Get the currently signed-in user
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
// Insert the new contact with stage 'new'
await supabase.from("contacts").insert({
...form,
user_id: user.id,
stage: "new",
updated_at: new Date().toISOString(),
});
setForm({ full_name: "", phone: "", email: "", notes: "", source: "" });
setIsOpen(false);
onContactAdded();
}
What does this code do?
The component stores all five form fields in a single form state object to keep the code compact.
When the learner submits, handleSubmit gets the authenticated user's ID, inserts the contact with stage: "new", then resets the form and calls onContactAdded so the parent can refresh the contact list.
- Still in AddContactDialog.tsx, add the dialog's UI below the handleSubmit function:
// When closed, render just the trigger button
if (!isOpen) {
return (
<button onClick={() => setIsOpen(true)}
className="bg-blue-600 text-white px-4 py-2 rounded">
+ Add Contact
</button>
);
}
// When open, render the overlay with form
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<form onSubmit={handleSubmit} className="bg-white rounded-lg p-6 w-full max-w-md space-y-3">
<h2 className="text-lg font-semibold">Add New Contact</h2>
<input required value={form.full_name} onChange={e => setForm({...form, full_name: e.target.value})} placeholder="Full name" className="w-full border rounded px-3 py-2" />
<input value={form.phone} onChange={e => setForm({...form, phone: e.target.value})} placeholder="Phone" className="w-full border rounded px-3 py-2" />
<input value={form.email} onChange={e => setForm({...form, email: e.target.value})} placeholder="Email" className="w-full border rounded px-3 py-2" />
<input value={form.source} onChange={e => setForm({...form, source: e.target.value})} placeholder="Source (e.g. referral, event)" className="w-full border rounded px-3 py-2" />
<textarea value={form.notes} onChange={e => setForm({...form, notes: e.target.value})} placeholder="Notes" className="w-full border rounded px-3 py-2" rows={3} />
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setIsOpen(false)} className="px-4 py-2 border rounded">Cancel</button>
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">Save Contact</button>
</div>
</form>
</div>
);
}
What does the UI do?
When isOpen is false, the component renders a small button labeled "+ Add Contact". Clicking it flips isOpen to true, which renders a full-screen overlay with a form.
The full_name field has required on it, so the browser prevents empty submissions. Clicking "Cancel" closes the dialog without saving.
The dialog is ready, but it needs a home on the page. You need to update your dashboard page and pipeline board so the button appears and contacts refresh after submission.
- Open app/dashboard/page.tsx and replace its contents with this simplified version that passes a flat contacts array to PipelineBoard:
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import PipelineBoard from "@/components/PipelineBoard";
export default async function DashboardPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/sign-in");
// Fetch all contacts for this user as a flat array
const { data: contacts } = await supabase
.from("contacts")
.select("*")
.eq("user_id", user.id);
// PipelineBoard handles grouping by stage internally
return <PipelineBoard initialContacts={contacts || []} />;
}
Why pass a flat array?
Previously the page grouped contacts into five separate arrays. Now that you will add drag-and-drop with optimistic state updates, it is cleaner to manage a single array inside PipelineBoard and filter by stage at render time.
- Open components/PipelineBoard.tsx and replace its contents with the following setup code for imports, stage config, and a shared Contact type:
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import ContactCard from "./ContactCard";
import AddContactDialog from "./AddContactDialog";
// Stage keys and display labels
const STAGES = ["new", "contacted", "presentation", "follow_up", "enrolled"];
const STAGE_LABELS: Record<string, string> = {
new: "New",
contacted: "Contacted",
presentation: "Presentation",
follow_up: "Follow-Up",
enrolled: "Enrolled",
};
// Shared Contact type used across components
export interface Contact {
id: string;
user_id: string;
full_name: string;
email: string;
phone: string;
notes: string;
source: string;
stage: string;
created_at: string;
updated_at: string;
}
What are these constants?
STAGES defines the column order as an array matching the database values. STAGE_LABELS maps each key to its display name so the column header reads "Follow-Up" instead of "follow_up".
The Contact interface is exported so other components like ContactCard can import and reuse the same type.
- Below the type definition in the same file, add the PipelineBoard component function:
export default function PipelineBoard(
{ initialContacts }: { initialContacts: Contact[] }
) {
const [contacts, setContacts] = useState<Contact[]>(initialContacts);
const router = useRouter();
// Keep local state in sync when server re-fetches after adding a contact
useEffect(() => { setContacts(initialContacts); }, [initialContacts]);
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Pipeline</h1>
<AddContactDialog onContactAdded={() => router.refresh()} />
</div>
<div className="flex gap-4 overflow-x-auto pb-4">
{STAGES.map((stage) => {
const sc = contacts.filter((c) => c.stage === stage);
return (
<div key={stage} className="min-w-[250px] bg-gray-100 rounded-lg p-3 flex-1">
<h2 className="font-semibold mb-3">
{STAGE_LABELS[stage]} ({sc.length})
</h2>
{sc.length === 0
? <p className="text-gray-400 text-sm">No contacts yet</p>
: sc.map((c) => <ContactCard key={c.id} contact={c} />)}
</div>
);
})}
</div>
</div>
);
}
What does this component do?
PipelineBoard copies the server-fetched contacts into local state with useState. The useEffect syncs state whenever the server component re-renders after a router.refresh() call.
The AddContactDialog sits in the header. Its onContactAdded callback triggers router.refresh(), which tells Next.js to re-fetch data from the server component and merge the updated payload without losing client state.
- Save AddContactDialog.tsx, PipelineBoard.tsx, and app/dashboard/page.tsx.
- Switch to your browser and refresh the dashboard. You should see the "+ Add Contact" button in the top-right corner of the pipeline header.
- Click + Add Contact, enter a name like Sarah Chen and a phone number, then click Save Contact.
- The dialog closes and a new card with that name appears in the "New" column.
Contact not appearing after saving?
Make sure you saved all three files. If the card still does not appear, open the browser console and check for errors. A common cause is a mismatched import path for createClient.
Verify that AddContactDialog imports from @/lib/supabase/client (the browser client), not @/lib/supabase/server (which only works in server components).
help me debug why my new contact is not appearing on the board
Implement drag-and-drop between columns
Adding contacts is only half the workflow. When you reach out to a prospect or book a presentation, you need to drag their card to the matching stage. The browser's built-in HTML Drag and Drop API handles this without extra dependencies.
- Open components/ContactCard.tsx and replace its contents with this draggable version:
"use client";
import { Contact } from "./PipelineBoard";
interface ContactCardProps {
contact: Contact;
onClick?: () => void;
}
export default function ContactCard({ contact, onClick }: ContactCardProps) {
return (
<div
draggable
onDragStart={(e) => e.dataTransfer.setData("contactId", contact.id)}
onClick={onClick}
className="bg-white rounded p-3 mb-2 shadow-sm cursor-grab
active:cursor-grabbing hover:shadow-md transition-shadow"
>
<p className="font-medium">{contact.full_name}</p>
{contact.source && (
<p className="text-sm text-gray-500">{contact.source}</p>
)}
</div>
);
}
How does drag-and-drop start?
The draggable attribute tells the browser this element can be picked up. When the drag begins, onDragStart stores the contact's id in the drag event's data transfer using setData. The drop target will read this ID with getData to know which contact was moved.
The card can be picked up, but the columns cannot receive it yet. You need to add a drop handler to PipelineBoard that updates both local state and Supabase.
- In components/PipelineBoard.tsx, find this line inside the component function:
useEffect(() => { setContacts(initialContacts); }, [initialContacts]);
- Add the following handleDrop function directly below that line:
// Handle drag-and-drop: update local state immediately, then persist
async function handleDrop(contactId: string, newStage: string) {
// Optimistic update: move the card in the UI right away
setContacts((prev) =>
prev.map((c) =>
c.id === contactId
? { ...c, stage: newStage, updated_at: new Date().toISOString() }
: c
)
);
// Persist the stage change to Supabase
const supabase = createClient();
await supabase
.from("contacts")
.update({ stage: newStage, updated_at: new Date().toISOString() })
.eq("id", contactId);
}
What is an optimistic update?
An optimistic update changes the UI instantly, before the server confirms the change. The card moves to the new column the moment you drop it. The Supabase update runs in the background.
This makes the board feel snappy. If the network call fails, the next page refresh will restore the correct state from the database.
- You also need to import createClient in this file. Find the import line for AddContactDialog and add a new import directly below it:
import { createClient } from "@/lib/supabase/client";
- Now hook the drop events into each column. Find the column <div> inside the STAGES.map callback. It currently looks like this:
<div key={stage} className="min-w-[250px] bg-gray-100 rounded-lg p-3 flex-1">
- Replace that line with this version that adds onDragOver and onDrop handlers:
<div key={stage} className="min-w-[250px] bg-gray-100 rounded-lg p-3 flex-1"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
const id = e.dataTransfer.getData("contactId");
handleDrop(id, stage);
}}>
Why call preventDefault on dragover?
By default, browsers do not allow elements to be drop targets. Calling e.preventDefault() on the dragover event tells the browser this column accepts drops. Without it, the drop event never fires.
- Save ContactCard.tsx and PipelineBoard.tsx.
- Switch to your browser and refresh the dashboard.
- Grab the contact card you created earlier and drag it from the "New" column to the "Contacted" column. The card should snap into the new column instantly.
- Refresh the page. The contact should still be in the "Contacted" column, because the stage change was persisted to Supabase.
Card not moving when you drop it?
Check that the column <div> has both onDragOver and onDrop handlers. Missing onDragOver is the most common cause because the browser blocks the drop silently.
Also verify that your ContactCard has the draggable attribute and a onDragStart handler that calls e.dataTransfer.setData("contactId", contact.id).
help me debug why drag-and-drop is not working on my Kanban board
Add a contact detail view
Dragging cards between columns tracks the big picture, but you also need to view and edit a contact's full details. Clicking a card should open a panel showing every field, with the ability to edit and save changes.
- Create a new file at components/ContactDetailPanel.tsx and add the component logic:
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { Contact } from "./PipelineBoard";
interface ContactDetailPanelProps {
contact: Contact;
onClose: () => void;
onUpdated: () => void;
}
export default function ContactDetailPanel(
{ contact, onClose, onUpdated }: ContactDetailPanelProps
) {
const [isEditing, setIsEditing] = useState(false);
const [form, setForm] = useState({
full_name: contact.full_name,
email: contact.email || "",
phone: contact.phone || "",
notes: contact.notes || "",
source: contact.source || "",
});
async function handleSave() {
const supabase = createClient();
await supabase.from("contacts")
.update({ ...form, updated_at: new Date().toISOString() })
.eq("id", contact.id);
setIsEditing(false);
onUpdated();
}
What does this code do?
The component receives a contact prop and copies its fields into editable form state. When the learner clicks "Save", handleSave pushes the updated fields to Supabase and calls onUpdated so the board refreshes.
- Below the handleSave function in the same file, add the panel's UI:
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">{contact.full_name}</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">X</button>
</div>
{isEditing ? (
<div className="space-y-3">
<input value={form.full_name} onChange={e => setForm({...form, full_name: e.target.value})} className="w-full border rounded px-3 py-2" placeholder="Full name" />
<input value={form.phone} onChange={e => setForm({...form, phone: e.target.value})} className="w-full border rounded px-3 py-2" placeholder="Phone" />
<input value={form.email} onChange={e => setForm({...form, email: e.target.value})} className="w-full border rounded px-3 py-2" placeholder="Email" />
<input value={form.source} onChange={e => setForm({...form, source: e.target.value})} className="w-full border rounded px-3 py-2" placeholder="Source" />
<textarea value={form.notes} onChange={e => setForm({...form, notes: e.target.value})} className="w-full border rounded px-3 py-2" placeholder="Notes" rows={3} />
<div className="flex justify-end gap-2">
<button onClick={() => setIsEditing(false)} className="px-4 py-2 border rounded">Cancel</button>
<button onClick={handleSave} className="bg-blue-600 text-white px-4 py-2 rounded">Save</button>
</div>
</div>
) : (
<div className="space-y-2">
<p><span className="font-medium">Phone:</span> {contact.phone || "N/A"}</p>
<p><span className="font-medium">Email:</span> {contact.email || "N/A"}</p>
<p><span className="font-medium">Source:</span> {contact.source || "N/A"}</p>
<p><span className="font-medium">Notes:</span> {contact.notes || "N/A"}</p>
<p><span className="font-medium">Stage:</span> {contact.stage}</p>
<p className="text-sm text-gray-400">Added {new Date(contact.created_at).toLocaleDateString()}</p>
<button onClick={() => setIsEditing(true)} className="mt-3 bg-gray-100 px-4 py-2 rounded hover:bg-gray-200">Edit</button>
</div>
)}
</div>
</div>
);
}
How does the edit mode work?
The panel toggles between a read-only view and an edit form based on isEditing state. In read-only mode, all fields display their current values with a single "Edit" button. Clicking "Edit" swaps every field to an input, and "Save" persists the changes.
The detail panel exists, but nothing opens it yet. You need to wire it into PipelineBoard so clicking a card reveals the panel.
- In components/PipelineBoard.tsx, add a new import for ContactDetailPanel below the createClient import:
import ContactDetailPanel from "./ContactDetailPanel";
- Inside the PipelineBoard function, find this line:
const router = useRouter();
- Add a new state variable directly below it to track the selected contact:
// Track which contact's detail panel is open
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
- Find where ContactCard is rendered inside the STAGES.map callback. It currently looks like this:
: sc.map((c) => <ContactCard key={c.id} contact={c} />)}
- Replace that line to pass an onClick handler:
: sc.map((c) => (
<ContactCard key={c.id} contact={c}
onClick={() => setSelectedContact(c)} />
))}
- Finally, add the ContactDetailPanel right before the closing </div> of the component's return. Find the last </div> before the component's closing brace and add this just above it:
{selectedContact && (
<ContactDetailPanel
contact={selectedContact}
onClose={() => setSelectedContact(null)}
onUpdated={() => { setSelectedContact(null); router.refresh(); }}
/>
)}
How does clicking a card open the panel?
Each ContactCard receives an onClick that sets selectedContact to that contact object. When selectedContact is not null, ContactDetailPanel renders as a modal overlay. Closing or saving sets it back to null.
- Save ContactDetailPanel.tsx and PipelineBoard.tsx.
- Switch to your browser and refresh the dashboard.
- Click on your contact card. A detail panel should open showing the contact's name, phone, email, source, notes, stage, and created date.
- Click Edit, change the phone number, then click Save.
- Refresh the page to confirm the updated phone number persisted.
Detail panel not opening when you click a card?
If clicking a card does nothing, check that ContactCard has the onClick prop in its interface and passes it to the wrapping <div>.
If the panel opens but editing does not save, check that your Supabase contacts table has an UPDATE RLS policy allowing auth.uid() = user_id.
help me debug why clicking a contact card does not open the detail panel
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files: AddContactDialog.tsx, ContactCard.tsx, ContactDetailPanel.tsx, PipelineBoard.tsx, and app/dashboard/page.tsx.
ⓧ I'd like to double check the full code
Scroll back through this step and confirm each code block is present and in order in your files:
- AddContactDialog.tsx: Two chunks. The first has imports, state, and handleSubmit. The second has the early return for the trigger button and the dialog form JSX.
- app/dashboard/page.tsx: Server component fetching contacts and passing them as initialContacts to PipelineBoard.
- ContactCard.tsx: Imports Contact from PipelineBoard. Has draggable, onDragStart, and onClick on the wrapping div.
- ContactDetailPanel.tsx: Two chunks. The first has imports, state, and handleSave. The second has the read/edit toggle UI.
- PipelineBoard.tsx: Two chunks for setup and component. Imports include AddContactDialog, createClient, and ContactDetailPanel. The component has contacts state, selectedContact state, handleDrop, drag event handlers on columns, onClick on each ContactCard, and ContactDetailPanel rendered conditionally.
Before you move on, let's make sure everything works end to end.
Think about what should happen when you add a brand new contact and then immediately drag it to a different column. Do you expect the stage change to survive a page refresh?
- Click + Add Contact, enter a name and phone number, and click Save Contact.
- Drag the new card from the "New" column to the "Contacted" column.
- Refresh the page.
The contact should still be in the "Contacted" column because the stage change was persisted to Supabase.
Your pipeline now has real CRM functionality. You can add prospects, move them through your workflow, and view their full details. Next up, you will polish the board with contact counts, sorting, and search to turn it from a working prototype into a tool that helps you prioritize.
Polish the Pipeline with Counts, Sorting, and Empty States
Your contacts are flowing through the pipeline. You can add new prospects, drag them between stages, and view their details.
The board works, but it does not help you prioritize yet. Without counts on each column, you cannot tell at a glance where your pipeline is heavy or light.
In this step, you will sort contacts so the most neglected prospects always appear first. You will also add empty state messages and a search bar for instant filtering.
In this step, get ready to:
- Add contact counts to each column header and sort contacts by last updated.
- Display helpful empty state messages with quick-add buttons.
- Build a search bar to filter contacts by name across all columns.
Add contact counts and sort by last updated
Right now, every column looks the same whether it holds zero contacts or twenty. Sorting is also missing, so a prospect you added last week gets buried under one you added five minutes ago.
- In PipelineBoard.tsx, add this helper function above your component's return statement to sort contacts by their last update:
// Sort contacts by updated_at ascending (oldest first)
// Untouched prospects rise to the top
function sortByOldestFirst(contacts: Contact[]) {
return [...contacts].sort(
(a, b) =>
new Date(a.updated_at).getTime() -
new Date(b.updated_at).getTime()
);
}
What does this function do?
The spread operator [...contacts] creates a copy of the array so the original is not mutated. The sort compares updated_at timestamps so that contacts you have not touched in the longest time appear at the top of each column.
This forces follow-up discipline. The prospects you have been neglecting will always be staring you in the face.
- Now update the column rendering inside your STAGES.map() to use the sort function and display a count in each column header:
{STAGES.map((stage) => {
// Get contacts for this stage and sort oldest first
const stageContacts = sortByOldestFirst(
contacts.filter((c) => c.stage === stage.key)
);
return (
<div
key={stage.key}
className="flex-1 min-w-[220px] bg-gray-800/50 rounded-lg p-3"
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, stage.key)}
>
{/* Column header with contact count */}
<h2 className="text-sm font-semibold text-gray-400 mb-3">
{stage.label} ({stageContacts.length})
</h2>
{stageContacts.map((contact) => (
<ContactCard key={contact.id} contact={contact} />
))}
</div>
);
})}
What changed here?
Each column now filters contacts by stage, then sorts them with sortByOldestFirst before rendering. The column header shows the stage label followed by the contact count in parentheses, like New (2).
- Save PipelineBoard.tsx.
- Return to your browser at http://localhost:3000 and sign in if needed.
- Each column header now shows a count in parentheses. If you have existing contacts, the oldest-updated ones appear at the top of their column.
Counts all showing 0?
Make sure you are filtering by c.stage === stage.key and not stage.label. The stage column in your database stores lowercase values like new and follow_up, not the display labels.
If your contacts still do not appear, confirm your contacts prop is being passed correctly from the dashboard page.
Need more help? Why are my column counts showing 0 even though I have contacts in Supabase?
Add empty state messages with quick-add buttons
Empty columns currently show nothing at all. A blank column gives no guidance to the user. You will add a helpful message and a button that opens the add-contact dialog with the stage pre-selected.
- In PipelineBoard.tsx, add this empty state block inside each column, right after the stageContacts.map() call:
{stageContacts.length === 0 && (
<div className="text-center text-gray-500 py-6">
<p className="text-sm mb-2">
No {stage.label.toLowerCase()} prospects yet.
</p>
<button
onClick={() => openAddDialog(stage.key)}
className="text-xs text-blue-400 hover:text-blue-300"
>
Add your first contact!
</button>
</div>
)}
What does this code do?
When a column has zero contacts, the empty state renders instead of a blank space. The openAddDialog function passes the current stage.key so the dialog knows which stage to pre-select for the new contact.
- Now update AddContactDialog.tsx to accept an optional defaultStage prop so the quick-add button works:
// Accept an optional default stage for quick-add
interface AddContactDialogProps {
open: boolean;
onClose: () => void;
onContactAdded: () => void;
defaultStage?: string;
}
export default function AddContactDialog({
open,
onClose,
onContactAdded,
defaultStage = "new",
}: AddContactDialogProps) {
// Use defaultStage when inserting the contact
// instead of hardcoding 'new'
}
What changed?
The defaultStage prop defaults to "new" so existing calls to AddContactDialog still work without changes. When the quick-add button in an empty column passes a stage like "contacted", the new contact is inserted directly into that column.
- Save both PipelineBoard.tsx and AddContactDialog.tsx.
- Refresh your browser. Empty columns now display a message like "No new prospects yet." with an "Add your first contact!" link below it.
- Click the quick-add button in an empty column. The dialog opens and the new contact should land in that column after you submit.
Quick-add button not working?
Check that your openAddDialog function sets both the dialog open state and the selected stage. The defaultStage prop must be passed through to the AddContactDialog component.
Also confirm that your Supabase insert uses the defaultStage value instead of hardcoded 'new'.
Still stuck? Help me debug why my quick-add button is not inserting contacts into the correct pipeline stage.
Add a search bar to filter contacts by name
Once you have a dozen prospects, scrolling through five columns to find someone by name gets tedious. A search bar at the top of the dashboard will filter contacts instantly across all columns.
- In PipelineBoard.tsx, add a searchQuery state variable and a text input above the columns:
// Search state for filtering contacts by name
const [searchQuery, setSearchQuery] = useState("");
// Filter contacts before grouping by stage
const filteredContacts = contacts.filter((c) =>
c.full_name.toLowerCase().includes(
searchQuery.toLowerCase()
)
);
Why client-side filtering?
The dashboard already fetches all contacts for the logged-in user on page load. Filtering on the client avoids an extra round trip to Supabase for every keystroke. The results appear instantly as the user types.
- Add the search input JSX above your STAGES.map() block:
{/* Search bar for filtering contacts */}
<div className="mb-4">
<input
type="text"
placeholder="Search contacts by name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-3 py-2 bg-gray-800 border
border-gray-700 rounded-lg text-sm text-white
placeholder-gray-500 focus:outline-none
focus:border-blue-500"
/>
</div>
- Update your STAGES.map() to filter from filteredContacts instead of the raw contacts prop. Change this line inside the map:
// Before:
const stageContacts = sortByOldestFirst(
contacts.filter((c) => c.stage === stage.key)
);
- Replace contacts with filteredContacts so the line now looks like this:
// After: use filteredContacts for search support
const stageContacts = sortByOldestFirst(
filteredContacts.filter((c) => c.stage === stage.key)
);
- Save PipelineBoard.tsx.
Before you test, think about what you expect to see. If you type a contact's name in the search bar, which columns should still show that contact?
- Refresh your browser. You should see a search input at the top of the dashboard.
- Type part of a contact's name. Only matching contacts appear in the columns. The counts update to reflect the filtered results.
- Clear the search bar. All contacts reappear in their columns.
Search not filtering anything?
Make sure you changed the STAGES.map() to filter from filteredContacts instead of the original contacts prop. If you are still filtering from contacts, the search state has no effect on the displayed cards.
Also check that searchQuery is wired to the input's onChange handler. Open your browser's DevTools console and look for any errors.
Need help? Why is my search bar not filtering contacts on the Kanban board?
✔️ Awesome, I've got everything!
Great. Double check that you have saved PipelineBoard.tsx and AddContactDialog.tsx.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these pieces is present in your PipelineBoard.tsx, in order:
- The sortByOldestFirst helper function.
- A searchQuery state variable and filteredContacts derived from it.
- The search input <input> above the columns.
- The STAGES.map() block using filteredContacts.filter() and sortByOldestFirst.
- Column headers showing {stage.label} ({stageContacts.length}).
- The empty state block with a quick-add button calling openAddDialog(stage.key).
In AddContactDialog.tsx, confirm you have a defaultStage prop that defaults to "new" and is used in your Supabase insert call.
Time for the full end-of-step check. Do you think the counts, sorting, empty states, and search all work together correctly?
- Add 3-4 more contacts across different stages using the add-contact button.
- Each column header should show an accurate count (e.g., "New (2)").
- Within each column, contacts you have not updated recently should appear at the top.
- Any empty column should display a helpful message and a quick-add button.
- Type a name in the search bar. Only matching contacts appear. Clear the search to restore all contacts.
Your pipeline is no longer a toy. It tells you where to focus, surfaces neglected prospects, and lets you find anyone in seconds. Next up, you will tackle the Secret Mission and add follow-up reminders for stale contacts.
Secret mission
Add Follow-Up Reminders for Stale Contacts
The #1 reason network marketers lose prospects is failing to follow up. In this bonus challenge, you will create a Supabase database function to detect stale contacts and add visual follow-up reminders to your Kanban board.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on the Supabase free tier and your local machine, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Supabase project (network-crm) with a PostgreSQL database, contacts table, RLS policies, and the get_stale_contacts SQL function.
- Local Next.js application in the network-crm folder on your Desktop.
- Environment file (.env.local) containing your Supabase project URL and publishable key.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue the project soon.
- Your Supabase project stays active on the free tier indefinitely as long as it receives some database activity each week.
- Keep your .env.local file safe. It contains your Supabase connection details.
- Your local network-crm folder stays on your Desktop. You can restart the dev server any time with npm run dev.
Pause - I'll come back to this later
Shut down running processes to free up local resources. Pause your Supabase project to preserve your data without generating any activity.
- Stop the Next.js dev server by pressing Ctrl+C in the terminal where it is running.
- In the Supabase dashboard, open your network-crm project.
- Navigate to Settings then General.
- Scroll to Project availability and click Pause Project.
- Your data is preserved. When you are ready to resume, open the project in the Supabase dashboard and click Resume project.
How long can I pause?
Supabase free-tier projects that are inactive for 7 days are automatically paused. You can restore a paused project from the dashboard. Keep your local network-crm folder intact so you can pick up where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent and cannot be undone.
- In the Supabase dashboard, open your network-crm project.
- Navigate to Settings then General.
- Scroll down to Delete project and click Delete Project.
- Enter your project name exactly as it appears to confirm, then click Delete.
- Delete the local project folder by running this command on macOS or Linux:
cd ~/Desktop
rm -rf network-crm
- On Windows, run this command instead:
cd ~\Desktop
rmdir /s /q network-crm
This is permanent
Deleting your Supabase project permanently removes all data, including your contacts table, RLS policies, and the get_stale_contacts function. This action cannot be reversed.
Nice Work!
Nice Work!
You just built a full-stack contact pipeline CRM from scratch using Next.js and Supabase. You can add prospects, drag them through your pipeline, and trust that Row Level Security keeps every user's data completely isolated.
You've learned how to:
- Build a multi-tenant CRM with cookie-based auth and Row Level Security, ensuring each user only ever sees their own contacts.
- Create a Kanban pipeline board with five network marketing stages and drag-and-drop that persists stage changes to Supabase with optimistic updates.
- Add contact management (create, view, edit), search and filtering, and sorting by last activity to turn a basic board into a prioritization tool.
- Secret Mission: Built a stale contact detection system using a Supabase RPC function and visual follow-up reminders on the board.
Ready to quiz yourself?