Build a Persistent CRUD API
Build an Express CRUD API with Drizzle and persistent Supabase PostgreSQL.
Introduction
30 Second Summary
A task list only helps when your tasks are still there the next time you need them. Temporary data turns a useful tool into a disappearing note.
In this project, you will build a JavaScript REST API for creating, reading, updating, and deleting tasks. You will connect Express to a PostgreSQL database hosted by Supabase through Drizzle ORM so every task survives a server restart.
What You'll Build
TaskFlow will answer PowerShell requests with persistent JSON task data, even after an Express restart.
By the end of this project, you'll have:
- A persistent task workflow where you create a task, restart Express, and retrieve the same stored row.
- Complete CRUD controls that retrieve tasks by ID, update their fields, and return deleted rows as confirmation.
- Predictable JSON error responses for invalid IDs, invalid bodies, missing tasks, and unknown routes.
- Secret Mission: Add completion filters so ?completed=true and ?completed=false return only matching tasks.
Are there any prerequisites?
You need basic JavaScript familiarity with functions, objects, arrays, and async/await.
You also need Visual Studio Code, Node.js, and a Supabase account. The first step checks your Node.js version before you build anything.
Before We Start
Before any hands-on work, commit to building TaskFlow as a persistent task CRUD API. PostgreSQL gives each task durable storage so it survives Express server restarts.
Set Up the JavaScript API
Setup uncertainty can make every later bug harder to diagnose. This step gives TaskFlow a known local foundation.
A verified Node.js runtime gives every later route a stable foundation. A small Express health route proves requests can reach the server.
In this step, get ready to:
- Verify that Node.js and npm meet the API's runtime requirements.
- Prepare the taskflow-api workspace with its pinned package manifest.
- Prove the Express server works through its health response.
Verify Node.js and npm
Express 5 requires Node.js 18 or newer. The version check confirms that your runtime can support the pinned packages before you install them.
- Press the Windows key to open Windows search.
- Type Windows PowerShell into the search field.
- Press Enter to open PowerShell.
- Check the installed Node.js and npm versions by running these commands:
node --version
npm --version
How to read the versions
The first line reports your Node.js version. The second line reports the npm version bundled with your runtime.
The Node.js result determines your next action. Choose the tab that matches what PowerShell shows.
✔️ Node.js meets the requirement
Your Node.js result is 18 or higher. Your runtime can support Express 5.
- Keep this PowerShell window open for the project setup commands.
ⓧ I see an older version
Your installed runtime is below the minimum required by Express 5. Updating it now prevents dependency and syntax failures later.
- Visit the official Node.js download page.
- Download Node.js v24.21.0 LTS for Windows.
- Complete the installer using its default options.
- Close the current PowerShell window after installation.
- Press the Windows key to reopen Windows search.
- Type Windows PowerShell into the search field.
- Press Enter to open a fresh PowerShell window.
- Verify the updated runtime by running these commands:
node --version
npm --version
What should change?
The fresh PowerShell window reads the updated system path. You should now see Node.js v24.21.0 LTS plus an npm version.
ⓧ Command not found
PowerShell cannot currently find Node.js. Installing the current LTS release adds the runtime plus npm to your computer.
- Visit the official Node.js download page.
- Download Node.js v24.21.0 LTS for Windows.
- Complete the installer using its default options.
- Close the current PowerShell window after installation.
- Press the Windows key to reopen Windows search.
- Type Windows PowerShell into the search field.
- Press Enter to open a fresh PowerShell window.
- Verify the installation by running these commands:
node --version
npm --version
What should appear?
PowerShell should now report Node.js v24.21.0 LTS plus an npm version. Those two results confirm the installation is available to your shell.
Configure the project workspace
The package.json file records how TaskFlow starts. It also locks every dependency to the version used throughout this project.
- Create the taskflow-api folder on your Desktop by running these commands:
Set-Location -Path $HOME\Desktop
New-Item -ItemType Directory -Path taskflow-api
Set-Location -Path taskflow-api
What do these commands do?
- The first command moves PowerShell to your Desktop.
- The second command creates the taskflow-api folder.
- The final command makes that folder your current location.
Visual Studio Code may show a trust prompt when it opens this new folder. You created the folder yourself, so it is safe to trust.
- Open the current folder in Visual Studio Code by running this command:
code .
What does this command do?
The dot represents your current folder. Visual Studio Code opens taskflow-api as the active workspace.
- Confirm that you trust the folder if Visual Studio Code asks.
- Press Ctrl+` in Visual Studio Code to open its integrated terminal.
The terminal prompt should end with taskflow-api. This confirms new files and packages land in the correct folder.
Visual Studio Code did not open?
The Visual Studio Code command may be missing from your system path. Open Visual Studio Code through Windows search instead.
- Select File from the top menu.
- Select Open Folder....
- Choose the taskflow-api folder from your Desktop.
Ask for help if the folder still does not open: Help me open my taskflow-api folder in Visual Studio Code on Windows.
- Create the initial npm package manifest from the Visual Studio Code terminal by running:
npm init -y
What does this command do?
npm creates package.json without opening its setup questionnaire. The generated file gives the project a package name plus a starting version.
You should now see package.json in the Visual Studio Code Explorer. That file proves npm initialized the workspace.
Package manifest missing?
Check that the terminal prompt ends with taskflow-api. Running the command from another folder creates the manifest in the wrong location.
Ask for help if npm does not create the file: Help me troubleshoot npm init inside my taskflow-api folder.
- Install the exact runtime dependencies by running:
npm install --save-exact express@5.2.1 drizzle-orm@0.45.3 postgres@3.4.9 dotenv@18.0.5
What are these dependencies for?
- Express receives each HTTP request and sends the response.
- Drizzle ORM provides the database query layer used later.
- Postgres.js provides the PostgreSQL connection driver.
- dotenv loads configuration from an environment file.
- The exact-version option prevents npm from storing version ranges.
The install may stay busy while npm downloads the packages. You will see a package summary when the command finishes.
Dependency installation failed?
Confirm that PowerShell still has internet access. Check that the terminal prompt ends with taskflow-api before trying again.
Ask for help with the terminal output: Help me troubleshoot the TaskFlow runtime dependency installation.
- Install the exact Drizzle Kit development dependency by running:
npm install --save-dev --save-exact drizzle-kit@0.31.11
Why is Drizzle Kit a development dependency?
Drizzle Kit applies schema changes during development. TaskFlow does not need the toolkit inside its running API process.
The terminal should finish with another package summary. The project now has every dependency needed by the later database steps.
Drizzle Kit did not install?
Check that the command uses version 0.31.11. A missing digit can request a package version that does not exist.
Ask for help with the installation output: Help me troubleshoot the pinned Drizzle Kit installation.
- Select package.json in the Visual Studio Code Explorer.
- Replace its contents with this complete package manifest:
{
"name": "taskflow-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js",
"db:push": "drizzle-kit push"
},
"dependencies": {
"dotenv": "18.0.5",
"drizzle-orm": "0.45.3",
"express": "5.2.1",
"postgres": "3.4.9"
},
"devDependencies": {
"drizzle-kit": "0.31.11"
}
}
What does this manifest configure?
- The private setting protects this local project from accidental package publication.
- The module type enables JavaScript import syntax.
- The start script runs src/server.js.
- The db:push script applies the database schema in a later step.
- Every dependency uses an exact version for repeatable results.
- Save package.json by pressing Ctrl+S.
Your saved manifest should show the start and db:push scripts. It should also show the five pinned packages.
✔️ Awesome, I've got everything!
Your package manifest now matches the pinned setup for TaskFlow.
ⓧ I'd like to double check the full code
{
"name": "taskflow-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js",
"db:push": "drizzle-kit push"
},
"dependencies": {
"dotenv": "18.0.5",
"drizzle-orm": "0.45.3",
"express": "5.2.1",
"postgres": "3.4.9"
},
"devDependencies": {
"drizzle-kit": "0.31.11"
}
}
Build and test the health route
A health route provides the smallest useful proof that the server is reachable. Its response isolates runtime setup from the task logic you add later.
- Create the src folder plus src/server.js by running these commands in the Visual Studio Code terminal:
New-Item -ItemType Directory -Path src
New-Item -ItemType File -Path src\server.js
What do these commands create?
The first command creates the src folder for application code. The second command creates the empty server file inside it.
You should now see src in the Explorer. Expanding it should reveal server.js.
- Select src/server.js in the Visual Studio Code Explorer.
- Add the health server by pasting this code into the file:
import express from 'express';
const app = express();
const port = 3000;
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(port, () => {
console.log(`TaskFlow API running at http://localhost:${port}`);
});
What does this server do?
- The import loads Express into the server file.
- The app variable holds the Express application.
- The port variable keeps the local port at 3000.
- The express.json() middleware prepares the API to read JSON request bodies.
- The GET /health route returns a status object.
- The listener starts the server and reports its local address.
- Save src/server.js by pressing Ctrl+S.
✔️ Awesome, I've got everything!
Your server file now contains the middleware, health route, and listener required for the first API check.
ⓧ I'd like to double check the full code
import express from 'express';
const app = express();
const port = 3000;
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(port, () => {
console.log(`TaskFlow API running at http://localhost:${port}`);
});
Before you start the server, what message do you expect the terminal to show when Express begins listening?
- Start TaskFlow from the Visual Studio Code terminal by running:
npm start
What should the server show?
The start script runs src/server.js. You should see TaskFlow API running at http://localhost:3000 in the terminal.
The terminal remains occupied while Express listens for requests. Keep this process running for the health check.
Server did not start?
Check that src/server.js is saved. Compare the start script in package.json with the full-code tab above.
Ask for help with the startup output: Help me troubleshoot why the TaskFlow Express server will not start.
- Switch back to the Windows PowerShell window from the start of this step.
Before you send the request, what status value do you expect the health route to return?
- Call the running health endpoint from PowerShell by running:
Invoke-RestMethod -Uri http://localhost:3000/health -Method Get
What should I see?
PowerShell should show an object whose status value is ok. That response proves your request reached Express.
Health request did not work?
Confirm that the Visual Studio Code terminal still shows the running server. Check that the request uses port 3000 plus the /health path.
Ask for help with both terminal outputs: Help me troubleshoot the TaskFlow health endpoint.
That first response is live. TaskFlow now has a verified runtime plus a reachable API foundation.
Next, you will add task creation and listing in memory. A server restart will expose exactly why TaskFlow needs persistent storage.
Build an In-Memory Task API
Your health route proves Express receives requests. It also proves the app can return JSON.
An API can look correct while storing its state inside one running process. This step tests that boundary by creating tasks before restarting the server.
In this step, get ready to:
- Create an in-memory task collection.
- Build routes that list and create tasks.
- Test task state across a server restart.
List in-memory tasks
An array gives the Node.js process a simple place to hold task objects. The new route returns the current contents of that in-memory storage.
- Switch back to src/server.js in Visual Studio Code.
- Find this section near the top of the file:
const port = 3000;
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
Where does the new state belong?
This section defines the server settings before registering routes. Placing the task state here makes it available to every route below it.
- Replace that section with the expanded version below:
const port = 3000;
const tasks = [];
let nextId = 1;
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.get('/tasks', (req, res) => {
res.json(tasks);
});
What does this code do?
- The tasks array holds each task object created during the current server run.
- The nextId counter provides the next numeric task ID.
- The GET /tasks route sends the current array as JSON.
- Save src/server.js.
- Restart the server in the VS Code terminal by running:
npm start
The terminal confirms that TaskFlow is listening at http://localhost:3000.
Before you call the new route, what JSON value do you expect from a task collection that has no entries?
- Call the task-list route from the second PowerShell terminal by running:
Invoke-RestMethod -Uri http://localhost:3000/tasks -Method Get
PowerShell returns no task objects because the route sent an empty JSON array. Your first task endpoint is live.
Task route not responding?
Confirm that the VS Code terminal still shows the running server. Check that app.get('/tasks', ...) sits above app.listen(...) in src/server.js.
Ask for help if the request still fails: Help me debug why my Express GET /tasks route is not responding in this TaskFlow API.
Create and validate tasks
The existing JSON middleware makes the submitted body available through req.body. Validation keeps missing titles out of the task collection.
- Switch back to the VS Code terminal.
- Stop the running server by pressing Ctrl+C.
- In src/server.js, place the following route immediately below the GET /tasks route:
app.post('/tasks', (req, res) => {
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (!title) {
return res.status(400).json({ error: 'Title is required' });
}
const task = {
id: nextId++,
title,
completed: false,
};
tasks.push(task);
return res.status(201).json(task);
});
What does this code do?
- The route accepts a JSON body through POST /tasks.
- The title expression accepts only a string. It removes surrounding spaces with trim().
- The validation branch returns status 400 when the resulting title is empty.
- A valid request receives the created task with status 201.
- Save src/server.js.
- Start the updated API in the VS Code terminal by running:
npm start
The terminal confirms that the updated server is listening for requests.
- Switch back to the second PowerShell terminal.
- Create a task by running this command:
$task = Invoke-RestMethod -Uri http://localhost:3000/tasks -Method Post -ContentType 'application/json' -Body (@{ title = 'Learn Drizzle' } | ConvertTo-Json)
What does this command do?
PowerShell converts the title object into JSON. The returned task is stored in $task for the next check.
- Display the returned task by running:
$task
You will see a task with ID 1. Its title is Learn Drizzle. Its completion value is false.
That is a concrete create response. The API has validated the request and returned the object it stored.
Task creation failing?
Confirm that express.json() appears before the routes. Check that the PowerShell request includes the JSON content type.
Ask for help with the request body: Help me debug why POST /tasks does not create a task in my Express API.
Expose the persistence gap
The create response shows that the object exists during this server run. A restart tests whether that state belongs to the application or only to the current process.
Before the restart, what do you expect the list route to return?
- List the current tasks from the second PowerShell terminal by running:
Invoke-RestMethod -Uri http://localhost:3000/tasks -Method Get
You will see the task titled Learn Drizzle in the response. The list route and create route share the same array.
- Switch back to the VS Code terminal.
- Stop the server by pressing Ctrl+C.
- Start the server again by running:
npm start
The terminal confirms that a fresh server process is listening at http://localhost:3000.
Before you call the list route again, do you think the task survived the fresh server process?
- Call the list route again from the second PowerShell terminal by running:
Invoke-RestMethod -Uri http://localhost:3000/tasks -Method Get
PowerShell now returns no task objects. The API sent an empty JSON array because the restarted process created a fresh tasks array.
The empty result is intentional
The old array existed only in the process you stopped. The new process begins with tasks set to an empty array.
You have proved that process memory cannot preserve API resources across restarts. That persistence gap is the problem your database will solve.
✔️ Awesome, I've got everything!
Great work. Double-check that you saved src/server.js after adding both task routes.
ⓧ I'd like to double check the full code
import express from 'express';
const app = express();
const port = 3000;
const tasks = [];
let nextId = 1;
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.get('/tasks', (req, res) => {
res.json(tasks);
});
app.post('/tasks', (req, res) => {
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (!title) {
return res.status(400).json({ error: 'Title is required' });
}
const task = {
id: nextId++,
title,
completed: false,
};
tasks.push(task);
return res.status(201).json(task);
});
app.listen(port, () => {
console.log(`TaskFlow API running at http://localhost:${port}`);
});
Your API can create tasks. It can list them too.
The restart exposed its persistence gap. Next, you will define durable task data in PostgreSQL with Drizzle Kit.
Model Tasks in Supabase
The restart test proved that process memory cannot preserve your tasks. Every restart gives the in-memory array a clean slate.
Now you will give TaskFlow a durable home in Supabase.
Drizzle ORM will express the database schema in JavaScript. Drizzle Kit will apply that schema to PostgreSQL.
In this step, get ready to:
- Connect TaskFlow securely to a Supabase Free project.
- Define the durable tasks table in JavaScript.
- Apply the table to PostgreSQL with Drizzle Kit.
Connect Supabase securely
A Supabase project provides the hosted database that survives local server restarts. The Free plan keeps this project at $0 within its included quotas.
Your .env file stores a live database password. Careful local storage keeps that credential out of shared project files.
- Open the Supabase dashboard in your browser.
- Sign in with your existing Supabase account.
- Create a project through the dashboard's Free plan flow.
- Complete the project form with a secure database password.
- Store the database password in your password manager.
- Continue when the project dashboard opens.
Your hosted database is ready. The next connection setting determines how your local API reaches it.
- Click Connect at the top of the project dashboard.
- Choose Session pooler as the connection method.
- Copy the provided connection URI.
Why Use the Session Pooler?
The local Express server is a persistent backend. The Session pooler gives it an IPv4-compatible route to the hosted database.
This connection mode also supports the session features expected by a long-running backend. It fits TaskFlow's local server model.
An environment variable keeps the connection URI outside your JavaScript source. The open Visual Studio Code workspace already points to taskflow-api.
- Create .env directly inside taskflow-api using the Explorer sidebar.
- Paste the copied Session pooler URI into .env.
- Replace the password placeholder in the URI with your database password.
Encode Reserved Password Characters
Reserved characters can be mistaken for URI separators. Percent-encoding keeps each character inside the password portion of the connection string.
- An ampersand becomes %26.
- A number sign becomes %23.
- A question mark becomes %3F.
- A space becomes %20.
- Update any reserved characters in the password portion with their percent-encoded forms.
- Save .env.
- Confirm that .env appears directly inside taskflow-api.
The real URI now stays local. A separate example file documents the required variable without exposing your password.
- Create .env.example directly inside taskflow-api using the Explorer sidebar.
- Add the safe connection template by copying the code below:
DATABASE_URL=postgresql://postgres.your-project-ref:your-password@your-pooler-host:5432/postgres
Why Keep an Environment Example?
The example records the shape of DATABASE_URL without containing a live credential. Anyone using the project can see which configuration value the application expects.
- Save .env.example.
- Confirm that the file contains one connection template with no real password.
✔️ Awesome, I've got everything!
Your safe environment template matches the project format.
ⓧ I'd like to double check the full code
- Compare your .env.example file with this complete reference:
DATABASE_URL=postgresql://postgres.your-project-ref:your-password@your-pooler-host:5432/postgres
What Does This File Record?
This reference names the required variable. Its placeholder URI shows the expected connection structure without revealing your database password.
The final protection is a .gitignore file. It tells Git which local files should stay outside future commits.
- Create .gitignore directly inside taskflow-api using the Explorer sidebar.
- Add the ignore rules by copying the code below:
node_modules/
.env
Why Ignore These Files?
The node_modules/ directory can be recreated from package.json. The .env file contains the private connection URI.
- Save .gitignore.
- Confirm that .gitignore shows both entries on separate lines.
Is the Secret File Still Exposed?
- Check that the file is named exactly .gitignore.
- Check that .env starts at the beginning of its own line.
Help me check whether my TaskFlow environment file is safely ignored.
✔️ Awesome, I've got everything!
Your ignore file protects the local dependency folder and the secret environment file.
ⓧ I'd like to double check the full code
- Compare your .gitignore file with this complete reference:
node_modules/
.env
What Do These Rules Protect?
The first rule excludes installed packages. The second rule excludes the file containing your live database connection.
Define the tasks table
A table definition gives every stored task a consistent shape. Drizzle uses this JavaScript structure to describe columns plus database constraints.
- Create a db folder inside the existing src folder using the Explorer sidebar.
You will see db nested under src in the Explorer sidebar.
- Create schema.js inside src/db using the Explorer sidebar.
- Define the tasks table by copying the code below:
import {
boolean,
integer,
pgTable,
text,
timestamp,
} from 'drizzle-orm/pg-core';
export const tasks = pgTable('tasks', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
title: text('title').notNull(),
completed: boolean('completed').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
});
What Does This Schema Define?
- The tasks export represents the PostgreSQL table used by future queries.
- The id column generates a positive integer identity for each stored row.
- The title column requires text for every task.
- The completed column requires a Boolean value with false as its default.
- The createdAt property maps to a timezone-aware created_at column with the database time as its default.
- Save src/db/schema.js.
- Confirm that the Explorer sidebar shows schema.js inside src/db.
Seeing Problems in the Schema File?
- Check that every imported builder comes from drizzle-orm/pg-core.
- Check that createdAt uses the database column name created_at.
- Check that every opening brace has a matching closing brace.
Help me compare my TaskFlow Drizzle schema with the required table definition.
✔️ Awesome, I've got everything!
Your JavaScript schema defines every field required by a persistent task.
ⓧ I'd like to double check the full code
- Compare src/db/schema.js with this complete reference:
import {
boolean,
integer,
pgTable,
text,
timestamp,
} from 'drizzle-orm/pg-core';
export const tasks = pgTable('tasks', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
title: text('title').notNull(),
completed: boolean('completed').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
});
Why Compare the Full Schema?
A missing constraint changes what PostgreSQL accepts. This reference confirms that every task receives the intended identity plus defaults.
Configure Drizzle Kit and push the schema
Drizzle Kit needs a configuration file before it can reach the database. The configuration limits this push to the public schema plus the tasks table.
- Create drizzle.config.js directly inside taskflow-api using the Explorer sidebar.
- Configure the schema push by copying the code below:
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is not set');
}
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.js',
dbCredentials: {
url: databaseUrl,
},
schemaFilter: ['public'],
tablesFilter: ['tasks'],
});
What Does This Configuration Do?
- The dotenv import loads the private connection URI from .env.
- The missing-value check stops the push when DATABASE_URL is unavailable.
- The postgresql dialect tells Drizzle Kit which database syntax to use.
- The schema path points to the table definition in src/db/schema.js.
- The filters keep the operation focused on public.tasks.
- Save drizzle.config.js.
Before you run the push, which table do you expect Drizzle Kit to apply?
- Apply the schema from the existing PowerShell terminal by running this command:
npm run db:push
What Does This Command Do?
The db:push script reads drizzle.config.js. Drizzle Kit compares the JavaScript schema with the connected database.
It generates the required change for public.tasks and applies that change directly. This workflow keeps the first schema iteration fast.
You should see the command complete without an error. The terminal will report that the tasks schema change was applied to the database.
Did the Schema Push Fail?
- Check that .env contains the exact DATABASE_URL name.
- Confirm that the copied connection URI uses the Session pooler.
- Recheck every reserved character in the password portion for percent-encoding.
Help me debug my TaskFlow Drizzle schema push without sharing my database password.
✔️ Awesome, I've got everything!
Your Drizzle configuration successfully applied the TaskFlow table.
ⓧ I'd like to double check the full code
- Compare drizzle.config.js with this complete reference:
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is not set');
}
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.js',
dbCredentials: {
url: databaseUrl,
},
schemaFilter: ['public'],
tablesFilter: ['tasks'],
});
Why Compare the Full Configuration?
Every path plus filter controls what Drizzle Kit reads or changes. This reference keeps the push connected to the intended table.
Great work. Your public.tasks table is now the durable storage foundation for TaskFlow.
The local API still uses its in-memory array. Next up, you will replace that temporary storage with database queries.
Make Create and Read Persistent
Your database schema is live in Supabase. Your Express API still stores each task inside its running process.
This step swaps the temporary array for Drizzle ORM queries. Every created task will live in PostgreSQL after the server stops.
In this step, get ready to:
- Connect the API to Supabase through an SSL-required database client.
- Replace the in-memory create and list routes with persistent Drizzle queries.
- Retrieve one task by ID after restarting Express.
Connect the API to Supabase
A database client manages the connection between your API and the Session pooler. Drizzle uses that client to send queries through the connection stored in DATABASE_URL.
- Return to the Explorer sidebar in VS Code.
- Select the existing src/db folder.
- Click the New File icon at the top of the Explorer sidebar.
- Enter client.js as the file name.
- Build the database client by pasting this code into src/db/client.js:
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is not set');
}
const client = postgres(databaseUrl, { ssl: 'require' });
export const db = drizzle({ client });
What Does This Client Do?
- The dotenv import loads your ignored .env file into process.env.
- The missing-value check stops the API before it attempts a connection without DATABASE_URL.
- The Postgres.js client requires SSL for its database connection.
- The exported db object gives your routes access to Drizzle queries.
- Save src/db/client.js.
- Confirm that client.js appears inside src/db in the Explorer sidebar.
Client File Missing or Unsaved?
Check that client.js sits beside schema.js inside src/db. Confirm that the editor tab no longer shows an unsaved-change indicator.
Help me check the location and contents of my database client.
✔️ Awesome, I've got everything!
Your database client is saved. It is ready to power the API routes.
ⓧ I'd like to double check the full code
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is not set');
}
const client = postgres(databaseUrl, { ssl: 'require' });
export const db = drizzle({ client });
How to Use This Reference
Compare this reference with your saved src/db/client.js. Pay close attention to the import paths and the SSL setting.
The list route is the first place where the API reads from the database. Ordering by descending ID places the newest task at the top of the response.
- Select src/server.js in the Explorer sidebar.
- Press Ctrl+A to select the current file contents.
- Replace the selected contents by pasting this persistent read server:
import express from 'express';
import { desc, eq } from 'drizzle-orm';
import { db } from './db/client.js';
import { tasks } from './db/schema.js';
const app = express();
const port = 3000;
app.use(express.json());
function parseTaskId(value) {
const id = Number(value);
return Number.isInteger(id) && id > 0 ? id : null;
}
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.get('/tasks', async (req, res) => {
const rows = await db.select().from(tasks).orderBy(desc(tasks.id));
res.json(rows);
});
app.listen(port, () => {
console.log(`TaskFlow API running at http://localhost:${port}`);
});
How Persistent Reads Work
- The imports connect the route to the exported database client and task schema.
- The parseTaskId() helper prepares the server for safe numeric task lookups.
- The GET /tasks route selects rows from the database table.
- The descending order returns the highest task ID first.
- Save src/server.js.
- Stop the running server by pressing Ctrl+C in the server PowerShell terminal.
- Start the persistent read server by running:
npm start
What Should You See?
The terminal should show TaskFlow API running at http://localhost:3000. The process stays active while Express waits for requests.
- Switch to the request PowerShell terminal from earlier.
- Read the rows stored in Supabase by running:
Invoke-RestMethod -Uri http://localhost:3000/tasks -Method Get
What Does the Response Prove?
You should see a JSON array from the Supabase table. A new table returns an empty array until you create its first task.
Your read route now reaches the database. The next route writes the first persistent row.
Database Request Failing?
Confirm that DATABASE_URL remains in .env. Check that the copied URI uses the Session pooler connection.
Help me debug my TaskFlow database connection.
Persist task creation and retrieval
A persistent create route inserts the validated title into the task table. PostgreSQL generates the ID and timestamp before Drizzle returns the stored row.
- In src/server.js, locate the app.listen block near the bottom.
- Insert this create route directly above app.listen:
app.post('/tasks', async (req, res) => {
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (!title) {
return res.status(400).json({ error: 'Title is required' });
}
const [task] = await db.insert(tasks).values({ title }).returning();
return res.status(201).json(task);
});
How Does the Insert Work?
- The title validation keeps empty values out of the database.
- The values({ title }) call maps the validated title to the schema column.
- The returning() call returns the stored row with its generated values.
- The route sends HTTP status 201 after a successful insert.
- Save src/server.js.
- Stop Express by pressing Ctrl+C in the server PowerShell terminal.
- Restart Express by running:
npm start
Why Restart Express?
Express loads the updated route when the process starts again. The startup message confirms that the new server code is running.
- Switch back to the request PowerShell terminal.
- Create a stored task by running:
$task = Invoke-RestMethod -Uri http://localhost:3000/tasks -Method Post -ContentType 'application/json' -Body (@{ title = 'Learn Drizzle' } | ConvertTo-Json)
What Did PowerShell Store?
The request creates a task titled Learn Drizzle. PowerShell saves the returned object in $task for later requests.
The object now includes a generated id and createdAt value. Those fields prove the row came from PostgreSQL.
Task Creation Failing?
Confirm that Express is still running in the server terminal. Check that the request terminal is separate from the terminal occupied by the server.
Help me debug my persistent POST route.
The API also needs a precise way to fetch one task. A positive integer check prevents invalid path values from reaching the database query.
- In src/server.js, locate the new app.post('/tasks' route.
- Insert this get-by-ID route directly above the create route:
app.get('/tasks/:id', async (req, res) => {
const id = parseTaskId(req.params.id);
if (id === null) {
return res.status(400).json({ error: 'Task ID must be a positive integer' });
}
const [task] = await db.select().from(tasks).where(eq(tasks.id, id));
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
return res.json(task);
});
How Does the ID Lookup Work?
- The route passes the path value to parseTaskId() before querying.
- An invalid ID produces a 400 JSON response.
- The eq(tasks.id, id) condition selects the matching database row.
- A valid ID with no matching row produces a 404 JSON response.
- Save src/server.js.
- Stop Express by pressing Ctrl+C in the server PowerShell terminal.
- Restart Express by running:
npm start
What Should Restart Successfully?
The startup message should return without a syntax error. Express is now serving the create route and both read routes.
- Switch back to the request PowerShell terminal.
- Retrieve the task stored in $task by running:
Invoke-RestMethod -Uri "http://localhost:3000/tasks/$($task.id)" -Method Get
What Should the Lookup Return?
You should see the Learn Drizzle task with the same ID held in $task. The response comes from a filtered database query.
Task Lookup Not Working?
Confirm that you created $task in the same request PowerShell terminal. A new terminal does not retain that variable.
Help me debug my get-by-ID route.
✔️ Awesome, I've got everything!
Your server now creates persistent tasks. It can list every row or retrieve one row by ID.
ⓧ I'd like to double check the full code
import express from 'express';
import { desc, eq } from 'drizzle-orm';
import { db } from './db/client.js';
import { tasks } from './db/schema.js';
const app = express();
const port = 3000;
app.use(express.json());
function parseTaskId(value) {
const id = Number(value);
return Number.isInteger(id) && id > 0 ? id : null;
}
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.get('/tasks', async (req, res) => {
const rows = await db.select().from(tasks).orderBy(desc(tasks.id));
res.json(rows);
});
app.get('/tasks/:id', async (req, res) => {
const id = parseTaskId(req.params.id);
if (id === null) {
return res.status(400).json({ error: 'Task ID must be a positive integer' });
}
const [task] = await db.select().from(tasks).where(eq(tasks.id, id));
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
return res.json(task);
});
app.post('/tasks', async (req, res) => {
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (!title) {
return res.status(400).json({ error: 'Title is required' });
}
const [task] = await db.insert(tasks).values({ title }).returning();
return res.status(201).json(task);
});
app.listen(port, () => {
console.log(`TaskFlow API running at http://localhost:${port}`);
});
How to Compare the Server
Compare the imports and route order with your saved src/server.js. Confirm that the old in-memory array and nextId counter are gone.
Prove the task survives a restart
A database proves its value when a new server process can retrieve an earlier row. Restarting Express removes all process memory while leaving the Supabase table intact.
- Switch to the server PowerShell terminal.
- Stop Express by pressing Ctrl+C.
- Start a fresh Express process by running:
npm start
What Did the Restart Clear?
This is a new Express process with fresh memory. The database row remains outside that process in Supabase.
- Switch to the request PowerShell terminal.
Before you query the API, do you expect the task created by the earlier process to appear?
- List the tasks from the restarted API by running:
Invoke-RestMethod -Uri http://localhost:3000/tasks -Method Get
What Proves Persistence?
You should see the same Learn Drizzle task with its original ID and timestamp. The restarted process recovered the row from PostgreSQL.
- Retrieve the same stored task by ID again by running:
Invoke-RestMethod -Uri "http://localhost:3000/tasks/$($task.id)" -Method Get
What Does the Second Check Confirm?
The response should match the task returned before the restart. Both read routes now use the same persistent source.
That persistence gap is closed. TaskFlow can now create a row and recover it after Express restarts.
Task Missing After Restart?
Check that src/server.js no longer declares an array named tasks. Confirm that the list route calls db.select().
Help me find why my task disappeared after restarting Express.
Create and read now survive a restart. Next up, you will add safe updates and deletion with consistent JSON errors.
Complete CRUD and Error Handling
Your Express API already stores tasks in Supabase. A task now survives a server restart. That proves the database connection works.
Persistent create and read routes cover only half of CRUD. Clients still need safe ways to modify records. Clients also need a way to remove records. Every failure needs a predictable JSON response.
In this step, get ready to:
- Validate task updates before writing them with Drizzle ORM.
- Delete tasks with confirmation from PostgreSQL.
- Add centralized error handling with Express middleware.
Add validated task updates
An update can contain a new title. It can contain a new completion state. The route copies only validated values into an updates object before sending the change to PostgreSQL.
The update route has two logical halves. The first half validates input. The second half writes the validated fields.
- Return to the server PowerShell terminal from earlier.
- Stop the running server by pressing Ctrl+C.
- Switch back to src/server.js in Visual Studio Code.
- Find the closing }); for the POST /tasks route.
- Add request validation below that route by copying this first segment:
app.patch('/tasks/:id', async (req, res) => {
const id = parseTaskId(req.params.id);
if (id === null) {
return res.status(400).json({ error: 'Task ID must be a positive integer' });
}
const body =
req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? req.body
: {};
const updates = {};
if ('title' in body) {
if (typeof body.title !== 'string' || !body.title.trim()) {
return res.status(400).json({ error: 'Title must be a non-empty string' });
}
updates.title = body.title.trim();
}
if ('completed' in body) {
if (typeof body.completed !== 'boolean') {
return res.status(400).json({ error: 'Completed must be a Boolean' });
}
updates.completed = body.completed;
}
How Does Update Validation Work?
- The route uses parseTaskId() to reject invalid URL parameters before querying the database.
- The body check accepts a plain request object. Other body shapes become an empty object.
- Each accepted field is copied into updates only after its value passes validation.
This segment deliberately ends after validation. The next segment completes the same route before you save the file.
- Complete the update route by copying this code directly below the first segment:
if (Object.keys(updates).length === 0) {
return res
.status(400)
.json({ error: 'Provide title or completed to update' });
}
const [task] = await db
.update(tasks)
.set(updates)
.where(eq(tasks.id, id))
.returning();
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
return res.json(task);
});
What Does the Database Update Do?
- The empty-object check rejects requests that contain no supported update fields.
- The where() condition limits the update to the requested task ID.
- The returning() call gives the route the stored row after PostgreSQL applies the change.
- An empty database result becomes a 404 response because no matching task exists.
- Save src/server.js.
- Restart the updated API by running this command:
npm start
What Does This Command Do?
The start script launches src/server.js with your new update route. The server keeps this terminal occupied while it listens for requests.
- Switch to the second PowerShell terminal from earlier.
- Create a fresh task for the update check by running this command:
$task = Invoke-RestMethod -Uri http://localhost:3000/tasks -Method Post -ContentType 'application/json' -Body (@{ title = 'Learn Drizzle' } | ConvertTo-Json)
What Does This Request Store?
The request creates a task named Learn Drizzle. PowerShell stores the returned task object in $task so its generated ID can be reused.
- Update the stored title and completion state by running this request:
$updated = Invoke-RestMethod -Uri "http://localhost:3000/tasks/$($task.id)" -Method Patch -ContentType 'application/json' -Body (@{ title = 'Finish CRUD'; completed = $true } | ConvertTo-Json)
$updated
What Should This Request Return?
The response contains the same generated ID. Its title value is now Finish CRUD. Its completed value is now True.
That closes the update half of CRUD. Your API can now modify a stored row without replacing the whole task.
Validation should reject ambiguous updates before they reach the database. These checks prove that each guardrail returns a useful response.
- Probe the title, completion-state, and empty-body guardrails by running these requests:
try {
Invoke-RestMethod -Uri "http://localhost:3000/tasks/$($task.id)" -Method Patch -ContentType 'application/json' -Body (@{ title = ' ' } | ConvertTo-Json)
} catch {
$_.ErrorDetails.Message
}
try {
Invoke-RestMethod -Uri "http://localhost:3000/tasks/$($task.id)" -Method Patch -ContentType 'application/json' -Body (@{ completed = 'true' } | ConvertTo-Json)
} catch {
$_.ErrorDetails.Message
}
try {
Invoke-RestMethod -Uri "http://localhost:3000/tasks/$($task.id)" -Method Patch -ContentType 'application/json' -Body (@{} | ConvertTo-Json)
} catch {
$_.ErrorDetails.Message
}
What Do These Guardrails Prove?
The three responses report Title must be a non-empty string, Completed must be a Boolean, and Provide title or completed to update.
The original task remains available because invalid values never reach the update query.
Update Request Not Working?
Check that both route segments sit together below POST /tasks. Confirm that the second segment closes the route with });.
Make sure the server was restarted after saving src/server.js.
Help me debug the task update route.
Delete tasks safely
A delete request needs the same ID checks as a read or update request. Returning the deleted row gives the client concrete confirmation of what PostgreSQL removed.
- Return to the server terminal from earlier.
- Stop the running server by pressing Ctrl+C.
- Switch back to src/server.js in Visual Studio Code.
- Add the delete route below the completed PATCH /tasks/:id route by copying this code:
app.delete('/tasks/:id', async (req, res) => {
const id = parseTaskId(req.params.id);
if (id === null) {
return res.status(400).json({ error: 'Task ID must be a positive integer' });
}
const [task] = await db
.delete(tasks)
.where(eq(tasks.id, id))
.returning();
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
return res.json(task);
});
How Does Deletion Stay Safe?
- The route rejects invalid IDs before calling PostgreSQL.
- The where() condition targets one task.
- The returning() call captures the row that PostgreSQL deleted.
- A missing row returns the same Task not found response used by the read and update routes.
- Save src/server.js.
- Restart the API by running this command:
npm start
What Does This Restart Load?
The restarted process loads the new delete route. The task stored in $task remains in Supabase until you send the delete request.
- Switch to the second PowerShell terminal from earlier.
- Delete the task you created by running this request:
$deleted = Invoke-RestMethod -Uri "http://localhost:3000/tasks/$($task.id)" -Method Delete
$deleted
What Should the Delete Return?
PowerShell displays the deleted task with its ID, title, completion state, and creation timestamp. That returned row confirms which persistent record was removed.
The delete path now gives clients a clear confirmation. Your API supports every CRUD operation against persistent storage.
Task Still Appearing After Deletion?
Confirm that the delete route uses eq(tasks.id, id) inside where(). Check that the request URL uses $task.id from the task you created.
Help me debug the task deletion route.
Centralize errors and test the full flow
Known routes already return specific validation errors. Two final middleware functions cover requests that match no route and unexpected failures raised during request handling.
- Return to the server terminal from earlier.
- Stop the running server by pressing Ctrl+C.
- Switch back to src/server.js in Visual Studio Code.
- Add both middleware functions below the delete route by copying this code:
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
app.use((error, req, res, next) => {
if (res.headersSent) {
return next(error);
}
console.error(error);
return res.status(500).json({ error: 'Internal server error' });
});
How Does Centralized Error Handling Work?
The first middleware runs after every defined route. A request that reaches it receives a 404 response with Route not found.
The four-argument middleware handles unexpected failures. It logs the original error on the server. It returns a stable 500 response to the client.
Your final src/server.js should now match the complete API below.
✔️ Awesome, I've got everything!
Your server now contains persistent create, read, update, and delete routes. It also contains the final error middleware.
ⓧ I'd like to double check the full code
import express from 'express';
import { desc, eq } from 'drizzle-orm';
import { db } from './db/client.js';
import { tasks } from './db/schema.js';
const app = express();
const port = 3000;
app.use(express.json());
function parseTaskId(value) {
const id = Number(value);
return Number.isInteger(id) && id > 0 ? id : null;
}
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.get('/tasks', async (req, res) => {
const rows = await db.select().from(tasks).orderBy(desc(tasks.id));
res.json(rows);
});
app.get('/tasks/:id', async (req, res) => {
const id = parseTaskId(req.params.id);
if (id === null) {
return res.status(400).json({ error: 'Task ID must be a positive integer' });
}
const [task] = await db.select().from(tasks).where(eq(tasks.id, id));
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
return res.json(task);
});
app.post('/tasks', async (req, res) => {
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (!title) {
return res.status(400).json({ error: 'Title is required' });
}
const [task] = await db.insert(tasks).values({ title }).returning();
return res.status(201).json(task);
});
app.patch('/tasks/:id', async (req, res) => {
const id = parseTaskId(req.params.id);
if (id === null) {
return res.status(400).json({ error: 'Task ID must be a positive integer' });
}
const body =
req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? req.body
: {};
const updates = {};
if ('title' in body) {
if (typeof body.title !== 'string' || !body.title.trim()) {
return res.status(400).json({ error: 'Title must be a non-empty string' });
}
updates.title = body.title.trim();
}
if ('completed' in body) {
if (typeof body.completed !== 'boolean') {
return res.status(400).json({ error: 'Completed must be a Boolean' });
}
updates.completed = body.completed;
}
if (Object.keys(updates).length === 0) {
return res
.status(400)
.json({ error: 'Provide title or completed to update' });
}
const [task] = await db
.update(tasks)
.set(updates)
.where(eq(tasks.id, id))
.returning();
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
return res.json(task);
});
app.delete('/tasks/:id', async (req, res) => {
const id = parseTaskId(req.params.id);
if (id === null) {
return res.status(400).json({ error: 'Task ID must be a positive integer' });
}
const [task] = await db
.delete(tasks)
.where(eq(tasks.id, id))
.returning();
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
return res.json(task);
});
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
app.use((error, req, res, next) => {
if (res.headersSent) {
return next(error);
}
console.error(error);
return res.status(500).json({ error: 'Internal server error' });
});
app.listen(port, () => {
console.log(`TaskFlow API running at http://localhost:${port}`);
});
How to Use This Reference
Compare this file with your own src/server.js. Pay close attention to route order. Both middleware functions belong after every task route and before app.listen().
- Save src/server.js.
- Restart the complete API by running this command:
npm start
What Does the Final Restart Load?
The process loads all six routes followed by both middleware functions. Keep this terminal running so the second terminal can exercise the complete API.
Before you run the lifecycle check, what should the API return after a task is updated and then deleted?
- Switch to the second PowerShell terminal from earlier.
- Create, update, and delete one final task by running these requests:
$task = Invoke-RestMethod -Uri http://localhost:3000/tasks -Method Post -ContentType 'application/json' -Body (@{ title = 'Learn Drizzle' } | ConvertTo-Json)
$updated = Invoke-RestMethod -Uri "http://localhost:3000/tasks/$($task.id)" -Method Patch -ContentType 'application/json' -Body (@{ completed = $true } | ConvertTo-Json)
$deleted = Invoke-RestMethod -Uri "http://localhost:3000/tasks/$($task.id)" -Method Delete
$updated
$deleted
What Should the Lifecycle Show?
The first displayed row has completed set to True. The second displayed row has the same ID because it confirms the row that was deleted.
Before you run the error check, which response should come from ID validation and which response should come from the route fallback?
- Check the deleted ID, an invalid ID, and an unknown route by running these requests:
try {
Invoke-RestMethod -Uri "http://localhost:3000/tasks/$($task.id)" -Method Get
} catch {
$_.ErrorDetails.Message
}
try {
Invoke-RestMethod -Uri http://localhost:3000/tasks/not-a-number -Method Get
} catch {
$_.ErrorDetails.Message
}
try {
Invoke-RestMethod -Uri http://localhost:3000/unknown -Method Get
} catch {
$_.ErrorDetails.Message
}
What Do the Final Errors Prove?
The deleted numeric ID returns Task not found with status 404. The invalid ID returns Task ID must be a positive integer with status 400.
The unknown URL returns Route not found with status 404. Unexpected route failures now produce the centralized Internal server error response.
You have completed the persistent TaskFlow API. Every CRUD operation now returns stored data or a predictable error response.
Final Error Check Not Matching?
Confirm that the route-not-found middleware appears after DELETE /tasks/:id. Confirm that the four-argument error middleware appears after the route-not-found middleware.
Restart the server after correcting the order. Express evaluates routes and middleware from top to bottom.
Help me debug the final TaskFlow error responses.
Secret mission
Filter Tasks by Completion Status
Can your task list return only the work that matches a chosen completion state? Add an optional completion filter that queries the database while rejecting unclear values.
Clean Up Your Resources
Clean Up Your Resources
Your TaskFlow API is complete. Its resources cost $0/month within current Supabase Free plan quotas, so you can keep them available, pause the cloud database, or delete everything.
Resources you used:
- A running Express process in Windows PowerShell.
- A local taskflow-api folder containing the source files, dependency metadata, and .env secret.
- A Supabase project containing the persistent public.tasks table.
Keep everything running
No deletion is needed while you keep using TaskFlow. Your project files and database remain available for future demonstrations.
- Return to the PowerShell terminal where Express is running.
- Press Ctrl+C when you finish demonstrating the API.
- Keep the taskflow-api folder for future changes.
- Leave the Supabase project active so the public.tasks table preserves your task data.
Your task data stays in Supabase while the local Express process is stopped.
Pause - I'll come back to this later
This option stops Express. It pauses the Supabase project while the local taskflow-api folder stays in place.
- Return to the PowerShell terminal where Express is running.
- Press Ctrl+C to stop the local server.
- Return to the Supabase project dashboard from earlier.
- Select Settings in the sidebar.
- Select General.
- Find Project availability.
- Click Pause Project.
- Confirm that the project shows a paused state.
Only Free projects can currently be paused. Your project may also pause after low activity over a seven-day period.
- Select the paused project when you return.
- Click Resume project.
You can reconnect the API after the project finishes resuming.
Delete - I don't want to use this again
This option permanently removes the Supabase project. It also removes the local TaskFlow copy from your computer.
- Return to the PowerShell terminal where Express is running.
- Press Ctrl+C to stop the local server.
- Return to the Supabase project dashboard from earlier.
- Select Settings in the sidebar.
- Select General.
- Find Delete project.
- Click Delete Project.
Supabase asks for the exact project name before it completes the deletion. This confirmation protects the database from an accidental click.
- Enter the exact project name in the confirmation field.
- Click Delete.
Supabase permanently removes the database, data, backups, and configuration.
The cloud resource is gone. The remaining cleanup removes the local taskflow-api folder.
- Close VS Code.
- Press the Windows key to open the search bar.
- Search for File Explorer.
- Select File Explorer from the results.
- Navigate to the parent folder that contains taskflow-api.
- Select the taskflow-api folder.
- Press the Delete key.
- Empty the Recycle Bin to complete the removal.
Your local project is now removed. Its .env secret is gone with the folder.
Nice Work!
Nice Work!
You did it! TaskFlow is now a persistent CRUD API built with Express. Its validated task data survives server restarts in Supabase PostgreSQL through Drizzle ORM.
What you learned:
- Built complete RESTful CRUD operations that create, retrieve, update, and delete persistent tasks with clear JSON responses.
- Defined and applied a PostgreSQL task schema with Drizzle Kit. Connected the API through an SSL-required Session pooler.
- Tested the complete API from Windows PowerShell. Proved that stored tasks survive an Express server restart.
- Secret Mission: Added database-side completion filtering so clients can request completed tasks, incomplete tasks, or the full task list.
Ready to quiz yourself?