Build a Helpdesk Ticket App
Build a full-stack helpdesk app with React, Supabase auth, and row-level security.
Introduction
30 Second Summary
Every time you email IT support or lodge a complaint online, there is a system behind the scenes assigning your request a ticket number and tracking it until someone resolves it.
In this project, you will build a helpdesk ticket app using React and Supabase. Users will create accounts, submit support tickets with auto-generated ticket numbers, and track responses in a conversation thread.
What You'll Build
You will log in to your own support portal, submit a ticket describing a problem, and watch it appear instantly with a unique ticket number you can click into to read and post responses.
By the end of this project, you'll have:
- Submit support tickets and see them listed instantly with auto-incrementing ticket numbers like #1, #2, #3.
- Log in with email and password and see only your own tickets. Other users' data is invisible to you, enforced at the database level by Row Level Security.
- Click into any ticket to view its full details and a conversation thread where you add and read responses.
- Secret Mission: Add a status system with colour-coded badges (Open, In Progress, Resolved) and filtering.
Are there any prerequisites?
None. This guide walks you through setting up every tool you need, including Node.js, VS Code, and your Supabase account.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Development Environment
To build our helpdesk app, we need three things: a JavaScript runtime to power React locally, a build tool to scaffold the project, and a cloud database to store tickets.
In this step, you will install Node.js, scaffold a React app with Vite, and create a free Supabase project to store your data.
In this step, get ready to:
- Install Node.js and verify it in your terminal.
- Scaffold a React app with Vite and install the Supabase client library.
- Create a free Supabase account and project.
Install Node.js
Node.js is a JavaScript runtime that lets you run JavaScript outside the browser. React and Vite both need it to work.
- Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open a terminal inside VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
- Check if Node.js is already installed by running this command:
node --version
Check your terminal output and pick the tab below that matches what you see.
✔️ I see version 24 or higher
You have the right version of Node.js installed. You are ready to scaffold your React project.
ⓧ I see an older version
You have Node.js installed, but you need version 24 or higher for this project. The Supabase client library requires it.
- Go to https://nodejs.org in your browser.
- Click the LTS download button (you want version 24.x Krypton).
- Run the downloaded installer (.msi on Windows, .pkg on macOS) and accept the defaults.
- Close your current terminal in VS Code and open a new one with Ctrl+` so the updated PATH takes effect.
- Run node --version again. You should now see v24 or higher.
ⓧ Command not found
Node.js is not installed yet. Let's install it now.
- Go to https://nodejs.org in your browser.
- Click the LTS download button (version 24.x Krypton).
- Run the downloaded installer (.msi on Windows, .pkg on macOS) and accept all the defaults.
- Close your current terminal in VS Code and open a new one with Ctrl+` so the PATH updates take effect.
- Confirm the install by running:
node --version
You should see a version number starting with v24 (for example, v24.18.0).
Still not seeing a version number?
Make sure you opened a new terminal after installing. Existing terminals do not pick up PATH changes from a fresh install.
On Windows, try closing and reopening VS Code entirely, then open a new terminal.
Why isn't node --version working after I installed Node.js?
Scaffold the React app
Vite is a modern build tool that creates a ready-to-run React project in seconds. You will use it to generate the starter files for your helpdesk app.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your React project by running this command:
npm create vite@latest helpdesk-app -- --template react
What does this command do?
- npm create vite@latest downloads and runs the Vite project scaffolder.
- helpdesk-app is the folder name for your new project.
- -- --template react tells Vite to use the React starter template so it skips interactive prompts.
- Move into your new project folder by running:
cd helpdesk-app
- Install the project dependencies by running:
npm install
You should see output listing the installed packages, ending with a summary line.
- Install the Supabase client library by running:
npm install @supabase/supabase-js
What is @supabase/supabase-js?
This is the official JavaScript library for talking to your Supabase database. It handles queries, authentication, and real-time subscriptions from your React app.
- Confirm your project files were created by running:
ls
You should see files including package.json, src, index.html, and vite.config.js.
Seeing an error during npm install?
Make sure you ran cd helpdesk-app before running npm install. The install command must run inside the project folder.
If you see a permissions error, try closing VS Code and reopening it, then navigate back to the folder.
Help me fix npm install errors in my Vite React project.
Create a free Supabase account and project
Supabase is a cloud database platform built on PostgreSQL. It gives you a real database, user authentication, and auto-generated APIs on a generous free tier.
- Open https://supabase.com/dashboard in your browser.
- Click Sign Up and create a free account (you can sign up with GitHub or email).
- If prompted to create an organization, enter any name (e.g. your own name) and continue.
- Click New Project to create a new project.
- Enter a project name (e.g. helpdesk).
- Enter a strong database password and save it somewhere safe. You will not need to type it again for this project, but keep it in case you need it later.
- Select a region close to you for the best performance.
- Make sure the Automatically expose new tables checkbox is checked. This allows the Supabase client library to access your tables through the Data API.
- Click Create new project.
Supabase will take a minute or two to provision your database. While it works, let's verify that your React app runs.
What is the Supabase free tier?
The free tier includes 500MB of database storage and up to 2 active projects. Projects pause after 7 days of inactivity, but you can unpause them from the dashboard at any time.
- Switch back to your VS Code terminal (it should still be in the helpdesk-app folder).
- Start the development server by running:
npm run dev
Before you check the browser, what do you expect to see at the URL Vite prints in the terminal?
- Open http://localhost:5173 in your browser.
You should see the Vite + React starter page with spinning logos and a counter button. This confirms your development environment is working.
Not seeing the starter page?
Check your terminal for error messages. If the port is already in use, Vite will try the next available port and print a different URL.
Make sure you ran npm run dev from inside the helpdesk-app folder, not from ~/Desktop.
My dev server isn't starting or the page won't load.
Leave the dev server running
Keep this terminal running for the rest of the project. The dev server watches your files and refreshes the browser automatically when you make changes. If you need to run other commands, open a second terminal in VS Code with Ctrl+`.
Your environment is ready. You have Node.js powering your local dev server, a React app running in the browser, and a Supabase project provisioning in the cloud. Next up, you will create a database table and build the ticket submission form.
Create the Database and Build the Ticket Form
Your dev environment is ready and the Vite starter page is running. Now you need somewhere to actually store helpdesk tickets and a way for users to submit them.
In this step, you will create a database table in Supabase, connect your React app to it, and build a form that inserts tickets and displays them instantly.
In this step, get ready to:
- Create a tickets table in your Supabase database.
- Connect your React app to Supabase using environment variables.
- Build a ticket submission form and live ticket list.
Create the tickets table in Supabase
Your helpdesk app needs a database table to store each ticket's title, description, and who submitted it. You will create this table using SQL in the Supabase dashboard.
- Switch to your Supabase project dashboard in your browser.
- In the left sidebar, click SQL Editor.
- Paste the following SQL into the editor and click Run:
-- Create the tickets table
create table tickets (
id bigint generated always as identity primary key,
created_at timestamptz default now(),
title text,
description text,
user_id uuid
);
-- Grant Data API access to the table
grant select, insert, update, delete on public.tickets to anon;
grant select, insert, update, delete on public.tickets to authenticated;
What does this SQL do?
The first statement creates a tickets table with five columns. The id column auto-increments (1, 2, 3...) so each ticket gets a unique number automatically.
The grant statements tell Supabase's Data API that both anonymous and authenticated users can read and write to this table. Without these grants, the API cannot see your table at all.
- You should see a Success. No rows returned message after running the SQL.
- Click Table Editor in the left sidebar to confirm that a tickets table now appears in the list.
Don't see the tickets table?
Make sure you clicked Run (not just pasted the SQL). Check for any red error text in the results panel. A common mistake is a missing semicolon between statements.
Help me troubleshoot why my SQL didn't create the tickets table.
Connect your React app to Supabase
Your React app needs two pieces of information to talk to Supabase: the project URL and a publishable API key. You will store these in an environment file that Vite reads automatically.
- In the Supabase dashboard, click the Connect button in the top navigation bar.
- Copy the Project URL value (it looks like https://abcxyz.supabase.co).
- Copy the Publishable key value (it starts with sb_publishable_).
- Switch back to VS Code. Create a new file called .env.local in the root of your helpdesk-app folder and paste the following, replacing the placeholder values with your own:
VITE_SUPABASE_URL=your-supabase-url-here
VITE_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key-here
Why use a .env.local file?
Vite automatically loads any variable prefixed with VITE_ from .env.local and makes it available in your code via import.meta.env. This keeps your project URL and key out of your source code.
- Now create a file called supabaseClient.js inside your src folder. This file initializes the Supabase client that the rest of your app will use:
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
export const supabase = createClient(supabaseUrl, supabaseKey)
What does this code do?
It imports the createClient function from the Supabase library, reads your project URL and key from the environment variables, and exports a ready-to-use supabase client. Every file that needs to talk to your database will import from this one place.
- Save both .env.local and src/supabaseClient.js.
- Check the terminal where your dev server is running. It should still show no errors. If it restarted, that is normal after adding a .env.local file.
Seeing an error about VITE_SUPABASE_URL?
If the dev server logs an error referencing undefined, make sure .env.local is in the root helpdesk-app folder (next to package.json), not inside src. Vite only reads .env.local from the project root. You may also need to stop the dev server with Ctrl+C and restart it with npm run dev for the new variables to load.
Help me fix my Supabase environment variable error.
✔️ Awesome, I've got everything!
Great. Double check you saved both files.
ⓧ I'd like to double check the full code
Your src/supabaseClient.js should match the code block above exactly (6 lines). Your .env.local should have two lines: one for VITE_SUPABASE_URL and one for VITE_SUPABASE_PUBLISHABLE_KEY, each with your own project values.
Build the ticket form and list
Now for the exciting part. You will replace the default Vite starter page with a real ticket submission form and a live list that pulls tickets from your database.
- Open src/App.jsx in VS Code. Select all the existing code and delete it.
- Paste the following code, which sets up state and fetches tickets from your database:
import { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'
function App() {
const [tickets, setTickets] = useState([])
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
useEffect(() => { fetchTickets() }, [])
// Retrieve all tickets from the database
async function fetchTickets() {
const { data, error } = await supabase
.from('tickets')
.select()
.order('created_at', { ascending: false })
if (error) { console.error(error); return }
setTickets(data)
}
// Insert a new ticket and refresh the list
async function handleSubmit(e) {
e.preventDefault()
const { error } = await supabase
.from('tickets')
.insert({ title, description })
if (error) { console.error(error); return }
setTitle('')
setDescription('')
fetchTickets()
}
What does this code do?
- Three useState calls create state for the ticket list, the title input, and the description input.
- useEffect calls fetchTickets() once when the page loads, pulling all tickets ordered newest-first.
- handleSubmit inserts a new row into the tickets table, clears the form, and refreshes the list so the new ticket appears instantly.
- Directly below the code you just pasted (still inside the App function), add the return statement that renders the form and ticket list:
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<h1>Helpdesk</h1>
<form onSubmit={handleSubmit} style={{ marginBottom: '20px' }}>
<h2>Submit a Ticket</h2>
<div style={{ marginBottom: '10px' }}>
<input type="text" placeholder="Ticket title" value={title}
onChange={(e) => setTitle(e.target.value)} required
style={{ width: '100%', padding: '8px' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<textarea placeholder="Describe your issue..." value={description}
onChange={(e) => setDescription(e.target.value)} required
style={{ width: '100%', padding: '8px', minHeight: '80px' }} />
</div>
<button type="submit" style={{ padding: '10px 20px' }}>Submit Ticket</button>
</form>
<h2>Your Tickets</h2>
{tickets.length === 0 ? (
<p>No tickets yet. Submit one above!</p>
) : (
tickets.map((ticket) => (
<div key={ticket.id} style={{ border: '1px solid #ccc', padding: '12px', marginBottom: '8px', borderRadius: '4px' }}>
<strong>#{ticket.id}</strong> - {ticket.title}
<br />
<small>{new Date(ticket.created_at).toLocaleDateString()}</small>
</div>
))
)}
</div>
)
}
export default App
What does this JSX render?
- A form with a title input, a description textarea, and a submit button. All three are controlled by React state.
- Below the form, a list of tickets. Each ticket displays its auto-generated id as a ticket number (e.g. #1, #2) alongside the title and creation date.
- If no tickets exist yet, a placeholder message appears instead.
- Save src/App.jsx.
- Switch to your browser at http://localhost:5173. You should see the Helpdesk heading, the Submit a Ticket form, and a "No tickets yet" message below it.
Seeing a blank page or error?
Open the browser console (F12 then Console tab). If you see an import error mentioning supabaseClient, confirm that src/supabaseClient.js exists and exports supabase. If you see a network error, double check the URL in your .env.local matches your Supabase project exactly.
Help me fix my blank page or console error in the helpdesk app.
Before you run this, do you think submitting the form will successfully add a ticket to your list? What could go wrong?
- Type a title (e.g. Laptop won't turn on) and a description (e.g. Screen stays black after pressing power button) into the form fields.
- Click Submit Ticket.
The ticket appears in the list below with an auto-generated ticket number (e.g. #1). The form clears itself, ready for the next submission.
✔️ Awesome, I've got everything!
Great. Make sure both chunks are in src/App.jsx and the file is saved.
ⓧ I'd like to double check the full code
Your src/App.jsx should contain both code chunks you pasted above, in order:
- Chunk 1: imports, state, useEffect, fetchTickets, and handleSubmit.
- Chunk 2: the return statement with the form, ticket list, and export default App at the bottom.
Scroll up to each chunk and confirm they are present, in order, with no duplicate lines between them.
You have a working database, a connected React app, and a live ticket form. Next up, you will add user authentication so each person gets their own private set of tickets.
Add User Authentication
Right now, anyone can submit tickets without identifying themselves. There is no way to know who submitted which ticket or to keep one person's tickets separate from another's.
In this step, you will add user authentication so people can create an account, sign in, and have every ticket they submit linked to their identity. You will use Supabase Auth, which gives you a complete sign-up and sign-in system backed by your project's database.
In this step, get ready to:
- Build an authentication component with sign-up and sign-in forms.
- Track the user's session and show different views based on auth state.
- Link each ticket to the logged-in user so they only see their own.
Build the Auth component
Your authentication form needs to handle two flows: signing up a brand-new user, and signing in an existing one. You will build a single component that toggles between both modes.
- Create a new file called src/Auth.jsx in your project.
- Add the component logic by pasting this code:
import { useState } from 'react'
import { supabase } from './supabaseClient'
function Auth() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isSignUp, setIsSignUp] = useState(false)
const [message, setMessage] = useState('')
async function handleSubmit(e) {
e.preventDefault()
setMessage('')
if (isSignUp) {
const { error } = await supabase.auth.signUp({ email, password })
if (error) {
setMessage(error.message)
} else {
setMessage('Sign up successful! Check your email to confirm, then sign in.')
}
} else {
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
setMessage(error.message)
}
}
}
What does this code do?
- Four pieces of state track the form inputs (email, password), the current mode (isSignUp), and any feedback message (message).
- The handleSubmit function checks the mode. If isSignUp is true, it calls supabase.auth.signUp(). Otherwise it calls supabase.auth.signInWithPassword().
- Both methods return an error object if something goes wrong. The error message gets displayed to the user.
- Below the logic you just pasted, add the component's JSX return and export:
return (
<div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
<h1>Helpdesk Login</h1>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '10px' }}>
<input type="email" placeholder="Email" value={email}
onChange={(e) => setEmail(e.target.value)} required
style={{ width: '100%', padding: '8px' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<input type="password" placeholder="Password" value={password}
onChange={(e) => setPassword(e.target.value)} required
style={{ width: '100%', padding: '8px' }} />
</div>
<button type="submit" style={{ width: '100%', padding: '10px' }}>
{isSignUp ? 'Sign Up' : 'Sign In'}
</button>
</form>
<p style={{ textAlign: 'center', marginTop: '10px' }}>
<button onClick={() => setIsSignUp(!isSignUp)}
style={{ background: 'none', border: 'none', color: 'blue', cursor: 'pointer' }}>
{isSignUp ? 'Already have an account? Sign In' : "Don't have an account? Sign Up"}
</button>
</p>
{message && <p style={{ color: 'red', textAlign: 'center' }}>{message}</p>}
</div>
)
}
export default Auth
What does this code do?
- The form renders email and password inputs wired to state via onChange.
- The submit button label changes based on isSignUp so the same form serves both modes.
- A toggle button at the bottom flips between Sign Up and Sign In by toggling isSignUp.
- If message has content, it appears as red text below the form.
- Save src/Auth.jsx.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
Your src/Auth.jsx should contain both chunks in order:
- The imports, state declarations, and handleSubmit function (first chunk).
- The return statement with the full JSX form, toggle button, message display, closing brace, and export default Auth (second chunk).
Update App.jsx with session management
Your app needs to know whether someone is signed in so it can show the right view. Supabase provides an event listener that fires whenever the auth state changes.
- Open src/App.jsx and replace its entire contents with the following code. Start with the imports and auth listener:
import { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'
import Auth from './Auth'
import TicketForm from './TicketForm'
import TicketList from './TicketList'
function App() {
const [session, setSession] = useState(null)
const [tickets, setTickets] = useState([])
// Listen for auth state changes (sign in, sign out)
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
if (event === 'SIGNED_OUT') {
setSession(null)
} else if (session) {
setSession(session)
}
}
)
return () => {
subscription.unsubscribe()
}
}, [])
What does this code do?
- The session state holds the current user's session object, or null if nobody is signed in.
- The onAuthStateChange listener fires whenever a user signs in or signs out. It updates session accordingly.
- The cleanup function (subscription.unsubscribe()) prevents memory leaks when the component unmounts.
- Below the auth listener, add the data fetching, sign out handler, and the rendered UI:
// Fetch tickets when user signs in
useEffect(() => {
if (session) {
fetchTickets()
}
}, [session])
async function fetchTickets() {
const { data, error } = await supabase
.from('tickets')
.select()
.order('created_at', { ascending: false })
if (error) {
console.error(error)
return
}
setTickets(data)
}
async function handleSignOut() {
const { error } = await supabase.auth.signOut()
if (error) console.error(error)
}
// Show login form if no session exists
if (!session) {
return <Auth />
}
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1>Helpdesk</h1>
<button onClick={handleSignOut}>Sign Out</button>
</header>
<TicketForm session={session} onTicketCreated={fetchTickets} />
<TicketList tickets={tickets} />
</div>
)
}
export default App
What does this code do?
- A second useEffect calls fetchTickets() whenever session changes, so the list refreshes after sign-in.
- The if (!session) check is the key gate. When no session exists, the app renders <Auth /> instead of the ticket interface.
- When a session does exist, the app renders a header with a Sign Out button, the ticket form, and the ticket list.
- Save src/App.jsx.
- Switch to your browser at http://localhost:5173. You should see the Helpdesk Login form with email and password fields.
Disable email confirmation for easier testing
By default, Supabase requires new users to confirm their email before they can sign in. For faster testing, you can disable this.
- In the Supabase dashboard, go to Authentication, then Providers, then expand the Email provider.
- Toggle off Confirm email and click Save.
With this off, you can sign up and sign in immediately without checking your inbox.
- In the login form, click the toggle to switch to Sign Up mode.
- Enter a test email and password (at least 6 characters), then click Sign Up.
- Sign in with the same credentials. You should see the Helpdesk header with the Sign Out button and your ticket form.
Still seeing the login form after sign-up?
- If email confirmation is enabled, you need to confirm your email first. Check your inbox for a confirmation link, or disable confirmation as described above.
- Make sure your password is at least 6 characters long.
- Check the browser console for errors. If you see a network error, confirm your .env.local values match your Supabase project.
Why can't I sign in after signing up?
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Your src/App.jsx should contain both chunks in order:
- Imports of useState, useEffect, supabase, Auth, TicketForm, TicketList. State for session and tickets. The onAuthStateChange useEffect (first chunk).
- The fetch-on-session-change useEffect, fetchTickets, handleSignOut, the if (!session) gate rendering <Auth />, and the main return with header, TicketForm, and TicketList (second chunk).
Link tickets to the logged-in user
Your tickets table already has a user_id column, but nothing is filling it in yet. You need to stamp each new ticket with the current user's ID and filter the list so users only see their own.
- Open src/TicketForm.jsx and find the function parameter line:
function TicketForm({ onTicketCreated }) {
- Add session as a prop so the line looks like this:
function TicketForm({ session, onTicketCreated }) {
- Now find the .insert() call inside handleSubmit:
.insert({ title, description })
- Add user_id: session.user.id so the line becomes:
.insert({ title, description, user_id: session.user.id })
- Save src/TicketForm.jsx.
Why pass user_id from the client?
Every ticket now carries the ID of the person who created it. This lets you filter the list so users only see their own tickets.
In a later step, you will move this trust to the database level using Row Level Security. For now, the client-side approach gets the feature working.
- Open src/App.jsx and find the fetchTickets function. Locate these lines:
const { data, error } = await supabase
.from('tickets')
.select()
.order('created_at', { ascending: false })
- Add a .eq() filter between .select() and .order() so it reads:
const { data, error } = await supabase
.from('tickets')
.select()
.eq('user_id', session.user.id)
.order('created_at', { ascending: false })
- Save src/App.jsx.
Before you test, think about what you expect to see. You signed up as a new user who has never submitted a ticket. What should the ticket list show?
- Refresh your browser. You should see an empty ticket list (because your new account has no tickets yet).
- Submit a new ticket with any title and description. It should appear in the list with a ticket number.
- Click Sign Out. You should see the login form again.
- Sign back in. Your ticket should still be there.
Ticket not appearing after submission?
- Open the browser console and check for errors. A common issue is that the user_id column has a constraint that rejects the value. Confirm the column type is uuid and accepts any UUID, not just references to a specific table.
- Make sure you saved both src/TicketForm.jsx and src/App.jsx.
- If old tickets from before authentication still appear, that is expected. The .eq() filter only returns tickets whose user_id matches yours. Tickets with a null user_id from earlier testing will be hidden.
My tickets are not showing up after sign-in. Help me debug.
Your app now has a real authentication system. Users sign up, sign in, and only see their own tickets. In the next step, you will discover why filtering on the client side is not secure enough and lock things down at the database level.
Secure the Database with Row Level Security
Your app currently filters tickets on the client side using .eq('user_id', session.user.id).
This feels secure, but it is not. Anyone with your Supabase URL and publishable key could query all tickets directly, bypassing your React code entirely. Row Level Security solves this by enforcing access rules at the database level, making it impossible for any client to see data it should not.
In this step, get ready to:
- Enable Row Level Security on the tickets table.
- Create SELECT and INSERT policies so users can only access their own tickets.
- Remove the client-side filter and let the database handle security.
Enable RLS and create a SELECT policy
Right now the tickets table has no access restrictions at the database level. Any request that reaches Supabase can read every row.
- In your Supabase dashboard, click SQL Editor in the left sidebar.
- Paste the following SQL and click Run to enable RLS and create a SELECT policy:
-- Lock down the tickets table so no one can read without a policy
ALTER TABLE tickets ENABLE ROW LEVEL SECURITY;
-- Allow logged-in users to read only their own tickets
CREATE POLICY "Users can view own tickets"
ON tickets
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
What does this SQL do?
ALTER TABLE tickets ENABLE ROW LEVEL SECURITY flips a switch on the table. Once enabled, every query must pass through a policy before it can return rows.
The CREATE POLICY statement defines who can do what. This one says: for SELECT queries, only the authenticated role may read rows, and only where auth.uid() (the logged-in user's ID) matches the row's user_id column.
You should see a Success. No rows returned message confirming the statements ran without error.
Seeing a permission error?
Make sure you are running the query as the default postgres role (shown in the Role dropdown at the top of the SQL Editor). Only the postgres role has permission to alter tables and create policies.
help me fix a permission error when enabling RLS in Supabase SQL Editor
Create an INSERT policy and test ticket creation
RLS now blocks reads without a matching policy. You also need an INSERT policy so authenticated users can still create tickets.
- In the same SQL Editor, paste and run this statement:
-- Allow logged-in users to insert tickets linked to their own user_id
CREATE POLICY "Users can create tickets"
ON tickets
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
What does WITH CHECK do?
For INSERT policies, WITH CHECK validates that the new row satisfies the condition. This ensures a user can only insert a ticket where the user_id matches their own ID. They cannot insert tickets pretending to be someone else.
- Switch to your browser tab with the app running at http://localhost:5173.
- Submit a new ticket with any title and description.
The ticket should still appear in your list. This confirms the INSERT policy is working because your user_id matches.
New ticket not appearing?
Open your browser's developer console (F12 or Cmd+Option+J on macOS). Look for an error containing a message about RLS policy violation. This means either the INSERT or SELECT policy is misconfigured.
Double check that both policies target TO authenticated and reference auth.uid() = user_id exactly.
help me debug why my ticket insert is blocked after enabling RLS in Supabase
Remove the client-side filter
Your app still has a .eq('user_id', session.user.id) filter in the ticket query. This is now redundant because RLS enforces the same rule at the database level. Removing it proves that RLS is doing the work.
- In VS Code, open src/App.jsx and find the fetchTickets function.
- Find this line inside the function:
.eq('user_id', session.user.id)
- Delete that line entirely. Your query should now look like this:
async function fetchTickets() {
const { data, error } = await supabase
.from('tickets')
.select()
.order('created_at', { ascending: false })
if (error) {
console.error(error)
return
}
setTickets(data)
}
Why remove the filter?
Client-side filters are cosmetic. They hide data in the UI, but the data is still sent over the network. With RLS, the database itself never returns rows the user should not see. The query can simply ask for all tickets, and the database returns only the ones that belong to the current user.
- Save src/App.jsx.
- Switch to your browser. The ticket list should still show only your tickets, exactly as before.
Nothing visible changed. That is the point. The security moved from your fragile JavaScript to the database itself.
Ticket list is empty after removing the filter?
Make sure you are still signed in. If your session expired, sign in again. RLS only returns rows for authenticated users whose auth.uid() matches the user_id on the ticket.
help me debug why my ticket list is empty after removing the client-side filter and enabling RLS
Before you move on, let's prove RLS is actually blocking unauthorized access.
- Switch back to the Supabase dashboard SQL Editor.
- Find the Role dropdown at the top of the editor. Change it from postgres to anon.
What do you think will happen when you query the tickets table as an unauthenticated role?
- Paste and run this query:
select * from tickets;
You see zero rows returned, even though tickets exist in the table. The anon role has no policy granting access, so RLS blocks everything. This confirms your data is protected at the database level.
Why does anon see nothing?
Your SELECT policy targets TO authenticated only. The anon role represents someone who is not signed in. Since there is no policy granting anon access, Postgres returns an empty result set.
- Change the Role dropdown back to postgres before continuing (the postgres role bypasses RLS for admin tasks).
✔️ Awesome, I've got everything!
Great. Double check you have saved src/App.jsx with the .eq() filter removed.
ⓧ I'd like to double check the full code
Your fetchTickets function in src/App.jsx should look exactly like this:
async function fetchTickets() {
const { data, error } = await supabase
.from('tickets')
.select()
.order('created_at', { ascending: false })
if (error) {
console.error(error)
return
}
setTickets(data)
}
Everything else in App.jsx stays the same. The only change was deleting the .eq('user_id', session.user.id) line.
Your database is now locked down. Users can only see and create their own tickets, enforced by Postgres itself. Next up, you will add a responses table so users can reply to tickets and hold a conversation thread.
Add Ticket Responses
Your database is now secure. Row Level Security ensures users only see their own tickets, even if someone tries to bypass your frontend.
A helpdesk system is not complete without the ability to respond to tickets. In this step, you will add a second table with a foreign key relationship and build a detail view to display conversation threads.
In this step, get ready to:
- Create a responses table linked to tickets with a foreign key.
- Build a ticket detail view that shows full ticket information and a list of responses.
- Add a response form so users can reply to their tickets.
Create the responses table
Each response belongs to a specific ticket. A foreign key is a column that points to the primary key of another table, creating a parent-child relationship between tickets and responses.
- In the Supabase dashboard, open the SQL Editor from the left sidebar.
- Create the responses table and secure it with RLS by running this SQL:
create table responses (
id bigint primary key generated always as identity,
created_at timestamptz default now(),
ticket_id bigint references tickets(id),
content text,
user_id uuid
);
alter table responses enable row level security;
create policy "Users can view own responses"
on responses for select
to authenticated
using (auth.uid() = user_id);
create policy "Users can create responses"
on responses for insert
to authenticated
with check (auth.uid() = user_id);
What does this SQL do?
The ticket_id bigint references tickets(id) line creates the foreign key. Every response must point to an existing ticket.
The RLS policies follow the same pattern as your tickets table. Only authenticated users can read and create responses, and only for rows where auth.uid() matches the user_id column.
- Click Run in the SQL Editor.
- Open the Table Editor in the left sidebar. You should see both tickets and responses listed.
Don't see the responses table?
Check the SQL Editor output for error messages. The most common cause is a typo in references tickets(id). Make sure your tickets table exists and has an id column.
help me debug my responses table SQL
Extract components and build the detail view
Your current App.jsx has the ticket form and list built directly inside it. To add the detail view cleanly, you will first extract the form and list into their own component files.
- Create a new file src/TicketForm.jsx in VS Code. Add the component logic by pasting this code:
import { useState } from 'react'
import { supabase } from './supabaseClient'
function TicketForm({ session, onTicketCreated }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
// Handle form submission to create a new ticket
async function handleSubmit(e) {
e.preventDefault()
const { error } = await supabase
.from('tickets')
.insert({ title, description, user_id: session.user.id })
if (error) {
console.error(error)
return
}
// Clear the form and notify the parent to refresh
setTitle('')
setDescription('')
onTicketCreated()
}
- Below the handleSubmit function, add the form JSX and export:
return (
<form onSubmit={handleSubmit} style={{ marginBottom: '20px' }}>
<h2>Submit a Ticket</h2>
<div style={{ marginBottom: '10px' }}>
<input
type="text"
placeholder="Ticket title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
style={{ width: '100%', padding: '8px' }}
/>
</div>
<div style={{ marginBottom: '10px' }}>
<textarea
placeholder="Describe your issue..."
value={description}
onChange={(e) => setDescription(e.target.value)}
required
style={{ width: '100%', padding: '8px', minHeight: '80px' }}
/>
</div>
<button type="submit" style={{ padding: '10px 20px' }}>Submit Ticket</button>
</form>
)
}
export default TicketForm
What does this component do?
This is the same ticket form logic that was previously inside App.jsx. Moving it into its own file keeps each component focused on one responsibility. The onTicketCreated callback tells App to re-fetch the ticket list after submission.
- Save src/TicketForm.jsx.
- Create another new file src/TicketList.jsx and paste the full component:
// Displays the list of tickets as clickable cards
function TicketList({ tickets, onSelectTicket }) {
if (tickets.length === 0) {
return <p>No tickets yet. Submit one above!</p>
}
return (
<div>
<h2>Your Tickets</h2>
{tickets.map((ticket) => (
<div
key={ticket.id}
onClick={() => onSelectTicket(ticket)}
style={{
border: '1px solid #ccc',
padding: '12px',
marginBottom: '8px',
borderRadius: '4px',
cursor: 'pointer',
}}
>
<strong>#{ticket.id}</strong> - {ticket.title}
<br />
<small>{new Date(ticket.created_at).toLocaleDateString()}</small>
</div>
))}
</div>
)
}
export default TicketList
What does this component do?
Each ticket renders as a clickable card. When clicked, onSelectTicket(ticket) passes the full ticket object up to App so it can show the detail view. The cursor: 'pointer' style makes it obvious to users that tickets are clickable.
- Save src/TicketList.jsx.
- Now create src/TicketDetail.jsx. This component fetches and displays responses for a specific ticket. Start with the logic:
import { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'
function TicketDetail({ ticket, session, onBack }) {
const [responses, setResponses] = useState([])
const [newResponse, setNewResponse] = useState('')
// Fetch responses whenever the ticket changes
useEffect(() => {
fetchResponses()
}, [ticket.id])
async function fetchResponses() {
const { data, error } = await supabase
.from('responses')
.select()
.eq('ticket_id', ticket.id)
.order('created_at', { ascending: true })
if (error) {
console.error(error)
return
}
setResponses(data)
}
What does this code do?
The query chain .from('responses').select().eq('ticket_id', ticket.id).order('created_at') fetches only responses belonging to this specific ticket, sorted oldest first so the conversation reads top-to-bottom.
The useEffect with [ticket.id] as a dependency means responses re-fetch automatically if the user navigates to a different ticket.
- Below the fetchResponses function, add the submit handler and the component's JSX:
// Insert a new response into the database
async function handleSubmitResponse(e) {
e.preventDefault()
const { error } = await supabase
.from('responses')
.insert({ ticket_id: ticket.id, content: newResponse, user_id: session.user.id })
if (error) {
console.error(error)
return
}
setNewResponse('')
fetchResponses()
}
- Below the handleSubmitResponse function, add the return statement with the ticket detail view:
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<button onClick={onBack} style={{ marginBottom: '16px' }}>Back to Tickets</button>
<h1>Ticket #{ticket.id}</h1>
<h2>{ticket.title}</h2>
<p>{ticket.description}</p>
<small>Submitted: {new Date(ticket.created_at).toLocaleDateString()}</small>
<hr style={{ margin: '20px 0' }} />
<h3>Responses</h3>
{responses.length === 0 ? (
<p>No responses yet.</p>
) : (
responses.map((response) => (
<div
key={response.id}
style={{
background: '#f5f5f5',
padding: '10px',
marginBottom: '8px',
borderRadius: '4px',
}}
>
<p>{response.content}</p>
<small>{new Date(response.created_at).toLocaleString()}</small>
</div>
))
)}
- Finally, add the response form, close the component, and export it:
<form onSubmit={handleSubmitResponse} style={{ marginTop: '16px' }}>
<textarea
placeholder="Write a response..."
value={newResponse}
onChange={(e) => setNewResponse(e.target.value)}
required
style={{ width: '100%', padding: '8px', minHeight: '60px' }}
/>
<button type="submit" style={{ marginTop: '8px', padding: '8px 16px' }}>
Send Response
</button>
</form>
</div>
)
}
export default TicketDetail
What does the response form do?
When submitted, it inserts a new row into the responses table with the current ticket_id, the typed content, and the logged-in user's user_id.
After insertion, fetchResponses() re-fetches the list so the new response appears immediately without a page refresh.
- Save src/TicketDetail.jsx.
Seeing red underlines in TicketDetail.jsx?
Make sure you have the opening function TicketDetail({ ticket, session, onBack }) { at the top and a matching closing } before export default TicketDetail. Every chunk you added goes inside that function body.
help me fix syntax errors in my TicketDetail component
Update App.jsx to use the new components
Now replace the contents of src/App.jsx with the refactored version that imports your new components and adds a selectedTicket state for navigation.
- Select all the code in src/App.jsx and delete it. Paste the new imports, state declarations, and auth logic:
import { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'
import Auth from './Auth'
import TicketForm from './TicketForm'
import TicketList from './TicketList'
import TicketDetail from './TicketDetail'
function App() {
const [session, setSession] = useState(null)
const [tickets, setTickets] = useState([])
// Track which ticket the user clicked on
const [selectedTicket, setSelectedTicket] = useState(null)
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
if (event === 'SIGNED_OUT') {
setSession(null)
} else if (session) {
setSession(session)
}
}
)
return () => {
subscription.unsubscribe()
}
}, [])
- Below the auth useEffect, add the ticket-fetching logic and sign out handler:
useEffect(() => {
if (session) {
fetchTickets()
}
}, [session])
async function fetchTickets() {
const { data, error } = await supabase
.from('tickets')
.select()
.order('created_at', { ascending: false })
if (error) {
console.error(error)
return
}
setTickets(data)
}
async function handleSignOut() {
const { error } = await supabase.auth.signOut()
if (error) console.error(error)
}
- Finally, add the conditional rendering and main return statement:
// Show login if no session
if (!session) {
return <Auth />
}
// Show detail view if a ticket is selected
if (selectedTicket) {
return (
<TicketDetail
ticket={selectedTicket}
session={session}
onBack={() => setSelectedTicket(null)}
/>
)
}
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1>Helpdesk</h1>
<button onClick={handleSignOut}>Sign Out</button>
</header>
<TicketForm session={session} onTicketCreated={fetchTickets} />
<TicketList tickets={tickets} onSelectTicket={setSelectedTicket} />
</div>
)
}
export default App
How does the navigation work?
The selectedTicket state controls what the user sees. When it is null, App shows the form and list. When a ticket is selected, App renders TicketDetail instead.
The onBack callback sets selectedTicket back to null, returning the user to the main list.
- Save src/App.jsx.
Seeing a blank page or import errors?
Check that your file names match exactly: TicketForm.jsx, TicketList.jsx, and TicketDetail.jsx. File names are case-sensitive. Also confirm each file has an export default at the bottom.
help me fix my component imports
Test the full response flow
Before you run the test, think about what you expect to happen when you click on a ticket. Will you see a detail view, or an error?
- Switch back to your browser at http://localhost:5173.
- Sign in with your test account.
- Click on any ticket in your list.
You should see the detail view with your ticket number, title, description, and a "No responses yet." message.
- Type a test response in the text area and click Send Response.
The response appears in the thread below the ticket information. Submit a second response to confirm the conversation thread grows.
- Click Back to Tickets to return to the main list.
Response not appearing after submit?
Open your browser's developer console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows). Look for error messages. The most common cause is a missing RLS INSERT policy on the responses table.
help me debug why my response insert is failing
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files: App.jsx, TicketForm.jsx, TicketList.jsx, and TicketDetail.jsx.
ⓧ I'd like to double check the full code
Scroll up and confirm you have each of these chunks in order:
- src/TicketForm.jsx: imports, handleSubmit function, form JSX with title input, description textarea, and submit button, followed by export default TicketForm.
- src/TicketList.jsx: function with empty-state check, map over tickets rendering clickable cards with ticket id and title, followed by export default TicketList.
- src/TicketDetail.jsx: imports, state for responses and newResponse, useEffect calling fetchResponses, fetchResponses function, handleSubmitResponse function, return with Back button, ticket info, responses list, and response form, followed by export default TicketDetail.
- src/App.jsx: imports for all components, session/tickets/selectedTicket state, auth useEffect, session useEffect, fetchTickets, handleSignOut, conditional returns for Auth and TicketDetail, main return with header, TicketForm, and TicketList, followed by export default App.
Your helpdesk app now has a complete conversation system. Users can submit tickets, click into them, and add responses to build a thread. Next up, you will tackle the Secret Mission and add status badges with filtering to make this look like a professional support tool.
Secret mission
Add Ticket Status with Colour-Coded Badges
A real helpdesk distinguishes between open, in-progress, and resolved tickets. In this bonus challenge, you will add a status column to your database, build a dropdown to change status, display colour-coded badges in the ticket list, and add filter buttons so users can view tickets by status.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs on the Supabase free tier, so there are no ongoing costs as long as you use your project at least once every 7 days.
Resources you used:
- Supabase project (free tier): contains your tickets and responses tables, Row Level Security policies, and user authentication data.
- Local helpdesk-app folder on your Desktop: contains all your React source code, dependencies, and environment variables.
Keep everything running
No action needed. Choose this if you are still actively building or plan to return within a week.
- The Supabase free tier costs nothing. Your database, auth, and RLS policies remain active.
- Your app is accessible anytime you run npm run dev inside your helpdesk-app folder.
- If you do not use the Supabase project for 7 days, it will automatically pause. You can restore it from the dashboard at any time.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and cloud data stay intact.
- Stop the Vite dev server by pressing Ctrl+C in the terminal where it is running.
- Your Supabase project will automatically pause after 7 days of inactivity. You can also pause it manually from the dashboard by navigating to Settings > General > Project availability and clicking Pause Project.
- To resume later, restore the project from your Supabase dashboard and run npm run dev in your helpdesk-app folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Delete your Supabase project: open the Supabase dashboard, navigate to Settings > General > Delete project. Click Delete Project, enter your project name exactly as it appears to confirm, then click Delete. This permanently removes all data including your tables, auth users, and RLS policies.
- Delete your local project folder. On macOS or Linux, run this command in your terminal:
rm -rf ~/Desktop/helpdesk-app
- On Windows, run this command in PowerShell:
Remove-Item -Recurse -Force ~\Desktop\helpdesk-app
Nice Work!
Nice Work!
Nice work! You just built a fully functional helpdesk ticket app with user authentication, a real cloud database, and row-level security.
You've learned how to:
- Build a complete React app from scratch using Vite, with user authentication (sign-up, sign-in, sign-out) and CRUD operations against a real cloud database.
- Secure your database with Row Level Security so users can only ever access their own data, enforced at the database level rather than in client code.
- Design a relational database with two linked tables (tickets and responses) using foreign keys, complete with auto-incrementing ticket numbers and a conversation thread.
- Secret Mission: Add a ticket status system with colour-coded badges (Open, In Progress, Resolved) and filtering.
Ready to quiz yourself?