Basic Example
Let's Start
Let's build the same Books API we built with MongoDB and PostgreSQL with TypeORM, this time with Supabase. Comparing the three versions side by side is a good way to see what each tool does for you.
Project Setup
Follow the setup doc to create the Supabase project, the books table, and the .env file. Then install the dependencies:
- npm
- Yarn
- pnpm
- Bun
npm install express @supabase/supabase-js dotenv
npm install -D nodemon
yarn add express @supabase/supabase-js dotenv
yarn add --dev nodemon
pnpm add express @supabase/supabase-js dotenv
pnpm add -D nodemon
bun add express @supabase/supabase-js dotenv
bun add --dev nodemon
{
"name": "express-with-supabase",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
}
}
Folder Structure
📂 express-with-supabase
├── 📂 config
│ └── 📄 supabase.js
├── 📂 routes
│ └── 📄 book.js
├── 📂 utils
│ └── 📄 supabase-error.js
├── 📄 .env
├── 📄 index.js
└── 📄 package.json
config/supabase.js
import "dotenv/config";
import { createClient } from "@supabase/supabase-js";
const { SUPABASE_URL, SUPABASE_SECRET_KEY } = process.env;
if (!SUPABASE_URL || !SUPABASE_SECRET_KEY) {
throw new Error("SUPABASE_URL and SUPABASE_SECRET_KEY must be set");
}
export const supabase = createClient(SUPABASE_URL, SUPABASE_SECRET_KEY, {
auth: { persistSession: false, autoRefreshToken: false },
});
utils/supabase-error.js
Turns Postgres error codes into HTTP status codes, so a duplicate book name returns 409 instead of a generic 500:
const STATUS_BY_CODE = {
23505: 409,
23514: 400,
23502: 400,
"22P02": 400,
PGRST116: 404,
};
export function sendSupabaseError(res, error) {
const status = STATUS_BY_CODE[error.code] ?? 500;
// Hide database internals from clients on unexpected errors
const message = status === 500 ? "Something went wrong" : error.message;
if (status === 500) console.error(error);
res.status(status).json({ message });
}
index.js
import express from "express";
import { supabase } from "./config/supabase.js";
import bookRouter from "./routes/book.js";
const app = express();
app.use(express.json());
app.use("/book", bookRouter);
const { error } = await supabase.from("books").select("id").limit(1);
if (error) {
console.error("Supabase connection failed:", error.message);
process.exit(1);
}
console.log("Supabase Connected ~");
app.listen(9999, () => console.log("Server running on port 9999"));
routes/book.js
GET /bookgets books, with paginationGET /book/:idgets a single bookPOST /bookcreates a bookPATCH /book/:idupdates a bookDELETE /book/:iddeletes a book
import { Router } from "express";
import { supabase } from "../config/supabase.js";
import { sendSupabaseError } from "../utils/supabase-error.js";
const router = Router();
const EDITABLE_FIELDS = ["name", "author", "price", "stock"];
// Only copy known columns from the body, so clients cannot set id or created_at
function pickBookFields(body) {
return Object.fromEntries(
Object.entries(body).filter(([key]) => EDITABLE_FIELDS.includes(key))
);
}
router.get("/", async (req, res) => {
const page = Math.max(parseInt(req.query.page) || 1, 1);
const limit = Math.min(parseInt(req.query.limit) || 10, 100);
const from = (page - 1) * limit;
const { data, count, error } = await supabase
.from("books")
.select("*", { count: "exact" })
.order("id")
.range(from, from + limit - 1);
if (error) return sendSupabaseError(res, error);
res.json({
data,
meta: { page, limit, total_items: count, total_pages: Math.ceil(count / limit) },
});
});
router.get("/:id", async (req, res) => {
const { data, error } = await supabase
.from("books")
.select("*")
.eq("id", req.params.id)
.maybeSingle();
if (error) return sendSupabaseError(res, error);
if (!data) return res.status(404).json({ message: "Book not found" });
res.json(data);
});
router.post("/", async (req, res) => {
const { data, error } = await supabase
.from("books")
.insert(pickBookFields(req.body))
.select()
.single();
if (error) return sendSupabaseError(res, error);
res.status(201).json(data);
});
router.patch("/:id", async (req, res) => {
const changes = pickBookFields(req.body);
if (Object.keys(changes).length === 0) {
return res.status(400).json({ message: "No valid fields to update" });
}
const { data, error } = await supabase
.from("books")
.update({ ...changes, updated_at: new Date().toISOString() })
.eq("id", req.params.id)
.select()
.maybeSingle();
if (error) return sendSupabaseError(res, error);
if (!data) return res.status(404).json({ message: "Book not found" });
res.json(data);
});
router.delete("/:id", async (req, res) => {
const { data, error } = await supabase
.from("books")
.delete()
.eq("id", req.params.id)
.select();
if (error) return sendSupabaseError(res, error);
if (data.length === 0) return res.status(404).json({ message: "Book not found" });
res.json({ message: "Book deleted" });
});
export default router;
A few things to notice in the routes:
- No
try/catch: supabase-js returns errors instead of throwing them, so we checkerrorafter every query. A network failure can still throw, so in a real project add the async error handler as well. pickBookFields(): the secret key bypasses RLS, so the database will accept any column you send. Whitelisting fields stops a client from overwritingidorcreated_at. For full validation, use Joi..maybeSingle(): returnsdata: nullfor a missing row, which we turn into a404.delete().select(): returns the deleted rows, so an empty array means the ID did not exist.
Running the App
- npm
- Yarn
- pnpm
- Bun
npm run dev
yarn dev
pnpm run dev
bun run dev
You should see:
Supabase Connected ~
Server running on port 9999
Testing the API
Create a book:
POST http://localhost:9999/book
Content-Type: application/json
{
"name": "Clean Code",
"author": "Robert C. Martin",
"price": 29.99,
"stock": 50
}
Send the same request again and you get 409 Conflict, because name is unique. Send "price": -5 and you get 400, because of the check (price > 0) rule.
Get books, page 1:
GET http://localhost:9999/book?page=1&limit=10
Get one book:
GET http://localhost:9999/book/1
Update a book:
PATCH http://localhost:9999/book/1
Content-Type: application/json
{
"price": 24.99
}
Delete a book:
DELETE http://localhost:9999/book/1
You can also open Table Editor in the Supabase dashboard and watch the rows change as you send requests.
Conclusion
In this article, we built a Books CRUD API with Express and Supabase. Compared to the TypeORM version, there are no entities or DataSource: the schema lives in SQL, and queries go over HTTP through supabase-js. In the next doc, we will add authentication with Supabase Auth.