Build a People File Vault with Node.js
Build and deploy a full-stack file management app with Node.js and Supabase.
Introduction
30 Second Summary
Think about how many files you deal with every day that relate to specific people. Resumes for job candidates, contracts for clients, receipts for vendors. Keeping them organized and searchable is a challenge that every team faces.
In this project, you will build a full-stack web application called People File Vault. It lets users add people by name, upload files linked to each person, and search for anyone instantly.
What You'll Build
A file management app with a Node.js backend, a PostgreSQL database on Supabase, cloud file storage, and a live deployment anyone can access.
By the end of this project, you'll have:
- An Express.js REST API that handles file uploads and stores metadata in a cloud database.
- A Supabase project with a PostgreSQL database for people and file records, plus a storage bucket holding the actual uploaded files.
- A clean frontend with a search bar, file upload interface, and download links, all deployed to a public URL on Render.
- Secret Mission: Add file type filtering so users can filter a person's files by category (documents, images, or videos).
Are there any prerequisites?
You should be comfortable with JavaScript and Node.js basics (variables, functions, callbacks). You also need a GitHub account.
Before We Start
Before We Start...
Set Up Your Development Environment
Every full-stack app starts with an empty folder and a runtime. Before you can write any code for the People File Vault, you need Node.js installed on your machine and a project structure ready to go.
In this step, you will install Node.js, initialize a project with npm, pull in three dependencies, and create the folder layout your app needs.
In this step, get ready to:
- Install Node.js v24 LTS on Fedora and verify it works.
- Initialize a new project and install dependencies.
- Create the project file structure.
Install Node.js v24 LTS on Fedora
Node.js is the JavaScript runtime that powers your server. Fedora 44 does not ship with the version you need, so you will add the NodeSource repository to get Node.js v24 LTS directly from the maintainers.
- Open a terminal by pressing the Super key (the Windows key on your keyboard), typing Terminal, and pressing Enter.
- Check whether Node.js is already installed by running this command:
node --version
✔️ I see v24 or higher
You already have the right version. Skip ahead to the next substep.
ⓧ I see a version lower than 24
You have an older Node.js installed. Follow the steps below to add the NodeSource repository and upgrade to v24.
ⓧ Command not found
Node.js is not installed yet. Follow the steps below to install it.
- Download the NodeSource setup script for Node.js 24 by running this command:
curl -fsSL https://rpm.nodesource.com/setup_24.x -o nodesource_setup.sh
- Run the setup script to add the NodeSource repository:
sudo bash nodesource_setup.sh
What does this script do?
The setup script writes a new repository file to /etc/yum.repos.d/ so that dnf knows where to find Node.js 24 packages. It does not install Node.js itself.
- Install Node.js from the newly added repository by running:
sudo dnf install nodejs -y
- Verify the installation by running:
node --version
- You should see a version starting with v24 (for example, v24.16.0).
- Also confirm npm is available:
npm --version
- You should see a version number printed (the exact number depends on the bundled npm release).
Seeing permission errors?
Make sure you included sudo before bash nodesource_setup.sh and before dnf install. These commands modify system-level files and require root access.
If dnf installs a version lower than 24, your system may have a conflicting nodejs module. Disable it first with sudo dnf module disable -y nodejs, then re-run sudo dnf install nodejs -y.
Help me troubleshoot Node.js installation on Fedora
Initialize the project and install dependencies
With Node.js ready, you can create your project folder and pull in the three packages the server needs: Express for the web server, Multer for handling file uploads, and the Supabase client for connecting to your database and storage.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir people-file-vault
cd people-file-vault
- Initialize a new Node.js project by running:
npm init -y
What does npm init -y do?
This creates a package.json file with sensible defaults. The -y flag answers "yes" to every prompt so you skip the interactive wizard.
- Install the three project dependencies by running:
npm install express@4.19.2 multer@2.2.0 @supabase/supabase-js@2.108.1
- You should see output ending with a summary of added packages. A node_modules/ folder and a package-lock.json file now exist in your project.
What are these packages?
- express@4.19.2 is a lightweight web framework that handles HTTP routes and serves files.
- multer@2.2.0 is middleware that processes file uploads sent from the browser.
- @supabase/supabase-js@2.108.1 is the official client library for talking to your Supabase database and storage bucket.
Now update package.json so the scripts section includes a start and dev command.
- Switch back to VS Code.
- Press Ctrl+O to open a file.
- Navigate to Desktop/people-file-vault, select package.json, and click Open.
- Replace the entire contents of package.json with the following:
{
"name": "people-file-vault",
"version": "1.0.0",
"description": "A web app to store files linked to people with search",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --env-file=.env server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@supabase/supabase-js": "2.108.1",
"express": "4.19.2",
"multer": "2.2.0"
}
}
What do these scripts do?
- start runs the server without loading a .env file. Render (your deployment host) injects environment variables directly, so this script is what production uses.
- dev uses the Node.js --env-file flag to load variables from .env before starting the server. This is for local development only.
- Save package.json with Ctrl+S.
npm install failed?
Make sure you are inside the people-file-vault folder before running npm install. Run pwd to check your current directory.
If you see network errors, check your internet connection and try again.
Help me fix npm install errors
Create the project file structure
Your app needs a few empty files in the right places before you start coding. You will create the server entry point, a folder for frontend assets, and a .env file for secrets.
- In the terminal (make sure you are still inside people-file-vault), create all project files by running these commands:
touch server.js
mkdir public
touch public/index.html public/style.css public/app.js
touch .env
- Confirm the files exist by running:
ls
- You should see node_modules, package.json, package-lock.json, public, server.js, and .env in the output.
- Also check the public/ folder:
ls public
- You should see app.js, index.html, and style.css.
Now add placeholder values to .env so the file is ready for the next step.
- In VS Code, open .env from the Explorer sidebar and paste the following:
SUPABASE_URL=your-supabase-project-url
SUPABASE_ANON_KEY=your-supabase-anon-key
PORT=3000
Why placeholder values?
You will replace your-supabase-project-url and your-supabase-anon-key with real values in the next step when you create your Supabase project. For now, this structure ensures the file is ready.
- Save .env with Ctrl+S.
✔️ Awesome, I've got everything!
Great. Double check you have saved both package.json and .env.
ⓧ I'd like to double check the full code
Here is what each file should contain at this point.
{
"name": "people-file-vault",
"version": "1.0.0",
"description": "A web app to store files linked to people with search",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --env-file=.env server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@supabase/supabase-js": "2.108.1",
"express": "4.19.2",
"multer": "2.2.0"
}
}
SUPABASE_URL=your-supabase-project-url
SUPABASE_ANON_KEY=your-supabase-anon-key
PORT=3000
The files server.js, public/index.html, public/style.css, and public/app.js should all be empty at this point.
Your dev environment is locked and loaded. Next up, you will create a Supabase project to give your app a real database and file storage bucket.
Set Up Supabase
Your project files are ready, but they have nowhere to store data yet. Your app needs a database to track people and their files, plus cloud storage to hold the actual uploaded files.
In this step, you will set up Supabase as a backend-as-a-service that provides both a PostgreSQL database and S3-compatible file storage in one place.
In this step, get ready to:
- Create a Supabase project and database tables for people and files.
- Create a storage bucket for uploaded files.
- Connect your local project to Supabase and verify the connection.
Create a Supabase project and database tables
Supabase gives you a full PostgreSQL database hosted in the cloud. You will create two tables: one for people and one for file metadata.
- Open your browser and go to database.new to create a new Supabase project.
- Sign up with your GitHub account or email if you do not have a Supabase account yet.
- Enter a project name (e.g. people-file-vault).
- Set a database password and save it somewhere safe.
- Choose a region close to you and click Create new project.
- Wait for the project to finish provisioning (this takes about 1 minute).
What is Supabase?
Supabase is an open-source backend-as-a-service built on PostgreSQL. It gives you a database, file storage, authentication, and auto-generated APIs all in one dashboard.
For this project, you are using two of its features: the database (to store people and file metadata) and Storage (to hold the actual uploaded files).
Now you will create the database tables using the SQL Editor.
- In the left sidebar of your Supabase dashboard, click SQL Editor.
Create the people table by pasting this SQL and clicking Run:
create table people (
id uuid primary key default gen_random_uuid(),
name text not null,
notes text,
created_at timestamptz default now()
);
What does this SQL do?
- id creates a unique identifier for each person using a UUID.
- name stores the person's name and cannot be empty (not null).
- notes is an optional text field for any extra info about the person.
- created_at automatically records when the person was added.
- You should see a Success. No rows returned message confirming the table was created.
- Clear the editor, then paste this SQL to create the files table and click Run:
create table files (
id uuid primary key default gen_random_uuid(),
person_id uuid references people(id),
file_name text,
file_type text,
file_size bigint,
storage_path text,
public_url text,
created_at timestamptz default now()
);
What does this SQL do?
- person_id links each file to a person using a foreign key relationship.
- file_name, file_type, and file_size store metadata about the uploaded file.
- storage_path records where the file lives in Supabase Storage.
- public_url stores the download link so you do not have to recompute it every time.
- You should see another Success. No rows returned message.
Seeing an error when running the SQL?
- Make sure you ran the people table SQL first. The files table references people(id), so it must already exist.
- Check for missing semicolons or extra characters you may have accidentally added.
Why is my SQL failing in Supabase?
Create a storage bucket and copy your credentials
The database stores metadata about files, but the actual file contents (PDFs, images, videos) need a separate storage system. Supabase Storage provides an S3-compatible bucket for this.
- In the left sidebar, click Storage.
- Click New Bucket.
- Enter uploads as the bucket name.
- Toggle the Public bucket switch to ON so files can be accessed via public URLs.
- Click Create Bucket.
Why make the bucket public?
A public bucket lets anyone with the file URL download the file. This simplifies the project because you can generate a permanent download link when a file is uploaded.
In a production app, you would use a private bucket with signed URLs or Row Level Security policies to control access.
Now you need to copy your project credentials into your local .env file so your app can connect to Supabase.
- In the left sidebar, click Settings (the gear icon at the bottom).
- Click API Keys in the Settings menu.
- Under the Legacy API Keys tab, locate your Project URL (it looks like https://abc123.supabase.co) and copy it.
- Also copy the anon key (a long string starting with eyJ).
Where to find the Project URL
The Project URL is also shown at the top of the Connect dialog when you click the Connect button in the toolbar. It is the base URL for all Supabase API calls in your project.
- Switch back to VS Code and open .env.
- Replace the placeholder values with your real credentials:
SUPABASE_URL=your-supabase-project-url
SUPABASE_ANON_KEY=your-supabase-anon-key
PORT=3000
- Paste your copied Project URL in place of your-supabase-project-url.
- Paste your copied anon key in place of your-supabase-anon-key.
- Save .env.
Keep your anon key private
Never commit your .env file to GitHub. The .gitignore you will create later will prevent this, but double check that .env is listed there.
Test the database connection
Before building the full API, you should confirm that your app can actually talk to Supabase. You will run a quick test script that inserts a person and reads it back.
- Create a file called test-connection.js in your project root by running:
touch test-connection.js
- Open test-connection.js in VS Code and paste the following code:
const { createClient } = require('@supabase/supabase-js');
// Load environment variables
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY
);
async function testConnection() {
// Insert a test person
const { data: inserted, error: insertError } = await supabase
.from('people')
.insert({ name: 'Test Person', notes: 'Connection test' })
.select()
.single();
if (insertError) {
console.error('Insert failed:', insertError.message);
return;
}
console.log('Inserted:', inserted);
// Read the person back
const { data: people, error: readError } = await supabase
.from('people')
.select('*')
.eq('id', inserted.id);
if (readError) {
console.error('Read failed:', readError.message);
return;
}
console.log('Read back:', people);
// Clean up the test record
await supabase.from('people').delete().eq('id', inserted.id);
console.log('Test passed! Connection to Supabase is working.');
}
testConnection();
What does this script do?
- It creates a Supabase client using your URL and anon key from environment variables.
- It inserts a test person named "Test Person" into the people table.
- It reads that person back to confirm the round-trip works.
- It deletes the test record so your database stays clean.
- Run the test script with the --env-file flag so Node.js loads your .env variables:
node --env-file=.env test-connection.js
- You should see output like:
Inserted: { id: '...', name: 'Test Person', notes: 'Connection test', created_at: '...' }
Read back: [ { id: '...', name: 'Test Person', notes: 'Connection test', created_at: '...' } ]
Test passed! Connection to Supabase is working.
Seeing "Insert failed" or "relation does not exist"?
- Double check that you ran both SQL statements in the SQL Editor and saw "Success" for each.
- Verify your SUPABASE_URL in .env starts with https:// and ends with .supabase.co.
- Make sure there are no extra spaces or quotes around the values in .env.
Why is my Supabase connection test failing?
- Once the test passes, delete the test file since you no longer need it:
rm test-connection.js
Your backend is now wired up to a real cloud database and storage bucket. Next, you will build the Express API that creates people, uploads files, and searches by name.
Build the Express API
Your Supabase project is set up with tables and storage ready to go. Now it's time to build the server that connects your frontend to all of that cloud infrastructure.
Express.js is a lightweight web framework for Node.js that handles incoming HTTP requests and routes them to the right logic. In this step, you'll write the entire API layer that lets users create people, upload files, search, and delete.
In this step, get ready to:
- Set up Express with middleware and initialize the Supabase client.
- Create API routes for adding and searching people.
- Create file upload and delete routes that work with Supabase Storage.
Set up Express and initialize the Supabase client
Before you can define any routes, the server needs to know which libraries to use, how to connect to Supabase, and how to handle incoming requests. This first block wires all of that together.
- Open server.js in VS Code and paste the following code:
const express = require('express');
const multer = require('multer');
const { createClient } = require('@supabase/supabase-js');
const path = require('path');
const crypto = require('crypto');
const app = express();
const PORT = process.env.PORT || 3000;
// Connect to Supabase using environment variables
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY
);
// Configure multer to hold files in memory before uploading to Supabase
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 }
});
// Parse JSON request bodies and serve static frontend files
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
What does this code do?
This block handles five responsibilities:
- Imports express, multer (file uploads), @supabase/supabase-js (database and storage client), path (file paths), and crypto (unique IDs).
- Creates the Express app and reads the port from the environment (defaulting to 3000).
- Initializes the Supabase client with your project URL and anon key from .env.
- Configures multer to store uploaded files in memory (as Buffers) with a 50 MB size limit matching the Supabase free tier maximum.
- Tells Express to parse JSON bodies and serve any files in the public/ folder as static assets.
- Save server.js.
Why memoryStorage instead of disk?
When you deploy to Render, the filesystem is ephemeral. Files saved to disk disappear on every deploy. By holding the file in memory briefly, you can forward it straight to Supabase Storage where it persists permanently.
Create the people routes
The app needs endpoints to add a new person and to search existing people by name. Each route talks directly to the people table you created in Supabase.
- Below the middleware lines you just wrote, add the route for creating a person:
// Create a person
app.post('/api/people', async (req, res) => {
const { name, notes } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const { data, error } = await supabase
.from('people')
.insert({ name, notes: notes || '' })
.select()
.single();
if (error) return res.status(500).json({ error: error.message });
res.status(201).json(data);
});
What does this route do?
- Reads name and notes from the request body.
- Returns a 400 error if no name is provided.
- Inserts a new row into the people table, then uses .select().single() to return the newly created record.
- Below the create route, add the search route:
// Search people by name
app.get('/api/people', async (req, res) => {
const { search } = req.query;
let query = supabase.from('people').select('*').order('created_at', { ascending: false });
if (search) {
query = query.ilike('name', `%${search}%`);
}
const { data, error } = await query;
if (error) return res.status(500).json({ error: error.message });
res.json(data);
});
What does this route do?
This GET endpoint returns all people, ordered newest first. If a ?search= query parameter is present, it uses PostgreSQL's ILIKE operator for case-insensitive partial matching. Searching "jo" would match "John", "Josephine", and "Major".
- Below the search route, add the route that lists files for a specific person:
// Get files for a person
app.get('/api/people/:id/files', async (req, res) => {
const { id } = req.params;
let query = supabase
.from('files')
.select('*')
.eq('person_id', id)
.order('created_at', { ascending: false });
const { data, error } = await query;
if (error) return res.status(500).json({ error: error.message });
res.json(data);
});
What does this route do?
This route takes a person's id from the URL path and queries the files table for all rows where person_id matches. The frontend uses this to display a person's uploaded files when they are selected.
- Save server.js.
Seeing red squiggles in your editor?
If VS Code shows errors on require or async, make sure you opened the people-file-vault folder itself in VS Code (not a parent folder). The editor needs to see package.json at the root to resolve dependencies.
help me fix editor errors in my Express project
Create the file upload and delete routes
The upload route is the most complex endpoint in the app. It receives a file from the browser, pushes it to Supabase Storage, then saves the metadata (name, type, size, URL) into the files table.
- Below the get-files route, add the file upload route:
// Upload a file for a person
app.post('/api/people/:id/files', upload.single('file'), async (req, res) => {
const { id } = req.params;
const file = req.file;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
// Generate a unique storage path using the person's ID and a random UUID
const fileExt = path.extname(file.originalname);
const fileName = `${id}/${crypto.randomUUID()}${fileExt}`;
// Upload the file buffer to Supabase Storage
const { error: uploadError } = await supabase.storage
.from('uploads')
.upload(fileName, file.buffer, {
contentType: file.mimetype
});
if (uploadError) return res.status(500).json({ error: uploadError.message });
// Get the public URL for the uploaded file
const { data: urlData } = supabase.storage
.from('uploads')
.getPublicUrl(fileName);
// Save file metadata to the database
const { data, error: dbError } = await supabase
.from('files')
.insert({
person_id: id,
file_name: file.originalname,
file_type: file.mimetype,
file_size: file.size,
storage_path: fileName,
public_url: urlData.publicUrl
})
.select()
.single();
if (dbError) return res.status(500).json({ error: dbError.message });
res.status(201).json(data);
});
What does this route do?
This route performs three operations in sequence:
- multer intercepts the incoming file and stores it in memory as req.file.buffer.
- The buffer is uploaded to the uploads bucket in Supabase Storage with a unique path (personId/randomUUID.ext).
- The file's metadata (original name, MIME type, size, storage path, and public URL) is saved to the files table so the app can list and link to it later.
- Below the upload route, add the delete route:
// Delete a file
app.delete('/api/files/:id', async (req, res) => {
const { id } = req.params;
// Look up the file's storage path before deleting
const { data: file, error: fetchError } = await supabase
.from('files')
.select('storage_path')
.eq('id', id)
.single();
if (fetchError) return res.status(500).json({ error: fetchError.message });
// Remove the file from Supabase Storage
await supabase.storage.from('uploads').remove([file.storage_path]);
// Delete the metadata row from the database
const { error: deleteError } = await supabase
.from('files')
.delete()
.eq('id', id);
if (deleteError) return res.status(500).json({ error: deleteError.message });
res.json({ message: 'File deleted' });
});
What does this route do?
Deleting a file requires two steps: removing the actual file from Supabase Storage, then deleting the metadata row from the files table. The route looks up the storage_path first so it knows which file to remove from the bucket.
- Finally, add the server start at the bottom of server.js:
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
- Save server.js.
- Start the server locally by running this command in your terminal:
npm run dev
You should see Server running on port 3000 printed in the terminal.
Why npm run dev instead of npm start?
The dev script runs node --env-file=.env server.js, which loads your .env file automatically using Node's built-in --env-file flag. The start script skips this because Render injects environment variables directly at runtime.
Seeing an error about missing .env or Supabase?
- If you see an error about the .env file not found, make sure you are running the command from inside your people-file-vault folder. The --env-file flag looks for the file relative to where you run the command.
- If you see a Supabase connection error, double-check that SUPABASE_URL and SUPABASE_ANON_KEY in your .env file match the values from your Supabase project settings.
help me fix my Express server startup error
✔️ Awesome, I've got everything!
Great. Double check you've saved server.js and your terminal shows Server running on port 3000.
ⓧ I'd like to double check the full code
Here is the complete server.js file. Compare yours against this:
const express = require('express');
const multer = require('multer');
const { createClient } = require('@supabase/supabase-js');
const path = require('path');
const crypto = require('crypto');
const app = express();
const PORT = process.env.PORT || 3000;
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY
);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 }
});
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// Create a person
app.post('/api/people', async (req, res) => {
const { name, notes } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const { data, error } = await supabase
.from('people')
.insert({ name, notes: notes || '' })
.select()
.single();
if (error) return res.status(500).json({ error: error.message });
res.status(201).json(data);
});
// Search people by name
app.get('/api/people', async (req, res) => {
const { search } = req.query;
let query = supabase.from('people').select('*').order('created_at', { ascending: false });
if (search) {
query = query.ilike('name', `%${search}%`);
}
const { data, error } = await query;
if (error) return res.status(500).json({ error: error.message });
res.json(data);
});
// Get files for a person
app.get('/api/people/:id/files', async (req, res) => {
const { id } = req.params;
let query = supabase
.from('files')
.select('*')
.eq('person_id', id)
.order('created_at', { ascending: false });
const { data, error } = await query;
if (error) return res.status(500).json({ error: error.message });
res.json(data);
});
// Upload a file for a person
app.post('/api/people/:id/files', upload.single('file'), async (req, res) => {
const { id } = req.params;
const file = req.file;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
const fileExt = path.extname(file.originalname);
const fileName = `${id}/${crypto.randomUUID()}${fileExt}`;
const { error: uploadError } = await supabase.storage
.from('uploads')
.upload(fileName, file.buffer, {
contentType: file.mimetype
});
if (uploadError) return res.status(500).json({ error: uploadError.message });
const { data: urlData } = supabase.storage
.from('uploads')
.getPublicUrl(fileName);
const { data, error: dbError } = await supabase
.from('files')
.insert({
person_id: id,
file_name: file.originalname,
file_type: file.mimetype,
file_size: file.size,
storage_path: fileName,
public_url: urlData.publicUrl
})
.select()
.single();
if (dbError) return res.status(500).json({ error: dbError.message });
res.status(201).json(data);
});
// Delete a file
app.delete('/api/files/:id', async (req, res) => {
const { id } = req.params;
const { data: file, error: fetchError } = await supabase
.from('files')
.select('storage_path')
.eq('id', id)
.single();
if (fetchError) return res.status(500).json({ error: fetchError.message });
await supabase.storage.from('uploads').remove([file.storage_path]);
const { error: deleteError } = await supabase
.from('files')
.delete()
.eq('id', id);
if (deleteError) return res.status(500).json({ error: deleteError.message });
res.json({ message: 'File deleted' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Your API is live and listening for requests. Next up, you'll build the frontend that lets users interact with these endpoints through a browser.
Build the Frontend
Your API is built and ready to handle requests. Now you need a way for users to actually interact with it.
In this step, you'll create the browser-based interface that talks to your Express API. The frontend includes a search bar, a form to add people, clickable person cards, and a file upload area that appears when someone is selected.
In this step, get ready to:
- Create the HTML structure with a search bar, forms, and a file upload section.
- Style the interface with CSS for a clean, card-based layout.
- Write the frontend JavaScript to handle search, file uploads, and display results.
Create the HTML structure
The HTML file defines the skeleton of your app. It contains all the sections users will see and interact with.
- Open public/index.html in VS Code and add the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>People File Vault</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>People File Vault</h1>
<!-- Search bar for finding people by name -->
<div class="search-section">
<input type="text" id="searchInput" placeholder="Search for a person...">
</div>
<!-- Form to add a new person -->
<div class="add-person-section">
<h2>Add a Person</h2>
<form id="addPersonForm">
<input type="text" id="personName" placeholder="Name" required>
<input type="text" id="personNotes" placeholder="Notes (optional)">
<button type="submit">Add Person</button>
</form>
</div>
<!-- People results rendered by JavaScript -->
<div class="results-section">
<h2>People</h2>
<div id="peopleList"></div>
</div>
<!-- File section shown when a person is selected -->
<div class="files-section" id="filesSection" style="display: none;">
<h2>Files for <span id="selectedPersonName"></span></h2>
<form id="uploadForm">
<input type="file" id="fileInput" required>
<button type="submit">Upload File</button>
</form>
<div id="filesList"></div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>
What does this HTML do?
- The #searchInput field lets users type a name to search for.
- The #addPersonForm sends the name and notes to your POST /api/people endpoint.
- The #filesSection is hidden by default with display: none and only appears when JavaScript reveals it after a person is clicked.
- The #peopleList and #filesList divs are empty containers that JavaScript will fill with dynamic content.
- Save public/index.html.
- Start your dev server by running this command in your terminal:
npm run dev
- Open http://localhost:3000 in your browser. You should see the heading "People File Vault", a search input, the Add a Person form with two text inputs and a button, and a "People" heading with nothing beneath it.
Seeing a blank page or an error?
- Make sure you saved the file. Check that the terminal shows Server running on port 3000 after running npm run dev.
- Confirm the file is saved as index.html inside the public/ folder, not in the project root.
Why isn't my page showing up at localhost:3000?
Style the interface with CSS
The page works but looks bare. CSS will give it a polished card-based layout with clear visual hierarchy.
- Open public/style.css and add the base layout and form styles:
/* Reset browser defaults */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Page-level layout */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #2d3748;
}
h2 {
margin-bottom: 15px;
color: #4a5568;
}
/* Search bar */
.search-section {
margin-bottom: 30px;
}
#searchInput {
width: 100%;
padding: 12px 16px;
font-size: 16px;
border: 2px solid #e2e8f0;
border-radius: 8px;
outline: none;
}
#searchInput:focus {
border-color: #4299e1;
}
/* Add person form card */
.add-person-section {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.add-person-section form {
display: flex;
gap: 10px;
}
.add-person-section input {
flex: 1;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 14px;
}
/* Shared button style */
button {
padding: 10px 20px;
background: #4299e1;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
button:hover {
background: #3182ce;
}
What does this CSS do?
- The reset (* { box-sizing: border-box }) ensures padding doesn't expand element widths unexpectedly.
- The .container caps the content at 800px and centers it on wider screens.
- The .add-person-section uses flexbox to lay out the inputs and button in a single row.
- Below the button styles you just added, paste the person card and file section styles:
/* Results area */
.results-section {
margin-bottom: 30px;
}
/* Clickable person cards */
.person-card {
background: white;
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 10px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s;
}
.person-card:hover {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.person-card h3 {
color: #2d3748;
}
.person-card p {
color: #718096;
font-size: 14px;
}
/* Files section (shown after selecting a person) */
.files-section {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
#uploadForm {
display: flex;
gap: 10px;
margin-bottom: 20px;
align-items: center;
}
#fileInput {
flex: 1;
}
/* Individual file rows */
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 8px;
}
.file-item a {
color: #4299e1;
text-decoration: none;
font-weight: 500;
}
.file-item a:hover {
text-decoration: underline;
}
.file-item .file-meta {
color: #718096;
font-size: 12px;
}
.file-item .delete-btn {
background: #e53e3e;
padding: 6px 12px;
font-size: 12px;
}
.file-item .delete-btn:hover {
background: #c53030;
}
.empty-message {
text-align: center;
color: #a0aec0;
padding: 20px;
}
What does this CSS do?
- The .person-card uses cursor: pointer and a hover shadow transition to signal that cards are clickable.
- The .file-item uses flexbox with justify-content: space-between to push the file name to the left and the delete button to the right.
- The .delete-btn is styled red to clearly distinguish destructive actions from the default blue buttons.
- Save public/style.css and refresh your browser.
- You should see a centered page with a full-width search bar at the top, a white card containing the Add a Person form with side-by-side inputs, and a blue "Add Person" button.
Page still looks unstyled?
- Check that your index.html has <link rel="stylesheet" href="style.css"> in the <head> section.
- Both files must be in the same public/ folder. The href path is relative, so it only works if both files are siblings.
Why isn't my CSS loading in the browser?
✔️ Awesome, I've got everything!
Great. Make sure you have saved public/style.css before moving on.
ⓧ I'd like to double check the full code
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #2d3748;
}
h2 {
margin-bottom: 15px;
color: #4a5568;
}
.search-section {
margin-bottom: 30px;
}
#searchInput {
width: 100%;
padding: 12px 16px;
font-size: 16px;
border: 2px solid #e2e8f0;
border-radius: 8px;
outline: none;
}
#searchInput:focus {
border-color: #4299e1;
}
.add-person-section {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.add-person-section form {
display: flex;
gap: 10px;
}
.add-person-section input {
flex: 1;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 14px;
}
button {
padding: 10px 20px;
background: #4299e1;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
button:hover {
background: #3182ce;
}
.results-section {
margin-bottom: 30px;
}
.person-card {
background: white;
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 10px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s;
}
.person-card:hover {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.person-card h3 {
color: #2d3748;
}
.person-card p {
color: #718096;
font-size: 14px;
}
.files-section {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
#uploadForm {
display: flex;
gap: 10px;
margin-bottom: 20px;
align-items: center;
}
#fileInput {
flex: 1;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 8px;
}
.file-item a {
color: #4299e1;
text-decoration: none;
font-weight: 500;
}
.file-item a:hover {
text-decoration: underline;
}
.file-item .file-meta {
color: #718096;
font-size: 12px;
}
.file-item .delete-btn {
background: #e53e3e;
padding: 6px 12px;
font-size: 12px;
}
.file-item .delete-btn:hover {
background: #c53030;
}
.empty-message {
text-align: center;
color: #a0aec0;
padding: 20px;
}
Write the frontend JavaScript
The page has structure and style, but nothing happens when you type or click. The JavaScript file connects your HTML to the Express API using fetch calls.
- Open public/app.js and add the state, DOM references, and display functions:
// Track which person is currently selected
let selectedPersonId = null;
// Cache references to DOM elements
const searchInput = document.getElementById('searchInput');
const addPersonForm = document.getElementById('addPersonForm');
const uploadForm = document.getElementById('uploadForm');
const peopleList = document.getElementById('peopleList');
const filesSection = document.getElementById('filesSection');
const filesList = document.getElementById('filesList');
const selectedPersonName = document.getElementById('selectedPersonName');
// Fetch people from the API, optionally filtering by name
async function searchPeople(query) {
const url = query ? `/api/people?search=${encodeURIComponent(query)}` : '/api/people';
const res = await fetch(url);
const people = await res.json();
renderPeople(people);
}
// Build clickable person cards from the API response
function renderPeople(people) {
if (people.length === 0) {
peopleList.innerHTML = '<p class="empty-message">No people found.</p>';
return;
}
peopleList.innerHTML = people.map(person => `
<div class="person-card" onclick="selectPerson('${person.id}', '${person.name.replace(/'/g, "\\\'")}')">
<h3>${person.name}</h3>
<p>${person.notes || 'No notes'}</p>
</div>
`).join('');
}
What does this code do?
- selectedPersonId stores which person the user last clicked. Upload and file-fetch calls use this ID.
- searchPeople() builds the URL with an optional ?search= query string, then calls your GET /api/people endpoint.
- renderPeople() maps the JSON array into HTML cards. Each card calls selectPerson() on click to load that person's files.
- Below the renderPeople function, add the file management functions:
// Show the files section for the selected person
async function selectPerson(id, name) {
selectedPersonId = id;
selectedPersonName.textContent = name;
filesSection.style.display = 'block';
await loadFiles();
}
// Fetch files for the currently selected person
async function loadFiles() {
const res = await fetch(`/api/people/${selectedPersonId}/files`);
const files = await res.json();
renderFiles(files);
}
// Convert bytes to a human-readable size string
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// Display file rows with download links and delete buttons
function renderFiles(files) {
if (files.length === 0) {
filesList.innerHTML = '<p class="empty-message">No files uploaded yet.</p>';
return;
}
filesList.innerHTML = files.map(file => `
<div class="file-item">
<div>
<a href="${file.public_url}" target="_blank">${file.file_name}</a>
<div class="file-meta">${file.file_type} - ${formatFileSize(file.file_size)}</div>
</div>
<button class="delete-btn" onclick="deleteFile('${file.id}')">Delete</button>
</div>
`).join('');
}
// Remove a file from storage and the database
async function deleteFile(fileId) {
if (!confirm('Delete this file?')) return;
await fetch(`/api/files/${fileId}`, { method: 'DELETE' });
await loadFiles();
}
What does this code do?
- selectPerson() reveals the hidden files section and immediately fetches that person's uploaded files.
- formatFileSize() converts raw byte counts (like 1048576) into readable labels (like "1.0 MB").
- deleteFile() sends a DELETE request to your API, which removes the file from both Supabase Storage and the database.
- Below the deleteFile function, add the event listeners and the initial page load call:
// Search as the user types
searchInput.addEventListener('input', (e) => {
searchPeople(e.target.value);
});
// Handle adding a new person
addPersonForm.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('personName').value;
const notes = document.getElementById('personNotes').value;
await fetch('/api/people', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, notes })
});
document.getElementById('personName').value = '';
document.getElementById('personNotes').value = '';
searchPeople(searchInput.value);
});
// Handle file uploads
uploadForm.addEventListener('submit', async (e) => {
e.preventDefault();
const fileInput = document.getElementById('fileInput');
const formData = new FormData();
formData.append('file', fileInput.files[0]);
await fetch(`/api/people/${selectedPersonId}/files`, {
method: 'POST',
body: formData
});
fileInput.value = '';
await loadFiles();
});
// Load all people on first page visit
searchPeople('');
What does this code do?
- The input listener on the search bar fires on every keystroke, calling your search API in real time.
- The add-person submit handler sends a POST with JSON, clears the inputs, then re-runs the search so the new person appears immediately.
- The upload handler uses FormData to send the file as multipart data. This is what multer on the server expects.
- searchPeople('') at the bottom loads all people when the page first opens.
- Save public/app.js and refresh your browser at http://localhost:3000.
- Type a name (like Alice) into the **Name** field, optionally add notes, then click **Add Person**.
- You should see a white card appear below the "People" heading showing the name you entered.
- Click the person card. A "Files for Alice" section should appear below with a file input and an "Upload File" button.
- Choose any small file (an image or PDF) and click **Upload File**. The file should appear in a row with its name as a download link, its MIME type, size, and a red Delete button.
Person not appearing or upload failing?
- Open your browser's DevTools console (press **F12**, then click the **Console** tab). Look for any red error messages that point to the problem.
- If you see a 500 error on /api/people, check that your .env file has the correct SUPABASE_URL and SUPABASE_ANON_KEY values.
- If the upload fails, confirm your uploads storage bucket in Supabase has public access enabled.
Help me debug why my frontend can't talk to the API.
✔️ Awesome, I've got everything!
Great. Make sure all three files in public/ are saved before moving on.
ⓧ I'd like to double check the full code
let selectedPersonId = null;
const searchInput = document.getElementById('searchInput');
const addPersonForm = document.getElementById('addPersonForm');
const uploadForm = document.getElementById('uploadForm');
const peopleList = document.getElementById('peopleList');
const filesSection = document.getElementById('filesSection');
const filesList = document.getElementById('filesList');
const selectedPersonName = document.getElementById('selectedPersonName');
async function searchPeople(query) {
const url = query ? `/api/people?search=${encodeURIComponent(query)}` : '/api/people';
const res = await fetch(url);
const people = await res.json();
renderPeople(people);
}
function renderPeople(people) {
if (people.length === 0) {
peopleList.innerHTML = '<p class="empty-message">No people found.</p>';
return;
}
peopleList.innerHTML = people.map(person => `
<div class="person-card" onclick="selectPerson('${person.id}', '${person.name.replace(/'/g, "\\\'")}')">
<h3>${person.name}</h3>
<p>${person.notes || 'No notes'}</p>
</div>
`).join('');
}
async function selectPerson(id, name) {
selectedPersonId = id;
selectedPersonName.textContent = name;
filesSection.style.display = 'block';
await loadFiles();
}
async function loadFiles() {
const res = await fetch(`/api/people/${selectedPersonId}/files`);
const files = await res.json();
renderFiles(files);
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function renderFiles(files) {
if (files.length === 0) {
filesList.innerHTML = '<p class="empty-message">No files uploaded yet.</p>';
return;
}
filesList.innerHTML = files.map(file => `
<div class="file-item">
<div>
<a href="${file.public_url}" target="_blank">${file.file_name}</a>
<div class="file-meta">${file.file_type} - ${formatFileSize(file.file_size)}</div>
</div>
<button class="delete-btn" onclick="deleteFile('${file.id}')">Delete</button>
</div>
`).join('');
}
async function deleteFile(fileId) {
if (!confirm('Delete this file?')) return;
await fetch(`/api/files/${fileId}`, { method: 'DELETE' });
await loadFiles();
}
searchInput.addEventListener('input', (e) => {
searchPeople(e.target.value);
});
addPersonForm.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('personName').value;
const notes = document.getElementById('personNotes').value;
await fetch('/api/people', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, notes })
});
document.getElementById('personName').value = '';
document.getElementById('personNotes').value = '';
searchPeople(searchInput.value);
});
uploadForm.addEventListener('submit', async (e) => {
e.preventDefault();
const fileInput = document.getElementById('fileInput');
const formData = new FormData();
formData.append('file', fileInput.files[0]);
await fetch(`/api/people/${selectedPersonId}/files`, {
method: 'POST',
body: formData
});
fileInput.value = '';
await loadFiles();
});
searchPeople('');
Your People File Vault is fully functional on your local machine. You can add people, search by name, upload files, and delete them. Next, you'll deploy the whole thing to Render so anyone with the URL can use it.
Deploy to Render
Your People File Vault is working locally, which means all the pieces are in place. The final step is putting it on the internet so anyone with the URL can use it.
To deploy, you need two things: a GitHub repository to store your code, and a hosting platform to run it. Render connects directly to GitHub and automatically builds your app whenever you push new code.
In this step, get ready to:
- Push your project to a GitHub repository.
- Deploy the app on Render with environment variables.
- Test the live app at a public URL.
Initialize Git and push to GitHub
Render pulls your code from a Git repository. You need to create a .gitignore file first so that sensitive files and bulky dependencies stay out of your repository.
- Create a .gitignore file in your people-file-vault folder by running this command:
printf 'node_modules/\n.env\n' > .gitignore
What does this file do?
The .gitignore file tells Git which files to skip when tracking changes.
The node_modules/ folder contains thousands of dependency files that Render reinstalls from package.json anyway. The .env file holds your Supabase secrets, which should never be committed to a public repository.
- Confirm the file was created by running:
cat .gitignore
You should see two lines: node_modules/ and .env.
- Initialize a Git repository and make your first commit by running these commands:
git init
git add .
git commit -m "Initial commit: People File Vault"
What do these commands do?
- git init creates a new Git repository in your project folder.
- git add . stages all files (except those in .gitignore) for the commit.
- git commit -m saves a snapshot of your code with a descriptive message.
- Open GitHub in your browser and create a new repository.
- Click the + icon in the top-right corner, then select New repository.
- Name the repository people-file-vault.
- Leave it set to Public.
- Do NOT check any of the initialization boxes (no README, no .gitignore, no license).
- Click Create repository.
GitHub shows a page with setup commands. You need the commands under the "push an existing repository" section.
- Connect your local repository to GitHub and push your code by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/people-file-vault.git"]]
git branch -M main
git push -u origin main
- Refresh your GitHub repository page in the browser.
You should see all your project files listed: server.js, package.json, .gitignore, and the public/ folder. You should NOT see node_modules/ or .env.
Push failed or permission denied?
- If you see a permission error, make sure you are signed into the correct GitHub account in your terminal. You may need to set up a personal access token or SSH key for authentication.
- If the repository was not empty (you accidentally checked a README box), delete the GitHub repo and recreate it without any initialization options.
Help me push my code to GitHub. I'm getting a permission or authentication error.
Create a Render Web Service
Render is a cloud hosting platform that can run your Node.js server 24/7 at a public URL. The free tier gives you 750 instance hours per month, which is plenty for this project.
- Open https://render.com in your browser.
- Click Get Started and sign up using your GitHub account.
- Authorize Render to access your GitHub repositories when prompted.
Why sign up with GitHub?
Signing up with GitHub automatically connects your repositories to Render. This means Render can pull your code and redeploy whenever you push changes.
- In the Render Dashboard, click + New in the top-right corner.
- Select Web Service from the dropdown menu.
- Select Git Provider, then choose your people-file-vault repository from the list.
- Click Connect.
The service creation form appears with fields to configure your deployment.
- Enter people-file-vault in the Name field.
- Select Node as the Language.
- Enter npm install in the Build Command field.
- Enter node server.js in the Start Command field.
- Select the Free instance type.
What are build and start commands?
The build command (npm install) runs once during deployment to install your dependencies from package.json. The start command (node server.js) runs your server after the build finishes.
Render injects a PORT environment variable automatically. Your server.js already reads process.env.PORT, so it will bind to the correct port on Render without any changes.
Now you need to add your Supabase credentials so the deployed app can connect to your database.
- Scroll down to the Advanced section and expand it.
- Under Environment Variables, click Add Environment Variable.
- Add the first variable with key SUPABASE_URL and paste your Supabase project URL as the value (the same URL from your local .env file).
- Click Add Environment Variable again.
- Add the second variable with key SUPABASE_ANON_KEY and paste your Supabase anon key as the value.
- Click Create Web Service.
Render kicks off the first build and deploy. You can watch the progress in the Events log.
Build failed?
- Check that your Build Command is exactly npm install and your Start Command is exactly node server.js.
- Make sure your package.json was committed and pushed to GitHub. It must be in the root of the repository.
- Verify that both SUPABASE_URL and SUPABASE_ANON_KEY environment variables are set correctly with no extra spaces.
Help me debug a failed Render deployment. My Node.js app is not building or starting correctly.
Test the live deployment
Once the deploy finishes, Render assigns your app a public URL. The deployment typically takes 2-3 minutes.
- Wait for the deploy status to show Live in the Render Dashboard.
- Click the URL at the top of your service page (it looks like https://people-file-vault.onrender.com).
Why does the first load take a moment?
Render's free tier spins down your service after 15 minutes of inactivity. The first request after a spin-down takes about a minute to wake the server back up. Subsequent requests respond quickly.
- Test adding a person: type a name in the Name field and click Add Person.
- Click the person card that appears to select them.
- Test uploading a file: click Choose File, select any small file (an image or PDF), then click Upload File.
- Test search: type part of the person's name in the search bar and confirm the results filter correctly.
- Click the file's download link to confirm it opens from Supabase Storage.
You should see your app working exactly like it did locally. The person appears in the list, the file shows up under their name, and the download link opens the file from Supabase Storage.
App loads but API calls fail?
- Open your browser's developer console (F12) and check the Console and Network tabs for errors.
- A 500 error usually means the environment variables are wrong. Return to your Render service's Environment settings and double-check the SUPABASE_URL and SUPABASE_ANON_KEY values.
- After fixing environment variables, click Manual Deploy then Deploy latest commit to trigger a redeployment.
Help me debug my deployed app on Render. The page loads but adding people or uploading files returns errors.
Secret mission
Add File Type Filtering
Your vault stores all kinds of files, but finding that one PDF buried among dozens of images is painful. What if users could filter by file type with a single click?
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 uses Supabase (free tier) and Render (free tier), so there are no ongoing costs. Both services auto-pause when idle.
Resources you used:
- Supabase project (database tables, storage bucket, 500 MB database, 1 GB file storage).
- Render web service (free tier, 512 MB RAM).
- GitHub repository.
- Local people-file-vault project folder.
Keep everything running
No action needed. Choose this if you are still actively building or want to show the app to others.
- Your Render service spins down after 15 minutes without traffic and wakes automatically on the next request.
- Your Supabase project pauses after 1 week of inactivity. Visit your app occasionally to keep it active.
- Your local project folder and GitHub repository stay as they are.
Pause - I'll come back to this later
Both services pause automatically when idle. No manual action is needed.
- Render spins down your service after 15 minutes of no traffic. It wakes on the next request (takes about 1 minute).
- Supabase pauses your project after 1 week of inactivity. You can unpause it from the Supabase Dashboard when you return.
- Your local files and GitHub repository remain intact for when you come back.
Delete - I don't want to use this again
Remove all project resources to fully clean up.
- Delete the Render web service: open your Render Dashboard, select the People File Vault service, go to Settings, scroll to the bottom, and click Delete Web Service.
- Delete the Supabase project: open your Supabase Dashboard, select the People File Vault project, go to Project Settings, scroll to the Danger Zone section, and click Delete project.
- Delete the GitHub repository: open the repo on GitHub, go to Settings, scroll to the Danger Zone section, and click Delete this repository.
- Delete the local project folder by running this command:
rm -rf ~/Desktop/people-file-vault
On Windows, run this instead:
Remove-Item -Recurse -Force ~\Desktop\people-file-vault
Nice Work!
Nice Work!
You just built and deployed a full-stack file management application from scratch. Your People File Vault is live on the internet, storing data in a cloud database and serving files from cloud storage.
You've learned how to:
- Built a REST API with Express.js that handles file uploads via multer and stores metadata in PostgreSQL.
- Used Supabase as a backend-as-a-service for both a cloud database (people and files tables) and cloud file storage.
- Deployed a full-stack Node.js application to Render with a public URL anyone can access.
- Secret Mission: Added file type filtering with categorized API queries and interactive filter buttons.
Ready to quiz yourself?