Build an AI-Augmented Kanban Board
Build a full-stack Kanban board with Supabase auth and an AI task breakdown feature.
Introduction
30 Second Summary
Every developer knows the feeling of staring at a vague task like "fix the authentication flow" with no idea where to start. What if your task board could break that down for you automatically?
In this project, you will build an AI-augmented Kanban board called Vibe-Check. Users sign up, create tasks, drag them between columns, and hit an "AI Co-Pilot" button to get any vague task broken down into actionable sub-tasks.
What You'll Build
Vibe-Check is a full-stack task manager built with React, Supabase for authentication and database, and OpenAI for intelligent task breakdown.
By the end of this project, you'll have:
- A React frontend scaffolded with Vite 8 and deployed live on Vercel.
- User authentication with Supabase and a PostgreSQL database secured with Row Level Security.
- An AI Co-Pilot feature that uses OpenAI to break vague tasks into structured technical checklists.
- Secret Mission: A GitHub Actions workflow that runs ESLint on every push and blocks deployment if linting fails.
Are there any prerequisites?
You should have basic JavaScript and React knowledge before starting this project. You will also need a GitHub account.
Before We Start
Before We Start...
Set Up Your Development Environment
Before you can build Vibe-Check, you need a local development environment with the right tools installed.
In this step, you will install Node.js, scaffold a React project using Vite, and push your code to GitHub. This gives you a solid foundation to build on for the rest of the project.
In this step, get ready to:
- Install and verify Node.js v20+.
- Scaffold a Vite + React project and prepare base files.
- Initialize Git and push to a GitHub repository.
Install Node.js
Node.js is the JavaScript runtime that powers your development tools. Vite, React, and every package you install in this project all depend on it.
- Check if Node.js is already installed by running this command in your terminal:
Open your terminal
Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
node --version
✔️ I see version 20 or higher
If you see v20.x.x or higher (like v22.x.x or v24.x.x), Node.js is ready to go.
ⓧ I see an older version
If your version is below 20, you need to upgrade. Vite 8 requires Node.js 20.19+ or 22.12+ to run.
- Visit https://nodejs.org/en/download and download the latest LTS installer for your operating system.
- Run the installer and follow the on-screen prompts.
- Close your terminal completely and reopen it.
- Run node --version again to confirm you see version 20 or higher.
ⓧ Command not found
Node.js is not installed on your machine yet.
- Visit https://nodejs.org/en/download and download the LTS installer for your operating system.
- Run the installer and follow the on-screen prompts to complete the setup.
- Close your terminal completely and reopen it.
- Run node --version again to confirm you see version 20 or higher.
Scaffold the Vite + React project
Vite is a modern build tool that creates a ready-to-use React project in seconds. It gives you a development server with hot reload and an optimized production build out of the box.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your new Vite + React project by running this command:
npm create vite@latest vibe-check -- --template react
What does this command do?
- npm create vite@latest runs the Vite project scaffolding tool at the latest version.
- vibe-check is the name of your project folder.
- --template react tells Vite to generate a React project with JSX support.
- Move into the new project folder and install dependencies by running these commands:
cd vibe-check
npm install
- Confirm the install worked by running:
ls
You should see files like package.json, vite.config.js, and a src/ folder in the output.
Seeing a permission error or command not found?
Make sure Node.js is properly installed by running node --version again. If npm is not recognized, close and reopen your terminal after installing Node.js.
help me fix an npm permission or command not found error when scaffolding my Vite project
Now you will replace the default scaffold files with clean starting code for this project.
- Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the vibe-check folder, and click Open.
- In the Explorer sidebar, expand the src/ folder and open main.jsx.
- Select all the existing content and replace it with this code:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
What does this code do?
- It imports React and ReactDOM, then mounts your App component into the HTML page.
- React.StrictMode helps catch potential bugs during development by running extra checks.
- The './index.css' import loads global styles for the entire app.
- Save main.jsx.
- Now open src/index.css in the Explorer sidebar.
- Select all the existing content and replace it with this code:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1a202c;
background: #ffffff;
}
What does this code do?
This is a CSS reset that removes default browser spacing and applies consistent base styles. It ensures your app looks the same across all browsers and gives you a clean typographic foundation.
- Save index.css.
- Verify the dev server works by running this in your terminal:
npm run dev
You should see a local URL like http://localhost:5173 printed in the terminal. Open that URL in your browser to confirm the default Vite + React page loads.
- Press Ctrl+C in the terminal to stop the dev server before continuing.
Dev server not starting?
Check that you are inside the vibe-check folder by running pwd (macOS) or cd (Windows). If npm install was skipped or failed, run it again before retrying npm run dev.
help me fix my Vite dev server not starting
✔️ Awesome, I've got everything!
Great. Make sure both src/main.jsx and src/index.css are saved.
ⓧ I'd like to double check the full code
Here is what your two modified files should look like at this point.
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1a202c;
background: #ffffff;
}
Initialize Git and push to GitHub
Git tracks every change you make to your code. Pushing to GitHub stores your code in the cloud so you can deploy it later with Vercel.
- Initialize a Git repository in your project by running this command from the vibe-check folder:
git init
- Stage all files and create your first commit by running these commands:
git add .
git commit -m "Initial commit"
- Go to https://github.com/new in your browser to create a new repository.
- Enter vibe-check as the Repository name.
- Leave the repository set to Public.
- Do not check any boxes for README, .gitignore, or license (your project already has files).
- Click Create repository.
GitHub shows setup commands after creating the repo. Copy the commands from the section labelled ...or push an existing repository from the command line and run them in your terminal.
- The commands will look similar to this (your URL will be different):
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/vibe-check.git"]]
git branch -M main
git push -u origin main
- Refresh your GitHub repository page in the browser.
- You should see all your project files listed on the page.
Push failed or authentication error?
GitHub requires authentication for push operations. If prompted, sign in with your GitHub credentials or a personal access token. If you see a 403 error, check that the repository URL matches the one GitHub gave you.
help me fix a Git push authentication error to GitHub
Your development environment is ready. Next up, you will create a Supabase project and set up the database that powers your Kanban board.
Set Up Supabase Project and Database
In the previous step, you set up your local development environment and pushed your code to GitHub.
Now you need a place to store user data and tasks. Supabase gives you a hosted PostgreSQL database with built-in authentication and security. In this step, you will create a Supabase project, design your tasks table, and lock it down so each user can only access their own data.
In this step, get ready to:
- Create a Supabase project and grab your API credentials.
- Build a tasks table with the right columns for your Kanban board.
- Enable Row Level Security so users can only see their own tasks.
Create a free Supabase project
Supabase provides the database and authentication backend for your Kanban board. You will create a project on the Free Tier, which includes 500 MB of database storage and 50,000 monthly active users.
- Open your browser and go to supabase.com.
- Click Start your project and sign up for a free account (you can use your GitHub login).
- Once logged in, click New Project.
- Enter vibe-check as the project name.
- Set a strong database password and save it somewhere safe (you will not need it in your app code, but you may need it for direct database access later).
- Choose the region closest to you.
- Click Create new project.
- Wait for the project to finish provisioning (this takes about 1 minute).
What is Supabase?
Supabase is an open-source backend platform built on top of PostgreSQL. It gives you a database, authentication, and auto-generated REST APIs out of the box.
The Free Tier allows up to 2 active projects and pauses automatically after 1 week of inactivity.
Now copy your project credentials so your React app can connect to Supabase.
- In your Supabase project dashboard, click the Connect button near the top of the page.
- Copy the Project URL (it looks like https://abcdefg.supabase.co).
- Copy the anon key (found under the Legacy API Keys tab in Settings > API Keys). This is the public key safe for browser usage.
Where to find your keys
You can also find all keys by navigating to Settings > API Keys in the left sidebar. The anon key is under the Legacy API Keys tab.
Now save these credentials in your project as environment variables.
- In VS Code, create a new file called .env.local in the root of your vibe-check folder (at the same level as package.json).
- Add the following content to .env.local:
VITE_SUPABASE_URL=your-supabase-project-url
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
VITE_OPENAI_API_KEY=your-openai-api-key
- Replace your-supabase-project-url with the Project URL you copied.
- Replace your-supabase-anon-key with the anon key you copied.
- Leave VITE_OPENAI_API_KEY as your-openai-api-key for now. You will fill this in during a later step.
- Save .env.local.
Why use .env.local?
Vite automatically loads environment variables from .env.local and makes any variable prefixed with VITE_ available in your browser code via import.meta.env. The .local suffix means Git ignores this file by default, keeping your keys out of version control.
Not sure where to create .env.local?
The file should sit alongside package.json in the top-level vibe-check folder. If you accidentally put it inside src/, Vite will not find it.
help me figure out where to put my .env.local file
Create the tasks table
Your Kanban board needs a place to store each task's title, status (which column it belongs to), and any AI-generated breakdown. You will create this table using Supabase's SQL Editor.
- In the Supabase dashboard, click SQL Editor in the left sidebar.
- Click New query.
- Paste the following SQL and click Run:
-- Create the tasks table for the Kanban board
create table public.tasks (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users not null,
title text not null,
status text not null default 'To Do',
ai_breakdown text,
created_at timestamptz default now() not null
);
What does this SQL do?
- id generates a unique UUID for each task automatically.
- user_id links each task to the authenticated user who created it.
- title stores the task name the user types in.
- status tracks which Kanban column the task belongs to (To Do, In Progress, or Done).
- ai_breakdown stores the AI-generated checklist (empty until the user clicks the AI Co-Pilot button).
- created_at records when the task was created, used for ordering.
- Confirm you see a Success. No rows returned message after running the query.
- Click Table Editor in the left sidebar to verify your new tasks table appears in the list.
Seeing a permission error?
Make sure you are running the SQL in the correct project. Check the project name in the top-left corner of the Supabase dashboard matches vibe-check.
help me troubleshoot a SQL error when creating my tasks table in Supabase
Enable Row Level Security and add policies
Right now, anyone with your anon key could read or write every task in the database. Row Level Security (RLS) adds rules so that each user can only access the rows they created.
- In the SQL Editor, click New query to open a fresh tab.
- Paste the following SQL and click Run:
-- Enable Row Level Security on the tasks table
alter table public.tasks enable row level security;
-- Policy: users can read their own tasks
create policy "Users can view their own tasks"
on public.tasks for select
using (auth.uid() = user_id);
-- Policy: users can insert their own tasks
create policy "Users can insert their own tasks"
on public.tasks for insert
with check (auth.uid() = user_id);
-- Policy: users can update their own tasks
create policy "Users can update their own tasks"
on public.tasks for update
using (auth.uid() = user_id);
-- Policy: users can delete their own tasks
create policy "Users can delete their own tasks"
on public.tasks for delete
using (auth.uid() = user_id);
What does Row Level Security do here?
Each policy checks that auth.uid() (the logged-in user's ID) matches the user_id column on the row. This means User A can never see, edit, or delete User B's tasks, even though they share the same table.
Without RLS, your publishable API key would give any browser full access to every row in the table.
- Confirm you see Success. No rows returned after running the query.
- To double-check, click Authentication in the left sidebar, then click Policies.
- You should see the tasks table listed with four policies (select, insert, update, delete) and RLS marked as enabled.
Don't see your policies listed?
Make sure you ran both SQL queries. The first one creates the table and the second one enables RLS and creates the policies. If you only see the table but no policies, run the second query again.
help me verify that RLS policies are working on my Supabase tasks table
Your database is ready. Next up, you will connect your React app to Supabase and build a login form so users can sign up and authenticate.
Build Authentication with Supabase
Your Supabase project is live with a tasks table and Row Level Security policies ready to go.
RLS locks down your database so each user can only access their own data. But for that to work, you need to know WHO the user is. In this step, you'll build a full authentication flow so users can sign up, sign in, and sign out of Vibe-Check.
In this step, get ready to:
- Install the Supabase client library and create a reusable client utility.
- Build a Login/Signup form component with email and password fields.
- Wire up session management so the app knows who is logged in.
Install the Supabase client library
The @supabase/supabase-js package gives your React app the ability to talk to your Supabase project. It handles authentication, database queries, and real-time subscriptions through a single client object.
- In your VS Code terminal, make sure you are inside the vibe-check/ folder, then install the Supabase client by running:
npm install @supabase/supabase-js
Seeing permission errors or EACCES?
Make sure your terminal is inside the vibe-check folder, not your Desktop or home directory. Run cd ~/Desktop/vibe-check and try again.
Why is my npm install failing?
Now you need a utility file that creates and exports a single Supabase client instance. Every component in your app will import from this one file instead of creating its own client.
- Create a new file called src/supabaseClient.js and add the following code:
import { createClient } from '@supabase/supabase-js'
// Read Supabase credentials from environment variables
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
// Create and export a single Supabase client for the whole app
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
What does this code do?
import.meta.env.VITE_SUPABASE_URL reads the Supabase URL you stored in .env.local during the previous step. Vite automatically injects any environment variable prefixed with VITE_ into your app at build time.
createClient() initializes the connection to your Supabase project. By exporting a single instance, every component shares the same authenticated session.
- Save src/supabaseClient.js.
Build the Login/Signup form component
Your app needs a way for users to create an account and sign in. You will build a single component that toggles between sign-up and sign-in modes.
- Create a src/components/ folder inside your src/ directory.
- Create a new file at src/components/Auth.jsx and add the following code:
import { useState } from 'react'
import { supabase } from '../supabaseClient'
export default function Auth() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isSignUp, setIsSignUp] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
async function handleSubmit(e) {
e.preventDefault()
setLoading(true)
setError(null)
// Call signUp or signInWithPassword based on the current mode
const { error } = isSignUp
? await supabase.auth.signUp({ email, password })
: await supabase.auth.signInWithPassword({ email, password })
if (error) setError(error.message)
setLoading(false)
}
return (
<div className="auth-container">
<h2>{isSignUp ? 'Sign Up' : 'Sign In'}</h2>
<form onSubmit={handleSubmit} className="auth-form">
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
<button type="submit" disabled={loading}>
{loading ? 'Loading...' : isSignUp ? 'Sign Up' : 'Sign In'}
</button>
</form>
{error && <p className="error">{error}</p>}
<p>
{isSignUp ? 'Already have an account?' : "Don't have an account?"}{' '}
<button onClick={() => setIsSignUp(!isSignUp)} className="toggle-btn">
{isSignUp ? 'Sign In' : 'Sign Up'}
</button>
</p>
</div>
)
}
What does this component do?
- isSignUp toggles the form between creating a new account and logging into an existing one.
- handleSubmit calls either supabase.auth.signUp() or supabase.auth.signInWithPassword() depending on the current mode.
- If Supabase returns an error (like "invalid email" or "password too short"), it gets displayed below the form.
- Save src/components/Auth.jsx.
Why one component for both sign-up and sign-in?
The form fields are identical for both actions. The only difference is which Supabase method gets called. Using a single component with an isSignUp toggle keeps your codebase lean and avoids duplicating form logic.
Wire up session management and the sign-out button
The Auth component handles sign-up and sign-in, but your App component needs to track whether a user is currently logged in. This is called session management.
You will update App.jsx to listen for authentication changes and show either the login form or the Kanban board based on the session state.
- First, create a placeholder file at src/components/Board.jsx so the import resolves. Add this single line:
// Placeholder - we'll build the full Board in the next step
export default function Board({ session }) {
return <p>Board coming soon...</p>
}
- Now open src/App.jsx and replace all its contents with the following:
import { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'
import Auth from './components/Auth'
import Board from './components/Board'
import './App.css'
function App() {
// Track the current user session
const [session, setSession] = useState(null)
useEffect(() => {
// Check if a session already exists on page load
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session)
})
// Listen for future auth changes (sign in, sign out)
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session)
})
// Clean up the listener when the component unmounts
return () => subscription.unsubscribe()
}, [])
return (
<div className="app">
<header className="app-header">
<h1>Vibe-Check</h1>
{session && (
<button onClick={() => supabase.auth.signOut()} className="sign-out-btn">
Sign Out
</button>
)}
</header>
<main>{!session ? <Auth /> : <Board session={session} />}</main>
</div>
)
}
export default App
What does this code do?
- getSession() checks for an existing session when the page loads. If the user previously logged in and their session token is still valid, they skip the login screen.
- onAuthStateChange() listens for any auth event (sign in, sign out, token refresh) and updates the session state automatically.
- The conditional render (!session ? <Auth /> : <Board />) shows the login form when nobody is logged in, and the board when a session exists.
- The Sign Out button only appears when session is not null, and calls supabase.auth.signOut() to clear the session.
Finally, add the styles that make the auth form and header look polished.
- Open src/App.css and replace all its contents with:
.app {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.app-header h1 {
font-size: 1.8rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.sign-out-btn {
padding: 0.5rem 1rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: white;
cursor: pointer;
}
.auth-container {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.auth-form input {
padding: 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 1rem;
}
.auth-form button {
padding: 0.75rem;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
.toggle-btn {
background: none;
border: none;
color: #667eea;
cursor: pointer;
text-decoration: underline;
}
.error {
color: #e53e3e;
margin-top: 0.5rem;
}
- Save both src/App.jsx and src/App.css.
- Start your dev server by running:
npm run dev
- Open the URL shown in your terminal (usually http://localhost:5173) in your browser.
- You should see the "Vibe-Check" heading with a gradient color and a centered sign-in form below it with email and password fields.
- Try signing up with an email and password (minimum 6 characters). After signing up, you should see the text "Board coming soon..." which confirms the session is working.
Seeing a blank page or console errors?
- Check that .env.local is in the root of your vibe-check/ folder (not inside src/). Vite only reads env files from the project root.
- Make sure your VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY values are filled in (not the placeholder text). You need the real values from your Supabase dashboard.
- If you changed .env.local after starting the dev server, stop it with Ctrl+C and restart with npm run dev. Vite does not hot-reload env changes.
Help me debug my Supabase auth setup in Vite.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file you created or updated in this step.
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
import { useState } from 'react'
import { supabase } from '../supabaseClient'
export default function Auth() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isSignUp, setIsSignUp] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
async function handleSubmit(e) {
e.preventDefault()
setLoading(true)
setError(null)
const { error } = isSignUp
? await supabase.auth.signUp({ email, password })
: await supabase.auth.signInWithPassword({ email, password })
if (error) setError(error.message)
setLoading(false)
}
return (
<div className="auth-container">
<h2>{isSignUp ? 'Sign Up' : 'Sign In'}</h2>
<form onSubmit={handleSubmit} className="auth-form">
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
<button type="submit" disabled={loading}>
{loading ? 'Loading...' : isSignUp ? 'Sign Up' : 'Sign In'}
</button>
</form>
{error && <p className="error">{error}</p>}
<p>
{isSignUp ? 'Already have an account?' : "Don't have an account?"}{' '}
<button onClick={() => setIsSignUp(!isSignUp)} className="toggle-btn">
{isSignUp ? 'Sign In' : 'Sign Up'}
</button>
</p>
</div>
)
}
import { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'
import Auth from './components/Auth'
import Board from './components/Board'
import './App.css'
function App() {
const [session, setSession] = useState(null)
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session)
})
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session)
})
return () => subscription.unsubscribe()
}, [])
return (
<div className="app">
<header className="app-header">
<h1>Vibe-Check</h1>
{session && (
<button onClick={() => supabase.auth.signOut()} className="sign-out-btn">
Sign Out
</button>
)}
</header>
<main>{!session ? <Auth /> : <Board session={session} />}</main>
</div>
)
}
export default App
.app {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.app-header h1 {
font-size: 1.8rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.sign-out-btn {
padding: 0.5rem 1rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: white;
cursor: pointer;
}
.auth-container {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.auth-form input {
padding: 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 1rem;
}
.auth-form button {
padding: 0.75rem;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
.toggle-btn {
background: none;
border: none;
color: #667eea;
cursor: pointer;
text-decoration: underline;
}
.error {
color: #e53e3e;
margin-top: 0.5rem;
}
Your authentication system is complete. Users can sign up, sign in, and sign out. Next up, you will build the Kanban board UI that authenticated users see after logging in.
Build the Kanban Board UI
Your authentication system is working. Users can sign up and sign in, and your app conditionally renders different views based on session state.
Now you need to build the core feature: the Kanban board itself. A Kanban board organizes tasks into columns that represent stages of progress. Your board will have three columns: To Do, In Progress, and Done.
In this step, get ready to:
- Create a Board component with three columns and task fetching from Supabase.
- Build a TaskCard component with a placeholder AI Co-Pilot button.
- Add board and card styles to your CSS.
Create the Board component
The Board component is the heart of your Kanban app. It fetches tasks from Supabase, renders three columns, and provides a form for adding new tasks.
- Create a new file called src/components/Board.jsx and paste the following code:
import { useState, useEffect } from 'react'
import { supabase } from '../supabaseClient'
import TaskCard from './TaskCard'
// Define the three Kanban columns
const COLUMNS = ['To Do', 'In Progress', 'Done']
export default function Board({ session }) {
const [tasks, setTasks] = useState([])
const [newTaskTitle, setNewTaskTitle] = useState('')
// Fetch tasks from Supabase when the component mounts
useEffect(() => {
fetchTasks()
}, [])
async function fetchTasks() {
// Query all tasks ordered by creation date
const { data, error } = await supabase
.from('tasks')
.select('*')
.order('created_at', { ascending: true })
if (!error) setTasks(data)
}
async function addTask(e) {
e.preventDefault()
if (!newTaskTitle.trim()) return
// Insert a new task with default status "To Do"
const { data, error } = await supabase
.from('tasks')
.insert({
title: newTaskTitle,
status: 'To Do',
user_id: session.user.id,
})
.select()
if (!error && data) {
setTasks([...tasks, data[0]])
setNewTaskTitle('')
}
}
// Update a task in local state without re-fetching from the database
function updateTaskLocally(updatedTask) {
setTasks(tasks.map((t) => (t.id === updatedTask.id ? updatedTask : t)))
}
return (
<div className="board-container">
<form onSubmit={addTask} className="new-task-form">
<input
type="text"
placeholder="Add a new task..."
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
/>
<button type="submit">Add</button>
</form>
<div className="board">
{COLUMNS.map((column) => (
<div className="column" key={column}>
<h3>{column}</h3>
{tasks
.filter((task) => task.status === column)
.map((task) => (
<TaskCard key={task.id} task={task} onUpdate={updateTaskLocally} />
))}
</div>
))}
</div>
</div>
)
}
What does this code do?
This component handles the entire board layout and data flow.
- COLUMNS is a constant array that defines the three stages. Each task's status field matches one of these strings.
- fetchTasks() pulls all the user's tasks from Supabase, ordered oldest-first. Row Level Security ensures you only see your own tasks.
- addTask() inserts a new task with status "To Do" and appends it to local state so the UI updates instantly.
- updateTaskLocally() lets child components (like TaskCard) push changes up without triggering a full re-fetch.
- Save src/components/Board.jsx.
Seeing a red squiggly under the TaskCard import?
That is expected. You have not created TaskCard.jsx yet. The error will disappear after the next substep.
Help me fix my Board.jsx import errors
Build the TaskCard component
Each task on the board is rendered by a TaskCard. For now, the AI Co-Pilot button is a placeholder. You will wire it up to OpenAI in the next step.
- Create a new file called src/components/TaskCard.jsx and paste the following code:
import { useState } from 'react'
import { supabase } from '../supabaseClient'
export default function TaskCard({ task, onUpdate }) {
const [loading, setLoading] = useState(false)
const [showBreakdown, setShowBreakdown] = useState(false)
async function handleAiBreakdown() {
setLoading(true)
// AI integration coming in next step
setLoading(false)
}
return (
<div className="task-card">
<p className="task-title">{task.title}</p>
{/* AI Co-Pilot button triggers task breakdown */}
<button
onClick={handleAiBreakdown}
disabled={loading}
className="ai-btn"
>
{loading ? 'Thinking...' : '🤖 AI Co-Pilot'}
</button>
{/* Show breakdown toggle only when AI data exists */}
{task.ai_breakdown && (
<div className="breakdown">
<button
onClick={() => setShowBreakdown(!showBreakdown)}
className="toggle-breakdown"
>
{showBreakdown ? 'Hide' : 'Show'} Breakdown
</button>
{showBreakdown && (
<div
className="breakdown-content"
dangerouslySetInnerHTML={{ __html: task.ai_breakdown }}
/>
)}
</div>
)}
</div>
)
}
What does this code do?
TaskCard renders a single task and prepares for AI integration.
- handleAiBreakdown() is a stub. It toggles loading state but does nothing yet. You will fill this in when you connect OpenAI.
- The ai_breakdown section only renders when the task already has breakdown data. The toggle button stays hidden until the AI has responded.
- dangerouslySetInnerHTML renders the markdown-formatted AI response as HTML.
- Save src/components/TaskCard.jsx.
VS Code still showing errors?
Make sure the file is saved inside src/components/ and the filename matches exactly: TaskCard.jsx (capital T, capital C).
Help me fix my TaskCard import errors
Add board styles
Your components need CSS to create the three-column grid layout and style the task cards.
- Open src/App.css and add the following styles at the bottom of the file, below the existing .error rule:
.board-container {
width: 100%;
}
.new-task-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.new-task-form input {
flex: 1;
padding: 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 1rem;
}
.new-task-form button {
padding: 0.75rem 1.5rem;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.column {
background: #f7fafc;
border-radius: 8px;
padding: 1rem;
min-height: 400px;
}
.column h3 {
margin-bottom: 1rem;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #4a5568;
}
.task-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.5rem;
}
.task-title {
font-weight: 500;
margin-bottom: 0.5rem;
}
.ai-btn {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
background: #f0fff4;
border: 1px solid #c6f6d5;
border-radius: 4px;
cursor: pointer;
}
.ai-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.breakdown {
margin-top: 0.5rem;
}
.toggle-breakdown {
font-size: 0.75rem;
background: none;
border: none;
color: #667eea;
cursor: pointer;
}
.breakdown-content {
margin-top: 0.5rem;
padding: 0.75rem;
background: #f7fafc;
border-radius: 4px;
font-size: 0.85rem;
white-space: pre-wrap;
}
What do these styles do?
The CSS is organized into four groups.
- .board uses a CSS Grid with three equal columns. Each column gets a light gray background and a minimum height so the board looks complete even when empty.
- .new-task-form is a flexbox row. The input stretches to fill available space, and the button sits to the right.
- .task-card gives each card a white background with a subtle border, making it visually distinct from the column.
- .ai-btn uses a soft green background to signal it is an enhancement feature, not a primary action.
- Save src/App.css.
- Start your dev server by running:
npm run dev
- Open the app in your browser and sign in. You should see a text input with an Add button at the top, and three columns labeled TO DO, IN PROGRESS, and DONE below it.
- Type a task name (e.g. Build landing page) and click Add. The task card should appear in the To Do column with a green AI Co-Pilot button.
Board not showing after sign-in?
Make sure src/App.jsx still imports Board from ./components/Board. If you accidentally changed the import path, the component will not render.
Also check that Board.jsx and TaskCard.jsx are both inside the src/components/ folder. Filename casing matters.
Help me debug my Kanban board not showing
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each file at this point.
import { useState, useEffect } from 'react'
import { supabase } from '../supabaseClient'
import TaskCard from './TaskCard'
const COLUMNS = ['To Do', 'In Progress', 'Done']
export default function Board({ session }) {
const [tasks, setTasks] = useState([])
const [newTaskTitle, setNewTaskTitle] = useState('')
useEffect(() => {
fetchTasks()
}, [])
async function fetchTasks() {
const { data, error } = await supabase
.from('tasks')
.select('*')
.order('created_at', { ascending: true })
if (!error) setTasks(data)
}
async function addTask(e) {
e.preventDefault()
if (!newTaskTitle.trim()) return
const { data, error } = await supabase
.from('tasks')
.insert({
title: newTaskTitle,
status: 'To Do',
user_id: session.user.id,
})
.select()
if (!error && data) {
setTasks([...tasks, data[0]])
setNewTaskTitle('')
}
}
function updateTaskLocally(updatedTask) {
setTasks(tasks.map((t) => (t.id === updatedTask.id ? updatedTask : t)))
}
return (
<div className="board-container">
<form onSubmit={addTask} className="new-task-form">
<input
type="text"
placeholder="Add a new task..."
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
/>
<button type="submit">Add</button>
</form>
<div className="board">
{COLUMNS.map((column) => (
<div className="column" key={column}>
<h3>{column}</h3>
{tasks
.filter((task) => task.status === column)
.map((task) => (
<TaskCard key={task.id} task={task} onUpdate={updateTaskLocally} />
))}
</div>
))}
</div>
</div>
)
}
import { useState } from 'react'
import { supabase } from '../supabaseClient'
export default function TaskCard({ task, onUpdate }) {
const [loading, setLoading] = useState(false)
const [showBreakdown, setShowBreakdown] = useState(false)
async function handleAiBreakdown() {
setLoading(true)
// AI integration coming in next step
setLoading(false)
}
return (
<div className="task-card">
<p className="task-title">{task.title}</p>
<button
onClick={handleAiBreakdown}
disabled={loading}
className="ai-btn"
>
{loading ? 'Thinking...' : '🤖 AI Co-Pilot'}
</button>
{task.ai_breakdown && (
<div className="breakdown">
<button
onClick={() => setShowBreakdown(!showBreakdown)}
className="toggle-breakdown"
>
{showBreakdown ? 'Hide' : 'Show'} Breakdown
</button>
{showBreakdown && (
<div
className="breakdown-content"
dangerouslySetInnerHTML={{ __html: task.ai_breakdown }}
/>
)}
</div>
)}
</div>
)
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.app-header h1 {
font-size: 1.8rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.sign-out-btn {
padding: 0.5rem 1rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: white;
cursor: pointer;
}
.auth-container {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.auth-form input {
padding: 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 1rem;
}
.auth-form button {
padding: 0.75rem;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
.toggle-btn {
background: none;
border: none;
color: #667eea;
cursor: pointer;
text-decoration: underline;
}
.error {
color: #e53e3e;
margin-top: 0.5rem;
}
.board-container {
width: 100%;
}
.new-task-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.new-task-form input {
flex: 1;
padding: 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 1rem;
}
.new-task-form button {
padding: 0.75rem 1.5rem;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.column {
background: #f7fafc;
border-radius: 8px;
padding: 1rem;
min-height: 400px;
}
.column h3 {
margin-bottom: 1rem;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #4a5568;
}
.task-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.5rem;
}
.task-title {
font-weight: 500;
margin-bottom: 0.5rem;
}
.ai-btn {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
background: #f0fff4;
border: 1px solid #c6f6d5;
border-radius: 4px;
cursor: pointer;
}
.ai-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.breakdown {
margin-top: 0.5rem;
}
.toggle-breakdown {
font-size: 0.75rem;
background: none;
border: none;
color: #667eea;
cursor: pointer;
}
.breakdown-content {
margin-top: 0.5rem;
padding: 0.75rem;
background: #f7fafc;
border-radius: 4px;
font-size: 0.85rem;
white-space: pre-wrap;
}
Your Kanban board is rendering and saving tasks to Supabase. Next up, you will add drag-and-drop so tasks can move between columns.
Add Drag-and-Drop Between Columns
Right now your tasks are stuck in whatever column they were created in. A real Kanban board needs fluid movement between columns so you can track work as it progresses.
In this step, you'll install a drag-and-drop library and wire it into your Board component. When you drop a task into a new column, the change saves to Supabase instantly.
In this step, get ready to:
- Install @hello-pangea/dnd for drag-and-drop support.
- Implement drag handlers that sync status changes to Supabase.
- Verify that moved tasks persist their new column after a page refresh.
Install the drag-and-drop library
Your board columns already render tasks, but there is no way to move a card from one column to another. The @hello-pangea/dnd library gives you three React components that handle drag interactions for list-based UIs.
- In your terminal, make sure you are inside the vibe-check project folder, then install the library by running:
npm install @hello-pangea/dnd
- Confirm the install succeeded by checking that @hello-pangea/dnd appears under dependencies in your package.json.
Install failed?
Make sure you are in the vibe-check folder (the one containing package.json). Run ls and verify you can see package.json in the output.
help me fix an npm install error for @hello-pangea/dnd
Add the drag handler to Board.jsx
The library exposes three components. DragDropContext wraps the entire draggable area. Droppable marks each column as a valid drop target. Draggable marks each task card as something that can be picked up.
- Open src/components/Board.jsx in VS Code.
- Replace the entire file contents with the following code:
import { useState, useEffect } from 'react'
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'
import { supabase } from '../supabaseClient'
import TaskCard from './TaskCard'
const COLUMNS = ['To Do', 'In Progress', 'Done']
export default function Board({ session }) {
const [tasks, setTasks] = useState([])
const [newTaskTitle, setNewTaskTitle] = useState('')
useEffect(() => {
fetchTasks()
}, [])
async function fetchTasks() {
const { data, error } = await supabase
.from('tasks')
.select('*')
.order('created_at', { ascending: true })
if (!error) setTasks(data)
}
async function addTask(e) {
e.preventDefault()
if (!newTaskTitle.trim()) return
const { data, error } = await supabase
.from('tasks')
.insert({
title: newTaskTitle,
status: 'To Do',
user_id: session.user.id,
})
.select()
if (!error && data) {
setTasks([...tasks, data[0]])
setNewTaskTitle('')
}
}
async function handleDragEnd(result) {
if (!result.destination) return
const taskId = result.draggableId
const newStatus = result.destination.droppableId
setTasks(
tasks.map((task) =>
task.id === taskId ? { ...task, status: newStatus } : task
)
)
await supabase.from('tasks').update({ status: newStatus }).eq('id', taskId)
}
function updateTaskLocally(updatedTask) {
setTasks(tasks.map((t) => (t.id === updatedTask.id ? updatedTask : t)))
}
return (
<div className="board-container">
<form onSubmit={addTask} className="new-task-form">
<input
type="text"
placeholder="Add a new task..."
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
/>
<button type="submit">Add</button>
</form>
<DragDropContext onDragEnd={handleDragEnd}>
<div className="board">
{COLUMNS.map((column) => (
<Droppable droppableId={column} key={column}>
{(provided) => (
<div
className="column"
ref={provided.innerRef}
{...provided.droppableProps}
>
<h3>{column}</h3>
{tasks
.filter((task) => task.status === column)
.map((task, index) => (
<Draggable
key={task.id}
draggableId={task.id}
index={index}
>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<TaskCard
task={task}
onUpdate={updateTaskLocally}
/>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
))}
</div>
</DragDropContext>
</div>
)
}
What does this code do?
- DragDropContext wraps the board and fires handleDragEnd whenever the user releases a card.
- Each column becomes a Droppable with its column name as the droppableId. This tells the library which drop zone the card landed in.
- Each task card becomes a Draggable with the task's UUID as its draggableId. The library tracks which card was picked up.
- handleDragEnd reads the destination column from result.destination.droppableId, updates local state immediately for a snappy UI, then persists the new status to Supabase.
- Save src/components/Board.jsx.
Seeing a compile error?
Check that the import line uses the exact package name @hello-pangea/dnd (not react-beautiful-dnd). The two packages share the same API, but the import path is different.
help me fix a compile error in Board.jsx after adding drag-and-drop
Verify tasks persist after a page refresh
The optimistic local update makes the UI feel instant, but the real test is whether the status change survived a round trip to your database.
- Make sure your dev server is running. If it is not, start it by running:
npm run dev
- Open your app in the browser and sign in.
- Create a task if you do not have one already.
- Drag the task card from the To Do column and drop it into the In Progress column.
- Refresh the page. The task should still appear in the In Progress column.
Task snaps back to its original column?
This means the Supabase update is failing silently. Open your browser's DevTools console and look for a network error on the PATCH request.
The most common cause is a Row Level Security policy that does not allow UPDATE operations. In the Supabase dashboard, confirm your tasks table has an RLS policy that permits authenticated users to update their own rows.
help me debug why my drag-and-drop status changes are not saving to Supabase
✔️ Awesome, I've got everything!
Great. Double check you have saved src/components/Board.jsx and that your tasks persist across a page refresh.
ⓧ I'd like to double check the full code
Here is the complete src/components/Board.jsx file at this point in the project:
import { useState, useEffect } from 'react'
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'
import { supabase } from '../supabaseClient'
import TaskCard from './TaskCard'
const COLUMNS = ['To Do', 'In Progress', 'Done']
export default function Board({ session }) {
const [tasks, setTasks] = useState([])
const [newTaskTitle, setNewTaskTitle] = useState('')
useEffect(() => {
fetchTasks()
}, [])
async function fetchTasks() {
const { data, error } = await supabase
.from('tasks')
.select('*')
.order('created_at', { ascending: true })
if (!error) setTasks(data)
}
async function addTask(e) {
e.preventDefault()
if (!newTaskTitle.trim()) return
const { data, error } = await supabase
.from('tasks')
.insert({
title: newTaskTitle,
status: 'To Do',
user_id: session.user.id,
})
.select()
if (!error && data) {
setTasks([...tasks, data[0]])
setNewTaskTitle('')
}
}
async function handleDragEnd(result) {
if (!result.destination) return
const taskId = result.draggableId
const newStatus = result.destination.droppableId
setTasks(
tasks.map((task) =>
task.id === taskId ? { ...task, status: newStatus } : task
)
)
await supabase.from('tasks').update({ status: newStatus }).eq('id', taskId)
}
function updateTaskLocally(updatedTask) {
setTasks(tasks.map((t) => (t.id === updatedTask.id ? updatedTask : t)))
}
return (
<div className="board-container">
<form onSubmit={addTask} className="new-task-form">
<input
type="text"
placeholder="Add a new task..."
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
/>
<button type="submit">Add</button>
</form>
<DragDropContext onDragEnd={handleDragEnd}>
<div className="board">
{COLUMNS.map((column) => (
<Droppable droppableId={column} key={column}>
{(provided) => (
<div
className="column"
ref={provided.innerRef}
{...provided.droppableProps}
>
<h3>{column}</h3>
{tasks
.filter((task) => task.status === column)
.map((task, index) => (
<Draggable
key={task.id}
draggableId={task.id}
index={index}
>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<TaskCard
task={task}
onUpdate={updateTaskLocally}
/>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
))}
</div>
</DragDropContext>
</div>
)
}
Your Kanban board now lets you drag tasks between columns with changes persisting in real time. Next up, you'll wire the AI Co-Pilot button to OpenAI so it can break vague tasks into actionable checklists.
Integrate the AI Co-Pilot with OpenAI
Your Kanban board now handles authentication, task creation, and drag-and-drop. The one feature still stubbed out is the AI Co-Pilot button.
In this step, you'll connect to the OpenAI API so that clicking the button sends your task title to a language model and gets back a structured checklist of sub-tasks.
In this step, get ready to:
- Install the openai package and create an AI client utility.
- Wire the AI Co-Pilot button to call the API and save results to Supabase.
- Test the AI breakdown by creating a task and clicking the button.
Install the openai package and create the AI client
The openai npm package gives you a JavaScript client for calling OpenAI's models. You'll also need an API key from your OpenAI account to authenticate requests.
- Install the openai package by running this command in your terminal (make sure you're inside the vibe-check folder):
npm install openai
What does this install?
The openai package is the official Node.js SDK for the OpenAI API. It handles authentication, request formatting, and response parsing so you can call models with a few lines of code.
- Confirm the install succeeded by checking that openai appears in package.json under dependencies.
Install failed?
Make sure your terminal is inside the vibe-check folder. Run ls and confirm you see package.json in the output.
help me fix my npm install error for the openai package
Now you need an OpenAI API key. This key authenticates your app's requests so OpenAI knows who is calling.
- Open https://platform.openai.com/api-keys in your browser and sign in to your OpenAI account.
- Click Create new secret key.
- Give it a name like vibe-check and click Create secret key.
- Copy the key immediately. You will not be able to see it again after you close the dialog.
You need billing credit on your OpenAI account
OpenAI requires a minimum $5 prepaid credit to use the API. If you have not added billing yet, go to Settings > Billing in the OpenAI Platform and add a payment method before continuing.
This project's AI usage costs well under $0.05 in total.
- In VS Code, open .env.local at the root of your vibe-check project.
- Find the line that says:
VITE_OPENAI_API_KEY=your-openai-api-key
- Replace your-openai-api-key with the key you just copied. The line should now contain your real key starting with sk-.
- Save .env.local.
Never commit .env.local to Git
Your .env.local file contains your secret API key. Vite automatically ignores this file from Git if you have a standard .gitignore. Double-check that .env.local is listed in your .gitignore before pushing.
With the package installed and your key in place, you can now create the AI client utility that your app will use to call OpenAI.
- Create a new file called src/aiClient.js and paste in the following code:
import OpenAI from 'openai'
// Create an OpenAI client using the API key from environment variables
const openai = new OpenAI({
apiKey: import.meta.env.VITE_OPENAI_API_KEY,
dangerouslyAllowBrowser: true,
})
// Send a task title to OpenAI and get back a markdown checklist of sub-tasks
export async function generateTaskBreakdown(taskTitle) {
const response = await openai.chat.completions.create({
model: 'gpt-4.1-nano',
messages: [
{
role: 'system',
content:
'You are a senior developer assistant. Given a vague task, break it down into a structured markdown checklist of 3-6 specific technical sub-tasks. Include suggested files to look at where relevant. Be concise.',
},
{
role: 'user',
content: `Break down this task: "${taskTitle}"`,
},
],
max_tokens: 500,
})
return response.choices[0].message.content
}
What does this code do?
The OpenAI client is initialized with your API key from the environment variable. The dangerouslyAllowBrowser: true flag lets you call OpenAI directly from the browser. This is fine for a learning project but should go through a backend in production.
The generateTaskBreakdown function sends two messages to the model: a system prompt that tells it to act as a senior developer assistant, and a user message with the task title. The model responds with a markdown checklist of 3-6 sub-tasks.
- Save src/aiClient.js.
Seeing a red underline on the import?
If your editor shows an error on import OpenAI from 'openai', the openai package may not have installed correctly. Re-run npm install openai and restart VS Code.
help me fix my openai import error in aiClient.js
Wire the AI Co-Pilot button to call the API
The TaskCard.jsx component already has a stubbed handleAiBreakdown function that does nothing. You'll replace it with code that calls your new AI client, saves the result to Supabase, and triggers the UI to show the breakdown.
- Open src/components/TaskCard.jsx.
- Find this import line near the top:
import { supabase } from '../supabaseClient'
- Add a new import directly below it:
import { generateTaskBreakdown } from '../aiClient'
- Now find the existing handleAiBreakdown function. It currently looks like this:
async function handleAiBreakdown() {
setLoading(true)
// AI integration coming in next step
setLoading(false)
}
- Replace that entire function with the following:
async function handleAiBreakdown() {
setLoading(true)
try {
// Call OpenAI to generate a breakdown of the task
const breakdown = await generateTaskBreakdown(task.title)
// Save the AI-generated breakdown to the task's ai_breakdown column
const { data, error } = await supabase
.from('tasks')
.update({ ai_breakdown: breakdown })
.eq('id', task.id)
.select()
if (!error && data) {
onUpdate(data[0])
setShowBreakdown(true)
}
} catch (err) {
console.error('AI breakdown failed:', err)
}
setLoading(false)
}
What does this code do?
When the user clicks the AI Co-Pilot button, generateTaskBreakdown(task.title) sends the task title to OpenAI and waits for a markdown checklist response.
The response is saved to the ai_breakdown column in Supabase so it persists across page refreshes. Then onUpdate(data[0]) updates the local state and setShowBreakdown(true) reveals the breakdown below the task card.
- Save src/components/TaskCard.jsx.
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/aiClient.js and src/components/TaskCard.jsx.
ⓧ I'd like to double check the full code
Here is the full src/aiClient.js:
import OpenAI from 'openai'
const openai = new OpenAI({
apiKey: import.meta.env.VITE_OPENAI_API_KEY,
dangerouslyAllowBrowser: true,
})
export async function generateTaskBreakdown(taskTitle) {
const response = await openai.chat.completions.create({
model: 'gpt-4.1-nano',
messages: [
{
role: 'system',
content:
'You are a senior developer assistant. Given a vague task, break it down into a structured markdown checklist of 3-6 specific technical sub-tasks. Include suggested files to look at where relevant. Be concise.',
},
{
role: 'user',
content: `Break down this task: "${taskTitle}"`,
},
],
max_tokens: 500,
})
return response.choices[0].message.content
}
Here is the full src/components/TaskCard.jsx:
import { useState } from 'react'
import { supabase } from '../supabaseClient'
import { generateTaskBreakdown } from '../aiClient'
export default function TaskCard({ task, onUpdate }) {
const [loading, setLoading] = useState(false)
const [showBreakdown, setShowBreakdown] = useState(false)
async function handleAiBreakdown() {
setLoading(true)
try {
const breakdown = await generateTaskBreakdown(task.title)
const { data, error } = await supabase
.from('tasks')
.update({ ai_breakdown: breakdown })
.eq('id', task.id)
.select()
if (!error && data) {
onUpdate(data[0])
setShowBreakdown(true)
}
} catch (err) {
console.error('AI breakdown failed:', err)
}
setLoading(false)
}
return (
<div className="task-card">
<p className="task-title">{task.title}</p>
<button
onClick={handleAiBreakdown}
disabled={loading}
className="ai-btn"
>
{loading ? 'Thinking...' : '🤖 AI Co-Pilot'}
</button>
{task.ai_breakdown && (
<div className="breakdown">
<button
onClick={() => setShowBreakdown(!showBreakdown)}
className="toggle-breakdown"
>
{showBreakdown ? 'Hide' : 'Show'} Breakdown
</button>
{showBreakdown && (
<div
className="breakdown-content"
dangerouslySetInnerHTML={{ __html: task.ai_breakdown }}
/>
)}
</div>
)}
</div>
)
}
Test the AI-generated markdown checklist
The display logic for the AI breakdown already exists in your TaskCard JSX from a previous step. Now that the data flows in, you can test the full loop.
- Start your dev server by running:
npm run dev
- Open the local URL shown in your terminal (usually http://localhost:5173).
- Sign in with your account.
- Create a new task with a vague title like Set up user onboarding flow.
- Click the AI Co-Pilot button on that task card.
- Wait a few seconds. The button text changes to "Thinking..." while the request is in flight.
Once the response returns, you should see a Show Breakdown link appear below the task. Click it to reveal the AI-generated checklist of sub-tasks.
What should I see?
A markdown-formatted list of 3-6 specific technical sub-tasks that break down your vague task title. The checklist persists after a page refresh because it is stored in your Supabase tasks table.
Button stays on "Thinking..." forever?
Open your browser's developer console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows). Look for an error message.
If you see a 401 error, your API key is invalid or missing. Double-check .env.local has the correct key and restart the dev server with npm run dev (Vite only reads env vars on startup).
If you see a 429 error, you have hit a rate limit. Wait a minute and try again, or check that you have billing credit on your OpenAI account.
my AI Co-Pilot button is not working and I see an error in the console
Your AI Co-Pilot is working. Next up, you'll deploy your app live on Vercel so anyone can access it.
Deploy to Vercel
Your Kanban board works locally with authentication, drag-and-drop, and AI-powered task breakdowns. The last step is putting it online so you can access it from anywhere.
Vercel connects directly to your GitHub repository and deploys your app automatically every time you push new code. In this step, you will import your repo into Vercel, configure your environment variables, and verify that everything works live.
In this step, get ready to:
- Import your GitHub repository into Vercel as a new project.
- Set environment variables for Supabase and OpenAI in Vercel's project settings.
- Verify your live deployment works with all features.
Push your latest code to GitHub
Before deploying, make sure your latest code is on GitHub. Any uncommitted changes will not be included in your deployment.
- Commit and push all changes by running these commands:
git add .
git commit -m "Ready for deployment"
git push origin main
What if I already pushed everything?
If Git says there is nothing to commit, that means your code is already up to date on GitHub. You can skip ahead to the next substep.
Import your repository into Vercel
Vercel is a hosting platform that connects to your GitHub repo and deploys your app automatically. The Hobby plan is free and includes automatic deployments from every push to your main branch.
- Open your browser and go to vercel.com.
- Click Sign Up if you do not have an account, or Log In if you already do.
- Sign in with your GitHub account so Vercel can access your repositories.
Why sign in with GitHub?
Signing in with GitHub lets Vercel read your repositories and set up automatic deployments. Every time you push to your main branch, Vercel will rebuild and redeploy your app without any manual action.
- From the Vercel dashboard, click Add New... and select Project.
- Find your vibe-check repository in the list and click Import.
Vercel detects that your project uses Vite and automatically sets the correct build configuration. You will see the Framework Preset set to Vite.
Set environment variables in Vercel
Your app needs the same environment variables it uses locally. Without these, the deployed app cannot connect to Supabase or OpenAI.
- On the project configuration page, scroll down to the Environment Variables section.
- Add the first variable. In the Key field, enter VITE_SUPABASE_URL. In the Value field, paste your Supabase project URL from your .env.local file.
- Click Add More to add another variable.
- Add VITE_SUPABASE_ANON_KEY as the key. Paste your Supabase anon key as the value.
- Click Add More again.
- Add VITE_OPENAI_API_KEY as the key. Paste your OpenAI API key as the value.
Where do I find these values?
Open your .env.local file in your code editor. Copy each value from there and paste it into the corresponding Vercel field. The variable names must match exactly.
- Make sure the Environments dropdown shows Production and Preview selected for each variable.
- Click Deploy to start the deployment.
Vercel will install your dependencies, build your project, and deploy it. This takes about 30-60 seconds.
Deployment failed?
Check the build logs on Vercel for error details. Common causes include a typo in the environment variable names or a missing dependency. Make sure your variable names are exactly VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY, and VITE_OPENAI_API_KEY.
If the build log shows a lint or type error, fix it locally, commit, and push again. Vercel will automatically redeploy.
help me debug my Vercel deployment failure
Verify the live deployment
Once the deployment finishes, Vercel gives you a live URL. Time to test that everything works in production.
- Click the Visit button on the deployment success page to open your live app.
- Sign in with the email and password you created earlier.
- Create a new task (e.g. "Set up CI/CD pipeline") and verify it appears in the To Do column.
- Drag the task to the In Progress column. Refresh the page to confirm it persists in the new column.
- Click the AI Co-Pilot button on a task and wait for the breakdown to appear.
What is my live URL?
Vercel assigns a URL based on your project name, like vibe-check-abc123.vercel.app. You can find this URL on your project dashboard or in the deployment success screen. Bookmark it so you can return anytime.
Authentication not working on the live site?
Open your Supabase project dashboard and go to Authentication settings. Check that your live Vercel URL is added to the Redirect URLs list. If it is missing, add it and try signing in again.
help me fix Supabase authentication on my Vercel deployment
Your full-stack app is live on the internet. Future pushes to your main branch automatically trigger a new Vercel deployment, so any changes you make will go live within a minute of pushing. Next up, the Secret Mission takes your workflow to the next level with automated code quality checks.
Secret mission
Add a GitHub Actions ESLint CI Check
Your Kanban board is deployed, but what happens when someone pushes sloppy code? Add a GitHub Actions workflow that runs ESLint on every push and blocks deployment if linting fails. This is how real teams enforce code quality at scale.
Clean Up Your Resources
Clean Up Your Resources
This project created a Supabase project on the Free Tier and uses the OpenAI API on a pay-per-use basis. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Cost warning
Your OpenAI API key is pay-per-use. If you leave it active, any application or person with access to the key can make API calls that charge your account. If you are done with this project, delete the key to avoid unexpected charges.
Resources you used:
- Supabase project (Free Tier, auto-pauses after 1 week of inactivity).
- Vercel project (Hobby plan, free, stays live indefinitely).
- OpenAI API key (pay-per-use, charges per request).
- GitHub repository.
- Local project folder (vibe-check).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep your live deployment available.
- Your Supabase project will auto-pause after 1 week of inactivity on the Free plan. It reactivates when you make a request.
- Your Vercel Hobby deployment stays live indefinitely at no cost.
- Your OpenAI API key remains active. Monitor usage at platform.openai.com to avoid unexpected charges.
- Your local vibe-check folder remains on your Desktop.
Pause - I'll come back to this later
Free up resources while keeping your project recoverable. Supabase pauses automatically, so no manual action is needed there.
- Your Supabase project pauses automatically after 1 week of inactivity. No action required.
- Your Vercel deployment stays live at no cost. No action required.
- Consider deleting your OpenAI API key if you will not use it for a while. You can always create a new one later.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
- Delete your Supabase project: open your project in the Supabase dashboard, click Settings in the sidebar, scroll to the bottom of the General page, and click Delete project. Confirm when prompted. This permanently removes your database and all associated data.
- Delete your Vercel project: open the Vercel dashboard, select your project, click Settings in the sidebar, scroll to the bottom of the General page, and click Delete in the Delete Project section. Enter your project name to confirm, then click Continue.
- Delete your OpenAI API key: go to the API keys page at platform.openai.com and click the trashcan icon next to the key you created for this project.
- Delete your GitHub repository: open your repo on GitHub, click Settings, scroll to the bottom of the page to the Danger Zone section, and click Delete this repository. Follow the confirmation prompts.
- Delete your local project files by running these commands:
macOS
rm -rf ~/Desktop/vibe-check
Windows
rmdir /s /q %USERPROFILE%\Desktop\vibe-check
That's a Wrap!
That's a Wrap!
Nice work! You just built and deployed a full-stack AI-powered Kanban board from scratch.
You've learned how to:
- Built a React app with Vite and deployed it live to Vercel with automatic deploys from GitHub.
- Implemented user authentication with Supabase and designed a PostgreSQL schema protected by Row Level Security.
- Integrated the OpenAI API to generate intelligent task breakdowns using prompt engineering.
- Added a GitHub Actions linting pipeline that gates deployments.
Ready to quiz yourself?