Build a Comments API
In this tutorial, you will use D1 and Hono ↗ to build a JSON API that stores and retrieves comments for a blog. You will create a D1 database, define a schema, and wire up GET and POST endpoints that read from and write to the database.
- Sign up for a Cloudflare account ↗.
- Install
Node.js↗.
Node.js version manager
Use a Node version manager like Volta ↗ or nvm ↗ to avoid permission issues and change Node.js versions. Wrangler, discussed later in this guide, requires a Node version of 16.17.0 or later.
-
Create a new project named
d1-comments-apiby running:Terminal window npm create cloudflare@latest -- d1-comments-apiTerminal window yarn create cloudflare d1-comments-apiTerminal window pnpm create cloudflare@latest d1-comments-apiFor setup, select the following options:
- For What would you like to start with?, choose
Hello World example. - For Which template would you like to use?, choose
Worker only. - For Which language do you want to use?, choose
TypeScript. - For Do you want to use git for version control?, choose
Yes. - For Do you want to deploy your application?, choose
No(we will be making some changes before deploying).
- For What would you like to start with?, choose
-
Move into the project directory:
Terminal window cd d1-comments-api
Install Hono ↗, a lightweight web framework for building APIs on Workers:
npm i honoyarn add honopnpm add hono-
Create a new D1 database with Wrangler:
Terminal window npx wrangler@latest d1 create d1-comments-api -
When prompted
Would you like Wrangler to add it on your behalf?, selectYes. This automatically adds theDBbinding to your Wrangler configuration file.Confirm that your Wrangler configuration file contains the
d1_databasesbinding and the full project configuration:{"$schema": "./node_modules/wrangler/config-schema.json","name": "d1-comments-api","main": "src/index.ts",// Set this to today's date"compatibility_date": "2026-03-18","d1_databases": [{"binding": "DB","database_name": "d1-comments-api","database_id": "<YOUR_DATABASE_ID>"}]}name = "d1-comments-api"main = "src/index.ts"# Set this to today's datecompatibility_date = "2026-03-18"[[d1_databases]]binding = "DB" # available in your Worker on env.DBdatabase_name = "d1-comments-api"database_id = "<YOUR_DATABASE_ID>"Replace
<YOUR_DATABASE_ID>with the ID output by thewrangler d1 createcommand.
Bindings allow your Workers to access resources, like D1 databases, KV namespaces, and R2 buckets, using a variable name in code. Your D1 database is accessible in your Worker on env.DB.
-
Create a
schemas/schema.sqlfile with the following contents:DROP TABLE IF EXISTS comments;CREATE TABLE IF NOT EXISTS comments (id INTEGER PRIMARY KEY AUTOINCREMENT,author TEXT NOT NULL,body TEXT NOT NULL,post_slug TEXT NOT NULL);CREATE INDEX idx_comments_post_slug ON comments (post_slug);-- Optionally, uncomment the below query to insert seed data-- INSERT INTO comments (author, body, post_slug) VALUES ('Kristian', 'Great post!', 'hello-world'); -
Run the schema against your local database first:
Terminal window npx wrangler d1 execute d1-comments-api --local --file schemas/schema.sql -
Verify the table was created locally:
Terminal window npx wrangler d1 execute d1-comments-api --local --command "SELECT name FROM sqlite_schema WHERE type = 'table'"┌──────────┐│ name │├──────────┤│ comments │└──────────┘ -
Once you are satisfied with the schema, apply it to your remote (production) database:
Terminal window npx wrangler d1 execute d1-comments-api --remote --file schemas/schema.sql
Replace the contents of src/index.ts with the following code. This sets up a Hono application with a typed Bindings interface so that env.DB is correctly typed as a D1Database:
import { Hono } from "hono";
const app = new Hono();
app.get("/api/posts/:slug/comments", async (c) => { // Do something and return an HTTP response // Optionally, do something with c.req.param("slug")});
app.post("/api/posts/:slug/comments", async (c) => { // Do something and return an HTTP response // Optionally, do something with c.req.param("slug")});
export default app;import { Hono } from "hono";
type Bindings = { DB: D1Database;};
const app = new Hono<{ Bindings: Bindings }>();
app.get("/api/posts/:slug/comments", async (c) => { // Do something and return an HTTP response // Optionally, do something with c.req.param("slug")});
app.post("/api/posts/:slug/comments", async (c) => { // Do something and return an HTTP response // Optionally, do something with c.req.param("slug")});
export default app;Add the logic for the GET endpoint to retrieve comments for a given post. This uses the D1 Workers Binding API to prepare and execute a parameterized query:
app.get("/api/posts/:slug/comments", async (c) => { const { slug } = c.req.param(); const { results } = await c.env.DB.prepare( "SELECT * FROM comments WHERE post_slug = ?", ) .bind(slug) .run(); return c.json(results);});app.get("/api/posts/:slug/comments", async (c) => { const { slug } = c.req.param(); const { results } = await c.env.DB.prepare( "SELECT * FROM comments WHERE post_slug = ?", ) .bind(slug) .run(); return c.json(results);});The code uses prepare to create a parameterized statement, bind to safely pass the slug value (preventing SQL injection), and run to execute the query.
Add the POST endpoint to create new comments. This validates the request body before inserting a row:
app.post("/api/posts/:slug/comments", async (c) => { const { slug } = c.req.param(); const { author, body } = await c.req.json();
if (!author) return c.text("Missing author value for new comment", 400); if (!body) return c.text("Missing body value for new comment", 400);
const { success } = await c.env.DB.prepare( "INSERT INTO comments (author, body, post_slug) VALUES (?, ?, ?)", ) .bind(author, body, slug) .run();
if (success) { c.status(201); return c.text("Created"); } else { c.status(500); return c.text("Something went wrong"); }});app.post("/api/posts/:slug/comments", async (c) => { const { slug } = c.req.param(); const { author, body } = await c.req.json<{ author: string; body: string; }>();
if (!author) return c.text("Missing author value for new comment", 400); if (!body) return c.text("Missing body value for new comment", 400);
const { success } = await c.env.DB.prepare( "INSERT INTO comments (author, body, post_slug) VALUES (?, ?, ?)", ) .bind(author, body, slug) .run();
if (success) { c.status(201); return c.text("Created"); } else { c.status(500); return c.text("Something went wrong"); }});If you plan to call this API from a front-end application on a different origin, add CORS middleware. Import the cors module from Hono and add it before your routes:
import { Hono } from "hono";import { cors } from "hono/cors";
const app = new Hono();app.use("/api/*", cors());import { Hono } from "hono";import { cors } from "hono/cors";
type Bindings = { DB: D1Database;};
const app = new Hono<{ Bindings: Bindings }>();app.use("/api/*", cors());When you make requests to /api/*, Hono will automatically generate and add CORS headers to responses from your API.
-
Log in to your Cloudflare account (if you have not already):
Terminal window npx wrangler whoamiIf you are not logged in, Wrangler will prompt you to log in.
-
Deploy your Worker:
Terminal window npx wrangler deploy -
Test the API by inserting and then retrieving a comment:
Terminal window # Replace <YOUR_SUBDOMAIN> with your workers.dev subdomaincurl -X POST https://d1-comments-api.<YOUR_SUBDOMAIN>.workers.dev/api/posts/hello-world/comments \-H "Content-Type: application/json" \-d '{"author": "Kristian", "body": "Great post!"}'CreatedTerminal window curl https://d1-comments-api.<YOUR_SUBDOMAIN>.workers.dev/api/posts/hello-world/comments[{"id": 1,"author": "Kristian","body": "Great post!","post_slug": "hello-world"}]
The complete src/index.ts with all routes and CORS support:
import { Hono } from "hono";import { cors } from "hono/cors";
const app = new Hono();app.use("/api/*", cors());
app.get("/api/posts/:slug/comments", async (c) => { const { slug } = c.req.param(); const { results } = await c.env.DB.prepare( "SELECT * FROM comments WHERE post_slug = ?", ) .bind(slug) .run(); return c.json(results);});
app.post("/api/posts/:slug/comments", async (c) => { const { slug } = c.req.param(); const { author, body } = await c.req.json();
if (!author) return c.text("Missing author value for new comment", 400); if (!body) return c.text("Missing body value for new comment", 400);
const { success } = await c.env.DB.prepare( "INSERT INTO comments (author, body, post_slug) VALUES (?, ?, ?)", ) .bind(author, body, slug) .run();
if (success) { c.status(201); return c.text("Created"); } else { c.status(500); return c.text("Something went wrong"); }});
export default app;import { Hono } from "hono";import { cors } from "hono/cors";
type Bindings = { DB: D1Database;};
const app = new Hono<{ Bindings: Bindings }>();app.use("/api/*", cors());
app.get("/api/posts/:slug/comments", async (c) => { const { slug } = c.req.param(); const { results } = await c.env.DB.prepare( "SELECT * FROM comments WHERE post_slug = ?", ) .bind(slug) .run(); return c.json(results);});
app.post("/api/posts/:slug/comments", async (c) => { const { slug } = c.req.param(); const { author, body } = await c.req.json<{ author: string; body: string; }>();
if (!author) return c.text("Missing author value for new comment", 400); if (!body) return c.text("Missing body value for new comment", 400);
const { success } = await c.env.DB.prepare( "INSERT INTO comments (author, body, post_slug) VALUES (?, ?, ?)", ) .bind(author, body, slug) .run();
if (success) { c.status(201); return c.text("Created"); } else { c.status(500); return c.text("Something went wrong"); }});
export default app;- Refer to the D1 Workers Binding API for a full list of available methods.
- Learn about D1 local development for testing your database without deploying.
- Explore community projects built on D1.