React Router v7 and Fastify

Written by on .

remix
react-router
fastify

  1. remix-fastify
    1. createReactRouterRequestHandler
      1. Using createReactRouterRequestHandler
        1. Handling static assets

        This is a not typical post about AI, but I wanted to share my experience migrating Glama from Remix to React Router v7 + Fastify v5.

        In the process, I was really confused by how to set up Fastify and React Router v7.

        This post is a quick summary of the steps I took to get it working.

        remix-fastify

        There is a @mcansh/remix-fastify package that provides Fastify middleware for React Router v7.

        If you are Okay with using middlware that abstracts away everything related to route loading, then you can just use this package.

        However, my preference is to handle the request myself. Luckily, I was able to look at the source code of @mcansh/remix-fastify and piece together a solution that allowed me to do just that.

        createReactRouterRequestHandler

        This code is almost verbatim taken from the @mcansh/remix-fastify package. It is just pieced together from different files for ease of maintenance.

        What follows is a React Router compatible request handler – it simply takes Fastify request / reply and proxies them to the React Router server. This is exactly how the remix-fastify package does it.

        import { createReadableStreamFromReadable } from '@react-router/node'; import { type createReadableStreamFromReadable as RRCreateReadableStreamFromReadable } from '@react-router/node'; import { type FastifyReply, type FastifyRequest, type RouteGenericInterface, } from 'fastify'; import type * as http from 'node:http'; import type * as http2 from 'node:http2'; import type * as https from 'node:https'; import { Readable } from 'node:stream'; import { type AppLoadContext, createRequestHandler, type ServerBuild, } from 'react-router'; type GenericGetLoadContextFunction< Server extends HttpServer, AppLoadContext, > = ( request: FastifyRequest<RouteGenericInterface, Server>, reply: FastifyReply<RouteGenericInterface, Server>, ) => AppLoadContext | Promise<AppLoadContext>; type GetLoadContextFunction<Server extends HttpServer = HttpServer> = GenericGetLoadContextFunction<Server, AppLoadContext>; type HttpServer = | http2.Http2SecureServer | http2.Http2Server | http.Server | https.Server; type RequestHandler<Server extends HttpServer> = ( request: FastifyRequest<RouteGenericInterface, Server>, reply: FastifyReply<RouteGenericInterface, Server>, ) => Promise<void>; export function createReactRouterRequestHandler<Server extends HttpServer>({ build, getLoadContext, // eslint-disable-next-line n/no-process-env mode = process.env.NODE_ENV, }: { build: (() => Promise<ServerBuild> | ServerBuild) | ServerBuild; getLoadContext?: GetLoadContextFunction<Server>; mode?: string; }): RequestHandler<Server> { const handleRequest = createRequestHandler(build, mode); return async (request, reply) => { const remixRequest = createReactRouterRequest(request, reply); const loadContext = await getLoadContext?.(request, reply); const response = await handleRequest(remixRequest, loadContext); return sendResponse(reply, response); }; } function createHeaders(requestHeaders: FastifyRequest['headers']): Headers { const headers = new Headers(); for (const [key, values] of Object.entries(requestHeaders)) { if (values) { if (Array.isArray(values)) { for (const value of values) { headers.append(key, value); } } else { headers.set(key, values); } } } return headers; } function createReactRouterRequest<Server extends HttpServer>( request: FastifyRequest<RouteGenericInterface, Server>, reply: FastifyReply<RouteGenericInterface, Server>, ): Request { return createRequest(request, reply, createReadableStreamFromReadable); } function createRequest<Server extends HttpServer>( request: FastifyRequest<RouteGenericInterface, Server>, reply: FastifyReply<RouteGenericInterface, Server>, createReadableStreamFromReadable: typeof RRCreateReadableStreamFromReadable, ): Request { const url = getUrl(request); let controller: AbortController | null = new AbortController(); const init: RequestInit = { headers: createHeaders(request.headers), method: request.method, signal: controller.signal, }; // Abort action/loaders once we can no longer write a response if we have // not yet sent a response (i.e., `close` without `finish`) // `finish` -> done rendering the response // `close` -> response can no longer be written to reply.raw.on('finish', () => (controller = null)); reply.raw.on('close', () => controller?.abort()); if (request.method !== 'GET' && request.method !== 'HEAD') { init.body = createReadableStreamFromReadable(request.raw); init.duplex = 'half'; } return new Request(url, init); } function getUrl<Server extends HttpServer>( request: FastifyRequest<RouteGenericInterface, Server>, ): string { const origin = `${request.protocol}://${request.host}`; // Use `request.originalUrl` so Remix and React Router are aware of the full path const url = `${origin}${request.originalUrl}`; return url; } function responseToReadable(response: Response): null | Readable { if (!response.body) return null; const reader = response.body.getReader(); const readable = new Readable(); readable._read = async () => { const result = await reader.read(); if (result.done) { readable.push(null); } else { readable.push(Buffer.from(result.value)); } }; return readable; } async function sendResponse<Server extends HttpServer>( reply: FastifyReply<RouteGenericInterface, Server>, nodeResponse: Response, ): Promise<void> { reply.status(nodeResponse.status); for (const [key, values] of nodeResponse.headers.entries()) { reply.headers({ [key]: values }); } if (nodeResponse.body) { const stream = responseToReadable(nodeResponse.clone()); return reply.send(stream); } return reply.send(await nodeResponse.text()); }

        The code is simple enough to just copy and paste into your project.

        Using createReactRouterRequestHandler

        Your production could should import a React Router build (built using react-router build command) and pass it to createReactRouterRequestHandler.

        This will create a request handler that will render the request using the React Router server.

        const serverBuild = (await import('../../build/server')) as ServerBuild; await app.register(async (instance) => { instance.removeAllContentTypeParsers(); instance.addContentTypeParser('*', (_request, payload, done) => { done(null, payload); }); const handler = createReactRouterRequestHandler({ build: serverBuild, getLoadContext, mode: 'production', }); instance.all('*', async (request, reply) => { try { return handler(request, reply); } catch (error) { captureException({ error, extra: { url: request.url, }, message: 'could not render request', }); return replyWithErrorPage(reply, error); } }); });

        Your development server should use Vite to render the request.

        import { createServer } from 'vite'; import middie from '@fastify/middie'; // ... const vite = await createServer({ server: { middlewareMode: true }, }); await app.register(middie); await app.use(vite.middlewares); await app.register(async (instance) => { instance.removeAllContentTypeParsers(); instance.addContentTypeParser('*', (_request, payload, done) => { done(null, payload); }); const handler = createReactRouterRequestHandler({ build: () => vite.ssrLoadModule('virtual:react-router/server-build'), getLoadContext, mode: 'development', }); instance.all('*', async (request, reply) => { try { return await handler(request, reply); } catch (error) { return replyWithErrorPage(reply, error); } }); });

        Handling static assets

        In production, you will want to handle static assets yourself.

        Just use @fastify/static to serve the assets from the build/client directory.

        import { fastifyStatic } from '@fastify/static'; // ... await app.register(fastifyStatic, { cacheControl: true, decorateReply: false, dotfiles: 'allow', etag: true, immutable: true, lastModified: true, maxAge: '1y', preCompressed: true, prefix: '/assets', root: path.join(import.meta.dirname, '../build/client/assets'), serveDotFiles: true, wildcard: true, }); await app.register(fastifyStatic, { cacheControl: true, dotfiles: 'allow', etag: true, immutable: true, lastModified: true, maxAge: '1y', preCompressed: true, prefix: '/', root: path.join(import.meta.dirname, '../build/client'), serveDotFiles: true, wildcard: false, });

        That's it! These steps are enough to get a working React Router v7 + Fastify server.

        If I missed anything, please don't hesitate to reach out to me on Twitter or Discord.

        Written by Frank Fiegel (@punkpeye)