Standalone Next.js with Custom Server
I was recently working on an initiative to use a custom server such as Express or Fastify to run Next.js project. Next.js does have its own server and it even generates a server file we can run when using standalone mode. Some benefits of using standalone mode includes:
- Reducing the deployment size and docker image.
- Simplifying the deployment process.
- Can be deployed to any web servers.
- By using automatic file tracing, it will determine which files are required for production.
The initial motivation of using custom server is to have a proper middleware setup that can pass relevant data and keep that for the lifetime of the current request. Next.js middleware doesn't fit that requirement as it runs on edge while the rest run on node so there's no way to include data for the current request internally besides updating headers. Pros and cons of having a custom server includes:
- Having more control over server implementations.
- Having more route matchers for different middlewares.
- Persisting data for the lifetime of a request.
- Requiring more complex setup for both dev and production build.
Initial Setup
We've chosen Express over Fastify as it has more resources and the middleware Fastify uses is based on Express so it makes sense to have everything together by adopting Express.
Here's a simplified example of using Express 5 as the custom server:
import next from 'next'
import express, { type Express, type Request, type Response } from 'express'
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev, customServer: true })
const handle = app.getRequestHandler()
async function start() {
await app.prepare()
const server: Express = express()
server.use('/account-next', {your middleware})
// Handle all other routes with Next.js
server.all(/(.*)/, (req: Request, res: Response) => {
return handle(req, res)
})
try {
server.listen({ port: 3400 })
console.log(`> Ready on http://localhost: 3400`)
} catch (err) {
if (err) {
throw err
}
}
}
void start()
We basically load an instance of Next and Express and direct all routes to Next. Before it goes there we can set up middleware to handle request, redirect, or append data. It should be straight forward by following the official Express documentations.
Local Development
Prior to setting up custom server, we've only needed to use Next.js to start a local dev server with hot-module reload capability. Now we need to run and compile the entry server file.
tsx watch server.ts -r dotenv/config
We have our Express server in TypeScript so we use tool like tsx to execute it but you can use other tools like tsup or even the basic tsc compiler.
watch
is for watching the file changes and automatically reload.-r dotenv/config
is for registering dotenv when running server.ts so I don't need to import. The other reason is at the time of writing I couldn't get it working in production build.
If you update your code in components or server functions, Next.js would still pick it up and do hot-module reload.
Context
In Express you can utilize res.locals
to save data for the current request but component and server functions in Next.js don't have access to it. We instead use AsyncLocalStorage
to achieve the same thing. The trick here is to save the instance of AsyncLocalStorage
in globalThis
so you have access to it no matter where you are calling it. AsyncLocalStorage
also ensures your data in the context is cleared at the end of the request to avoid pollusion between different requests.
globalThis.localContext = new AsyncLocalStorage<LocalContext>()
You will need to define your own type to ensure TypeScript understands it is inside globalThis
import { type AsyncLocalStorage } from 'async_hooks'
declare global {
var localContext: AsyncLocalStorage<LocalContext>
}
Production Build
Just like before, running next build
will take care of the JavaScript, CSS, and other assets needed for a production build. However it doesn't know about the Express custom server setup so it wouldn't pack them for us. Let's start from the beginning.
Compile TypeScript
If you use alias, for production build tsc wouldn't automatically convert them to actual paths for you. There's a library called tsc-alias
that is supposed to replace it but didn't work for me. So we used tsup
to the compilation step.
Sharing Code
Prior to custom server, we have codes marked as server only by importing server-only
package to tell Next.js not to accidentally import it on client. How it works is that server-only
package defines two exports and it will import the JS file that throws error if it's not a React Server Component environment.
"exports": {
".": {
"react-server": "./empty.js",
"default": "./index.js"
}
}
To trick your server, you can pass the --conditions
flag when running your node server.
node --conditions=react-server app.js
But it didn't work as there were more cascading errors that came with this. So I just deleted the import and since it is a data source file, no client components would actually import this.
This is to showcase how server-only
works and need to be careful if you are sharing some codes between your custom server and React Server Component environment.
node_modules
As mentioned, Next.js doesn't know about the custom server setup so it doesn't know its dependencies either. We could either bundle everything, which didn't work for me, or manually copy over those node_modules into the standalone directory. We first need to find out what dependencies our server needs.
We use @vercel/nft to figure out all of the dependencies our server needs.
const { fileList } = await nodeFileTrace('./server.mjs', {
base: path.resolve(__dirname, '../../')
})
This would essentially output a list of all of the files your server need.
'node_modules/.pnpm/ky@1.7.2/node_modules/ky/package.json',
'node_modules/.pnpm/ky@1.7.2/node_modules/ky/distribution/index.js',
'node_modules/.pnpm/zod@3.23.8/node_modules/zod/package.json',
'node_modules/.pnpm/zod@3.23.8/node_modules/zod/lib/index.mjs',
'node_modules/.pnpm/cookie-parser@1.4.7/node_modules/cookie-parser/index.js',
'node_modules/.pnpm/cookie-parser@1.4.7/node_modules/cookie-parser/package.json',
'node_modules/.pnpm/express@5.0.1/node_modules/express/index.js',
'node_modules/.pnpm/express@5.0.1/node_modules/express/package.json',
'node_modules/.pnpm/express@5.0.1/node_modules/express/lib/express.js'
You need to set the base accordingly so it can find the correct node_modules paths. We have a turborepo and pnpm setup so some of the node_modules we have are at the root and pnpm symlinks them from within the apps and packages.
After knowing the paths, I wrote a script to determine what the node_modules folders are and copy them into the node_modules folder inside the standalone directory. When you run the server, if you encounter errors like cannot find module cookie-parser
then you are not copying the dependencies correctly.
You might encounter errors like cannot find module './bundle5'. I'm not sure how Next.js works under the hood but in the server.js file that Next.js outputs in standalone mode contains file tracings and fortunately next build
command would still generate that JSON file. We just need to import it and assign it to process.env.__NEXT_PRIVATE_STANDALONE_CONFIG
.
if (!dev) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const configPath = path.resolve(
__dirname,
'./.next/required-server-files.json'
)
if (fs.existsSync(configPath)) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { config } = require(configPath)
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config)
} catch (error) {
console.error('Error reading required-server-files.json:', error)
}
}
}
One small gotcha when running the server is that I had to run the server in the same directory otherwise it wouldn't know where .next
folder is and complains it can't find a production bundle. I've also needed to set NODE_ENV=production
for Next.js to look for a .next
folder instead of app or pages folder.
If you find this useful please follow me on X, Threads, and Bluesky and share it with your friends.