REST API with Hono, Drizzle, and Supabase

Build a typed bookmarks API backed by a real PostgreSQL database.

Introduction

30 Second Summary

Every time you save a bookmark in your browser, something stores that link, remembers which folder it belongs to, and retrieves it later when you search. Behind the scenes, that's a database with relationships between your data.

In this project, you will build a bookmarks API from scratch. You'll design a relational database schema, connect it to a live cloud database, and expose endpoints that let you create, read, update, and delete data through HTTP requests.

What You'll Build

A REST API built with Hono and Drizzle ORM that organizes bookmarks into collections, backed by a Supabase PostgreSQL database.

By the end of this project, you'll have:

  • A Hono API server with typed routes for collections and bookmarks, running on Node.js.
  • A Drizzle ORM schema defining two related tables with a one-to-many relationship, pushed to a live database.
  • Working CRUD endpoints you can test with curl, including cross-table joins that query across relationships.
  • Secret Mission: Add filtering and sorting to your bookmarks endpoint so users can query by collection and order results by date.

Are there any prerequisites?

You should be familiar with TypeScript and have a basic understanding of HTTP methods (GET, POST, PUT, DELETE). You'll also need Node.js installed on your machine.

Set Up Your Project

To build your bookmarks API, you need a project folder with the right tools installed and a live database connection.

In this step, you will scaffold a Hono project, install Drizzle ORM and its dependencies, and connect to your Supabase PostgreSQL database.

In this step, get ready to:
  • Scaffold a Hono project and install all dependencies.
  • Retrieve your Supabase connection string and store it in a .env file.
  • Create a database client and verify the server starts.
Scaffold the Hono project and install dependencies

Hono is a lightweight TypeScript web framework that runs on Node.js. You will use its official scaffolding tool to create your project structure, then add the database packages on top.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your Hono project by running this command:
npm create hono@latest bookmarks-api -- --template nodejs --install --pm npm

What does this command do?

npm create hono@latest runs the official Hono project scaffolder.

  • bookmarks-api is the name of your new project folder.
  • --template nodejs selects the Node.js starter template and skips the interactive template prompt.
  • --install tells it to install packages automatically after scaffolding.
  • --pm npm picks npm as the package manager.
  • Move into the new project folder by running:
cd bookmarks-api
  • Install the database packages your project needs by running:
npm install drizzle-orm postgres dotenv

What are these packages?

  • drizzle-orm is the ORM that lets you write SQL-shaped TypeScript queries.
  • postgres (postgres.js) is a lightweight PostgreSQL driver with zero dependencies.
  • dotenv loads environment variables from a .env file so your credentials stay out of code.
  • Install the dev dependencies by running:
npm install -D drizzle-kit tsx typescript

What are the dev dependencies for?

  • drizzle-kit provides CLI tools for pushing your schema to the database.
  • tsx runs TypeScript files directly without a separate compile step.
  • typescript provides the TypeScript compiler and type checking.
  • Confirm the packages installed by running:
ls node_modules/.bin/drizzle-kit

You should see the path printed without an error.

Seeing npm install errors?

Make sure you are inside the bookmarks-api folder before running npm install. Run pwd (macOS/Linux) or cd (Windows) to check your current directory.

If you see permission errors, try closing and reopening your terminal.

help me fix npm install errors in my Hono project

  • Open the bookmarks-api folder in your code editor. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code (or Cursor), and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the bookmarks-api folder, then click Open.

Now update package.json to add the scripts you will use throughout this project.

  • Open package.json in the Explorer sidebar.
  • Replace the entire "scripts" section with the following:
"scripts": {
  "dev": "tsx watch src/index.ts",
  "db:push": "drizzle-kit push",
  "db:studio": "drizzle-kit studio"
}

What do these scripts do?

  • dev starts your server and auto-restarts on file changes.
  • db:push syncs your Drizzle schema to the live database.
  • db:studio opens a visual database browser.
  • Also confirm the top of your package.json includes "type": "module" so Node.js uses ES module imports. The scaffolded file should already have this.
  • Replace the entire contents of tsconfig.json with the following:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  },
  "include": ["src"]
}

Why these TypeScript settings?

ES2022 enables modern JavaScript features like top-level await. bundler module resolution matches how tsx resolves imports. strict mode catches type errors early.

  • Save both package.json and tsconfig.json.
Retrieve your Supabase connection string

Your API needs a way to talk to the database. Supabase provides a connection string that includes your database host, credentials, and port.

  • Open your browser and go to your Supabase project dashboard.
  • Click the Connect button at the top of your project dashboard.
  • In the connection dialog, find the Transaction pooler section (port 6543).
  • Copy the connection string. It looks like this:
postgres://postgres.your-project-ref:your-password@aws-0-your-region.pooler.supabase.com:6543/postgres

Why transaction pooler mode?

Transaction mode (port 6543) reuses database connections between requests. This is ideal for a development API that opens and closes connections frequently.

Direct connections (port 5432) create a dedicated connection per client, which can exhaust the connection limit quickly.

  • In your code editor, create a new file called .env in the root of your bookmarks-api folder.
  • Paste the following into .env, replacing the placeholder URL with the actual connection string you copied:
DATABASE_URL=your-connection-string-here
  • Save .env.

Keep your .env out of version control

Never commit .env to Git. If you initialize a Git repository later, add .env to your .gitignore file.

Not sure where to find the connection string?

Make sure you are on your project's main dashboard page in Supabase. The Connect button appears in the top navigation bar. Look for the connection string that ends with port :6543.

help me find my Supabase connection string

Create the database client and verify the server

With your connection string stored, you can now initialize the Drizzle client that your API routes will use to talk to the database.

  • In the Explorer sidebar, expand the src folder.
  • Create a new folder inside src called db.
  • Create a new file inside src/db called index.ts.
  • Paste the following code into src/db/index.ts:
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';

// Read the connection string from .env
const connectionString = process.env.DATABASE_URL!;
// Create a postgres.js client pointed at Supabase
const client = postgres(connectionString);
// Initialize Drizzle with the client and schema
export const db = drizzle(client, { schema });

What does this code do?

  • import 'dotenv/config' loads your .env file into process.env so the connection string is available.
  • postgres(connectionString) creates a database client using the postgres.js driver.
  • drizzle(client, { schema }) wraps the client in Drizzle's query builder so you get typed queries.
  • The schema import references a file you will create in the next step. This will not cause issues now because the server does not import this file yet.

Now set up the Hono server in src/index.ts.

  • Open src/index.ts in the Explorer sidebar (the scaffolded file already exists).
  • Replace its entire contents with the following:
import { serve } from '@hono/node-server';
import { Hono } from 'hono';

const app = new Hono();

// Health check endpoint
app.get('/', (c) => c.json({ message: 'Bookmarks API is running' }));

serve({ fetch: app.fetch, port: 3000 }, (info) => {
  console.log(`Server is running on http://localhost:${info.port}`);
});

What does this code do?

  • serve() from @hono/node-server starts a Node.js HTTP server that routes requests through Hono.
  • The root / endpoint returns a JSON health-check message so you can confirm the server is alive.
  • Port 3000 is where your API will listen for requests.
  • Save both src/db/index.ts and src/index.ts.
  • Verify the server starts by running this command in your terminal:
npx tsx src/index.ts

You should see Server is running on http://localhost:3000 printed in your terminal.

  • Press Ctrl+C to stop the server.

Server not starting?

If you see an error about a missing module, confirm that src/index.ts only imports from @hono/node-server and hono as shown above. It should not import from src/db/index.ts yet.

If port 3000 is already in use, close any other servers you have running or change the port number temporarily.

help me debug why my Hono server won't start

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files: package.json, tsconfig.json, .env, src/db/index.ts, and src/index.ts.

ⓧ I'd like to double check the full code

Here is the full content of each file at this point:

{
  "name": "bookmarks-api",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "db:push": "drizzle-kit push",
    "db:studio": "drizzle-kit studio"
  },
  "dependencies": {
    "hono": "^4.12.25",
    "@hono/node-server": "^2.0.3",
    "drizzle-orm": "^0.45.2",
    "postgres": "^3.4.9",
    "dotenv": "^16.4.7"
  },
  "devDependencies": {
    "drizzle-kit": "^0.31.10",
    "tsx": "^4.19.4",
    "typescript": "^5.8.3"
  }
}
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  },
  "include": ["src"]
}
DATABASE_URL=postgres://postgres.your-project-ref:your-password@aws-0-your-region.pooler.supabase.com:6543/postgres
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';

const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString);
export const db = drizzle(client, { schema });
import { serve } from '@hono/node-server';
import { Hono } from 'hono';

const app = new Hono();

app.get('/', (c) => c.json({ message: 'Bookmarks API is running' }));

serve({ fetch: app.fetch, port: 3000 }, (info) => {
  console.log(`Server is running on http://localhost:${info.port}`);
});

Your project is scaffolded, connected to a live database, and the server starts cleanly. Next up, you will design your relational schema and push it to Supabase.

Design and Push Your Schema

Your server is running and the database connection is ready. Now you need to tell the database what data to store and how to organize it.

In this step, you will define two tables in TypeScript code and push them to your live Supabase PostgreSQL database. Defining your schema in code (instead of clicking through a GUI) means you can version-control it, share it with teammates, and reproduce it on any environment.

In this step, get ready to:
  • Define a relational schema with two tables connected by a foreign key.
  • Configure Drizzle Kit to sync your schema with the database.
  • Push your tables to the live Supabase PostgreSQL database.
Define the collections table

A Drizzle ORM schema file is where you declare your database tables as TypeScript objects. Each table maps to a real PostgreSQL table with columns, types, and constraints.

  • Create a new file called src/db/schema.ts and add the following code:
import { pgTable, serial, varchar, text, integer, timestamp } from 'drizzle-orm/pg-core';

// Define the collections table - each collection groups related bookmarks
export const collections = pgTable('collections', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 255 }).notNull(),
  description: text('description'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

What does this code do?

This defines a collections table with four columns:

  • serial('id').primaryKey() creates an auto-incrementing integer that uniquely identifies each row.
  • varchar('name', { length: 255 }) stores a string up to 255 characters. The .notNull() constraint means every collection must have a name.
  • text('description') stores an optional unlimited-length string.
  • timestamp('created_at').defaultNow() automatically records when the row was inserted.
  • Save src/db/schema.ts.
  • You should see the new file appear in your src/db/ folder in the editor sidebar.
Add the bookmarks table with a foreign key

The bookmarks table stores individual links. Each bookmark belongs to exactly one collection. This is a one-to-many relationship: one collection can have many bookmarks.

  • In src/db/schema.ts, add the following code below the collections table definition:
// Define the bookmarks table - each bookmark belongs to one collection
export const bookmarks = pgTable('bookmarks', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 255 }).notNull(),
  url: text('url').notNull(),
  // Foreign key linking each bookmark to its parent collection
  collectionId: integer('collection_id')
    .references(() => collections.id)
    .notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

What is a foreign key?

The .references(() => collections.id) line creates a foreign key constraint. It tells the database that every value in collection_id must match an existing id in the collections table.

This prevents orphaned bookmarks. You cannot insert a bookmark pointing to a collection that does not exist, and you cannot delete a collection that still has bookmarks referencing it.

  • Save src/db/schema.ts.
  • You won't see a visible change yet. The next substep uses this schema to create real tables in your database.

Seeing a red underline on the import?

Make sure the import line at the top includes all six types: pgTable, serial, varchar, text, integer, and timestamp. A missing type causes TypeScript errors on the lines that use it.

Also confirm drizzle-orm is installed by checking your package.json dependencies.

help me fix a TypeScript import error in my Drizzle schema file

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/db/schema.ts with both table definitions before moving on.

ⓧ I'd like to double check the full code

import { pgTable, serial, varchar, text, integer, timestamp } from 'drizzle-orm/pg-core';

// Define the collections table - each collection groups related bookmarks
export const collections = pgTable('collections', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 255 }).notNull(),
  description: text('description'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

// Define the bookmarks table - each bookmark belongs to one collection
export const bookmarks = pgTable('bookmarks', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 255 }).notNull(),
  url: text('url').notNull(),
  // Foreign key linking each bookmark to its parent collection
  collectionId: integer('collection_id')
    .references(() => collections.id)
    .notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});
Configure Drizzle Kit

Drizzle Kit is the CLI tool that reads your schema file and applies it to a real database. It needs a config file to know where your schema lives and how to connect.

  • Create a new file called drizzle.config.ts in the root of your project (next to package.json) and add the following code:
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

// Tell Drizzle Kit where your schema lives and how to connect
export default defineConfig({
  out: './drizzle',
  schema: './src/db/schema.ts',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

What does this config do?

  • schema points to the file you just created so Drizzle Kit knows which tables to push.
  • dialect: 'postgresql' tells Drizzle Kit you are targeting a PostgreSQL database.
  • dbCredentials.url reads the connection string from your .env file so credentials stay out of source code.
  • out: './drizzle' is where Drizzle Kit stores migration snapshots if you ever generate them.
  • Save drizzle.config.ts.
  • You won't see a change yet. The next command uses this config to push your schema.

✔️ Awesome, I've got everything!

Great. Make sure drizzle.config.ts is saved in your project root before continuing.

ⓧ I'd like to double check the full code

import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

// Tell Drizzle Kit where your schema lives and how to connect
export default defineConfig({
  out: './drizzle',
  schema: './src/db/schema.ts',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});
Push your schema to Supabase

Now that your schema and config are ready, you can push the table definitions to your live Supabase database with a single command.

  • Push your schema to the database by running this command:
npx drizzle-kit push

What does this command do?

This reads your drizzle.config.ts, finds your schema file, and creates the matching tables directly in your Supabase PostgreSQL database. It compares the current database state with your schema and applies any differences.

If Drizzle Kit asks you to confirm the changes, type yes and press Enter.

  • You should see output listing the tables being created, followed by a success message.
  • Return to your Supabase dashboard in the browser.
  • Click Table Editor in the left sidebar.
  • You should see both collections and bookmarks listed as tables.

Seeing a connection error?

Double check your DATABASE_URL in .env. Make sure it uses port 6543 (the transaction pooler port) and that your password is correct.

If you see a "relation already exists" warning, your tables were already created from a previous run. This is safe to ignore.

help me fix a Drizzle Kit connection error when pushing my schema to Supabase

Your database structure is live. Next up, you will build the API endpoints that read and write data to these tables.

Build CRUD Endpoints

Your database schema is live on Supabase with two related tables ready to store data. Now you need a way for the outside world to interact with that data.

In this step, you will build the API layer that sits on top of your database. Each endpoint maps to one of the four CRUD operations (Create, Read, Update, Delete) and uses Drizzle ORM to translate your TypeScript into real SQL queries.

In this step, get ready to:
  • Create route handlers for collections (list, get by ID, create).
  • Create route handlers for bookmarks (list, get by ID, create, update, delete).
  • Mount both routers in your main server and test every endpoint with curl.
Create the collections routes

Hono lets you group related routes into separate files, then mount them under a shared prefix. You will create a dedicated route module for collections with three endpoints: list all, get one by ID, and create.

  • Create a new folder called routes inside your src folder.
  • Create a new file called collections.ts inside src/routes/ and paste the following code:
import { Hono } from 'hono';
import { db } from '../db/index';
import { collections } from '../db/schema';
import { eq } from 'drizzle-orm';

const app = new Hono();

// GET / - list all collections
app.get('/', async (c) => {
  const allCollections = await db.select().from(collections);
  return c.json(allCollections);
});

// GET /:id - get a single collection by ID
app.get('/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const result = await db.select().from(collections).where(eq(collections.id, id));
  if (result.length === 0) {
    return c.json({ error: 'Collection not found' }, 404);
  }
  return c.json(result[0]);
});

// POST / - create a new collection
app.post('/', async (c) => {
  const body = await c.req.json();
  const newCollection = await db.insert(collections).values({
    name: body.name,
    description: body.description,
  }).returning();
  return c.json(newCollection[0], 201);
});

export default app;

What does this code do?

Each route handler is an async function that receives a context object c. You use c.req to read incoming data and c.json() to send JSON responses.

  • db.select().from(collections) generates a SELECT * FROM collections query.
  • eq(collections.id, id) generates a WHERE id = ? clause for filtering by a specific row.
  • db.insert(collections).values({...}).returning() inserts a new row and returns the created record, including the auto-generated id and created_at.
  • Save src/routes/collections.ts.
  • You will not see a change yet. The next substep mounts this route on your server so you can test it.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

import { Hono } from 'hono';
import { db } from '../db/index';
import { collections } from '../db/schema';
import { eq } from 'drizzle-orm';

const app = new Hono();

app.get('/', async (c) => {
  const allCollections = await db.select().from(collections);
  return c.json(allCollections);
});

app.get('/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const result = await db.select().from(collections).where(eq(collections.id, id));
  if (result.length === 0) {
    return c.json({ error: 'Collection not found' }, 404);
  }
  return c.json(result[0]);
});

app.post('/', async (c) => {
  const body = await c.req.json();
  const newCollection = await db.insert(collections).values({
    name: body.name,
    description: body.description,
  }).returning();
  return c.json(newCollection[0], 201);
});

export default app;
Create the bookmarks routes

The bookmarks router is larger because it supports all four CRUD operations. Update and delete operations use Drizzle's db.update() and db.delete() methods with a WHERE clause to target specific rows.

  • Create a new file called bookmarks.ts inside src/routes/ and paste the following code:
import { Hono } from 'hono';
import { db } from '../db/index';
import { bookmarks, collections } from '../db/schema';
import { eq } from 'drizzle-orm';

const app = new Hono();

// GET / - list all bookmarks
app.get('/', async (c) => {
  const result = await db.select().from(bookmarks);
  return c.json(result);
});

// GET /:id - get a single bookmark by ID
app.get('/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const result = await db.select().from(bookmarks).where(eq(bookmarks.id, id));
  if (result.length === 0) {
    return c.json({ error: 'Bookmark not found' }, 404);
  }
  return c.json(result[0]);
});

// POST / - create a new bookmark
app.post('/', async (c) => {
  const body = await c.req.json();
  const newBookmark = await db.insert(bookmarks).values({
    title: body.title,
    url: body.url,
    collectionId: body.collection_id,
  }).returning();
  return c.json(newBookmark[0], 201);
});

// PUT /:id - update an existing bookmark
app.put('/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const body = await c.req.json();
  const updated = await db.update(bookmarks).set({
    title: body.title,
    url: body.url,
    collectionId: body.collection_id,
  }).where(eq(bookmarks.id, id)).returning();

  if (updated.length === 0) {
    return c.json({ error: 'Bookmark not found' }, 404);
  }
  return c.json(updated[0]);
});

// DELETE /:id - delete a bookmark
app.delete('/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const deleted = await db.delete(bookmarks).where(eq(bookmarks.id, id)).returning();
  if (deleted.length === 0) {
    return c.json({ error: 'Bookmark not found' }, 404);
  }
  return c.json({ message: 'Bookmark deleted' });
});

export default app;

What does this code do?

This file introduces two new Drizzle operations on top of what you used in collections:

  • db.update(bookmarks).set({...}).where(eq(bookmarks.id, id)).returning() generates an UPDATE query that changes the specified columns for one row.
  • db.delete(bookmarks).where(eq(bookmarks.id, id)).returning() generates a DELETE query that removes a row and returns it so you can confirm what was deleted.
  • The collectionId: body.collection_id line in POST and PUT links each bookmark to its parent collection through the foreign key.
  • Save src/routes/bookmarks.ts.
  • You will not see a change yet. The next substep connects these routes to your server.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

import { Hono } from 'hono';
import { db } from '../db/index';
import { bookmarks, collections } from '../db/schema';
import { eq } from 'drizzle-orm';

const app = new Hono();

app.get('/', async (c) => {
  const result = await db.select().from(bookmarks);
  return c.json(result);
});

app.get('/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const result = await db.select().from(bookmarks).where(eq(bookmarks.id, id));
  if (result.length === 0) {
    return c.json({ error: 'Bookmark not found' }, 404);
  }
  return c.json(result[0]);
});

app.post('/', async (c) => {
  const body = await c.req.json();
  const newBookmark = await db.insert(bookmarks).values({
    title: body.title,
    url: body.url,
    collectionId: body.collection_id,
  }).returning();
  return c.json(newBookmark[0], 201);
});

app.put('/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const body = await c.req.json();
  const updated = await db.update(bookmarks).set({
    title: body.title,
    url: body.url,
    collectionId: body.collection_id,
  }).where(eq(bookmarks.id, id)).returning();

  if (updated.length === 0) {
    return c.json({ error: 'Bookmark not found' }, 404);
  }
  return c.json(updated[0]);
});

app.delete('/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const deleted = await db.delete(bookmarks).where(eq(bookmarks.id, id)).returning();
  if (deleted.length === 0) {
    return c.json({ error: 'Bookmark not found' }, 404);
  }
  return c.json({ message: 'Bookmark deleted' });
});

export default app;
Mount the routes and test with curl

Your route files are standalone modules that do not run until you attach them to your main Hono server. Hono's app.route() method mounts a sub-router under a URL prefix, so every handler in collections.ts becomes available under /collections.

  • Open src/index.ts and replace its entire contents with the following code:
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import collectionsRoute from './routes/collections';
import bookmarksRoute from './routes/bookmarks';

const app = new Hono();

// Health check route
app.get('/', (c) => c.json({ message: 'Bookmarks API is running' }));

// Mount sub-routers under their URL prefixes
app.route('/collections', collectionsRoute);
app.route('/bookmarks', bookmarksRoute);

serve({ fetch: app.fetch, port: 3000 }, (info) => {
  console.log(`Server is running on http://localhost:${info.port}`);
});

What does this code do?

  • app.route('/collections', collectionsRoute) takes every route defined in collections.ts and makes it available under the /collections prefix.
  • app.route('/bookmarks', bookmarksRoute) does the same for the bookmarks router.
  • The / health check route remains so you can quickly verify the server is alive.
  • Save src/index.ts.
  • Start your server by running this command:
npm run dev

You should see Server is running on http://localhost:3000 in your terminal.

  • Open a second terminal window to run curl commands while your server stays running.
  • Create a collection by running this command:
curl -X POST http://localhost:3000/collections \
  -H "Content-Type: application/json" \
  -d '{"name": "Dev Resources", "description": "Useful development links"}'

You should see a JSON response with the new collection, including an auto-generated id and createdAt timestamp.

  • List all collections by running:
curl http://localhost:3000/collections
  • Create a bookmark that references your collection by running:
curl -X POST http://localhost:3000/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"title": "Hono Docs", "url": "https://hono.dev", "collection_id": 1}'

The response includes a collectionId field set to 1, confirming the bookmark is linked to your collection.

  • List all bookmarks to confirm your data is persisted by running:
curl http://localhost:3000/bookmarks

What about PUT and DELETE?

You can test update and delete the same way. To update your bookmark, run a PUT with the bookmark's id:

curl -X PUT http://localhost:3000/bookmarks/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Hono Official Docs", "url": "https://hono.dev", "collection_id": 1}'

To delete it, run:

curl -X DELETE http://localhost:3000/bookmarks/1

Seeing connection errors or empty responses?

  • Make sure your server is still running in the first terminal window. If it crashed, check the error output for import path issues.
  • Verify that src/routes/collections.ts and src/routes/bookmarks.ts both use '../db/index' (two dots) for the import path. A single dot means a different folder.
  • If you see a foreign key error when creating a bookmark, make sure you created a collection first. The collection_id must reference an existing collection.

Help me debug my Hono API connection errors

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import collectionsRoute from './routes/collections';
import bookmarksRoute from './routes/bookmarks';

const app = new Hono();

app.get('/', (c) => c.json({ message: 'Bookmarks API is running' }));
app.route('/collections', collectionsRoute);
app.route('/bookmarks', bookmarksRoute);

serve({ fetch: app.fetch, port: 3000 }, (info) => {
  console.log(`Server is running on http://localhost:${info.port}`);
});

Your API is fully wired up. Every CRUD operation writes to and reads from your live Supabase database. Next up, you will query across both tables using joins to pull related data in a single request.

Query Across Relationships

Your API can create and retrieve bookmarks and collections independently. But the real power of a relational database shows up when you query across tables.

In this step, you will add two new endpoints that follow the foreign key relationship you defined in your schema. One fetches all bookmarks inside a specific collection. The other fetches a single bookmark and includes the name of its parent collection by joining the two tables together.

In this step, get ready to:
  • Add an endpoint that returns all bookmarks in a given collection.
  • Update the single-bookmark endpoint to join with the collections table.
  • Test cross-table queries and observe how Drizzle maps to real SQL.
Add a nested bookmarks endpoint to collections

When a client wants all bookmarks inside a specific collection, the natural URL is /collections/:id/bookmarks. This endpoint uses the collection_id foreign key you already defined to filter results.

  • In src/routes/collections.ts, find the line that says export default app; at the bottom of the file.
  • Add this new route just above that line:
app.get('/:id/bookmarks', async (c) => {
  const id = Number(c.req.param('id'));
  const { bookmarks: bookmarksTable } = await import('../db/schema');
  const result = await db.select().from(bookmarksTable).where(eq(bookmarksTable.collectionId, id));
  return c.json(result);
});

What does this code do?

This route responds to GET requests at /collections/:id/bookmarks. It grabs the collection ID from the URL, then queries the bookmarks table for rows whose collectionId matches.

The dynamic import('../db/schema') pulls in the bookmarks table definition. This avoids a circular import since the collections route file doesn't normally need the bookmarks schema at the top level.

  • Save src/routes/collections.ts.
  • Restart your server by running:
npx tsx src/index.ts
  • In a separate terminal, test the new endpoint by running this curl command (replace 1 with the ID of a collection you created earlier):
curl http://localhost:3000/collections/1/bookmarks
  • You should see a JSON array of bookmarks that belong to that collection. If the collection has no bookmarks yet, you will see an empty array [].

Getting an empty array when you expected results?

  • Double-check the collection ID in your URL matches an ID that actually exists. Run curl http://localhost:3000/collections to see your collections and their IDs.
  • Make sure you created bookmarks with a matching collection_id in the POST body. If not, create one now using the POST /bookmarks endpoint from the previous step.

Still stuck? Ask for help.

Update the single-bookmark endpoint with a join

Right now, GET /bookmarks/:id returns raw bookmark data including a collectionId number. That number is meaningless to a client unless they make a second request to look up the collection name. A JOIN solves this by pulling data from both tables in a single query.

  • In src/routes/bookmarks.ts, find the existing GET /:id handler. It currently looks like this:
app.get('/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const result = await db.select().from(bookmarks).where(eq(bookmarks.id, id));
  if (result.length === 0) {
    return c.json({ error: 'Bookmark not found' }, 404);
  }
  return c.json(result[0]);
});
  • Replace that entire handler with this version that uses innerJoin():
app.get('/:id', async (c) => {
  const id = Number(c.req.param('id'));
  // Join bookmarks with collections to include the collection name
  const result = await db
    .select({
      id: bookmarks.id,
      title: bookmarks.title,
      url: bookmarks.url,
      collectionId: bookmarks.collectionId,
      createdAt: bookmarks.createdAt,
      collectionName: collections.name,
    })
    .from(bookmarks)
    .innerJoin(collections, eq(bookmarks.collectionId, collections.id))
    .where(eq(bookmarks.id, id));

  if (result.length === 0) {
    return c.json({ error: 'Bookmark not found' }, 404);
  }
  return c.json(result[0]);
});

What does this code do?

  • .select({...}) with an explicit object lets you pick exactly which columns to return from the joined result. Without this, you would get duplicate column names from both tables.
  • .innerJoin(collections, eq(bookmarks.collectionId, collections.id)) tells the database to match each bookmark row with the collection row that shares the same ID. This is the SQL INNER JOIN ... ON pattern, written as TypeScript.
  • collectionName: collections.name pulls the collection's name into the response so the client gets a human-readable label alongside the numeric ID.
  • Save src/routes/bookmarks.ts.
  • Restart your server by running:
npx tsx src/index.ts
  • Test the updated endpoint by running (replace 1 with the ID of a bookmark you created earlier):
curl http://localhost:3000/bookmarks/1
  • You should see a JSON object that includes a collectionName field alongside the bookmark data. That field came from the collections table via the join.

Seeing a 'Bookmark not found' error?

  • An innerJoin only returns rows where both sides of the join match. If a bookmark's collection_id points to a collection that was deleted, the join produces zero rows and the endpoint returns 404.
  • Verify the bookmark exists by running curl http://localhost:3000/bookmarks to list all bookmarks and check their IDs.

Need more help debugging?

Observe the SQL tradeoff

Look at the code you just wrote for the join endpoint. Notice how it reads almost exactly like the SQL it generates under the hood: SELECT specific columns, FROM bookmarks, INNER JOIN collections ON a condition, WHERE an ID matches.

Why Drizzle instead of Prisma?

Higher-abstraction ORMs like Prisma hide joins behind an object-shaped API (e.g., include: { collection: true }). That is convenient, but you lose visibility into what SQL is actually running.

Drizzle ORM takes the opposite approach. Its query builder is SQL-shaped TypeScript. When you write .innerJoin(), you know a real INNER JOIN is happening. You can reason about performance, understand the query plan, and explain it in an interview.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before continuing.

ⓧ I'd like to double check the full code

Here is the full src/routes/collections.ts at this point:

import { Hono } from 'hono';
import { db } from '../db/index';
import { collections } from '../db/schema';
import { eq } from 'drizzle-orm';

const app = new Hono();

app.get('/', async (c) => {
  const allCollections = await db.select().from(collections);
  return c.json(allCollections);
});

app.get('/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const result = await db.select().from(collections).where(eq(collections.id, id));
  if (result.length === 0) {
    return c.json({ error: 'Collection not found' }, 404);
  }
  return c.json(result[0]);
});

app.post('/', async (c) => {
  const body = await c.req.json();
  const newCollection = await db.insert(collections).values({
    name: body.name,
    description: body.description,
  }).returning();
  return c.json(newCollection[0], 201);
});

app.get('/:id/bookmarks', async (c) => {
  const id = Number(c.req.param('id'));
  const { bookmarks: bookmarksTable } = await import('../db/schema');
  const result = await db.select().from(bookmarksTable).where(eq(bookmarksTable.collectionId, id));
  return c.json(result);
});

export default app;

And here is the full src/routes/bookmarks.ts at this point:

import { Hono } from 'hono';
import { db } from '../db/index';
import { bookmarks, collections } from '../db/schema';
import { eq } from 'drizzle-orm';

const app = new Hono();

app.get('/', async (c) => {
  const result = await db.select().from(bookmarks);
  return c.json(result);
});

app.get('/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const result = await db
    .select({
      id: bookmarks.id,
      title: bookmarks.title,
      url: bookmarks.url,
      collectionId: bookmarks.collectionId,
      createdAt: bookmarks.createdAt,
      collectionName: collections.name,
    })
    .from(bookmarks)
    .innerJoin(collections, eq(bookmarks.collectionId, collections.id))
    .where(eq(bookmarks.id, id));

  if (result.length === 0) {
    return c.json({ error: 'Bookmark not found' }, 404);
  }
  return c.json(result[0]);
});

app.post('/', async (c) => {
  const body = await c.req.json();
  const newBookmark = await db.insert(bookmarks).values({
    title: body.title,
    url: body.url,
    collectionId: body.collection_id,
  }).returning();
  return c.json(newBookmark[0], 201);
});

app.put('/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const body = await c.req.json();
  const updated = await db.update(bookmarks).set({
    title: body.title,
    url: body.url,
    collectionId: body.collection_id,
  }).where(eq(bookmarks.id, id)).returning();

  if (updated.length === 0) {
    return c.json({ error: 'Bookmark not found' }, 404);
  }
  return c.json(updated[0]);
});

app.delete('/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const deleted = await db.delete(bookmarks).where(eq(bookmarks.id, id)).returning();
  if (deleted.length === 0) {
    return c.json({ error: 'Bookmark not found' }, 404);
  }
  return c.json({ message: 'Bookmark deleted' });
});

export default app;

Your API now queries across relationships. You can fetch all bookmarks in a collection, or fetch a single bookmark enriched with its collection name. You have the vocabulary to explain exactly what SQL is running and why. Up next, the Secret Mission challenges you to add dynamic filtering and sorting to take your query skills further.

Secret mission

Add Filtering and Sorting

Your GET /bookmarks endpoint returns everything in the database right now. In a real app, users need to filter bookmarks by collection and sort them by date. Can you add dynamic query building to your API?

Clean Up Your Resources

Clean Up Your Resources

This project runs on the Supabase free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Supabase PostgreSQL database with two tables (collections and bookmarks).
  • Local bookmarks-api project folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the API with more features.

  • Your Supabase project stays on the free tier with no time limit.
  • Your local bookmarks-api folder remains on your Desktop for future development.

Pause - I'll come back to this later

Free up local resources while keeping your cloud database intact. Supabase free projects auto-pause after 7 days of inactivity, so no action is needed on the cloud side.

  • Stop the local dev server if it is still running by pressing Ctrl+C in the terminal.
  • Your Supabase database will auto-pause after 7 days of inactivity. You can restore it from the dashboard when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible for your Supabase data.

  • Delete your Supabase project by opening the Supabase Dashboard, selecting your project, then navigating to Project Settings in the sidebar.
  • Click General, scroll to the bottom, and click Delete Project. Confirm when prompted.
  • Delete your local project folder by running this command on macOS/Linux:
cd ~/Desktop
rm -rf bookmarks-api
  • On Windows, run this instead:
cd ~\Desktop
Remove-Item -Recurse -Force bookmarks-api

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional REST API backed by a live PostgreSQL database, with typed routes, relational data, and cross-table queries.

You've learned how to:

  • Design a relational PostgreSQL schema with a one-to-many relationship using Drizzle ORM and push it to a live database.
  • Connect to a live Supabase PostgreSQL database using a pooled connection string and perform CRUD operations through typed Hono routes.
  • Query across tables using innerJoin and articulate tradeoffs between SQL vs NoSQL, Drizzle vs Prisma, and why normalization matters.
  • Secret Mission: Added dynamic filtering and sorting to your bookmarks endpoint using Drizzle's $dynamic() chainable API with conditional query building.

Ready to quiz yourself?