If you practice continuous delivery on your projects, your main branch should always be in a deployable state. On a Next.js project, this means you must be able to commit incomplete pages to version control without them becoming publicly accessible when you deploy your website.
Next.js's usage of file-based routing makes excluding incomplete pages difficult: there's no central routes file like Django where you can conditionally include routes. You can return a 404 from getServerSideProps
, but Next also has getInitialProps
and getStaticProps
. Next.js allows server redirects, but redirects don't work on statically-exported sites. Neither does middleware. Ideally, we want a single solution that works 100% of the time.
We can make incomplete pages inaccessible on the production site by getting creative with Next's pageExtensions
setting. Let's dig in.
Leveraging page extensions
By default, Next.js will assume that anything in the pages/
directory with a js|jsx|ts|tsx
extension is a renderable page. However, this behavior is configurable.
We can leverage Next's page extension behavior to make separate extensions for development-only routes and production routes.
This post will show two different implementations of dev-only routes using page extensions.
Implementation 1:
- We will use
.dev.js
and.dev.jsx
extensions for pages that will only be accessible when running the development server. - We will use
.prod.js
and.prod.jsx
extensions for pages that will be accessible both in development and production.
Implementation 2:
- We will use
.dev.js
and.dev.jsx
extensions for pages that will only be accessible when running the development server. - We will use
.js
and.jsx
extensions for pages that will be accessible both in development and production.
Implementation 1
We will be making our changes in next.config.js
. Here's what the file looks like on a brand new Next.js project:
module.exports = {
reactStrictMode: true,
}
Here's what we want it to look like:
const { PHASE_DEVELOPMENT_SERVER } = require("next/constants");
module.exports = (phase, { defaultConfig }) => ({
...defaultConfig,
reactStrictMode: true,
pageExtensions: ["ts", "tsx", "js", "jsx"]
.map((extension) => {
const isDevServer = phase === PHASE_DEVELOPMENT_SERVER;
const prefixes = isDevServer ? ["dev", "prod"] : ["prod"];
return prefixes.map((prefix) => `${prefix}.${extension}`);
})
.flat(),
});
After updating next.config.js
, change the filenames for all pages that should be viewable in production. The site's homepage filename will change from index.js
to index.prod.js
. After you've changed all the page filenames to use the prod.js
extension, run npm run dev
and click through the site to make sure your pages work.
You've now finished implementing development-only routes. Continue reading if you want to know how this code works, or if you want to see an alternative implementation that doesn't require the .prod.js
extension.
Understanding the updated configuration
Let's look at the update next.config.js
file one more time. I've added line numbers to make it easier to point to specific sections.
1 const { PHASE_DEVELOPMENT_SERVER } = require("next/constants");
2
3 module.exports = (phase, { defaultConfig }) => ({
4 ...defaultConfig,
5 reactStrictMode: true,
6 pageExtensions: ["ts", "tsx", "js", "jsx"]
7 .map((extension) => {
8 const isDevServer = phase === PHASE_DEVELOPMENT_SERVER;
9 const prefixes = isDevServer ? ["dev", "prod"] : ["prod"];
10 return prefixes.map((prefix) => `${prefix}.${extension}`);
11 })
12 .flat(),
13 });
- On line 3, we've changed the export to a function. This gives us access to the
phase
argument, which we will use to determine if the build server is running (more details in the docs). - On line 6, we put the page extensions that our app can generate pages from. If your app has
md
ormdx
pages, you would also add them to this array. -
On lines 7-11, we map through these page extensions.
-
If the development server is running, the
map()
will generate the following output.
[ ["dev.ts", "prod.ts"], ["dev.tsx", "prod.tsx"], ["dev.js", "prod.js"], ["dev.jsx", "prod.jsx"], ];
-
If the development server is not running, the
map()
will generate the following output.
[["prod.ts"], ["prod.tsx"], ["prod.js"], ["prod.jsx"]];
-
On line 12, we flatten the nested arrays into a single array.
// Development ["dev.ts", "prod.ts", "dev.tsx", "prod.tsx", /** etc. */]; // Production ["prod.ts", "prod.tsx", "prod.js", "prod.jsx"];
-
This flat array is then used to determine what pages are visible in development and production.
Testing the dev-only pages
To test that our new development-only pages work, create a new page at pages/unpublished.dev.js
and add the following:
// pages/published.dev.js
export default function UnpublishedPage(props) {
return <div>I am an unpublished page.</div>;
}
Now run npm run dev
and navigate to http://localhost:3000/unpublished. You should see an unstyled page that returns the text, "I am an unpublished page."
Now let's try visiting the same page on a production build. Run npm run build && npm start
, then refresh http://localhost:3000/unpublished. You should see an error page, but the site's homepage should continue to work.
This dev-only pages implementation works, but it requires you to change the filename of every page in your app, by changing page extensions to .prod.js
. But maybe we don't have to.
Implementation 2
It's possible to get Next.js to ignore pages with a .dev.js
extension without having to use a .prod.js
extension on production pages if we use a negative lookbehind regular expression.
During the build process, Next flattens all of the page extensions down to a singular regular expression. We can hook into this regular expression to ensure that Next only builds pages that don't use .dev.[js|jsx]
.
The following code relies on hacks to deal with inconsistencies in Next.js's Regex parsing between the dev server and build process. At some point this code may break. You've been warned.
pageExtensions: ["ts", "tsx", "js", "jsx"]
.map((extension) => {
const isDevServer = phase === PHASE_DEVELOPMENT_SERVER;
const prodExtension = `(?<!dev\.)${extension}`;
const devExtension = `dev\.${extension}`;
return isDevServer ? [devExtension, extension] : prodExtension;
})
.flat(),
The interesting part of this code is the return
line. At the time of writing, Next's development server isn't able to process negative lookbehinds (GitHub issue). To get around this, the code above tries the .dev.[js|jsx]
first, then falls back to .[js|jsx]
.
This code will allow you to use .dev.js
extensions for dev-only pages, and .js
for pages that are publicly accessible.
Downsides of this implementation
Our development-only pages work pretty well, but this isn't a perfect solution. You may have to make changes to get this to work with an automated test suite. This solution may also break if you use a tool that asks you to wrap your Next.js config in a function call. For example, our next.config.js
function won't work with Vercel's own @next/mdx
package: it will only work with a config object (GitHub issue).
A lot of complexity could be avoided if there was a mechanism other than file-based routing to define routes in Next.js. For better or worse, file-based routing has been embraced by most full-stack JS frameworks, forcing developers to hack around the router to accomplish tasks that would be trivial in frameworks like Ruby on Rails.
Let me know if you found this article helpful, and let me know if you think there are improvements that can be made to these implementations.
Top comments (1)
Very helpful solution! ❤️
Some comments may only be visible to logged-in visitors. Sign in to view all comments.