Tutorial: Set Up a Node.js Server
In this tutorial, you will create a Node.js HTTP server from scratch using the Fetch API. You will learn how to handle different routes, return JSON responses, stream large responses, set up HTTPS, and handle errors gracefully.
Prerequisites
You should be comfortable with JavaScript. You will need Node.js version 18 or later installed (for built-in Fetch API support).
What is an HTTP Server?
An HTTP server is a program that listens for incoming requests from web browsers (or other clients) and sends back responses. When you type a URL into your browser, it sends an HTTP request to a server, which sends back the HTML, JSON, or other content you see.
Step 1: Project Setup
mkdir my-server
cd my-server
npm init -y
npm install remixCreate a file called server.ts.
Step 2: A Minimal Server
The simplest possible server returns the same response for every request:
// server.ts
import * as http from 'node:http'
import { createRequestListener } from 'remix/node-fetch-server'
async function handler(request: Request): Promise<Response> {
return new Response('Hello, world!')
}
let server = http.createServer(createRequestListener(handler))
server.listen(3000, () => {
console.log('Server running at http://localhost:3000')
})Run it:
npx tsx server.tsOpen http://localhost:3000 in your browser and you should see "Hello, world!".
What is happening here:
http.createServer()is Node.js's built-in way to start an HTTP server.createRequestListener()wraps your Fetch-stylehandlerfunction so Node.js can use it.- Your
handlerreceives a standardRequestand returns a standardResponse.
Step 3: Route Different URLs
Let's handle different URLs by inspecting the request:
// server.ts
import * as http from 'node:http'
import { createRequestListener } from 'remix/node-fetch-server'
async function handler(request: Request): Promise<Response> {
let url = new URL(request.url)
switch (url.pathname) {
case '/':
return new Response('<h1>Home Page</h1>', {
headers: { 'Content-Type': 'text/html' },
})
case '/about':
return new Response('<h1>About</h1>', {
headers: { 'Content-Type': 'text/html' },
})
case '/api/time':
return Response.json({
time: new Date().toISOString(),
})
default:
return new Response('Not Found', { status: 404 })
}
}
let server = http.createServer(createRequestListener(handler))
server.listen(3000, () => {
console.log('Server running at http://localhost:3000')
})Use the Router for Real Applications
This manual URL switching works for learning, but for real applications you should use fetch-router which handles pattern matching, parameter extraction, and method routing automatically.
Step 4: Use the Remix Router
Let's replace the manual URL switching with a proper router:
// server.ts
import * as http from 'node:http'
import { createRouter } from 'remix/fetch-router'
import { route } from 'remix/fetch-router/routes'
import { createRequestListener } from 'remix/node-fetch-server'
let routes = route({
home: '/',
about: '/about',
time: '/api/time',
greet: '/hello/:name',
})
let router = createRouter()
router.get(routes.home, () => {
return new Response('<h1>Home Page</h1>', {
headers: { 'Content-Type': 'text/html' },
})
})
router.get(routes.about, () => {
return new Response('<h1>About</h1>', {
headers: { 'Content-Type': 'text/html' },
})
})
router.get(routes.time, () => {
return Response.json({ time: new Date().toISOString() })
})
router.get(routes.greet, ({ params }) => {
return new Response(`Hello, ${params.name}!`)
})
let server = http.createServer(createRequestListener(router.fetch))
server.listen(3000, () => {
console.log('Server running at http://localhost:3000')
})Now try http://localhost:3000/hello/Remix -- the router extracts "Remix" from the URL and passes it as params.name.
Step 5: Handle Errors
If your handler throws an error, you do not want the server to crash. The onError option lets you catch errors and return a friendly response:
let server = http.createServer(
createRequestListener(router.fetch, {
onError(error) {
console.error('Unhandled error:', error)
return new Response(
JSON.stringify({ error: 'Something went wrong' }),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
)
},
})
)Without a custom error handler, node-fetch-server logs the error and returns a default 500 response.
Step 6: Stream a Large Response
One of the strengths of the Fetch API is built-in support for streaming -- sending data piece by piece instead of all at once. This is essential for large files or real-time data.
router.get('/stream', () => {
let stream = new ReadableStream({
async start(controller) {
for (let i = 1; i <= 5; i++) {
controller.enqueue(
new TextEncoder().encode(`Chunk ${i}\n`)
)
// Wait 500ms between chunks
await new Promise((resolve) => setTimeout(resolve, 500))
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/plain' },
})
})Test it with curl to see the chunks arrive one at a time:
curl -N http://localhost:3000/streamThe node-fetch-server package handles backpressure automatically -- if the client cannot receive data fast enough, the server pauses sending until the client catches up.
Step 7: Add HTTPS
HTTPS encrypts the connection between the client and server. For local development, you can create a self-signed certificate:
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \
-days 365 -nodes -subj '/CN=localhost'Then use https.createServer() instead of http.createServer():
import * as https from 'node:https'
import * as fs from 'node:fs'
import { createRequestListener } from 'remix/node-fetch-server'
let server = https.createServer(
{
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
},
createRequestListener(router.fetch)
)
server.listen(3000, () => {
console.log('HTTPS server running at https://localhost:3000')
})The createRequestListener function works identically with HTTPS -- it automatically detects the https: protocol and sets request URLs accordingly.
Production HTTPS
In production, you typically do not handle TLS certificates in your application. Instead, a reverse proxy like Nginx or a cloud load balancer terminates TLS and forwards plain HTTP to your app. Use the protocol: 'https:' option to tell createRequestListener that requests are actually HTTPS even though they arrive as HTTP.
Step 8: Access Client Information
Your handler receives a second argument with information about the client that sent the request:
async function handler(
request: Request,
client: { address: string; family: string; port: number }
): Promise<Response> {
console.log(`Request from ${client.address}`)
return new Response('OK')
}This is useful for logging, rate limiting, or IP-based access control.
Step 9: Use Lower-Level Functions
For advanced use cases, you can use createRequest and sendResponse directly instead of createRequestListener:
import * as http from 'node:http'
import { createRequest, sendResponse } from 'remix/node-fetch-server'
let server = http.createServer(async (req, res) => {
// Convert Node.js request to Fetch Request
let request = createRequest(req, res)
// Do something with the standard Request
console.log(request.method, request.url)
// Create a standard Response
let response = new Response('Hello from lower-level API')
// Send it through the Node.js response
await sendResponse(res, response)
})
server.listen(3000)This gives you full control over the Node.js request/response lifecycle while still using Fetch API types for your application logic.
What You Learned
createRequestListener()bridges Fetch API handlers to Node.jshttp.createServer().- The handler receives a standard
Requestand returns a standardResponse. - Streaming responses work automatically with
ReadableStream. - HTTPS works by switching to
https.createServer()with TLS certificates. - The
onErroroption prevents unhandled exceptions from crashing your server.
Next Steps
- API Reference -- Complete documentation of all functions and options.
- fetch-router Tutorial -- Build a full API with the router.
- fetch-proxy Overview -- Forward requests to other servers.