Build a Bookmark Manager with Supabase
Build a typed React app with full CRUD operations against a Supabase database.
Introduction
30 Second Summary
Every developer has dozens of browser tabs open with documentation pages, useful tools, and articles they want to revisit later. Most of those links get lost when you close the window.
In this project, you will build DevVault, a developer bookmark manager that saves your links to a real database and displays them in a clean, typed React app.
What You'll Build
A full-stack bookmark manager with a React + TypeScript frontend connected to a Supabase Postgres database through an auto-generated REST API.
By the end of this project, you'll have:
- A Supabase database table with Row Level Security enabled and a REST API you understand end-to-end.
- A typed data layer with custom TypeScript interfaces and a reusable hook using a discriminated union pattern.
- A working CRUD app that lets you add, view, and delete bookmarks organized by category.
- Secret Mission: Add category filtering and search to your bookmark list.
Are there any prerequisites?
You should be familiar with React basics (components, hooks, and state). Some knowledge of TypeScript syntax is helpful but not required.
Set Up the Project
Every full-stack app starts with a solid foundation. DevVault needs a React frontend that can talk to a database, so your first job is to scaffold a project with the right tools already wired in.
You will use Vite to scaffold a React + TypeScript project, then add the Supabase client library so your app can communicate with a Postgres database through a REST API.
In this step, get ready to:
- Scaffold a Vite + React + TypeScript project.
- Install the Supabase client library.
- Create environment variable placeholders for your database credentials.
Scaffold the project and install dependencies
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your DevVault project by running this command:
npm create vite@latest devvault -- --template react-ts
What does this command do?
- npm create vite@latest downloads and runs the Vite project scaffolding tool.
- devvault sets the project folder name.
- --template react-ts tells Vite to use the React + TypeScript starter template.
Prompted to install create-vite?
npm may ask: "Need to install the following packages: create-vite@latest Ok to proceed? (y)". Enter y and press Enter to continue.
- You should see a "Done" message confirming the project was scaffolded.
- Navigate into the project folder by running:
cd devvault
- Install the Supabase client library and all project dependencies by running these commands:
npm install @supabase/supabase-js@2.108.1
npm install
What does this install?
The first command adds @supabase/supabase-js to your project. This is the official client library that lets your React app talk to a Supabase database through its REST API.
The second command ensures all remaining dependencies from the Vite template are fully resolved.
Seeing permission or network errors?
- Make sure you are connected to the internet. npm needs to download packages from the registry.
- On macOS, if you see EACCES errors, try prefixing the command with sudo.
- If you see a version resolution error, confirm your Node.js version is 20.19+ by running node --version.
Help me debug my npm install error
- Start the dev server to confirm everything works by running:
npm run dev
- Open your browser and go to http://localhost:5173.
- You should see the default Vite + React starter page with spinning logos.
Don't see the page?
- Check your terminal for errors. If you see "port 5173 already in use", stop the other process or close the terminal using that port.
- Make sure you ran npm install before npm run dev. Without installed dependencies, the dev server cannot start.
Help me fix my Vite dev server
- Now open the devvault folder in VS Code so you can browse and edit files.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the devvault folder, then click Open.
- If VS Code asks whether you trust the authors of this folder, click Yes, I trust the authors.
Your VS Code file explorer now shows the full project structure on the left.
Create the environment variables file
Vite exposes environment variables to your app only if they start with the VITE_ prefix.
You will store your Supabase credentials in a .env.local file so they stay out of version control. For now, you will add placeholder values and fill in the real ones in the next step.
- In the VS Code file explorer, right-click in the empty space below the file list.
- Click New File.
- Name the file .env.local and press Enter.
- Paste the following content into the file:
VITE_SUPABASE_URL=your-project-url
VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key
What are these variables?
- VITE_SUPABASE_URL is the unique URL for your Supabase project's API. Every Supabase project gets one.
- VITE_SUPABASE_PUBLISHABLE_KEY is the public (anon) key that identifies your app to Supabase. It is safe to use in client-side code because Row Level Security controls what data it can access.
- Save the file with Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Double check you have saved .env.local in the project root (not inside src/).
ⓧ I'd like to double check the full code
VITE_SUPABASE_URL=your-project-url
VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key
Clean up the scaffolded files to start fresh
The Vite template includes demo code and assets you do not need. You will strip everything back to a blank canvas so the code you write from here is entirely yours.
- In the VS Code file explorer, expand the src folder.
- Click on App.css to open it.
- Select all the content with Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
- Save the now-empty file.
- Open src/App.tsx and select all the content.
- Replace everything with this minimal component:
function App() {
return (
<div>
<h1>DevVault</h1>
</div>
)
}
export default App
What does this component do?
This is the simplest possible React component. It renders a single heading that says "DevVault". You will build on top of this as you add your bookmark form and list in later steps.
- Save App.tsx.
- Right-click the assets folder inside src and click Delete.
- Confirm the deletion if prompted.
- Check your browser at http://localhost:5173.
- You should see a plain page with just the text "DevVault" as a heading.
Still seeing the old Vite starter page?
- Make sure you saved both App.tsx and App.css. Vite hot-reloads on save, so unsaved changes will not appear.
- If the dev server stopped (check the terminal for errors), open a new terminal in VS Code with Ctrl+` and run npm run dev again.
- Check that App.tsx contains the exact code shown above. A missing export default App line will break the page.
Help me fix my DevVault page not showing up
✔️ Awesome, I've got everything!
Great. Double check you have saved all files and your browser shows the "DevVault" heading.
ⓧ I'd like to double check the full code
function App() {
return (
<div>
<h1>DevVault</h1>
</div>
)
}
export default App
Your project foundation is ready. Next up, you will create a database table in Supabase and connect your app to a real backend.
Set Up Supabase and Test the REST API
Your local React app is running, but it has nowhere to store data yet. In this step, you'll create a real database and connect it to your project.
Supabase gives you a hosted Postgres database that automatically generates a REST API from your tables. You'll create the bookmarks table, lock it down with Row Level Security, and prove the API works by making raw HTTP requests with curl.
In this step, get ready to:
- Create the bookmarks table and configure Row Level Security in Supabase.
- Connect your app to the database with API credentials.
- Test the auto-generated REST API with curl to see the raw HTTP layer.
Create the bookmarks table in the Supabase Dashboard
Your app needs a place to store bookmark data. You'll define the table structure using SQL, which gives you precise control over column types and constraints.
- Open your Supabase project dashboard at supabase.com/dashboard.
- Select SQL Editor from the left sidebar.
- Click New Query to open a blank editor.
- Create the bookmarks table by pasting the following SQL and clicking Run:
-- Create the bookmarks table to store developer links
create table public.bookmarks (
id bigint generated always as identity primary key,
title text not null,
url text not null,
description text,
category text not null,
created_at timestamptz default now()
);
What does this SQL do?
- id is auto-generated by Postgres every time you insert a row. You never set it manually.
- title, url, and category are required fields (not null).
- description is optional, so it can be null.
- created_at automatically records when each bookmark was saved.
You should see a success message confirming the table was created.
- Now enable Row Level Security and create a policy by pasting this SQL in a new query and clicking Run:
-- Enable RLS so the table is locked down by default
alter table public.bookmarks enable row level security;
-- Create a policy that allows the anon role to read, insert, and delete
create policy "Allow anon access" on public.bookmarks
for all
to anon
using (true)
with check (true);
-- Grant the specific privileges to the anon role
grant select, insert, delete on public.bookmarks to anon;
What is Row Level Security?
Row Level Security (RLS) is a Postgres feature that controls which rows a user can access. When RLS is enabled, the table blocks all access by default until you create a policy.
Here you're creating a wide-open policy for the anon role because DevVault is a personal tool with no authentication. In a production app with user accounts, you'd write policies that restrict access per user.
You should see a success message confirming the policy was created.
Seeing a permission error?
- Make sure you're running the SQL inside the correct project. Check the project name in the top-left corner of the Dashboard.
- If you see an error about the table already existing, you may have run the first query twice. You can safely ignore it and continue with the RLS query.
Help me troubleshoot my Supabase SQL Editor error.
Get your API credentials and update the environment file
Your React app needs the project URL and a publishable key to communicate with Supabase. These are safe to use in client-side code because RLS protects your data at the database level.
- In the Supabase Dashboard, click Settings in the left sidebar.
- Click API Keys.
- Copy the Project URL value.
- Copy the anon public key (this is your publishable key).
Why is this key called publishable?
The anon key is designed to be included in client-side code that runs in the browser. It identifies your project but does not grant unrestricted access. Row Level Security policies are what actually control who can read or write data.
- Switch back to VS Code and open .env.local in the project root.
- Replace the placeholder values with the real credentials you just copied:
VITE_SUPABASE_URL=your-project-url-here
VITE_SUPABASE_PUBLISHABLE_KEY=your-publishable-key-here
What should I see?
Your VITE_SUPABASE_URL value looks like https://abcdefgh.supabase.co. Your VITE_SUPABASE_PUBLISHABLE_KEY is a long string starting with eyJ.
- Save .env.local.
- Restart your dev server so Vite picks up the new environment variables. In the terminal where the dev server is running, press Ctrl+C to stop it, then run:
npm run dev
The dev server should start again at http://localhost:5173 with the new environment variables loaded.
Still seeing your old placeholder values?
- Make sure you saved .env.local before restarting the server.
- Vite only reads .env.local at startup. You must fully stop and restart the dev server after changing environment variables.
- Confirm the file is named exactly .env.local (with the leading dot) and lives in the devvault project root.
Help me fix my Vite environment variables not loading.
Test the REST API directly with curl
Before writing any client code, you can prove the database and API work by making raw HTTP requests. This shows you the exact layer your app will use under the hood.
- Open a new terminal window (keep the dev server running in the other one).
- Run a GET request against your Supabase REST API to fetch all bookmarks:
curl https://[[SUPABASE_PROJECT_REF="your-project-ref"]].supabase.co/rest/v1/bookmarks \
-H "apikey: your-publishable-key-here"
What does this command do?
- curl makes an HTTP request from the command line.
- The URL points to the auto-generated REST endpoint for your bookmarks table.
- The apikey header authenticates the request with your publishable key.
Replace the URL with your VITE_SUPABASE_URL value (add /rest/v1/bookmarks to the end) and replace your-publishable-key-here with your VITE_SUPABASE_PUBLISHABLE_KEY value from .env.local.
You should see an empty array [] returned. The table exists but has no data yet.
- Insert a test bookmark by running this POST request:
curl https://[[SUPABASE_PROJECT_REF="your-project-ref"]].supabase.co/rest/v1/bookmarks \
-H "apikey: your-publishable-key-here" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"title": "React Docs", "url": "https://react.dev", "description": "Official React documentation", "category": "documentation"}'
What do these extra headers do?
- Content-Type: application/json tells the API you're sending JSON data.
- Prefer: return=representation asks the API to return the inserted row in the response so you can confirm it worked.
- -d sends the JSON body containing your bookmark data.
You should see a JSON array containing the bookmark you just inserted, complete with a generated id and created_at timestamp.
- Run the GET request again to confirm the bookmark persisted:
curl https://[[SUPABASE_PROJECT_REF="your-project-ref"]].supabase.co/rest/v1/bookmarks \
-H "apikey: your-publishable-key-here"
This time the response shows your test bookmark in the array.
Why test with curl first?
Making direct HTTP requests proves that your database, RLS policies, and API credentials all work correctly. If something breaks later in your React code, you'll know the problem is in the frontend, not the backend.
Getting an empty response or an error?
- Double-check that you replaced the URL and key placeholders with your actual values from .env.local.
- Make sure the URL ends with /rest/v1/bookmarks (not just /rest/v1/).
- If you see a permissions error, go back to the SQL Editor and confirm the RLS policy and grant statements ran successfully.
Help me debug my Supabase REST API curl request.
Your database is live, secured with RLS, and proven to work via raw HTTP. Next up, you'll write the TypeScript code that connects your React app to this API.
Create the Typed Supabase Client
You proved the Supabase REST API works by testing it with curl. Now your React app needs a way to talk to that same API from the browser.
The @supabase/supabase-js library you installed in Step 1 handles all those HTTP requests for you. In this step, you'll create a typed client module and a custom React hook that manages bookmark state using a TypeScript pattern called a discriminated union.
In this step, get ready to:
- Create a typed Supabase client module with TypeScript interfaces.
- Build a custom hook with discriminated union state for loading, error, and success.
Create the Supabase client module with TypeScript interfaces
The client module is the single place your app connects to Supabase. It also defines TypeScript interfaces that describe the shape of your data, so every component that touches bookmarks knows exactly what fields to expect.
- In VS Code, open the integrated terminal by pressing Ctrl+` (macOS and Windows).
- Create the src/lib/ folder by running:
mkdir src/lib
- Create a new file called supabase.ts inside src/lib/ by running:
touch src/lib/supabase.ts
- Open src/lib/supabase.ts in the Explorer sidebar.
- Add the following code to define your Supabase client and types:
import { createClient } from '@supabase/supabase-js'
// Define the shape of a bookmark from the database
export interface Bookmark {
id: number
title: string
url: string
description: string | null
category: string
created_at: string
}
// NewBookmark omits fields the database generates automatically
export type NewBookmark = Omit<Bookmark, 'id' | 'created_at'>
// Read the Supabase credentials from environment variables
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
// Create and export the Supabase client
export const supabase = createClient(supabaseUrl, supabaseKey)
What does this code do?
The Bookmark interface mirrors the columns in your database table. TypeScript uses it to catch mistakes like trying to access bookmark.titl (a typo) before you even run the code.
The NewBookmark type uses Omit to strip out id and created_at because the database generates those automatically. When you add a bookmark, you only send the fields you control.
The createClient() function initializes the Supabase client using the URL and publishable key from your .env.local file. Vite exposes these through import.meta.env because they have the VITE_ prefix.
- Save src/lib/supabase.ts.
- Check that there are no red underlines in the file. If your editor shows no errors, the types are valid.
Seeing red underlines on the import?
- Make sure you ran npm install in Step 1. The @supabase/supabase-js package must be installed for the import to resolve.
- Check that the file is inside src/lib/, not directly in src/. The folder path matters for imports later.
Why is my Supabase import showing an error in VS Code?
Create the useBookmarks custom hook with discriminated union state
A custom hook bundles your data-fetching logic into one reusable function. Any component that needs bookmarks can call this hook without duplicating the Supabase queries.
The hook uses a discriminated union for its state. This means TypeScript forces you to check whether data is loading, errored, or successful before you can access the bookmarks array. It eliminates an entire class of "undefined is not an object" bugs.
- Create the src/hooks/ folder by running:
mkdir src/hooks
- Create a new file called useBookmarks.ts inside src/hooks/ by running:
touch src/hooks/useBookmarks.ts
- Open src/hooks/useBookmarks.ts in the Explorer sidebar.
- Add the following code to define the hook's state type and the fetch function:
import { useEffect, useState } from 'react'
import { supabase, Bookmark, NewBookmark } from '../lib/supabase'
// Discriminated union: state is always ONE of these three shapes
type BookmarkState =
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; bookmarks: Bookmark[] }
export function useBookmarks() {
// State starts as 'loading' until the first fetch completes
const [state, setState] = useState<BookmarkState>({ status: 'loading' })
// Fetch all bookmarks, newest first
async function fetchBookmarks() {
setState({ status: 'loading' })
const { data, error } = await supabase
.from('bookmarks')
.select('*')
.order('created_at', { ascending: false })
if (error) {
setState({ status: 'error', error: error.message })
return
}
setState({ status: 'success', bookmarks: data as Bookmark[] })
}
// Insert a new bookmark then refresh the list
async function addBookmark(bookmark: NewBookmark): Promise<void> {
const { error } = await supabase.from('bookmarks').insert(bookmark)
if (error) {
throw new Error(error.message)
}
await fetchBookmarks()
}
// Delete a bookmark by ID then refresh the list
async function deleteBookmark(id: number): Promise<void> {
const { error } = await supabase.from('bookmarks').delete().eq('id', id)
if (error) {
throw new Error(error.message)
}
await fetchBookmarks()
}
// Fetch bookmarks once when the component mounts
useEffect(() => {
fetchBookmarks()
}, [])
return { state, addBookmark, deleteBookmark }
}
What does this hook do?
- BookmarkState is a discriminated union with three variants. When status is 'loading', there are no other fields. When it's 'success', bookmarks is guaranteed to exist. TypeScript enforces this at compile time.
- fetchBookmarks() queries the bookmarks table and orders results by created_at descending, so the newest bookmarks appear first.
- addBookmark() and deleteBookmark() perform the mutation, then call fetchBookmarks() again so the UI always reflects the latest data.
- useEffect with an empty dependency array triggers the first fetch when the component mounts.
Why use a discriminated union?
Without a discriminated union, you might store bookmarks, error, and isLoading as three separate state variables. That creates impossible combinations (like isLoading: true AND error: 'something broke' at the same time).
A discriminated union makes each state exclusive. Your component checks state.status once, and TypeScript narrows the available fields automatically. This is a production pattern that shows up frequently in professional React codebases.
- Save src/hooks/useBookmarks.ts.
- Check that there are no red underlines anywhere in the file. The import from ../lib/supabase should resolve without errors because you created that module in the previous substep.
Red underline on the supabase import?
- Confirm the file path is src/hooks/useBookmarks.ts (not src/useBookmarks.ts). The relative import ../lib/supabase goes up one level from hooks/ into src/, then into lib/.
- Make sure you saved src/lib/supabase.ts first. If that file has unsaved changes or a typo in its exports, the import will fail.
Help me fix my import path in useBookmarks.ts
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/lib/supabase.ts:
import { createClient } from '@supabase/supabase-js'
export interface Bookmark {
id: number
title: string
url: string
description: string | null
category: string
created_at: string
}
export type NewBookmark = Omit<Bookmark, 'id' | 'created_at'>
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
export const supabase = createClient(supabaseUrl, supabaseKey)
And the complete src/hooks/useBookmarks.ts:
import { useEffect, useState } from 'react'
import { supabase, Bookmark, NewBookmark } from '../lib/supabase'
type BookmarkState =
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; bookmarks: Bookmark[] }
export function useBookmarks() {
const [state, setState] = useState<BookmarkState>({ status: 'loading' })
async function fetchBookmarks() {
setState({ status: 'loading' })
const { data, error } = await supabase
.from('bookmarks')
.select('*')
.order('created_at', { ascending: false })
if (error) {
setState({ status: 'error', error: error.message })
return
}
setState({ status: 'success', bookmarks: data as Bookmark[] })
}
async function addBookmark(bookmark: NewBookmark): Promise<void> {
const { error } = await supabase.from('bookmarks').insert(bookmark)
if (error) {
throw new Error(error.message)
}
await fetchBookmarks()
}
async function deleteBookmark(id: number): Promise<void> {
const { error } = await supabase.from('bookmarks').delete().eq('id', id)
if (error) {
throw new Error(error.message)
}
await fetchBookmarks()
}
useEffect(() => {
fetchBookmarks()
}, [])
return { state, addBookmark, deleteBookmark }
}
Your typed data layer is in place. The Supabase client knows how to reach your database, and the hook manages the full lifecycle of fetching, adding, and deleting bookmarks. Next up, you'll build the React components that put this hook to work with a form and a bookmark list.
Build the UI Components
Your data layer is complete. The useBookmarks hook handles fetching, adding, and deleting bookmarks from Supabase. Now you need a way for users to actually interact with that data.
In this step, you will build the React components that display your bookmarks and let you add new ones. Each component receives typed props from the hook, so TypeScript catches errors before you even open the browser.
In this step, get ready to:
- Build a typed form component for adding bookmarks.
- Build a list component that renders bookmark cards with delete functionality.
- Wire the components into App.tsx and style the app with a dark-themed CSS stylesheet.
Build the BookmarkForm component
The form component needs controlled inputs for each bookmark field. It also needs to handle submission, show a loading state while saving, and clear itself on success.
- Create a src/components/ folder inside your devvault project.
- Create a file called BookmarkForm.tsx inside src/components/ and add the following code:
import { useState } from 'react'
import { NewBookmark } from '../lib/supabase'
interface BookmarkFormProps {
onAdd: (bookmark: NewBookmark) => Promise<void>
}
export function BookmarkForm({ onAdd }: BookmarkFormProps) {
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState('documentation')
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)
try {
await onAdd({ title, url, description: description || null, category })
setTitle('')
setUrl('')
setDescription('')
setCategory('documentation')
} catch (err) {
console.error('Failed to add bookmark:', err)
} finally {
setIsSubmitting(false)
}
}
return (
<form className="bookmark-form" onSubmit={handleSubmit}>
<h2>Add Bookmark</h2>
<div className="form-group">
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
value={title}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)}
placeholder="React Documentation"
required
/>
</div>
<div className="form-group">
<label htmlFor="url">URL</label>
<input
id="url"
type="url"
value={url}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)}
placeholder="https://react.dev"
required
/>
</div>
<div className="form-group">
<label htmlFor="description">Description (optional)</label>
<input
id="description"
type="text"
value={description}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDescription(e.target.value)}
placeholder="Official React docs with hooks guide"
/>
</div>
<div className="form-group">
<label htmlFor="category">Category</label>
<select
id="category"
value={category}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setCategory(e.target.value)}
>
<option value="documentation">Documentation</option>
<option value="tool">Tool</option>
<option value="article">Article</option>
<option value="repository">Repository</option>
<option value="other">Other</option>
</select>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Adding...' : 'Add Bookmark'}
</button>
</form>
)
}
What does this code do?
- BookmarkFormProps defines the contract: the parent must pass an onAdd function that accepts a NewBookmark and returns a Promise.
- Each input is a controlled component tied to its own useState value. TypeScript knows each onChange handler receives the correct event type.
- handleSubmit prevents the default page reload, calls onAdd with the form data, then resets all fields on success.
- isSubmitting disables the button while the request is in flight, preventing double submissions.
- Save src/components/BookmarkForm.tsx.
- Check your terminal. You should see no TypeScript errors. If you see a red underline on the NewBookmark import, make sure the import path reads '../lib/supabase'.
Seeing a TypeScript error?
- If the NewBookmark import shows an error, confirm your src/lib/supabase.ts exports NewBookmark as a named type.
- Make sure your file is inside src/components/ (not src/ directly). The relative import path depends on the folder structure.
Help me fix a TypeScript import error in my BookmarkForm component
Build the BookmarkList component
The list component receives an array of bookmarks and renders each one as a card. It also needs a delete button on each card and an empty-state message when there are no bookmarks.
- Create a file called BookmarkList.tsx inside src/components/ and add the following code:
import { Bookmark } from '../lib/supabase'
interface BookmarkListProps {
bookmarks: Bookmark[]
onDelete: (id: number) => Promise<void>
}
export function BookmarkList({ bookmarks, onDelete }: BookmarkListProps) {
if (bookmarks.length === 0) {
return <p className="empty-state">No bookmarks yet. Add your first one above!</p>
}
return (
<div className="bookmark-list">
{bookmarks.map((bookmark) => (
<div key={bookmark.id} className="bookmark-card">
<div className="bookmark-header">
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="bookmark-title"
>
{bookmark.title}
</a>
<span className={`category-badge category-${bookmark.category}`}>
{bookmark.category}
</span>
</div>
{bookmark.description && (
<p className="bookmark-description">{bookmark.description}</p>
)}
<div className="bookmark-footer">
<span className="bookmark-url">{bookmark.url}</span>
<button
className="delete-button"
onClick={() => onDelete(bookmark.id)}
>
Delete
</button>
</div>
</div>
))}
</div>
)
}
What does this code do?
- BookmarkListProps types the incoming props: an array of Bookmark objects and an onDelete callback that receives the bookmark's numeric id.
- The early return handles the empty state. If there are no bookmarks, the user sees a friendly message instead of a blank page.
- Each card shows the title as a clickable link (opens in a new tab), a color-coded category badge, the description if one exists, the raw URL, and a delete button.
- The dynamic class name category-${bookmark.category} maps each category to a unique badge color in CSS.
- Save src/components/BookmarkList.tsx.
- You won't see a change in the browser yet. The next substep wires these components into App.tsx.
Red underline on the Bookmark import?
- Confirm the import path is '../lib/supabase' (two dots, not one). The components/ folder is one level deeper than lib/.
- Make sure supabase.ts uses a named export (export interface Bookmark), not a default export.
Help me fix this TypeScript import error in BookmarkList
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the full BookmarkForm.tsx:
import { useState } from 'react'
import { NewBookmark } from '../lib/supabase'
interface BookmarkFormProps {
onAdd: (bookmark: NewBookmark) => Promise<void>
}
export function BookmarkForm({ onAdd }: BookmarkFormProps) {
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState('documentation')
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)
try {
await onAdd({ title, url, description: description || null, category })
setTitle('')
setUrl('')
setDescription('')
setCategory('documentation')
} catch (err) {
console.error('Failed to add bookmark:', err)
} finally {
setIsSubmitting(false)
}
}
return (
<form className="bookmark-form" onSubmit={handleSubmit}>
<h2>Add Bookmark</h2>
<div className="form-group">
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
value={title}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)}
placeholder="React Documentation"
required
/>
</div>
<div className="form-group">
<label htmlFor="url">URL</label>
<input
id="url"
type="url"
value={url}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)}
placeholder="https://react.dev"
required
/>
</div>
<div className="form-group">
<label htmlFor="description">Description (optional)</label>
<input
id="description"
type="text"
value={description}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDescription(e.target.value)}
placeholder="Official React docs with hooks guide"
/>
</div>
<div className="form-group">
<label htmlFor="category">Category</label>
<select
id="category"
value={category}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setCategory(e.target.value)}
>
<option value="documentation">Documentation</option>
<option value="tool">Tool</option>
<option value="article">Article</option>
<option value="repository">Repository</option>
<option value="other">Other</option>
</select>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Adding...' : 'Add Bookmark'}
</button>
</form>
)
}
And the full BookmarkList.tsx:
import { Bookmark } from '../lib/supabase'
interface BookmarkListProps {
bookmarks: Bookmark[]
onDelete: (id: number) => Promise<void>
}
export function BookmarkList({ bookmarks, onDelete }: BookmarkListProps) {
if (bookmarks.length === 0) {
return <p className="empty-state">No bookmarks yet. Add your first one above!</p>
}
return (
<div className="bookmark-list">
{bookmarks.map((bookmark) => (
<div key={bookmark.id} className="bookmark-card">
<div className="bookmark-header">
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="bookmark-title"
>
{bookmark.title}
</a>
<span className={`category-badge category-${bookmark.category}`}>
{bookmark.category}
</span>
</div>
{bookmark.description && (
<p className="bookmark-description">{bookmark.description}</p>
)}
<div className="bookmark-footer">
<span className="bookmark-url">{bookmark.url}</span>
<button
className="delete-button"
onClick={() => onDelete(bookmark.id)}
>
Delete
</button>
</div>
</div>
))}
</div>
)
}
Wire everything together in App.tsx and add styles
The components exist, but nothing renders them yet. You need to import the hook and both components into App.tsx, then use the discriminated union state to conditionally show loading, error, or success content.
- Replace the entire contents of src/App.tsx with the following code:
import { useBookmarks } from './hooks/useBookmarks'
import { BookmarkForm } from './components/BookmarkForm'
import { BookmarkList } from './components/BookmarkList'
import './App.css'
function App() {
// Pull state and actions from the custom hook
const { state, addBookmark, deleteBookmark } = useBookmarks()
return (
<div className="app">
<header className="app-header">
<h1>DevVault</h1>
<p>Your developer bookmark manager</p>
</header>
<main className="app-main">
<BookmarkForm onAdd={addBookmark} />
<section className="bookmarks-section">
<h2>Your Bookmarks</h2>
{/* Discriminated union: TypeScript knows which properties exist per status */}
{state.status === 'loading' && <p className="loading">Loading bookmarks...</p>}
{state.status === 'error' && <p className="error">Error: {state.error}</p>}
{state.status === 'success' && (
<BookmarkList bookmarks={state.bookmarks} onDelete={deleteBookmark} />
)}
</section>
</main>
</div>
)
}
export default App
How does the discriminated union work here?
TypeScript knows that when state.status === 'success', the state object has a bookmarks property. If you tried to access state.bookmarks outside that check, TypeScript would give you an error.
This pattern prevents runtime crashes. You can never accidentally render a list while the data is still loading or show stale data when an error occurred.
- Save src/App.tsx.
- Check your browser at http://localhost:5173. You should see the form inputs and your test bookmark from earlier (unstyled for now).
Seeing a blank page or import error?
- Check the browser console for errors. A common cause is a mismatched file path in the import statements.
- Confirm that BookmarkForm.tsx and BookmarkList.tsx use named exports (export function), not default exports.
- Make sure your .env.local has your real Supabase credentials (not placeholder values). If you updated it recently, restart the dev server.
Help me debug why my DevVault app shows a blank page
Now add the styling. The app works but looks raw. You will replace two CSS files to give DevVault a polished dark theme.
- Replace the entire contents of src/index.css with this minimal reset:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
background-color: #0f1117;
color: #e1e4e8;
line-height: 1.6;
min-height: 100vh;
}
#root {
min-height: 100vh;
}
What does this CSS reset do?
The universal selector removes default browser margins and padding. The box-sizing: border-box rule makes padding count inside an element's width instead of outside it.
The body styles set a dark background (#0f1117) and light text color for the entire app.
- Save src/index.css.
- Now replace the entire contents of src/App.css with the following stylesheet:
.app {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.app-header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #2d333b;
}
.app-header h1 {
font-size: 2.5rem;
color: #58a6ff;
margin-bottom: 0.25rem;
}
.app-header p {
color: #8b949e;
font-size: 1rem;
}
.app-main {
display: flex;
flex-direction: column;
gap: 2rem;
}
.bookmark-form {
background: #161b22;
border: 1px solid #2d333b;
border-radius: 8px;
padding: 1.5rem;
}
.bookmark-form h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #e1e4e8;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.875rem;
color: #8b949e;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.625rem 0.75rem;
background: #0d1117;
border: 1px solid #2d333b;
border-radius: 6px;
color: #e1e4e8;
font-size: 0.9rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #58a6ff;
}
.form-group input::placeholder {
color: #484f58;
}
.bookmark-form button[type="submit"] {
width: 100%;
padding: 0.75rem;
background: #238636;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.bookmark-form button[type="submit"]:hover {
background: #2ea043;
}
.bookmark-form button[type="submit"]:disabled {
background: #1a5c2a;
cursor: not-allowed;
opacity: 0.7;
}
.bookmarks-section h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #e1e4e8;
}
.bookmark-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bookmark-card {
background: #161b22;
border: 1px solid #2d333b;
border-radius: 8px;
padding: 1rem 1.25rem;
transition: border-color 0.2s;
}
.bookmark-card:hover {
border-color: #3d444d;
}
.bookmark-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.bookmark-title {
color: #58a6ff;
text-decoration: none;
font-weight: 600;
font-size: 1rem;
}
.bookmark-title:hover {
text-decoration: underline;
}
.category-badge {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
white-space: nowrap;
}
.category-documentation { background: #1f3a5f; color: #79c0ff; }
.category-tool { background: #3b2f63; color: #d2a8ff; }
.category-article { background: #3d2e00; color: #e3b341; }
.category-repository { background: #1b3626; color: #56d364; }
.category-other { background: #2d333b; color: #8b949e; }
.bookmark-description {
color: #8b949e;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.bookmark-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.bookmark-url {
font-size: 0.75rem;
color: #484f58;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}
.delete-button {
background: none;
border: 1px solid #3d444d;
color: #f85149;
padding: 0.3rem 0.6rem;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
}
.delete-button:hover {
background: #3d1f1f;
border-color: #f85149;
}
.empty-state {
text-align: center;
color: #8b949e;
padding: 2rem;
background: #161b22;
border: 1px dashed #2d333b;
border-radius: 8px;
}
.loading {
text-align: center;
color: #8b949e;
padding: 1.5rem;
}
.error {
text-align: center;
color: #f85149;
padding: 1.5rem;
background: #2d1215;
border: 1px solid #f8514933;
border-radius: 8px;
}
What is this stylesheet doing?
- The layout centers the app at 800px wide and uses flexbox to stack sections vertically.
- The form and bookmark cards use dark container backgrounds with subtle borders, matching the GitHub dark theme color palette.
- Each category gets a unique badge color through the class-based selectors (documentation is blue, tool is purple, article is gold, repository is green).
- The delete button uses a red accent color with a hover state that makes intent clear.
- Save src/App.css.
- Refresh your browser. You should see a polished dark-themed interface with a blue "DevVault" heading, a green "Add Bookmark" button, and your test bookmark displayed as a card with a colored category badge.
Styles not appearing?
- Make sure App.tsx has import './App.css' at the top. Without this import, Vite does not bundle the stylesheet.
- Check that you replaced src/index.css (not just src/App.css). The dark background comes from index.css.
- If you still see Vite's default purple styling, the old index.css content is still there. Delete everything in that file and paste the reset code from above.
Help me figure out why my CSS styles are not showing up
- Test the full flow: fill in the form fields and click Add Bookmark.
- Confirm the new bookmark appears at the top of the list with its category badge.
- Click Delete on any bookmark to confirm it disappears from the list.
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files before continuing.
ⓧ I'd like to double check the full code
Here is src/App.tsx:
import { useBookmarks } from './hooks/useBookmarks'
import { BookmarkForm } from './components/BookmarkForm'
import { BookmarkList } from './components/BookmarkList'
import './App.css'
function App() {
const { state, addBookmark, deleteBookmark } = useBookmarks()
return (
<div className="app">
<header className="app-header">
<h1>DevVault</h1>
<p>Your developer bookmark manager</p>
</header>
<main className="app-main">
<BookmarkForm onAdd={addBookmark} />
<section className="bookmarks-section">
<h2>Your Bookmarks</h2>
{state.status === 'loading' && <p className="loading">Loading bookmarks...</p>}
{state.status === 'error' && <p className="error">Error: {state.error}</p>}
{state.status === 'success' && (
<BookmarkList bookmarks={state.bookmarks} onDelete={deleteBookmark} />
)}
</section>
</main>
</div>
)
}
export default App
Here is src/index.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
background-color: #0f1117;
color: #e1e4e8;
line-height: 1.6;
min-height: 100vh;
}
#root {
min-height: 100vh;
}
Here is src/App.css:
.app {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.app-header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #2d333b;
}
.app-header h1 {
font-size: 2.5rem;
color: #58a6ff;
margin-bottom: 0.25rem;
}
.app-header p {
color: #8b949e;
font-size: 1rem;
}
.app-main {
display: flex;
flex-direction: column;
gap: 2rem;
}
.bookmark-form {
background: #161b22;
border: 1px solid #2d333b;
border-radius: 8px;
padding: 1.5rem;
}
.bookmark-form h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #e1e4e8;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.875rem;
color: #8b949e;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.625rem 0.75rem;
background: #0d1117;
border: 1px solid #2d333b;
border-radius: 6px;
color: #e1e4e8;
font-size: 0.9rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #58a6ff;
}
.form-group input::placeholder {
color: #484f58;
}
.bookmark-form button[type="submit"] {
width: 100%;
padding: 0.75rem;
background: #238636;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.bookmark-form button[type="submit"]:hover {
background: #2ea043;
}
.bookmark-form button[type="submit"]:disabled {
background: #1a5c2a;
cursor: not-allowed;
opacity: 0.7;
}
.bookmarks-section h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: #e1e4e8;
}
.bookmark-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bookmark-card {
background: #161b22;
border: 1px solid #2d333b;
border-radius: 8px;
padding: 1rem 1.25rem;
transition: border-color 0.2s;
}
.bookmark-card:hover {
border-color: #3d444d;
}
.bookmark-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.bookmark-title {
color: #58a6ff;
text-decoration: none;
font-weight: 600;
font-size: 1rem;
}
.bookmark-title:hover {
text-decoration: underline;
}
.category-badge {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
white-space: nowrap;
}
.category-documentation { background: #1f3a5f; color: #79c0ff; }
.category-tool { background: #3b2f63; color: #d2a8ff; }
.category-article { background: #3d2e00; color: #e3b341; }
.category-repository { background: #1b3626; color: #56d364; }
.category-other { background: #2d333b; color: #8b949e; }
.bookmark-description {
color: #8b949e;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.bookmark-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.bookmark-url {
font-size: 0.75rem;
color: #484f58;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}
.delete-button {
background: none;
border: 1px solid #3d444d;
color: #f85149;
padding: 0.3rem 0.6rem;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
}
.delete-button:hover {
background: #3d1f1f;
border-color: #f85149;
}
.empty-state {
text-align: center;
color: #8b949e;
padding: 2rem;
background: #161b22;
border: 1px dashed #2d333b;
border-radius: 8px;
}
.loading {
text-align: center;
color: #8b949e;
padding: 1.5rem;
}
.error {
text-align: center;
color: #f85149;
padding: 1.5rem;
background: #2d1215;
border: 1px solid #f8514933;
border-radius: 8px;
}
Your full-stack bookmark manager is working. You have typed components, a real database, and a clean UI that you can show off in a portfolio. Next up, you will take it further with filtering and search in the Secret Mission.
Secret mission
Add Category Filtering and Search
Your bookmark list is growing. Can you add category filter buttons and a search bar so you can find what you need instantly? You will use Supabase's typed query operators to filter server-side and keep the UI snappy.
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.
Resources you used:
- Supabase project with a bookmarks table (Free Tier, cloud-hosted).
- Local devvault project folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep adding bookmarks.
- Your Supabase project stays active at no cost. The Free Tier includes 500 MB database storage, unlimited API requests, and 2 active projects.
- Your local devvault folder remains on your Desktop ready to use.
- To start the app again later, open a terminal in the devvault folder and run npm run dev.
Pause - I'll come back to this later
Shut down the running process to free up system resources. Your data stays safe in Supabase.
- Stop the dev server by pressing Ctrl+C in the terminal where it is running.
- If you do not interact with your Supabase project for one week, Supabase will auto-pause it. You can unpause it anytime from the dashboard at no cost.
- Your local files remain on your Desktop. No need to delete anything.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Delete your Supabase project:
- Delete your local project folder by running these commands:
macOS
cd ~/Desktop
rm -rf devvault
Windows
cd ~\Desktop
rmdir /s /q devvault
That's a Wrap!
That's a Wrap!
Nice work! You just built DevVault, a full-stack developer bookmark manager with a React + TypeScript frontend connected to a Supabase Postgres database.
You've learned how to:
- Create a typed data layer with TypeScript interfaces that mirror your database schema, and a custom hook using the discriminated union pattern for loading, error, and success states.
- Set up Row Level Security policies in Supabase and connect a React frontend to a real Postgres database through its auto-generated REST API.
- Test the raw REST API with curl to understand the HTTP layer beneath the Supabase client library.
- Secret Mission: Add category filtering and search using Supabase query operators like .eq() and .ilike(), demonstrating interactive server-side data querying.
Ready to quiz yourself?