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.