Sometimes I just want a Node.js server that works without pulling in Express or any other heavy frameworks. This is a simple pattern I’ve been using for handling authentication with node:http and a library called better-auth. I’m writing this down for future me, so I don’t have to remember all the small details. Hope this helps you or at least shows you something new.
Why this approach?
- No frameworks: I don’t want to add Express/Koa just for a few endpoints.
- Minimal dependencies: better-auth handles the auth logic; everything else stays standard Node.
- Control: I can see exactly what’s happening in the request/response pipeline.
The Base Request Handler
This is a representation of a http.Server on request handler that receives an incommingMessage and serverResponse in order to return a generic type <T> or void by default.
// request-handler.ts
import http from "node:http";
export type RequestHandler<ReturnType = void> = (
incommingMessage: http.IncomingMessage,
serverResponse: http.ServerResponse,
) => ReturnType;
The Handler
The core of this setup is a small handler function that wraps your auth handler. It converts the raw request, runs the auth, sets headers, and streams the body if present.
// utils.ts
import http from "node:http";
export function incommingMessageToRequest(
incommingMessage: http.IncomingMessage,
baseUrl: URL,
): Request {
const method = incommingMessage.method || "GET";
const url = new URL(incommingMessage.url || "/", baseUrl);
const headers = new Headers();
for (const [key, value] of Object.entries(incommingMessage.headers)) {
if (Array.isArray(value)) {
value.forEach((v) => headers.append(key, v));
} else if (value !== undefined) {
headers.set(key, value);
}
}
const body =
method === "GET" || method === "HEAD" ? undefined : incommingMessage;
return new Request(url.toString(), {
method,
headers,
body,
duplex: body ? "half" : undefined,
});
}
// better-auth-handler.ts
import stream from "node:stream";
import streamPromises from "node:stream/promises";
import type { Auth, BetterAuthOptions } from "better-auth";
import type { RequestHandler } from "./request-handler";
import * as Utils from "./utils";
export type Config = {
baseUrl: URL;
};
export default function betterAuth(
auth: Auth<BetterAuthOptions>,
config: Config,
): RequestHandler<Promise<void>> {
return async (incommingMessage, serverResponse) => {
const response = await auth.handler(
Utils.incommingMessageToRequest(incommingMessage, config.baseUrl),
);
serverResponse.statusCode = response.status;
serverResponse.statusMessage = response.statusText;
response.headers.forEach((value, key) => {
if (key.toLowerCase() === "transfer-encoding") return;
serverResponse.setHeader(key, value);
});
if (!response.body) {
serverResponse.end();
return;
}
await streamPromises.pipeline(
stream.Readable.fromWeb(response.body),
serverResponse,
);
};
}
Hooking it up to a server
Here’s how I use it in a plain Node HTTP server:
// index.ts
import http from "node:http";
import BetterAuthHandler from "./better-auth-handler";
import { auth } from "./auth";
async function main() {
const httpServer = http.createServer();
const betterAuthHandler = BetterAuthHandler(auth, {
baseUrl: new URL(process.env.BETTER_AUTH_URL),
});
httpServer.on("request", async (incommingMessage, serverResponse) => {
await betterAuthHandler(incommingMessage, serverResponse);
if (serverResponse.writableEnded) {
return;
}
serverResponse.statusCode = 404;
serverResponse.end("Not Found");
return;
});
httpServer.listen(env.PORT, () => {
console.log(`http server listening on port ${env.PORT}`);
});
}
main();
That’s it. Nothing fancy, just a small, predictable setup that does what it needs to do.
If you’re reading this in the future: this worked, it stayed out of the way, and it didn’t require a framework.
Hopefully it’s useful to you too if you’re aiming for the same kind of minimal, boring-in-a-good-way Node server.
Top comments (0)