Serve Next.js with Fastify

RMAG news

Next.js is an exceptional framework for React applications that comes with a lot of bells and whistles for Server-Side Rendering and Static Site Generation. One of the quickest ways to start writing production-ready React without spending the time on setup.

Next.js comes with its own server that can be used out-of-the-box when starting a brand-new projects.
But what if you need to serve Next.js app from an existing Node server? Or maybe you want to have more flexibility additional flexibility for integrating middleware, handling custom routes, etc?

If that’s the case – this post is for you, it covers the setup of custom Next.js server with Fastify, solution for Express.js or plain Node.js server will be similar.

Example project used here is also available as a template on Github.

Initial setup

So imagine you have an existing Fastify project. For the sake of example I have a simple Fastify API here. It’s initialized from this great Fastify template and has a couple of endpoints returning mock data:

/_health – server status

/api/pokemons – Pokemons list

/api/stats – list of Pokemon stats

// src/app.ts

import { fastify as Fastify, FastifyServerOptions } from fastify;
import { POKEMONS, STATS } from ./mocks;

export default (opts?: FastifyServerOptions) => {
const fastify = Fastify(opts);

fastify.get(/_health, async (request, reply) => {
return { status: OK };
});

fastify.get(/api/pokemons, async (request, reply) => {
return POKEMONS;
});

fastify.get(/api/stats, async (request, reply) => {
return STATS;
});

return fastify;
};

Adding Next.js app

It’s as easy as just generating a new Next.js project using create-next-app, I’ll do it in ./src directory:

cd ./src && npx create-next-app nextjs-app

Handling requests using Next.js

To allow Next.js render pages Fastify needs to pass requests to it.

For this example, I want Next.js to handle all routes under /nextjs-app

// Path Next.js app is served at.
const NEXTJS_APP_ROOT = /nextjs-app;
fastify.all(`${NEXTJS_APP_ROOT}*`, (request, reply) => {
// Remove prefix to let Next.js handle request
// like it was made directly to it.
const nextjsAppUrl = parse(
request.url.replace(NEXTJS_APP_ROOT, “”) || /,
true
);

nextjsHandler(request.raw, reply.raw, nextjsAppUrl).then(() => {
reply.hijack();
reply.raw.end();
});
});

Next.js also makes requests to get static, client code chunks etc. on /_next/* routes, need to pass requests from Fastify to it:

// Let Next.js handle its static etc.
fastify.all(/_next*, (request, reply) => {
nextjsHandler(request.raw, reply.raw).then(() => {
reply.hijack();
reply.raw.end();
});
});

As a result, complete Fastify routing would look like this:

// src/fastify-app.ts

import { fastify as Fastify, FastifyServerOptions } from fastify;
import { POKEMONS, STATS } from ./mocks;
import nextjsApp from ./nextjs-app;
import { parse } from url;

const nextjsHandler = nextjsApp.getRequestHandler();

export default (opts?: FastifyServerOptions) => {
const fastify = Fastify(opts);

fastify.get(/_health, async (request, reply) => {
return { status: OK };
});

fastify.get(/api/pokemons, async (request, reply) => {
return POKEMONS;
});

fastify.get(/api/stats, async (request, reply) => {
return STATS;
});

// Path Next.js app is served at.
const NEXTJS_APP_ROOT = /nextjs-app;
fastify.all(`${NEXTJS_APP_ROOT}*`, (request, reply) => {
// Remove prefix to make URL relative to let Next.js handle request
// like it was made directly to it.
const nextjsAppUrl = parse(
request.url.replace(NEXTJS_APP_ROOT, “”) || /,
true
);

nextjsHandler(request.raw, reply.raw, nextjsAppUrl).then(() => {
reply.hijack();
reply.raw.end();
});
});

// Let Next.js handle its static etc.
fastify.all(/_next*, (request, reply) => {
nextjsHandler(request.raw, reply.raw).then(() => {
reply.hijack();
reply.raw.end();
});
});

return fastify;
};

Where the nextjsApp comes from Next.js initialization here:

// src/nextjs-app.ts

import next from next;
import env from ./env;

export default next({
dev: import.meta.env.DEV,
hostname: env.HOST,
port: env.PORT,
// Next.js project directory relative to project root
dir: ./src/nextjs-app,
});

And last but not the least – Next.js app needs to be initialized before starting the server:

nextjsApp.prepare().then(() => {
fastifyApp.listen({ port: env.PORT as number, host: env.HOST });
fastifyApp.log.info(`Server started on ${env.HOST}:${env.PORT}`);
});

Full server init will look like this:

// src/server.ts

import fastify from ./fastify-app;
import logger from ./logger;
import env from ./env;
import nextjsApp from ./nextjs-app;

const fastifyApp = fastify({
logger,
pluginTimeout: 50000,
bodyLimit: 15485760,
});

try {
nextjsApp.prepare().then(() => {
fastifyApp.listen({ port: env.PORT as number, host: env.HOST });
fastifyApp.log.info(`Server started on ${env.HOST}:${env.PORT}`);
});
} catch (err) {
fastifyApp.log.error(err);
process.exit(1);
}

Build updates

Now Next.js app needs to be built before starting the server, so a couple updates in package.json:

“scripts”: {
“build”: “concurrently npm:build:fastify npm:build:nextjs,
“build:fastify”: “vite build –outDir build –ssr src/server.ts”,
“build:nextjs”: “cd ./src/nextjs-app && npm run build”,
“start”: “pnpm run build && node build/server.mjs”,

Result

With these changes applied, Fastify keeps handling all the routes it initially had:

/_health – server status

/api/pokemons – Pokemons list

/api/stats – list of Pokemon stats

And everything under /nextjs-app is handled by Next.js:

/nextjs-app – main page of the new Next.js app, renders a list of Pokemons using the same data API does

Note on limitations

Vite HMR for the Fastify server became problematic after adding Next.js app – Next.js has separate build setup and it doesn’t play well with Vite Node plugin out of the box.
However, HMR for Next.js app works fine and can be used with next dev inside Next.js project.

As Next.js docs mention, using custom server disables automatic static optimizations and doesn’t allow Vercel deploys.

Please follow and like us:
Pin Share