How we removed NextJS Custom Server and migrated it to NextJS Middleware?

Atul Jain
GoComet
Published in
8 min readJun 7, 2023

With Next.js custom servers we encountered various challenges that impacted our development workflow and application performance. One common issue was the lack of access to source maps, making it difficult to effectively debug and troubleshoot client-side errors. This hindered the efficiency of the development process and extended the time required to identify and fix issues. Additionally, these custom servers often resulted in larger build sizes, leading to increased bandwidth consumption and slower loading times for the application. Furthermore, with the advancements in Next.js and the adoption of middleware, we can now have the opportunity to overcome these challenges and unlock a range of benefits. By leveraging middleware in Next.js, we can enhance performance, achieve faster and smaller builds, and seamlessly integrate hidden source maps on platforms like Sentry, enabling efficient debugging and a streamlined development experience.

Why did we implement a Custom server earlier?

By default, Next.js comes with a built-in server that handles rendering pages and serving static assets. However, In previous versions of Next.js one was not able to do rewrites, redirects, and dynamic routing effectively, or sometimes you might just need more flexibility or control over how the server works.

That’s where a custom server comes in, Next.js allows you to override the default behavior of the built-in server and define your own routes and logic.

For example, in old Next.js versions, you may want to

  1. Implement dynamic routing
  2. And add middleware to modify requests or responses.

With a custom server, you can do all of these things and more.

Dynamic routing in next was introduced in Next 9. Refer to the link here

Benefits of Removing the Custom Server in Next.js

Although the custom server in Next.js provides flexibility and control, there are significant advantages to removing it. Here’s why:

  1. Incompatibility with Sentry’s Source Map Uploads: One of the main reasons for removing the custom server is its lack of compatibility with Sentry’s source map uploads. This limitation hinders effective debugging and troubleshooting of client-side errors, making it challenging to identify and resolve issues efficiently.
  2. Lack of Key Performance Optimisations: The custom server lacks important performance optimizations, such as serverless functions and Automatic Static Optimization.
  3. Middleware for Custom Headers and Authentication: Removing the custom server does not mean sacrificing the ability to use custom headers for specific URLs or relying on in-house-made authentication and authorization mechanisms that rely on the request and response objects passed from Express to Next.js. Instead, you can achieve these functionalities by utilizing middleware. Middleware allows you to modify requests and responses, enabling you to customize headers and implement authentication and authorization seamlessly.

By considering the removal of the custom server in favor of Next.js’s default server and utilizing middleware, you can overcome the challenges posed by incompatibility, optimize your application’s performance, and maintain the desired level of customization.

Understanding Middleware in Next.js

In Next.js, middleware acts as an interceptor that can execute a process before a request is fully completed. This middleware intercepts incoming requests, providing the ability to modify the request header or response, rewrite content, redirect, or even directly respond. It allows for customizing and manipulating the request-handling process, enabling developers to tailor the behavior and responses based on specific needs or conditions.

In addition, an important aspect of middleware in Next.js is that it runs before cached content. This means that it can be used to personalize static files and pages by intercepting requests before they are served from the cache. By utilizing middleware in this manner, developers can dynamically modify or personalize the content of static files and pages, ensuring that each request is processed with the most up-to-date and customized information, even if caching is involved. This allows for a more dynamic and personalized user experience in Next.js applications.

Next Middleware was stable from Next version 12.2 and we are currently using Next 12.3.

Features like modifying request headers, response headers, and sending responses are available from Next.js version 13.0

But what’s the catch? What are the Limitations of Middleware?

Can’t pass data directly (or programmatically) to pages

  • Middleware functions run on the server before a page is rendered, and they can modify the request or response objects, but they do not have the ability to add or change the props of the component that will render the page (_app. jsx or the component directly) the reason behind this being middleware doesn’t work is that middleware doesn’t use Node.js APIs directly.
  • We need authenticated user data for 3rd party javascript libraries we use in _document.jsx.
  • Overall, while it is not possible to pass data directly from middleware to pages in Next.js, there are several workarounds that can be used to achieve the desired functionality.
  • Like using getInitialProps, serverRuntimeConfig, or publicRuntimeConfig options in your next.config.js. Store data in redux from the server response and then access that from pages

When should you use next.config.js instead of middleware?

Any static headers, redirects, or rewrites should be done in next.config.js. Meanwhile, if you need to do custom headers, redirects, or rewrites dynamically then middleware should be used.

For example -
In the below code, headers will always be passed for the route /some-public-route

//next-config.js

import getConfig from './config'
const config = getConfig();
module.exports = {
headers() {
return [
{
source: '/some-public-route',
headers: [
{
key: 'X-Frame-Options',
value: 'DENY',
},
],
},
];
},
...config
}

But if you need to determine whether to set headers dynamically or not, the code below will come into play

//middleware.js

import { NextResponse } from 'next/server'

export function middleware(request) {
let response = NextResponse.next()
if (request.geo.country == 'ANY_COUNTRY') {
// Clone the request headers and set a new header `i18n-language`
const requestHeaders = new Headers(request.headers)
requestHeaders.set('i18n-language', 'LANGUAGE_OF_ANY_COUNTRY')
response = NextResponse.next({
request: {
headers: requestHeaders,
},
})
response.headers.set('i18n-language', 'LANGUAGE_OF_ANY_COUNTRY')
}
return response
}

Let’s see how to port the custom server to middleware:

Custom server overview

Firstly, we’ll need to go through any dependencies used with our ExpressJs custom server. For us, these dependencies were compression and cookie-parser, both of which are already in Next.js now. These might be different for you.

Now let’s proceed with checking the current custom server file and identify changes we need to port. Below is an example of a custom server file, yours might be different:

// server/index.js

const server = require('express')();
const compression = require('compression');
const cookieParser = require('cookie-parser');
const { PUBLIC_PATHS } = require('./config/public_paths');
const { app, handle } = require('./app');
const routes = require('./routes');
const checkAuthAndRender = require('./config/checkAuthAndRender');

app.prepare()
.then(() => {
const PORT = process.env.PORT || 3000;
server.use(compression());
server.use(cookieParser());
server.use('/', routes);
server.get('*', (req, res, next) => {
const isPublicPath = PUBLIC_PATHS.map(path => req.url.includes(path)).includes(true);
if (isPublicPath) {
handle(req, res);
} else {
checkLoginAndHandle(req, res, app);
}
});
server.listen(PORT, err => {
if (err) throw err;
console.log(`> Ready on http://localhost:${PORT}`);
});
})
.catch(ex => {
console.error(ex.stack);
process.exit(1);
});

Next, it is essential to consider any custom Express middlewares or Routers that you may need to migrate. In our case, we had a custom authentication middleware specifically designed for private routes. These custom middlewares play a crucial role in handling authentication and authorization logic, ensuring that only authenticated users can access certain routes or resources.

The auth middleware is used to:

  • Parse and set subdomains in headers.
  • Call an HTTPS API to authenticate and verify the auth token of a logged-in user.
  • Redirect users to a specific URL based on their preferences or conditions.

It is important to evaluate and port these custom middlewares to Next.js, allowing for a seamless transition while preserving the functionality and security of private routes.

// server/config/checkAuthAndRender.js

import url from 'url';
import axios from 'axios';
import { SCHEMA_NAME } from './schema';

export default (req, res, next) => {
let renderUrl = req.url;
const domains = req.subdomains;
const loginUrl = 'https://example.com/login';
const hostUrl = 'https://example.com';

axios
.get(`${loginUrl}/api/v1/current`, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: req.cookies['sess'],
Schema: domains[0],
},
})
.then(response => {
if (response.status === 200) {
const data = (response.data || {}).user || {};
const { id = '', name = '', email = '' } = data;
const query = {
...queryParams,
...req.query,
user_id: id,
user_name: name,
user_email: email,
};
app.render(req, res, renderUrl, query);
} else {
const query = { ...req.query };
res.redirect(url.format({ pathname: '/login', query }));
}
})
.catch(e => {
if (e.response && [400, 401].includes(e.response.status)) {
const query = { ...req.query };
let redirectUrl = `${req.protocol}://${req.hostname}${req.path}`;
query.rd = redirectUrl;
const newLoginUrl = url.format({ pathname: `${hostUrl}/login`, query: req.query });
res.redirect(newLoginUrl);
} else {
res.redirect(url.format({ pathname: '/_error', query: { error_id: 1001 } }));
}
});
};

Now to port the above custom server, we will create a middleware file

Middleware File [/middleware.js]

import { NextResponse } from "next/server";
import {
entriesToObject,
generateUrlWithQuery,
getQueryFromSearchParams,
} from "util/middleware";

export async function middleware(request) {
const requestedUrlObject = new URL(request.url);
const renderURL = request.url;
const requestQuery = getQueryFromSearchParams(
requestedUrlObject.searchParams
);
const headers = entriesToObject(request.headers);
const domains = headers.host.split(".");
const loginUrl = "https://example.com/login";
const hostUrl = "https://example.com";

try {
const response = await fetch(`${loginUrl}/api/v1/current`, {
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: request.cookies.get("sess"),
Schema: domains[0],
},
});

if (response.status === 200) {
const responseData = await response.json();
const data = responseData.user || {};
const { id = "", name = "", email = "" } = data;
const query = {
...requestQuery,
user_id: id,
user_name: name,
user_email: email,
};
return NextResponse.rewrite(
generateUrlWithQuery(renderURL, hostUrl, query)
);
}

return NextResponse.redirect(
generateUrlWithQuery("/login", hostUrl, requestQuery)
);
} catch (e) {
if (
e.response &&
(e.response.status === 401 || e.response.status === 400)
) {
const { protocol, pathname, hostname } = requestedUrlObject;
let redirectUrl = `${protocol}//${hostname}${pathname}`;

if (process.env.SYSTEM_ENV === "development") {
redirectUrl = `${protocol}//${hostname}:${process.env.PORT}${pathname}`;
}

query.rd = redirectUrl;

return NextResponse.redirect(
generateUrlWithQuery("/login", hostUrl, requestQuery)
);
}

return NextResponse.redirect(
generateUrlWithQuery("/_error", hostUrl, { id: 1001 })
);
}
}

export const config = {
matcher: [
/*
* Negative regex checking for public routes
*/
"/((?!login|signup|error|ping|favicon.ico).*)",
],
};

Also, you may want to include utils that were provided by Express. We had some methods which were defined additionally in the same middleware.js file. You can also move them to utils/middleware.js

// middleware

const entriesToObject = (entriesObject) => {
const accEntries = {};
const entries = entriesObject.entries() || {};
for (const [key, value] of entries) {
accEntries[key] = value;
}
return accEntries;
};

const generateUrlWithQuery = (pathname, url, query = {}) => {
const urlObj = new URL(pathname, url);
Object.entries(query).forEach(([key, value]) => urlObj.searchParams.set(key, value));
return urlObj;
};

const getQueryFromSearchParams = (searchParams) => entriesToObject(searchParams);

These functions perform various operations such as converting entries to an object, generating a URL with query parameters, and getting the query from search parameters.

What did we achieve?

While there are numerous points and benefits that can be highlighted, here are some key advantages we have achieved:

  • Better performance
  • Faster and smaller builds
  • Working Hidden Source Maps on Sentry

These benefits highlight the advantages of using middleware in Next.js, including improved performance, faster build times, and enhanced debugging capabilities. By leveraging these features, you can optimize your application’s server-side processing, reduce build sizes, and streamline error handling, resulting in an overall more efficient and robust application.

--

--