Tutorial: Build a Blog API
In this tutorial, you will build a JSON API for a blog from scratch. Along the way, you will learn how to:
- Define routes for listing, showing, creating, updating, and deleting posts
- Write middleware for logging and parsing request bodies
- Organize handlers into controllers
- Use type-safe params and URL generation
By the end, you will have a working API that you can test with curl or any HTTP client.
Prerequisites
You should be comfortable with HTML, CSS, and JavaScript. No prior server programming experience is required. We will explain every concept as we go.
What is an API?
An API (Application Programming Interface) is a way for programs to talk to each other. A JSON API sends and receives data as JSON instead of HTML pages. When you visit a website, your browser sends a request to a server; an API works the same way, but the responses are structured data instead of web pages.
Step 1: Project Setup
Create a new directory and initialize a Node.js project:
mkdir blog-api
cd blog-api
npm init -y
npm install remixCreate a file called server.ts. This will be the entry point for our application.
Step 2: Define the Routes
Routes tell the router which URLs your application responds to. Let's define all the routes for our blog posts.
Create routes.ts:
// routes.ts
import { route, get, post, put, del } from 'remix/fetch-router/routes'
export let routes = route({
// A simple home page
home: '/',
// Blog post routes
posts: {
index: get('/posts'), // List all posts
show: get('/posts/:slug'), // Show one post
create: post('/posts'), // Create a new post
update: put('/posts/:slug'), // Update an existing post
destroy: del('/posts/:slug'), // Delete a post
},
})Let's break down what is happening:
route()creates a route map -- an object where each key is a route name and each value is a URL pattern.get('/posts')creates a route that only responds to GET requests at/posts.:slugis a dynamic segment -- a placeholder that matches any value. When someone requests/posts/hello-world, the router extracts{ slug: "hello-world" }as the params.post(),put(), anddel()work the same way but for different HTTP methods (the verbs that tell the server what action to take).
HTTP Methods
- GET means "give me this thing" (reading data)
- POST means "create a new thing"
- PUT means "replace this thing with an updated version"
- DELETE means "remove this thing"
Step 3: Create a Simple In-Memory Database
For this tutorial we will store posts in memory. In a real application, you would use a database.
Create db.ts:
// db.ts
export interface Post {
slug: string
title: string
body: string
createdAt: string
}
let posts: Post[] = [
{
slug: 'hello-world',
title: 'Hello, World!',
body: 'This is my first blog post.',
createdAt: new Date().toISOString(),
},
]
export function getAllPosts(): Post[] {
return posts
}
export function getPostBySlug(slug: string): Post | undefined {
return posts.find((p) => p.slug === slug)
}
export function createPost(data: {
title: string
body: string
slug: string
}): Post {
let post: Post = { ...data, createdAt: new Date().toISOString() }
posts.push(post)
return post
}
export function updatePost(
slug: string,
data: { title?: string; body?: string }
): Post | undefined {
let post = getPostBySlug(slug)
if (!post) return undefined
if (data.title) post.title = data.title
if (data.body) post.body = data.body
return post
}
export function deletePost(slug: string): boolean {
let index = posts.findIndex((p) => p.slug === slug)
if (index === -1) return false
posts.splice(index, 1)
return true
}Step 4: Write Your First Middleware
Middleware is a function that runs before your route handler. It can inspect or modify the request, add data that handlers can use, or return a response early (for example, to block unauthenticated users).
Let's create a logger middleware that prints information about every request:
Create middleware.ts:
// middleware.ts
import type { Middleware } from 'remix/fetch-router'
export let logger: Middleware = async (context, next) => {
let start = Date.now()
console.log(`--> ${context.method} ${context.url.pathname}`)
// Call next() to pass control to the next middleware or handler
let response = await next()
let duration = Date.now() - start
console.log(`<-- ${response.status} (${duration}ms)`)
return response
}The next() function is important. It tells the router "I'm done with my work, pass the request to the next thing in line." Middleware forms a chain -- each one can do work before and after calling next().
Now let's add a middleware that parses JSON request bodies. When someone sends a POST or PUT request with JSON data, we want to make that data easy to access:
// middleware.ts (continued)
import { createContextKey } from 'remix/fetch-router'
// A context key is a type-safe way to store data that
// middleware creates and handlers consume.
export let JsonBodyKey = createContextKey<unknown>()
export let jsonBody: Middleware = async (context, next) => {
let contentType = context.headers.get('Content-Type')
if (contentType?.includes('application/json')) {
let body = await context.request.json()
context.set(JsonBodyKey, body)
}
return next()
}Context Keys
A context key is like a labeled box. Middleware puts data into the box, and handlers take it out. The label (key) ensures type safety -- TypeScript knows exactly what type of data is in the box.
Step 5: Build the Posts Controller
A controller groups related route handlers together. Our posts controller will have an action for each route in our routes.posts map.
Create controllers/posts.ts:
// controllers/posts.ts
import type { Controller } from 'remix/fetch-router'
import type { routes } from '../routes.ts'
import { JsonBodyKey } from '../middleware.ts'
import * as db from '../db.ts'
export let postsController = {
actions: {
// GET /posts -- list all posts
index() {
let posts = db.getAllPosts()
return Response.json(posts)
},
// GET /posts/:slug -- show one post
show({ params }) {
let post = db.getPostBySlug(params.slug)
if (!post) {
return Response.json(
{ error: 'Post not found' },
{ status: 404 }
)
}
return Response.json(post)
},
// POST /posts -- create a new post
create(context) {
let body = context.get(JsonBodyKey) as {
title: string
body: string
slug: string
}
if (!body?.title || !body?.slug) {
return Response.json(
{ error: 'Title and slug are required' },
{ status: 400 }
)
}
let existing = db.getPostBySlug(body.slug)
if (existing) {
return Response.json(
{ error: 'A post with that slug already exists' },
{ status: 409 }
)
}
let post = db.createPost(body)
return Response.json(post, { status: 201 })
},
// PUT /posts/:slug -- update a post
update(context) {
let body = context.get(JsonBodyKey) as {
title?: string
body?: string
}
let post = db.updatePost(context.params.slug, body)
if (!post) {
return Response.json(
{ error: 'Post not found' },
{ status: 404 }
)
}
return Response.json(post)
},
// DELETE /posts/:slug -- delete a post
destroy({ params }) {
let deleted = db.deletePost(params.slug)
if (!deleted) {
return Response.json(
{ error: 'Post not found' },
{ status: 404 }
)
}
return new Response(null, { status: 204 })
},
},
} satisfies Controller<typeof routes.posts>The satisfies Controller<typeof routes.posts> annotation is key. It tells TypeScript to verify that:
- Every route in
routes.postshas a corresponding action. - Each action receives the correct
paramstype (for example,showgets{ slug: string }). - There are no extra actions that do not match a route.
If you misspell an action name or forget one, TypeScript will give you an error.
Step 6: Wire Everything Together
Now let's connect the routes, middleware, and controller into a working server.
Create server.ts:
// server.ts
import * as http from 'node:http'
import { createRouter } from 'remix/fetch-router'
import { createRequestListener } from 'remix/node-fetch-server'
import { routes } from './routes.ts'
import { logger, jsonBody } from './middleware.ts'
import { postsController } from './controllers/posts.ts'
// Create the router with global middleware
let router = createRouter({
middleware: [logger, jsonBody],
})
// Home page
router.get(routes.home, () => {
return Response.json({
message: 'Welcome to the Blog API',
endpoints: {
listPosts: routes.posts.index.href(),
showPost: routes.posts.show.href({ slug: ':slug' }),
},
})
})
// Mount the posts controller
router.map(routes.posts, postsController)
// Start the server
let port = Number(process.env.PORT) || 3000
let server = http.createServer(createRequestListener(router.fetch))
server.listen(port, () => {
console.log(`Blog API running at http://localhost:${port}`)
})Notice how routes.posts.index.href() generates the URL string "/posts". This is URL generation -- instead of hardcoding URL strings, you ask the route to build them for you. If you ever change the URL pattern, every generated URL updates automatically.
Step 7: Test It
Start your server (using a TypeScript runner like tsx):
npx tsx server.tsNow test each endpoint with curl:
List posts:
curl http://localhost:3000/postsShow a post:
curl http://localhost:3000/posts/hello-worldCreate a post:
curl -X POST http://localhost:3000/posts \
-H "Content-Type: application/json" \
-d '{"title": "Second Post", "slug": "second-post", "body": "More content."}'Update a post:
curl -X PUT http://localhost:3000/posts/hello-world \
-H "Content-Type: application/json" \
-d '{"title": "Hello, Remix!"}'Delete a post:
curl -X DELETE http://localhost:3000/posts/hello-worldStep 8: Use href() for Links Between Resources
One of the most powerful features of the Remix router is type-safe URL generation. Let's improve our index and show actions to include links:
// In controllers/posts.ts
index() {
let posts = db.getAllPosts().map((post) => ({
...post,
// Generate a URL to each individual post
url: routes.posts.show.href({ slug: post.slug }),
}))
return Response.json(posts)
},
show({ params }) {
let post = db.getPostBySlug(params.slug)
if (!post) {
return Response.json(
{ error: 'Post not found' },
{ status: 404 }
)
}
return Response.json({
...post,
links: {
self: routes.posts.show.href({ slug: post.slug }),
all: routes.posts.index.href(),
},
})
},TypeScript enforces that you pass the correct params to href(). If the route has a :slug segment, you must provide { slug: string }. Forget it, and you get a compile-time error.
What You Learned
- Routes map URL patterns to handlers. Dynamic segments like
:slugcapture values from the URL. - Middleware runs before handlers. It can log, parse bodies, check authentication, or return early responses.
- Controllers group related handlers and ensure type safety with
satisfies Controller<typeof routes>. href()generates URLs from routes, keeping your links in sync with your patterns.
Next Steps
- Read the API Reference for the full list of route helpers, middleware options, and controller types.
- Learn about resource routes to generate all CRUD routes with a single function call.
- Explore route-pattern to understand the URL matching engine under the hood.