Build a Full-Stack Volleyball Logger
Build a React and Supabase app that saves volleyball match results.
Introduction
30 Second Summary
Volleyball results often begin as hurried notes beside the court. By the next match day, those scores can be surprisingly hard to find.
In this project, you will build VolleyTrack, a full-stack volleyball match logger with Next.js, React, and Supabase. Each completed result remains in a relational database after you refresh the browser.
What You'll Build
Picture opening VolleyTrack after a busy match day to find a clean card for every saved result.
By the end of this project, you'll have:
- An interactive match form with clear feedback for invalid results and database errors.
- A persistent match history that returns after a browser refresh.
- Responsive result cards that keep winners and scores easy to scan on wide or narrow screens.
- Secret Mission: Add a team-name filter that quickly finds matching results in a longer history.
Are there any prerequisites?
You'll need basic JavaScript knowledge plus a free Supabase account. Step 1 verifies Node.js inside Visual Studio Code on Windows.
Before We Start
Before any hands-on work, this checkpoint locks in what you are building and the proof that matters. VolleyTrack is a full-stack volleyball match logger where a saved match stays in the history after a browser refresh.
Set Up the Local Next.js App
VolleyTrack needs a working local stack before it can record volleyball results. The pinned Next.js release depends on a compatible Node.js runtime.
You will verify that runtime in a Windows PowerShell terminal inside Visual Studio Code. You will then assemble the smallest page that proves your local stack works.
In this step, get ready to:
- Verify that Node.js meets the minimum version requirement.
- Create the local project structure with pinned dependencies.
- Run the Next.js development server to display the VolleyTrack heading.
Check Node.js in PowerShell
Next.js 16.3.4 requires Node.js 20.9.0 or newer. Verifying the runtime now prevents an incompatible installation.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
- Open the integrated terminal from the top menu.
- Set the terminal profile to PowerShell if another shell is active.
- Check the installed Node.js version by running this command:
node --version
What does this command do?
The --version option prints the installed Node.js version. The result tells you whether the pinned Next.js release can run on this computer.
✔️ I see version 20.9.0 or higher
Your runtime meets the requirement. Node.js is ready for VolleyTrack.
ⓧ I see an older version
The installed version is below the minimum required by Next.js. Upgrade Node.js before creating the project.
- Open the official Node.js download page in your browser.
- Download the current Node.js LTS release for Windows.
- Run the downloaded installer.
- Follow the installer prompts using the default selections.
- Close Visual Studio Code after the installation finishes.
- Reopen Visual Studio Code through Windows search.
- Repeat the version check in a new PowerShell terminal.
Continue when the result is 20.9.0 or higher.
ⓧ Command not found
PowerShell cannot find Node.js yet. Installing the current LTS release adds the runtime required by this project.
- Open the official Node.js download page in your browser.
- Download the current Node.js LTS release for Windows.
- Run the downloaded installer.
- Follow the installer prompts using the default selections.
- Close Visual Studio Code after the installation finishes.
- Reopen Visual Studio Code through Windows search.
- Repeat the version check in a new PowerShell terminal.
Continue when PowerShell prints version 20.9.0 or higher.
Still seeing the wrong version?
Close every Visual Studio Code window after installing Node.js. An older terminal can retain the previous environment configuration.
Use help me verify the Node.js version in PowerShell if the version check still fails.
Create the project structure
A manually written package.json keeps the starter project focused. It also makes every dependency version visible from the beginning.
Why write the manifest by hand?
A project generator can add starter files that VolleyTrack does not need. A small manifest keeps the first working page easy to understand.
- Move the PowerShell terminal to your Desktop by running this command:
Set-Location ([Environment]::GetFolderPath('Desktop'))
Why start on the Desktop?
PowerShell creates new folders inside its current location. Resolving the Windows Desktop path gives the volleytrack folder a predictable home.
- Create the VolleyTrack directory structure by running these commands:
New-Item -ItemType Directory -Path volleytrack
Set-Location volleytrack
New-Item -ItemType Directory -Path app, lib, supabase
What do these commands create?
- The first command creates the volleytrack folder on your Desktop.
- The second command moves PowerShell into volleytrack.
- The final command creates the app directory.
- It also creates the lib directory.
- It also creates the supabase directory.
PowerShell lists the new directories after creating them. Your terminal prompt now ends with the volleytrack folder name.
Already have a volleytrack folder?
An existing folder with the same name can interrupt the first command. Rename that folder if it contains work you want to keep.
Use help me resolve the PowerShell folder error if the directory structure still cannot be created.
- Use the folder picker in Visual Studio Code to load a local folder.
- Choose the volleytrack folder from your Desktop.
- Approve the workspace trust prompt if Visual Studio Code displays one.
- Confirm that the Explorer sidebar lists app.
- Confirm that the Explorer sidebar lists lib.
- Confirm that the Explorer sidebar lists supabase.
- Create package.json directly inside volleytrack using the Explorer sidebar.
- Paste the exact project manifest below into package.json:
{
"name": "volleytrack",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@supabase/supabase-js": "2.114.0",
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8"
}
}
What does this file control?
The JSON manifest defines the commands available to npm. Its dependency list pins the package versions used by VolleyTrack.
- The scripts section includes the development command used later in this step.
- Next.js is pinned to 16.3.4.
- React is pinned to 19.2.8.
- React DOM is pinned to 19.2.8.
- The Supabase JavaScript client is pinned to 2.114.0.
- Save package.json.
- Confirm that package.json appears directly inside volleytrack in the Explorer sidebar.
Seeing a JSON warning?
Compare every quote with the reference. Check each comma before saving the file again.
Ask why my package.json is invalid if the warning remains.
- Create .gitignore directly inside volleytrack using the Explorer sidebar.
- Paste the exact ignore rules below into .gitignore:
node_modules
.next
.env*
!.env.example
What do these rules protect?
The first two patterns keep installed packages and generated Next.js output out of Git. Both can be recreated locally.
The environment patterns exclude local configuration files. The exception allows a safe example file to be tracked later.
- Save .gitignore.
- Confirm that .gitignore appears directly inside volleytrack in the Explorer sidebar.
Missing the .gitignore file?
Check that the filename begins with a period. Confirm that Visual Studio Code did not add another file extension.
Use help me check the .gitignore filename if the file still does not appear.
The first dependency install can take a few minutes while npm downloads the pinned packages. A busy terminal during this download is expected.
- Return to the PowerShell terminal from earlier.
- Install the pinned dependencies by running this command:
npm install
What does npm install do?
npm reads the dependencies from package.json. It downloads those packages into the local node_modules folder.
- Wait for PowerShell to return to the command prompt.
- Confirm that node_modules appears inside volleytrack in the Explorer sidebar.
That installation is complete. VolleyTrack now has the pinned packages needed to start its local server.
Did the installation fail?
- Confirm that the terminal prompt ends with volleytrack.
- Check package.json for a missing quote.
- Check package.json for a missing comma.
- Confirm that your Node.js version is 20.9.0 or higher.
Ask why npm install failed in VolleyTrack using the terminal output you received.
Build and run the starter page
Next.js uses a root layout to wrap every page. The starter page stays small so its heading becomes an unmistakable success signal.
- Create app/globals.css inside the existing app directory using the Explorer sidebar.
You can now see app/globals.css inside the app directory.
- Confirm that app/globals.css contains no text.
- Save app/globals.css.
- Create app/layout.js inside the existing app directory using the Explorer sidebar.
- Paste the root layout below into app/layout.js:
import "./globals.css";
export const metadata = {
title: "VolleyTrack",
description: "A full-stack volleyball match logger",
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
What does the root layout do?
- The stylesheet import makes app/globals.css available across the app.
- The metadata object supplies the browser title.
- The metadata also supplies the page description.
- The RootLayout component wraps each page with the required document elements.
- Save app/layout.js.
- Confirm that app/layout.js appears beside app/globals.css in the Explorer sidebar.
Seeing a layout warning?
Confirm that app/globals.css exists beside app/layout.js. Compare every bracket with the reference.
Ask why my root layout has an error if the editor still reports a problem.
- Create app/page.js inside the existing app directory using the Explorer sidebar.
- Paste the starter page below into app/page.js:
export default function Home() {
return (
<main>
<h1>VolleyTrack</h1>
</main>
);
}
What does the starter page do?
The Home component returns the content for the main route. Its heading gives you a clear browser check that Next.js loaded the correct file.
- Save app/page.js.
- Confirm that app/page.js appears beside the other files in the app directory.
Seeing a page syntax warning?
Check that every opening JSX tag has a matching closing tag. Confirm that the filename is page.js.
Ask why my starter page has a JSX error if the warning remains.
Use this comparison to check the complete starter project before running it.
✔️ Awesome, I've got everything!
Great. Confirm that every project file is saved before starting the development server.
ⓧ I'd like to double check the full code
{
"name": "volleytrack",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@supabase/supabase-js": "2.114.0",
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8"
}
}
What should match in package.json?
Compare every script with your file. Compare each dependency version with the reference.
node_modules
.next
.env*
!.env.example
What should match in .gitignore?
Compare all four patterns with your file. These rules exclude generated files and local environment values.
The complete app/globals.css file is empty. Confirm that it contains no text.
import "./globals.css";
export const metadata = {
title: "VolleyTrack",
description: "A full-stack volleyball match logger",
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
What should match in the layout?
Compare the stylesheet import with your file. Confirm that the metadata and document elements match the reference.
export default function Home() {
return (
<main>
<h1>VolleyTrack</h1>
</main>
);
}
What should match on the page?
Compare the Home component with your file. Confirm that the heading reads VolleyTrack.
The first compilation can take a little longer while Next.js prepares the app. Keep this terminal running because the development server uses it continuously.
Before you run this, what heading do you expect the browser to show?
- Start the development server by running this command:
npm run dev
What does this command start?
npm runs the dev script from package.json. That script starts the Next.js development server.
The terminal remains occupied while the server is running. Leave it open while you view the app.
- Press the Windows key to open Windows search.
- Type the name of your web browser into the search field.
- Press Enter to open the browser.
- Enter http://localhost:3000 into the address bar.
You will see the heading VolleyTrack on the page. This proves that the pinned local stack can compile and serve your starter app.
Is the local page missing?
- Confirm that the development server is still running in the PowerShell terminal.
- Check that the terminal prompt was inside volleytrack when you started the server.
- Confirm that app/page.js contains the VolleyTrack heading from the reference.
Ask why my local VolleyTrack page is not loading using the terminal output you received.
Nice work. Your browser now proves that VolleyTrack can run through the pinned local stack.
Next, you will turn this static heading into an interactive match logger. You will also uncover why browser memory cannot preserve match history.
Log a Match in React Memory
Your local Next.js page now runs in the browser with the VolleyTrack heading.
A static heading cannot capture a volleyball result. In this step, React state turns the page into a match logger that can validate submitted scores.
In this step, get ready to:
- Create state for the match form.
- Validate each submitted result.
- Prove that an in-memory match disappears after refresh.
Turn the page into a client component
The useState Hook gives the component a place to hold each form value. The winnerFor() helper compares the two set totals whenever a match needs a winner.
- Select app/page.js from the file tree in Visual Studio Code.
- Replace the current contents of app/page.js with this client component:
"use client";
import { useState } from "react";
function winnerFor(match) {
return match.team_a_sets > match.team_b_sets
? match.team_a
: match.team_b;
}
export default function Home() {
const [teamA, setTeamA] = useState("");
const [teamB, setTeamB] = useState("");
const [teamASets, setTeamASets] = useState("0");
const [teamBSets, setTeamBSets] = useState("0");
const [matches, setMatches] = useState([]);
const [message, setMessage] = useState("");
return <main><h1>VolleyTrack</h1></main>;
}
What does this code do?
- The "use client" directive allows this component to use state in the browser.
- Each useState call stores one editable value or one piece of interface feedback.
- The matches array holds submitted results for the current browser session.
- The winnerFor() helper returns the team with the larger set total.
- Save app/page.js.
- Return to http://localhost:3000 in your browser.
You should still see the VolleyTrack heading with no compilation error. Your page now runs as an interactive client component.
Seeing a compilation error?
- Check that "use client" is the first line in app/page.js.
- Check that useState is imported from react.
- Compare the brackets around Home with the code above.
Help me fix the client component in app/page.js.
Validate and store a submitted result
A controlled form sends its current state into one submit handler. That handler cleans each value before deciding whether the match is complete.
- In app/page.js, locate the message state declaration.
- Add this function beneath the state declarations:
function handleSubmit(event) {
event.preventDefault();
setMessage("");
const cleanTeamA = teamA.trim();
const cleanTeamB = teamB.trim();
const scoreA = Number(teamASets);
const scoreB = Number(teamBSets);
if (!cleanTeamA || !cleanTeamB) {
setMessage("Enter both team names.");
return;
}
if (cleanTeamA.toLowerCase() === cleanTeamB.toLowerCase()) {
setMessage("Choose two different teams.");
return;
}
if (!Number.isInteger(scoreA) || !Number.isInteger(scoreB) || scoreA < 0 || scoreB < 0) {
setMessage("Set totals must be whole numbers of zero or more.");
return;
}
if (scoreA === scoreB) {
setMessage("A completed match needs a winner.");
return;
}
}
How does validation protect the result?
- The two trim() calls remove whitespace around the team names.
- The lowercase comparison rejects duplicate team names with different capitalization.
- The integer checks reject negative set totals or decimal values.
- The final check rejects ties because a completed volleyball match needs a winner.
- Save app/page.js.
- Return to the VolleyTrack page in your browser.
You should see the heading without a compilation error. This confirms that the validation handler is part of the component.
A valid result needs to become a match object. Prepending that object keeps the newest result at the start of the local array.
- In handleSubmit, locate the closing brace beneath the tied-score check.
- Add this state update before handleSubmit closes:
setMatches((currentMatches) => [
{
id: Date.now(),
team_a: cleanTeamA,
team_b: cleanTeamB,
team_a_sets: scoreA,
team_b_sets: scoreB,
},
...currentMatches,
]);
What does this state update do?
- The callback receives the current matches array.
- The new object stores the cleaned names plus the numeric set totals.
- The spread syntax preserves every earlier match after the new result.
- The Date.now() value gives the local match a unique identifier.
- Save app/page.js.
- Return to the running page at http://localhost:3000.
You should see VolleyTrack without a compilation error. The component can now reject invalid results or prepend a valid match to local state.
Does the page stop compiling?
- Check that setMatches remains inside handleSubmit.
- Check that the new match object ends before ...currentMatches.
- Check every closing bracket against the full file below.
Help me debug the match submission handler.
Use this comparison to confirm that every state variable plus the complete handler is in the right place.
✔️ Awesome, I've got everything!
Great. Double-check that you saved app/page.js before testing the local match flow.
ⓧ I'd like to double check the full code
"use client";
import { useState } from "react";
function winnerFor(match) {
return match.team_a_sets > match.team_b_sets
? match.team_a
: match.team_b;
}
export default function Home() {
const [teamA, setTeamA] = useState("");
const [teamB, setTeamB] = useState("");
const [teamASets, setTeamASets] = useState("0");
const [teamBSets, setTeamBSets] = useState("0");
const [matches, setMatches] = useState([]);
const [message, setMessage] = useState("");
function handleSubmit(event) {
event.preventDefault();
setMessage("");
const cleanTeamA = teamA.trim();
const cleanTeamB = teamB.trim();
const scoreA = Number(teamASets);
const scoreB = Number(teamBSets);
if (!cleanTeamA || !cleanTeamB) {
setMessage("Enter both team names.");
return;
}
if (cleanTeamA.toLowerCase() === cleanTeamB.toLowerCase()) {
setMessage("Choose two different teams.");
return;
}
if (!Number.isInteger(scoreA) || !Number.isInteger(scoreB) || scoreA < 0 || scoreB < 0) {
setMessage("Set totals must be whole numbers of zero or more.");
return;
}
if (scoreA === scoreB) {
setMessage("A completed match needs a winner.");
return;
}
setMatches((currentMatches) => [
{
id: Date.now(),
team_a: cleanTeamA,
team_b: cleanTeamB,
team_a_sets: scoreA,
team_b_sets: scoreB,
},
...currentMatches,
]);
}
return <main><h1>VolleyTrack</h1></main>;
}
Compare this reference with your saved app/page.js. Every identifier must match exactly.
Prove that memory resets on refresh
The fastest way to understand local state is to test its lifetime. First you will create a valid match during the current browser session.
Before you submit, predict which team the larger set total makes the winner.
- Enter Falcons in the Team A field.
- Enter 3 in the first Sets won field.
- Enter Sharks in the Team B field.
- Enter 1 in the second Sets won field.
- Click Save match.
You should see a match card showing Falcons as the winner with a 3 - 1 score. That result now lives in the page's local match history.
Before you refresh, predict whether the card can survive when its only home is React state.
- Refresh the browser by pressing Ctrl+R.
The match card disappears after the refresh. This is the intended persistence failure because the browser discarded the old in-memory state when it rebuilt the page.
You have proved the limitation of local state with a result you could see disappear. Next up, you will give VolleyTrack a Supabase database that keeps each match beyond the current page session.
Give the Match Logger a Database
Your React logger can validate a completed match and display its winner. A browser refresh then clears the result because the match exists only in memory.
VolleyTrack now needs a relational database that keeps each match after the page reloads. You will create that database with Supabase and protect its public access with Row Level Security.
In this step, get ready to:
- Create a Free plan Supabase project for VolleyTrack.
- Create the relational match table with explicit permissions.
- Copy the browser connection values for the next step.
Why Supabase for this project?
Supabase gives VolleyTrack a Postgres database where every match follows the same row structure. This makes the relationship between columns and completed results visible.
Cloud Firestore organizes data as documents inside collections. A relational table prepares VolleyTrack for future links between teams, players, sets, and tournaments.
Create the Supabase project
A Supabase project gives VolleyTrack its own hosted database. The project starts empty so you can define the exact structure that match records need.
The setup form may ask for a database password. A password manager keeps that private value out of your project files.
- Visit the Supabase Dashboard in your browser.
- Sign in with your Supabase account.
- Choose an organization that uses the Free plan.
- Start creating a new project from the organization Dashboard.
- Enter VolleyTrack as the project name.
- Choose a region close to you if the setup form asks for one.
- Enter a unique database password if the setup form asks for one.
- Store the database password in your password manager.
The Free plan starts at $0 per month. The sample match records in this project stay within that plan.
- Select the Free plan for the project.
- Submit the project creation form.
Database launch can take a few minutes. This wait is Supabase preparing the hosted Postgres services for your project.
- Wait until the Dashboard shows that the VolleyTrack project is running.
Good progress. Your hosted database is running and ready to receive the match schema.
Create the matches table
A SQL schema defines the columns that every saved match must contain. It also places the same validation rules from your React form at the database boundary.
- Switch back to Visual Studio Code from earlier.
- Select the supabase folder in the file tree.
- Create schema.sql inside the supabase folder.
- Paste the following SQL into supabase/schema.sql:
create table public.matches (
id bigint primary key generated always as identity,
team_a text not null,
team_b text not null,
team_a_sets integer not null check (team_a_sets >= 0),
team_b_sets integer not null check (team_b_sets >= 0),
played_at timestamptz not null default now(),
constraint different_team_names check (lower(team_a) <> lower(team_b)),
constraint completed_match_has_winner check (team_a_sets <> team_b_sets)
);
alter table public.matches enable row level security;
revoke all on table public.matches from anon, authenticated;
grant select, insert on table public.matches to anon;
create policy "Anyone can read matches"
on public.matches for select
to anon
using (true);
create policy "Anyone can add matches"
on public.matches for insert
to anon
with check (true);
What does this schema do?
- The public.matches table stores one completed result per row.
- The two nonnegative checks reject set totals below zero.
- The different_team_names constraint rejects matching team names.
- The completed_match_has_winner constraint rejects tied results.
- The revoke all statement clears the default client permissions.
- The grant select, insert statement gives anon only the operations this tutorial needs.
- The read policy permits anonymous clients to fetch match rows.
- The insert policy permits anonymous clients to add sample match rows.
- Save supabase/schema.sql.
- Return to the VolleyTrack project in the Supabase Dashboard.
- Open SQL Editor.
- Paste the contents of supabase/schema.sql into the editor.
- Click Run.
The SQL Editor confirms that the query completed. Supabase has accepted the table, constraints, grants, and policies.
- Open Table Editor from the Supabase Dashboard.
- Select the matches table under the public schema.
You will see an empty matches table with six columns. The empty table is ready for the first persistent result.
Table missing or query failed?
- Check that every line from supabase/schema.sql reached the SQL Editor.
- Check the SQL Editor result for the statement that failed.
- If the table already exists, avoid running the creation script a second time.
Help me fix the VolleyTrack schema in Supabase.
✔️ Awesome, I've got everything!
Your saved schema defines the complete database state for this step.
ⓧ I'd like to double check the full code
create table public.matches (
id bigint primary key generated always as identity,
team_a text not null,
team_b text not null,
team_a_sets integer not null check (team_a_sets >= 0),
team_b_sets integer not null check (team_b_sets >= 0),
played_at timestamptz not null default now(),
constraint different_team_names check (lower(team_a) <> lower(team_b)),
constraint completed_match_has_winner check (team_a_sets <> team_b_sets)
);
alter table public.matches enable row level security;
revoke all on table public.matches from anon, authenticated;
grant select, insert on table public.matches to anon;
create policy "Anyone can read matches"
on public.matches for select
to anon
using (true);
create policy "Anyone can add matches"
on public.matches for insert
to anon
with check (true);
Copy the browser connection values
Your Next.js app needs a project URL to find this database. It also needs a browser-safe key so Supabase can apply the permissions you just created.
Keep secret keys out of browser code
The publishable key is designed for browser code when Row Level Security controls access. A secret key bypasses Row Level Security.
Copy only the publishable key for VolleyTrack. Never place a secret key in client-side code.
- Return to the VolleyTrack project overview.
- Open the Connect dialog.
- Copy the project URL into a private temporary note.
- Copy the publishable key into the same private temporary note.
- Confirm that the note contains no secret key.
You now have the two browser connection values ready for the next step. Keep the note private and avoid including its contents in screenshots.
Before you check the final result, which six columns do you expect the schema to have created?
- Close the Connect dialog.
- Open Table Editor.
- Select the matches table under the public schema.
You will see id, team_a, team_b, team_a_sets, team_b_sets, and played_at. This confirms that the persistent data layer is ready.
That is the database foundation complete. Next, you will connect the React logger to Supabase so a saved match survives the browser refresh.
Persist and Reload Match History
Your Supabase table is ready to store volleyball results. Your React logger still keeps its displayed records in browser memory.
In this step, you will connect the existing Next.js client component to the database. Environment variables will provide the browser-safe connection settings.
In this step, get ready to:
- Configure a browser-safe Supabase client.
- Load saved matches into React state.
- Persist a submitted match across a browser refresh.
Configure the browser client
The browser client needs your project URL plus its publishable key. The publishable key is designed for browser code.
You are working with connection details here. Keep every secret key out of the project because secret keys bypass Row Level Security.
- Switch back to the volleytrack project in Visual Studio Code from earlier.
- Create .env.local inside the volleytrack folder using the project file tree.
- Populate .env.local by copying this configuration:
NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key
Why use these variable names?
- The NEXT_PUBLIC_ prefix makes each value available to browser code in Next.js.
- The project URL tells the client which Supabase project contains matches.
- The publishable key lets the client use the permissions granted to anon.
- Replace your-supabase-project-url with the project URL copied from Connect.
- Replace your-supabase-publishable-key with the publishable key copied from Connect.
- Save .env.local.
Your values differ from the template below. Compare the variable names while keeping your own project URL plus publishable key.
✔️ Awesome, I've got everything!
Your .env.local file now contains both browser-safe connection values.
ⓧ I'd like to double check the full code
NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key
What should you compare?
Check both variable names character by character. Your file should contain your copied values in place of the two template placeholders.
- Create lib/supabase.js inside the existing lib folder using the project file tree.
- Initialize the browser client by copying this code into lib/supabase.js:
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
);
What does this client do?
- The createClient import provides the Supabase client constructor.
- The exported supabase object gives app/page.js one reusable database connection.
- The environment variables keep project-specific connection values outside the JavaScript file.
- Save lib/supabase.js.
- Confirm that the project file tree lists .env.local plus lib/supabase.js.
✔️ Awesome, I've got everything!
Your reusable Supabase browser client is ready for the match logger.
ⓧ I'd like to double check the full code
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
);
What should you compare?
Check the import path plus both environment variable names. A typo in any of these values prevents the browser client from connecting.
- Return to the PowerShell terminal from earlier.
- Stop the running development process by pressing Ctrl+C.
- Restart the development server so Next.js loads .env.local by running:
npm run dev
Why restart the server?
Next.js loads environment files when the development server starts. Restarting makes both Supabase values available to the client code.
- Return to http://localhost:3000 in your browser.
You should see the existing VolleyTrack form. This confirms that the restarted local app can still render before the database logic is added.
Development server not starting?
- Confirm that the terminal is inside the volleytrack folder.
- Check that .env.local contains no spaces around either equals sign.
- Confirm that the publishable key came from the VolleyTrack project.
Help me debug the browser client setup.
Load saved matches into React
The page needs to request saved rows when the client component starts. A React Effect handles that asynchronous read after the first render.
- In app/page.js, replace the opening code through the message state declaration with this code:
"use client";
import { useEffect, useState } from "react";
import { supabase } from "../lib/supabase";
function winnerFor(match) {
return match.team_a_sets > match.team_b_sets
? match.team_a
: match.team_b;
}
export default function Home() {
const [teamA, setTeamA] = useState("");
const [teamB, setTeamB] = useState("");
const [teamASets, setTeamASets] = useState("0");
const [teamBSets, setTeamBSets] = useState("0");
const [matches, setMatches] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
What changes at the top of the page?
- The useEffect import lets the component run its first database read.
- The supabase import connects this page to the reusable browser client.
- The loading state tracks the initial read.
- The saving state tracks an active insert.
- Save app/page.js.
- Return to the browser to confirm that the VolleyTrack form still renders.
The page now imports the database client without breaking the existing form. That confirms the connection module is available to React.
- Add the loading Effect immediately before handleSubmit in app/page.js:
useEffect(() => {
let ignore = false;
async function loadMatches() {
setLoading(true);
const { data, error } = await supabase
.from("matches")
.select("*")
.order("played_at", { ascending: false });
if (ignore) return;
if (error) {
setMessage(`Could not load matches: ${error.message}`);
} else {
setMatches(data ?? []);
}
setLoading(false);
}
loadMatches();
return () => {
ignore = true;
};
}, []);
How does the database read work?
- The Effect runs loadMatches() when the client component starts.
- The query selects every column from matches.
- The played_at ordering places the newest result first.
- The cleanup guard prevents a stale asynchronous response from updating state after cleanup.
- Inside the history section of app/page.js, place these feedback states immediately before the match grid:
{loading && <p className="empty-state">Loading matches...</p>}
{!loading && matches.length === 0 && (
<p className="empty-state">No matches yet. Log the first result.</p>
)}
What will the learner see?
The loading message covers the active database request. The empty message appears after a successful read returns no rows.
Before you check the page, which feedback state do you expect from your new empty table?
- Save app/page.js.
- Refresh http://localhost:3000.
You should briefly see Loading matches.... You should then see No matches yet. Log the first result..
Matches not loading?
- Check that lib/supabase.js imports from @supabase/supabase-js.
- Confirm that NEXT_PUBLIC_SUPABASE_URL points to the VolleyTrack project.
- Confirm that the Anyone can read matches policy remains enabled.
Help me debug the initial match query.
Prove match persistence
The initial read can now rebuild match history from database rows. The submit handler must write each validated result to the same table.
- Update the handleSubmit declaration so it matches this asynchronous version:
async function handleSubmit(event) {
Why make the handler asynchronous?
The handler waits for Supabase to return the inserted row. That returned row includes the database-generated ID plus timestamp.
- Replace the local match creation at the end of handleSubmit with this database insert:
setSaving(true);
const { data, error } = await supabase
.from("matches")
.insert({
team_a: cleanTeamA,
team_b: cleanTeamB,
team_a_sets: scoreA,
team_b_sets: scoreB,
})
.select()
.single();
if (error) {
setMessage(`Could not save match: ${error.message}`);
setSaving(false);
return;
}
setMatches((currentMatches) => [data, ...currentMatches]);
setTeamA("");
setTeamB("");
setTeamASets("0");
setTeamBSets("0");
setMessage("Saved to Supabase.");
setSaving(false);
How does the database write work?
- The insert sends the cleaned team names plus numeric set totals to matches.
- The chained select() requests the inserted row.
- The single() modifier returns that row as one object.
- The successful row is prepended to the current history without another database request.
- Save app/page.js.
- Enter Falcons in the Team A field.
- Enter 3 in the first Sets won field.
- Enter Sharks in the Team B field.
- Enter 1 in the second Sets won field.
- Select the Save match button.
You should see a new Falcons result card at the top of the match history. The page should also show Saved to Supabase..
- Update the submit button in app/page.js so it reflects the active save:
<button type="submit" disabled={saving}>
{saving ? "Saving..." : "Save match"}
</button>
What does the saving state change?
The button becomes disabled during the insert. Its label tells you that the database request is still active.
- Replace the existing message output with this status block:
{message && (
<p
className={
message === "Saved to Supabase."
? "status success"
: "status error"
}
>
{message}
</p>
)}
How is feedback classified?
A successful insert receives the status success classes. Validation failures plus database failures receive the status error classes.
- Save app/page.js.
- Compare your complete file with the reference below.
✔️ Awesome, I've got everything!
Your page now loads existing rows plus inserts validated matches through Supabase.
ⓧ I'd like to double check the full code
"use client";
import { useEffect, useState } from "react";
import { supabase } from "../lib/supabase";
function winnerFor(match) {
return match.team_a_sets > match.team_b_sets
? match.team_a
: match.team_b;
}
export default function Home() {
const [teamA, setTeamA] = useState("");
const [teamB, setTeamB] = useState("");
const [teamASets, setTeamASets] = useState("0");
const [teamBSets, setTeamBSets] = useState("0");
const [matches, setMatches] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
let ignore = false;
async function loadMatches() {
setLoading(true);
const { data, error } = await supabase
.from("matches")
.select("*")
.order("played_at", { ascending: false });
if (ignore) return;
if (error) {
setMessage(`Could not load matches: ${error.message}`);
} else {
setMatches(data ?? []);
}
setLoading(false);
}
loadMatches();
return () => {
ignore = true;
};
}, []);
async function handleSubmit(event) {
event.preventDefault();
setMessage("");
const cleanTeamA = teamA.trim();
const cleanTeamB = teamB.trim();
const scoreA = Number(teamASets);
const scoreB = Number(teamBSets);
if (!cleanTeamA || !cleanTeamB) {
setMessage("Enter both team names.");
return;
}
if (cleanTeamA.toLowerCase() === cleanTeamB.toLowerCase()) {
setMessage("Choose two different teams.");
return;
}
if (
!Number.isInteger(scoreA) ||
!Number.isInteger(scoreB) ||
scoreA < 0 ||
scoreB < 0
) {
setMessage("Set totals must be whole numbers of zero or more.");
return;
}
if (scoreA === scoreB) {
setMessage("A completed match needs a winner.");
return;
}
setSaving(true);
const { data, error } = await supabase
.from("matches")
.insert({
team_a: cleanTeamA,
team_b: cleanTeamB,
team_a_sets: scoreA,
team_b_sets: scoreB,
})
.select()
.single();
if (error) {
setMessage(`Could not save match: ${error.message}`);
setSaving(false);
return;
}
setMatches((currentMatches) => [data, ...currentMatches]);
setTeamA("");
setTeamB("");
setTeamASets("0");
setTeamBSets("0");
setMessage("Saved to Supabase.");
setSaving(false);
}
return (
<main className="page-shell">
<section className="hero">
<p className="eyebrow">Full-stack volleyball tracker</p>
<h1>VolleyTrack</h1>
<p>Record completed matches and keep the results after refresh.</p>
</section>
<section className="panel">
<h2>Log a match</h2>
<form onSubmit={handleSubmit} className="match-form">
<label>
Team A
<input
value={teamA}
onChange={(event) => setTeamA(event.target.value)}
placeholder="Falcons"
/>
</label>
<label>
Sets won
<input
type="number"
min="0"
value={teamASets}
onChange={(event) => setTeamASets(event.target.value)}
/>
</label>
<label>
Team B
<input
value={teamB}
onChange={(event) => setTeamB(event.target.value)}
placeholder="Sharks"
/>
</label>
<label>
Sets won
<input
type="number"
min="0"
value={teamBSets}
onChange={(event) => setTeamBSets(event.target.value)}
/>
</label>
<button type="submit" disabled={saving}>
{saving ? "Saving..." : "Save match"}
</button>
</form>
{message && (
<p
className={
message === "Saved to Supabase."
? "status success"
: "status error"
}
>
{message}
</p>
)}
</section>
<section className="history">
<div className="section-heading">
<div>
<p className="eyebrow">Saved in Supabase</p>
<h2>Match history</h2>
</div>
<span className="count">{matches.length} matches</span>
</div>
{loading && <p className="empty-state">Loading matches...</p>}
{!loading && matches.length === 0 && (
<p className="empty-state">No matches yet. Log the first result.</p>
)}
<div className="match-grid">
{matches.map((match) => (
<article className="match-card" key={match.id}>
<span className="winner-badge">
Winner: {winnerFor(match)}
</span>
<div className="scoreline">
<span>{match.team_a}</span>
<strong>
{match.team_a_sets} - {match.team_b_sets}
</strong>
<span>{match.team_b}</span>
</div>
<time dateTime={match.played_at}>
{new Date(match.played_at).toLocaleString()}
</time>
</article>
))}
</div>
</section>
</main>
);
}
What should you compare?
Check the imports plus state declarations first. Then compare the loading Effect plus asynchronous submit handler.
Finish by checking the loading message plus database-backed match cards. Every rendered row should use its database id as the React key.
- Return to the PowerShell terminal from earlier.
- Stop the running development process by pressing Ctrl+C.
- Restart the completed database-backed app by running:
npm run dev
What does this final restart prove?
The restart confirms that the app can rebuild its history from Supabase without relying on the previous browser session. It also reloads the final environment configuration.
- Return to http://localhost:3000.
Before you refresh the page, do you expect the Falcons result to remain in the history?
- Refresh the browser.
You should see the saved Falcons result return from Supabase. The match now survives both a browser refresh plus a development-server restart.
Match not returning after refresh?
- Confirm that the initial read uses select("*") on matches.
- Check that the insert chains select() plus single().
- Confirm that both Anyone can read matches plus Anyone can add matches remain enabled.
Help me find why my match disappears after refresh.
That persistence gap is closed. Next up, you will turn the working database flow into a match-day interface with clearer feedback plus responsive result cards.
Make the Results Match-Day Ready
Your running Next.js app already saves each result to Supabase. A refresh proves the database path works.
A match-day tool also needs immediate feedback. In this step, you'll turn the working React interface into a responsive screen with clear result states.
In this step, get ready to:
- Replace the working page with its final feedback states.
- Apply responsive match-day styling.
- Verify persistence in the finished interface.
Connect feedback to the match flow
The page already contains the database logic. The final interface connects that logic to visible loading, saving, validation, success, empty-history, winner, timestamp, and match-count states.
- In the Explorer sidebar in Visual Studio Code, select app/page.js.
- Replace the entire contents of app/page.js with the full file in the double-check tab below.
✔️ Awesome, I've got everything!
Your page now preserves the Supabase workflow while presenting each application state to the learner.
ⓧ I'd like to double check the full code
"use client";
import { useEffect, useState } from "react";
import { supabase } from "../lib/supabase";
function winnerFor(match) {
return match.team_a_sets > match.team_b_sets
? match.team_a
: match.team_b;
}
export default function Home() {
const [teamA, setTeamA] = useState("");
const [teamB, setTeamB] = useState("");
const [teamASets, setTeamASets] = useState("0");
const [teamBSets, setTeamBSets] = useState("0");
const [matches, setMatches] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
let ignore = false;
async function loadMatches() {
setLoading(true);
const { data, error } = await supabase
.from("matches")
.select("*")
.order("played_at", { ascending: false });
if (ignore) return;
if (error) {
setMessage(`Could not load matches: ${error.message}`);
} else {
setMatches(data ?? []);
}
setLoading(false);
}
loadMatches();
return () => {
ignore = true;
};
}, []);
async function handleSubmit(event) {
event.preventDefault();
setMessage("");
const cleanTeamA = teamA.trim();
const cleanTeamB = teamB.trim();
const scoreA = Number(teamASets);
const scoreB = Number(teamBSets);
if (!cleanTeamA || !cleanTeamB) {
setMessage("Enter both team names.");
return;
}
if (cleanTeamA.toLowerCase() === cleanTeamB.toLowerCase()) {
setMessage("Choose two different teams.");
return;
}
if (
!Number.isInteger(scoreA) ||
!Number.isInteger(scoreB) ||
scoreA < 0 ||
scoreB < 0
) {
setMessage("Set totals must be whole numbers of zero or more.");
return;
}
if (scoreA === scoreB) {
setMessage("A completed match needs a winner.");
return;
}
setSaving(true);
const { data, error } = await supabase
.from("matches")
.insert({
team_a: cleanTeamA,
team_b: cleanTeamB,
team_a_sets: scoreA,
team_b_sets: scoreB,
})
.select()
.single();
if (error) {
setMessage(`Could not save match: ${error.message}`);
setSaving(false);
return;
}
setMatches((currentMatches) => [data, ...currentMatches]);
setTeamA("");
setTeamB("");
setTeamASets("0");
setTeamBSets("0");
setMessage("Saved to Supabase.");
setSaving(false);
}
return (
<main className="page-shell">
<section className="hero">
<p className="eyebrow">Full-stack volleyball tracker</p>
<h1>VolleyTrack</h1>
<p>Record completed matches and keep the results after refresh.</p>
</section>
<section className="panel">
<h2>Log a match</h2>
<form onSubmit={handleSubmit} className="match-form">
<label>
Team A
<input
value={teamA}
onChange={(event) => setTeamA(event.target.value)}
placeholder="Falcons"
/>
</label>
<label>
Sets won
<input
type="number"
min="0"
value={teamASets}
onChange={(event) => setTeamASets(event.target.value)}
/>
</label>
<label>
Team B
<input
value={teamB}
onChange={(event) => setTeamB(event.target.value)}
placeholder="Sharks"
/>
</label>
<label>
Sets won
<input
type="number"
min="0"
value={teamBSets}
onChange={(event) => setTeamBSets(event.target.value)}
/>
</label>
<button type="submit" disabled={saving}>
{saving ? "Saving..." : "Save match"}
</button>
</form>
{message && (
<p
className={
message === "Saved to Supabase."
? "status success"
: "status error"
}
>
{message}
</p>
)}
</section>
<section className="history">
<div className="section-heading">
<div>
<p className="eyebrow">Saved in Supabase</p>
<h2>Match history</h2>
</div>
<span className="count">{matches.length} matches</span>
</div>
{loading && <p className="empty-state">Loading matches...</p>}
{!loading && matches.length === 0 && (
<p className="empty-state">No matches yet. Log the first result.</p>
)}
<div className="match-grid">
{matches.map((match) => (
<article className="match-card" key={match.id}>
<span className="winner-badge">
Winner: {winnerFor(match)}
</span>
<div className="scoreline">
<span>{match.team_a}</span>
<strong>
{match.team_a_sets} - {match.team_b_sets}
</strong>
<span>{match.team_b}</span>
</div>
<time dateTime={match.played_at}>
{new Date(match.played_at).toLocaleString()}
</time>
</article>
))}
</div>
</section>
</main>
);
}
What does this page do?
- The loading state controls the temporary database-loading message.
- The saving state disables the button during an insert.
- The message state displays validation feedback or the save result.
- The winnerFor() helper derives the winning team from each stored score.
- The matches array supplies the count and the result cards.
- Save app/page.js.
- Return to http://localhost:3000 in your browser.
You'll see the VolleyTrack heading above a four-field form. Your saved match history appears below it.
- Enter Falcons in the first Team A field.
- Enter 1 in the first Sets won field.
- Enter Sharks in the Team B field.
- Enter 1 in the second Sets won field.
- Click Save match.
You'll see A completed match needs a winner. The tied result stays out of the database.
Page missing a feedback state?
Confirm that "use client"; remains the first line in app/page.js. Compare the state declarations and returned JSX with the double-check tab.
Ask for help with the first browser-console message you see: Help me debug the missing VolleyTrack feedback state.
Style the match-day interface
The page structure now exposes every important state. The stylesheet turns those states into a layout that stays readable beside the court or on a narrow phone screen.
- In the Explorer sidebar in Visual Studio Code, select app/globals.css.
- Replace the entire contents of app/globals.css with the full file in the double-check tab below.
✔️ Awesome, I've got everything!
Your stylesheet now gives VolleyTrack its responsive match-day layout.
ⓧ I'd like to double check the full code
:root {
color-scheme: light;
font-family: Arial, Helvetica, sans-serif;
background: #eef7f2;
color: #15352b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top right, #d8f3e5 0, transparent 35%),
#eef7f2;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.7;
}
.page-shell {
width: min(960px, calc(100% - 32px));
margin: 0 auto;
padding: 48px 0 72px;
}
.hero {
margin-bottom: 24px;
}
.hero h1,
.panel h2,
.history h2 {
margin: 0;
}
.hero h1 {
font-size: clamp(2.5rem, 8vw, 5rem);
letter-spacing: -0.06em;
}
.hero > p:last-child {
max-width: 600px;
color: #527267;
}
.eyebrow {
margin: 0 0 8px;
color: #087a53;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.panel,
.match-card {
border: 1px solid #c7ddd3;
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 18px 45px rgba(24, 72, 56, 0.08);
}
.panel {
padding: 24px;
}
.match-form {
display: grid;
grid-template-columns: 2fr 1fr 2fr 1fr;
gap: 16px;
margin-top: 20px;
}
.match-form label {
display: grid;
gap: 7px;
color: #36584d;
font-size: 0.85rem;
font-weight: 700;
}
.match-form input {
width: 100%;
border: 1px solid #b8d2c7;
border-radius: 10px;
padding: 11px 12px;
background: #ffffff;
color: #15352b;
}
.match-form input:focus {
border-color: #087a53;
outline: 3px solid rgba(8, 122, 83, 0.15);
}
.match-form button {
grid-column: 1 / -1;
border: 0;
border-radius: 10px;
padding: 12px 18px;
background: #087a53;
color: #ffffff;
font-weight: 800;
}
.status {
margin: 16px 0 0;
border-radius: 10px;
padding: 10px 12px;
}
.success {
background: #ddf7e9;
color: #126442;
}
.error {
background: #ffe7e4;
color: #8a2f26;
}
.history {
margin-top: 36px;
}
.section-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.count {
color: #527267;
font-weight: 700;
}
.match-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.match-card {
padding: 18px;
}
.winner-badge {
display: inline-block;
border-radius: 999px;
padding: 6px 10px;
background: #d8f3e5;
color: #087a53;
font-size: 0.78rem;
font-weight: 800;
}
.scoreline {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 10px;
margin: 22px 0 14px;
text-align: center;
}
.scoreline strong {
font-size: 1.4rem;
}
.match-card time,
.empty-state {
color: #668178;
font-size: 0.88rem;
}
.empty-state {
border: 1px dashed #afc9be;
border-radius: 14px;
padding: 24px;
text-align: center;
}
.team-filter {
width: min(320px, 100%);
border: 1px solid #b8d2c7;
border-radius: 10px;
padding: 10px 12px;
background: #ffffff;
color: #15352b;
}
@media (max-width: 700px) {
.match-form,
.match-grid {
grid-template-columns: 1fr;
}
.section-heading {
align-items: stretch;
flex-direction: column;
}
}
What does this stylesheet do?
- The page shell keeps the interface centered with space around the content.
- The form grid places the four inputs in one compact desktop row.
- The status classes separate successful saves from validation or database errors.
- The match grid turns stored rows into two-column result cards.
- The responsive rule stacks the form and cards below 700px.
- Save app/globals.css.
- Return to VolleyTrack in your browser.
You'll see a green match-day theme with a large heading. The form sits inside a raised white panel.
- Change the second Sets won field from 1 to 0.
- Click Save match.
You'll see Saved to Supabase. in a green status box. The form resets after the successful insert.
- Enter Comets in the Team A field.
- Enter 2 in the first Sets won field.
- Enter Waves in the Team B field.
- Enter 3 in the second Sets won field.
- Click Save match.
A second result card appears at the start of the history. Its winner badge names Waves.
Before you refresh, do you expect the styled result cards to remain or disappear?
- Refresh http://localhost:3000.
You'll briefly see Loading matches... before the saved cards return. Each card shows its winner, score, and timestamp.
Before you narrow the browser, which parts of the desktop grid do you expect to stack?
- Resize the browser window to a width below 700px.
You'll see the four-field form become one column. The result cards also stack into a single readable column.
Styles missing after saving?
Confirm that app/layout.js still imports ./globals.css. Check that selectors such as .match-form match the class names in app/page.js.
Ask for help with the layout that still looks wrong: Help me debug the VolleyTrack responsive styles.
That is the match-day version complete. VolleyTrack now turns persistent database rows into a responsive result board.
Secret mission
Filter the History by Team
A growing match history needs a faster way to find one team. Add a controlled search field that filters existing cards by either team name while leaving the Supabase workflow unchanged.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep, pause, or delete your VolleyTrack resources. The project costs $0 while its Supabase resources stay within Free plan limits.
Resources you used:
- Local files: The volleytrack project folder containing your source code plus installed npm packages.
- Cloud resource: The Supabase Free plan project containing the public.matches table plus its read and insert policies.
Keep Sample Data Disposable
The tutorial policies let the anon role read sample match records. They also let it insert records.
Keep this project limited to disposable sample data. A production scorekeeper needs authenticated users with owner-scoped policies.
Keep everything running
No action needed. Choose this if you are still adding matches or testing the team filter.
- Keep the volleytrack folder so the source code remains available.
- Keep the VolleyTrack Supabase project so saved match rows remain available.
- Use only disposable sample records while the public policies remain active.
Supabase Free projects can pause after one week of inactivity.
Pause - I'll come back to this later
Stop the local development server to free its terminal session. Pause the Supabase project while preserving your project data.
- Press Ctrl+C in the Visual Studio Code PowerShell terminal from earlier to stop the development server.
- Return to the VolleyTrack project in the Supabase Dashboard.
- Select Settings from the sidebar.
- Select General.
- Scroll to Project availability.
- Click Pause Project.
Your local files remain available. Your database data stays with the paused Supabase project.
Delete - I don't want to use this again
Project deletion is permanent, so choose this option only when your sample data is disposable. Removing the local folder also erases your VolleyTrack code.
Delete the Supabase project:
- Return to the VolleyTrack project in the Supabase Dashboard.
- Select Settings from the sidebar.
- Select General.
- Scroll to Delete project.
- Click Delete Project.
- Enter VolleyTrack as the exact project name.
- Click Delete to confirm the permanent deletion.
The public.matches table disappears with the Supabase project. Its public policies are removed too.
Delete the local app files:
- Press Ctrl+C in the PowerShell terminal from earlier if the development server is still running.
- Press the Windows key to open the search bar.
- Type File Explorer to find the app.
- Press Enter to open File Explorer.
- Return to the location where you created the volleytrack folder in Step 1.
- Select the volleytrack folder.
- Press Shift+Delete to remove the folder permanently.
- Confirm the deletion when Windows prompts you.
The local source code plus installed dependencies are now removed from your computer.
Nice Work!
Nice Work!
VolleyTrack is complete! Your first weekend-sequence build is a responsive Next.js match logger that keeps volleyball results in Supabase across browser refreshes.
What you learned:
- Built a controlled match form with React state for team names plus set totals. A browser refresh exposed why in-memory match history needs persistent storage.
- Created a relational public.matches table with constraints for valid completed results. Protected it with explicit grants plus separate Row Level Security policies for reads plus inserts.
- Connected the Next.js client component to Supabase with browser environment variables. The finished interface shows loading states, validation feedback, database errors, winner badges, timestamps, empty history, persistent records, plus responsive match cards.
- Secret Mission: Added a derived team-name filter that searches either team without mutating matches or making another database request.
Ready to quiz yourself?