Skip to content

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

bash
mkdir my-server
cd my-server
npm init -y
npm install remix

Create a file called server.ts.

Step 2: A Minimal Server

The simplest possible server returns the same response for every request:

ts
// 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:

bash
npx tsx server.ts

Open http://localhost:3000 in your browser and you should see "Hello, world!".

What is happening here:

  1. http.createServer() is Node.js's built-in way to start an HTTP server.
  2. createRequestListener() wraps your Fetch-style handler function so Node.js can use it.
  3. Your handler receives a standard Request and returns a standard Response.

Step 3: Route Different URLs

Let's handle different URLs by inspecting the request:

ts
// 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:

ts
// 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:

ts
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.

ts
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:

bash
curl -N http://localhost:3000/stream

The 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:

bash
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():

ts
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:

ts
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:

ts
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.js http.createServer().
  • The handler receives a standard Request and returns a standard Response.
  • Streaming responses work automatically with ReadableStream.
  • HTTPS works by switching to https.createServer() with TLS certificates.
  • The onError option prevents unhandled exceptions from crashing your server.

Next Steps

Released under the MIT License.