Next.js: The Good, Bad and Ugly
- Published on
Next.js is an open-source web framework created and maintained by Vercel. Out of the box, it provides features like server-side rendering (SSR), static-site generation (SSG), incremental-static-regeneration (ISR), serverless functions, and other things with zero-config.
I've been building applications using Next.js for a few months now and this article is the summation of my struggles and aha! moments when using Next.js.
Table of Contents
- What's Next.js?
- Folder Structure
- Pages and Routing
- Dynamic Pages
- APIs
- Data Fetching
- getStaticProps
- Incremental Static Regeneration aka ISR
- getStaticPaths
- getServerSideProps
- When to use Next.js?
- Issues with Next.js
- CSS Modules
- Automatic Static Optimization, but only if you do this
- Share data between getStaticPaths and getStaticProps
- Query Params for ISR
- _next/* and CDN
- Revalidate ISR Pages and CDN
- Multi-Instance Deployments and ISR
- Routing and Shared Layouts
- Custom Server
- Environment Variables
- In Conclusion
What's Next.js?
If you've never heard about Next.js, it's a framework built on top of React developed and primarily maintained by a company named Vercel. (formerly Zeit). Instead of messing around with Webpack configs and configuring routes, Next.js abstracts all of it and provides a nice boilerplate to start with.
Next.js gives you the best developer experience with all the features you need for production: hybrid static & server rendering, TypeScript support, smart bundling, route pre-fetching, and more. No config needed.
Folder Structure
This is how I usually configure Next.js projects:
tree
.
├── components/ #highlight-line
│ ├── Header/
│ │ ├── Header.jsx
│ │ └── Header.module.css
│ └── Footer/
│ ├── Footer.jsx
│ └── Footer.module.css
├── context/ #highlight-line
│ ├── Auth.context.js
│ ├── Theme.context.js
│ └── i8n.context.js
├── pages/ #highlight-line
│ ├── api/ #highlight-line
│ │ ├── hello.js
│ │ └── user.js
│ ├── home/
│ │ └── index.jsx
│ └── products/
│ ├── index.jsx
│ └── [id]/
│ └── index.jsx
├── services/ #highlight-line
│ └── api.service.js
└── utils/ #highlight-line
└── index.js
- Components : This is where isolated components like
Header
andFooter
lives. - Context : React context providers live here. Depending on your use-case, you may or may not need this
- Pages : This is where all the routing magic happens. More on this later
- API : Any file inside the folder
pages/api
is mapped to/api/*
and will be treated as an API endpoint instead of apage
. - Services : API calls and factory classes usually lives here.
- Utils : Utility classes and methods that don't belong anywhere else.
Pages and Routing
In Next.js, a page is a React Component exported from a JavaScript or Typescript file from the pages
directory. Each page is associated with a route based on the file path.
Example: If we create pages/home/
that exports a React component like below, it will be accessible at /home
.
function Home() {
return <div>About</div>
}
export default Home
We can even skip the folder and directly name the file pages/home.jsx
and it'll still be accessible at /home
.
Dynamic Pages
Next.js supports pages with dynamic routes. For example, if we export a page from pages/products/[id]/
, then it will be accessible at products/1
, products/2
, etc.
The table below shows how a folder path maps to a URL path:
Folder Path | Matches Path |
---|---|
pages/products/index.jsx | /products |
pages/products/[id]/index.jsx | /products/1 , /products/2 , /products/3 |
pages/products/[id]/checkout.js | /products/1/checkout , /products/2/checkout |
pages/url/[slug].js | /url/something , /url/123 , /url/abc , /url/foo |
pages/url/[...slug].js | /url/something , /url/123 , /url/something/123 , url/123/abc/foo |
pages/url/[[...slug]].js | Everything above and /url |
In the order of precedence, it's Predefined routes > dynamic routes > catch all routes. Take a look at the following examples:
pages/products/latest.jsx
- Will match/products/latest
pages/products/[id]/index.jsx
- Will match/products/1
,/products/abc
, etc. But not/products/latest
pages/products/[...slug].jsx
- Will match/products/1/2
,/products/a/b/c
, etc. But not/products/latest
,/products/abc
APIs
Any file inside the folder pages/api/
is mapped to the route /api/*
and will be treated as an API endpoint instead of a page
. This makes it easy to keep client and server code in a single codebase.
export default function handler(req, res) {
res.status(200).json({ name: 'Johnny Cash' })
}
This can be used as a proxy, or the newer term Backend for Frontend
or BFF
. We can hide the actual API service URL and instead use the /api
endpoint. For example, /api/authenticate
instead of https://api.server.dev/authenticate
. And because the api routes are same-origin only by default, we get an added security bonus for free.
Data Fetching
In a traditional SPA, the index.html
has a reference to JavaScript files like main.js
which loads the required scripts for the web framework to render the HTML. Then the scripts make some API calls to fetch the data and render on the UI. All of this process is usually hidden away by showing a loading spinner to the user.
While this is how normally 99% of SPAs work out in the wild, it does not mean that they all provide a consistent user experience. Factors like internet speed, VPN throttling, and cache misses are out of developers hands.
What developers can control is the ability to pre-render data and send a lighter template to clients. This removes the need for manual data fetching on the client side and rendering the components, i.e. pages load instantly and users get a better experience. This is the fundamental concept behind Server Side Rendering.
Next.js provides server-side-rendering (SSR) out-of-the-box. Meaning, it provides features to fetch data on the server, hydrate the template with necessary components, and send a generated HTML to the client.
Next.js provides these solutions for pre-rendering:
-
Static Generation: The HTML is generated at build time and will be reused on each request. This is useful when the page does not have a need to display dynamic data, for example a landing page or a blog post very much like this one.
-
Server-side Rendering: The HTML is generated on each request to your server. This is useful when a page has to fetch something from an API or a database to populate itself on every request, like a product description page, or a dynamic subscription page.
Next.js exposes APIs that can be use to fetch data and pre-render a page:
getStaticProps
This method is used to fetch data at build time. We can write server-side code directly in getStaticProps
. This includes reading from the filesystem or a database or use fetch()
to call an external API.
Here's an example which uses getStaticProps
to fetch products from an external API and pass it to a page.
// This function gets called at build time.
export async function getStaticProps() {
// Call an external API endpoint to get products.
const res = await fetch('https://api.server.dev/products')
const products = await res.json()
// the Products component will receive `products` as a prop at build time
return {
props: {
products,
},
}
}
// products will be populated at build time by getStaticProps()
function Products({ products }) {
return (
<ul>
{products.map((product) => (
<li>{product.name}</li>
))}
</ul>
)
}
export default Products
Incremental Static Regeneration aka ISR
getStaticProps
allows you to pass data to the pages at build time. Now what if you want to update a single/or a set of pages, without rebuilding the entire site? That's where Incremental Static Regeneration comes in.
Next.js allows creating or updating static pages after the site is built. Incremental Static Regeneration (ISR) enables to use static-generation on a per-page basis, without needing to rebuild the entire site.
We can create an HTML page which acts as a template and then populate the page with data at a later point post build.
Building on top of our previous example,
// This function gets called at build time on server-side.
export async function getStaticProps() {
// Call an external API endpoint to get products.
const res = await fetch('https://api.server.dev/products')
const products = await res.json()
// the Products component will receive `products` as a prop at build time
return {
props: {
products,
},
revalidate: 30, // fetch data at most every 30s when a request comes in.
}
}
- We define a revalidation time per-page (e.g. 30 seconds).
- The initial request to the product page will show the cached page.
- The data for the product is updated in the Database.
- Any requests to the page after the initial request and before the 30 seconds window will show the cached (stale) page.
- After the 30-second window, the next request will get the cached (stale) page, and Next.js triggers a regeneration of the page in the background.
- Once the page has been successfully generated, Next.js will invalidate the cache and send the updated product page for all future requests. If the background regeneration fails, the old page remains unaltered.
getStaticPaths
Let's say we know the paths of the pages during build. We can define which pages to statically pre-render at build-time based on the paths array returned by getStaticPaths
.
For example, we can generate pages, at build-time, to show top 3 products by returning the paths for the product IDs from getStaticPaths
.
export async function getStaticPaths() {
const products = await getLatestProducts()
const paths = products.map((product) => ({
params: { id: product.id },
}))
// => paths: [{params: {id: 1}}, {params: {id: 2}}, {params: {id: 3}}]
return { paths, fallback: 'blocking' }
}
// This function gets called at build time on server-side.
export async function getStaticProps({ params }) {
// Call an external API endpoint to get products.
const res = await fetch(`https://api.server.dev/products/${params.id}`)
const product = await res.json()
// the Products component will receive `products` as a prop at build time
return {
props: {
product,
},
revalidate: 30, // fetch data every 30 seconds.
}
}
Next.js will statically generate products/1
, products/2
, and products/3
pages at build time using the page component in pages/products/[id]/
.
the value for each params
must match the string used in the page file or folder name
The remaining pages can be generated on-demand by specifying fallback
as blocking
or true
in getStaticPaths
:
fallback: blocking
: when a request is made to a page that hasn't been generated, Next.js will server-render the page on the first request. Future requests will serve the static page from cache.fallback: true
: when a request is made to a page that hasn't been generated, Next.js will immediately serve a static page with a loading state on the first request. When the data is finished loading, the page will render using this data and cached in the server. Future requests will serve the static page from cache.fallback: false
: any paths not returned bygetStaticPaths
will result in a 404 page. If we consider our example above,products/4
will return a 404 as Next.js never generated a page for id4
.
By using getStaticProps
and getStaticPaths
, we can create a "hybrid" Next.js app by using Static Generation for common pages and using Server-side Rendering for pages containing dynamic data.
This enables us to build apps which load instantly (SSG), and are SEO friendly (SSR).
getServerSideProps
This method is used when we want to pre-render a page on each request using the data returned by getServerSideProps
. Unlike getStaticProps
, there is no static page that is cached on the server.getServerSideProps
is executed on each request sent to the server.
export async function getServerSideProps(context) {
const res = await fetch()
const data = await res.json()
return {
props: {}, // will be passed to the page component as props
notFound: !data,
}
}
getServerSideProps
should be used if there is a need to pre-render a page whose data must be fetched at request time. Time to first byte (TTFB) will be slower than getStaticProps
because the server generates the page on every request.
When to use Next.js?
Like all Web frameworks, the answer to this question is "it depends". When choosing a web framework, one has a lot of criteria which the framework must be able to ✅ before going all in.
These are some features I liked when using Next.js for my projects:
-
Excellent HMR - Hot Module Replacement is something which has spoiled all of us. Next.js comes with this out of the box. And it works splendidly.
-
Simplified Routing - Start adding components in the
pages
folder and Next.js handles the complexity of routing. No need to create aAppRouter
and manage routes in code. -
API Routes - Having the ability to write APIs in the same application is a major benefit. No need to maintain a separate build process for server code. Next.js encapsulates all that.
-
Server-Side Rendering - Being able to pre-render React components on the server side, before sending the HTML to the client is a game changer in terms page performance and SEO.
-
Incremental Static Regeneration - This is the feature that convinced me to use Next.js. Having the ability to create a template HTML and fill it with data in the server, all while having the page cached is just brilliant! Kudos to the Next.js team for building this feature.
-
Automatic Code Splitting - Next.js uses Webpack under the hood and takes away all the complexity of code splitting. You get manageable chunks (assuming the imports are tree-shakeable), and lazy loading components are cherry on top.
-
Dynamic Components - Next.js supports ES2020 dynamic
import()
for JavaScript. This is another way to split code into manageable chunks, leading to faster page loads. -
Static Export - Using the
next export
command, Next.js allows to export a fully static site. This can then be deployed to S3, or hosted using Github Pages.
Issues with Next.js
Alright, so before I start with my ranting, I'd want to put out a disclaimer.
There are issues I faced when working with Next.js. Some of these may have a "workaround". Some may have been solved by the time this article is published. (comment down below if I haven't updated this article) Maybe some issues I faced were because I did not fully understand how this framework works, or due to confusing documentation.
CSS Modules
A CSS Module is a CSS file in which all class names and animation names are scoped locally by default. CSS Modules let you write styles in CSS files but consume them as JavaScript objects for additional processing and safety. If you’re interested in learning more, Glen Madden has written extensively about CSS Modules and it's benefits.
Basically, CSS Modules takes your code from this:
.Button {
/* all styles for Normal */
}
.Button--disabled {
/* overrides for Disabled */
}
.Button--error {
/* overrides for Error */
}
.Button--in-progress {
/* overrides for In Progress */
}
<button class="Button Button--in-progress">Processing...</button>
To this:
.normal {
/* all styles for Normal */
}
.disabled {
/* all styles for Disabled */
}
.error {
/* all styles for Error */
}
.inProgress {
/* all styles for In Progress */
}
import styles from './submit-button.module.css'
buttonElem.outerHTML = `<button class=${styles.normal}>Submit</button>`
Now with Next.js, we can only use *.css
imports in the _app.jsx
file ONLY. Which means all other components MUST import styles using *.module.css
. The problem is, if one has to convert a legacy application which used plain old *.css
imports you just can't use them without a massive rewrite effort.
Of course a workaround could be to do this in the main index.css
,
import '../component/button.css';
import '../component/input.css';
.
.
.
.
But this is not a scalable solution. And if you decide to update a css file name or remove it, you need to touch the index.css
file again.
Another issue with having CSS modules enabled by default in Next.js is, we can't control what selectors can be used. For example,
a {
color: red;
}
input {
border: none;
}
will throw an error:
"a" is not pure (pure selectors must contain at least one local class or id).
This can be fixed by extending the css-loader config and changing mode: 'local'
property.
Next.js exposes the ability to update webpack configs in next.config.js
. We can simply extend the plugin config and provide our options.
So what's the big deal?
Nobody likes to mess with Webpack, and the Next.js Webpack config is highly sophisticated.
Next.js lets us update config only for a handful of plugins. For other plugins, hacks like this need to be written,
webpack: (config) => {
const oneOf = config.module.rules.find((rule) => typeof rule.oneOf === 'object')
if (oneOf) {
const moduleSassRule = oneOf.oneOf.find((rule) =>
regexEqual(rule.test, /\.module\.(scss|sass)$/)
)
if (moduleSassRule) {
const cssLoader = moduleSassRule.use.find(({ loader }) => loader.includes('css-loader'))
if (cssLoader) {
// Use the default CSS modules mode. Next.js use 'pure'. Not sure of all implications
cssLoader.options = {
...cssLoader.options,
modules: cssLoaderOptions(cssLoader.options.modules),
}
}
}
}
return config
}
And there is no way to opt-out of using CSS Modules at a component level.
As per the RFC for CSS Support,
[...] Next.js will only allow you to import Global CSS within a custom pages/_app.js
.
This is a very important distinction (and a design flaw in other frameworks), as allowing Global CSS to be imported anywhere in your application means nothing could be code-split due to the cascading nature of CSS.
This is an intentional constraint. Because when global CSS is imported in for example, a component, it will behave different when moving from one page to another.
I have to disagree on that! I can manage component styles by using things like SCSS and BEM naming convention. I don't want to use CSS Modules or any CSS-in-JS solutions. As a developer, it feels like dealing with this should be my job, not Next.js. Stop shipping over-engineered practices in the name of "optimization".
CSS Module itself is now in maintenance mode, so I am not sure where Next.js is heading with this.
I prefer keeping styles in a different file and not mix it with JavaScript, following a standard naming convention per-project and using classes for referencing the styles in HTML. It's a clear seperation of concern.
CSS-in-JS changed this whole paradigm. Now you've got styling inside of JS. WTF! Okay wait. I am not bashing CSS-in-JS here. It is a leap forward in building isolated re-usable components and I am all for it.
But the problem happens when your team decides to go all in with CSS-in-JS and then one day has to go back to normal CSS either because a "popular" CSS-in-JS library isn't maintained anymore, or the license of the library changed/does not work well with the orgs legal team. Which results in a huge engineering effort of converting and testing all the CSS, and bugs! Lots of it! These are real world problems, and I've seen the latter happening. (albeit not specifically for CSS modules)
Also, why I prefer Angular way of scaffolding components and not libraries like React where it's...chaotic. But that's a story for another time.
Automatic Static Optimization, but only if you do this
Next.js automatically determines that a page is static (can be pre-rendered) if it has no getServerSideProps
and getInitialProps
in the page. Which makes sense, because if a page needs fresh data on every request, it obviously cannot be generated as a static HTML page.
Since there is still no support for getStaticProps on _app, in order to share the initial data between pages (for example a publish API key for Stripe), we need to use getInitialProps
in the _app.js
file. This means, no Automatic Static Optimization!
As a long term Gatsby user, I was used to having data available on every component at build time. Any page or component which depended on shared data, just calls the API in one of the Gatsby plugins (ex: gatsby-source-graphql) and then the data is injected into the page context.
Next.js started as a Server-Side Rendering framework before pivoting to Static Site Generation, so I get the initial approach of getInitialProps
and getServerSideProps
. But having introduced Static Site Generation, this initial restriction is long due to be addressed.
Another issue is when we want to use a custom server. As the docs state,
Before deciding to use a custom server please keep in mind that it should only be used when the integrated router of Next.js can't meet your app requirements. A custom server will remove important performance optimizations, like serverless functions and Automatic Static Optimization.
Okay. So I get why serverless functions won't work with a custom server (duh!), but what's up with Automatic Static Optimization? And to make things more confusing,
Wait what? So Automatic Static Optimization
== pre-rendering
, but doesn't work when using a custom server?
To dig deeper, I created a sample project using Custom Express Server example and created build.
next build
info - Using webpack 5. Reason: Enabled by default https://nextjs.org/docs/messages/webpack5
info - Checking validity of types
warn - No ESLint configuration detected. Run next lint to begin setup
info - Creating an optimized production build
info - Compiled successfully
info - Collecting page data
info - Generating static pages (5/5) # highlight-line
info - Finalizing page optimization
Page Size First Load JS
┌ ○ / 1.65 kB 65.4 kB # highlight-line
├ ○ /404 3.17 kB 66.9 kB # highlight-line
├ ○ /a 262 B 64 kB # highlight-line
└ ○ /b 262 B 64 kB # highlight-line
+ First Load JS shared by all 63.8 kB
├ chunks/framework.64eb71.js 42 kB
├ chunks/main.a3a79a.js 20.2 kB
├ chunks/pages/_app.a8eaee.js 801 B
└ chunks/webpack.672781.js 766 B
λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps)
○ (Static) automatically rendered as static HTML (uses no initial props) # highlight-line
● (SSG) automatically generated as static HTML + JSON (uses getStaticProps)
(ISR) incremental static regeneration (uses revalidate in getStaticProps)
So, the pages at /a
and /b
are statically generated as per the build logs. And sure enough,
ls .next/server/pages
404.html 500.html _app.js _document.js _error.js a.html b.html index.html
I can see a.html
and b.html
generated. So the question, will a custom server really disable automatic static optimization?
Fine, /a
and /b
aren't dynamic paths. Maybe the static optimization does not work on dynamic paths? To test this, I created a dynamic path /[color]/c
and ran the build command again.
next build
info - Using webpack 5. Reason: Enabled by default https://nextjs.org/docs/messages/webpack5
info - Checking validity of types
warn - No ESLint configuration detected. Run next lint to begin setup
info - Creating an optimized production build
info - Compiled successfully
info - Collecting page data
info - Generating static pages (6/6) # highlight-line
info - Finalizing page optimization
Page Size First Load JS
┌ ○ / 1.65 kB 65.4 kB
├ ○ /[color]/c 269 B 64 kB # highlight-line
├ ○ /404 3.17 kB 66.9 kB
├ ○ /a 262 B 64 kB # highlight-line
└ ○ /b 262 B 64 kB # highlight-line
+ First Load JS shared by all 63.8 kB
├ chunks/framework.64eb71.js 42 kB
├ chunks/main.a3a79a.js 20.2 kB
├ chunks/pages/_app.a8eaee.js 801 B
└ chunks/webpack.672781.js 766 B
λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps)
○ (Static) automatically rendered as static HTML (uses no initial props) # highlight-line
● (SSG) automatically generated as static HTML + JSON (uses getStaticProps)
(ISR) incremental static regeneration (uses revalidate in getStaticProps)
ls .next/server/pages/\[color\]
c.html
Yeah, I am as confused as you are. I suppose the docs need updating here, or this limitation isn't a bug but a feature?
Share data between getStaticPaths
and getStaticProps
The SSG documentation recommends to fetch data for the list of path params using getStaticPaths
and fetch data for the individual pages using getStaticProps
. Sounds cool? Not when you care about API rate-limits and making redundant API calls to fetch data which the application just did.
GraphQL was introduced to overcome the RESTful way of calling multiple methods to fetch a resource. Using a single API call, data can be fetched and parsed.
await query(`
query {
products {
id
title
body // GraphQL let's us do this
}
}
`)
With a single query, one can fetch all product details, and even use filters if necessary.
Next.js doesn't support the ability to share data from getStaticPaths
to getStaticProps
yet.
Exhibit A:
// Get the list of products from an API
export async function getStaticPaths() {
const products = await query(`
query {
products {
id
title
body // GraphQL let's us do this
}
}
`)
const paths = products.map((product) => ({
params: { id: product.id },
}))
return { paths, fallback: false }
}
// Get product detail, again?!
export async function getStaticProps({ params }) {
const product = await query(
`query {
product(id: $id) {
title
body
}
}`,
{ variables: { id: params.id } }
)
return {
props: { product },
}
}
What if we implement a bare-bones singleton that fetches data and caches it in memory:
class DataCache {
constructor() {
this.data = null
}
async getData() {
if (this.data) {
return this.data
}
this.data = await this.fetchData()
return this.data
}
async getStaticPaths() {
const data = await this.getData()
return this._getPaths(data)
}
async getStaticProps(pagePath) {
const data = await this.getData()
return this._getDataForPath(data, pagePath)
}
async fetchData() {
/*...*/
}
_getPaths(data) {
/*...*/
}
_getDataForPath(data, pagePath) {
/*...*/
}
}
module.exports = new DataCache()
The answer is it won't work. Why?
So the only "workaround" is to cache the data in filesystem when calling the API from getStaticPaths
and read it in getStaticProps
. Definitely not a scalable solution when there are tons of pages relying on a large dataset.
Redis is an option, but now you are letting a React Framework dictate your infrastructure 🙃
Query Params for ISR
Incremental Static Revalidation is the feature which made me use Next.js. The issue is, getStaticProps
does not provide access to query params because,
But ISR is not always at build time. It's done at runtime when you don't return anything from getStaticPaths
and set fallback: true
.
Basically, there's no access to the request and response object in getStaticProps
method. If you need access to those, you have use getServerSideProps
, and this results in...say it with me, "No Static Optimization".
There's already a discussion around this specific issue, but looks like it hasn't caught the attention of the folks maintaining Next.js.
A possible workaround could be to leverage rewrites, but this brings in complexity to your routes and IMO over-engineering something which should have been available out-of-the box.
_next/*
and CDN
I deployed a Next.js application on AWS with an internal address which is private. For example, the internal address could be something like, http://next.app.internal
. This cannot be accessed publicly over the internet. (You know, security)
The external address which is open to public might be https://app.external
, which supports multiple other applications, supported by rewrites. Between them, is a CDN. In this case, Fastly. The Next.js app is accessible only on a single path using the external address i.e https://product.app.external/v2/products/foobar
.
The VCL in Fastly to route requests from /v2/products
=> http://next.app.internal
is:
if (req.url.path ~ "^/v2/products(/.*)?$") {
call commerce_redirect;
return(lookup);
}
# Load Next.JS assets properly
if (req.url ~ "^/_next(/?|/(.*))$") {
call commerce_redirect;
return(lookup);
}
If you observe the second condition, I have configured a rewrite when the request path starts with _next/
. So that all assets which live under the _next/
folder are resolved.
It doesn't work for data urls which start with the prefix _next/data
. The url for *.json
files is _next/data/:buildId/v2/products/${params}/*.json
. Fastly redirects the url to the Next.js instance as expected, it the way the url is formatted. The URL contains the path v2/products
which does not resolve to any known path configured in the Next.js application. Next.js expects the path to be :product/products/:params
.
The _next/data
path does not respect assetPrefix
or basePath
value.
Because I was already using a custom server, I was able to fix this by adding a middleware just for _next/data
route.
server.get('/_next/data/:buildId/v2/products/:path*', (req, res) => {
const parsedUrl = parse(req.url, true)
// => Incoming URL: /_next/data/:buildId/v2/products/foobar
parsedUrl.pathname = parsedUrl.pathname.replace('v2', req.headers['x-product'])
// => Outgoing URL: /_next/data/:buildId/product/products/foobar
handle(req, res, parsedUrl)
})
// Let Next.js handle rest of the routes
server.all('*', handle)
The dynamic value for product
param is sent as part of the request header from CDN. (CDN has all the configs).
This fix (re: hack), is extremely volatile. If the request path changes from v2
to v3
, then the Next.js app needs to be re-deployed. If Next.js decides to rewrite the _next/data
path to something else, this will end up breaking.
Honestly, finding a "workaround" for this issue was a ton of trial and error.
But the problem doesn't stop here. Because Next.js does not append any kind of basePath
or provide an option for dataPath
, (and the basePath is dynamic in my use case, so next.config.js won't work), it is kind of impossible to run multiple Next.js applications on the same domain when the urls include a locale like /en/app-1
and /en/app-2
because both applications are using the same /_next
prefix.
Revalidate ISR Pages and CDN
As described earlier in the Data Fetching section, we can return a revalidate
key in getStaticProps
to tell Next.js to fetch data and re-generate the page when a request comes in after a given threshold.
Works as expected. For most part.
The problem with revalidate
is that there are no external triggers apart from an incoming request to the page to tell Next.js to trigger a data fetch.
Why is this necessary?
Imagine a scenario where traffic to a product X page is nil. The data related to the product X got updated. After a marketing campaign went viral, traffic to the page spiked. Next.js being Next.js, triggers a revalidate
on the first request. Until a new page is generated, all requests are served a stale page.
This also means any CDN in front of Next.js will take sometime to update its global cache, and until then users are served a stale page.
I wouldn't complain much about the CDN cache here because most, if not all, the CDNs expose an endpoint to flush cache for a whole domain or a specific URL. And it's not in the scope of Next.js to clear CDN cache...somewhat.
When deciding the revalidate
period one has to take into consideration a lot of factors, ranging from API rate limits of the service, response time of the query, to the time it takes CDN to update the cache when page on the origin server is updated. (Unless you deploy on Vercel)
With the way ISR works, when a request is received at the server on/after the revalidate
threshold is crossed, Next.js still serves the stale page, but without a s-maxage:0
. This causes the CDN to cache the now stale page for n
seconds defined by the initial s-maxage
header. So if your revalidate
period is 30s
, it will take ~60s
for the CDN to start caching the updated page.
One solution is to have an API route, (aka webhook) to trigger the revalidation. This avoids having to deal with revalidate
timings and cache-control, we can trigger the webhook only when content changes, thereby reducing the number of API calls to the upstream service. This makes content and code truly independent.
This is what Next.js is missing right now. Here's to hoping it's available sometime in the near future.
Multi-Instance Deployments and ISR
The way ISR works in Next.js is that it stores the generated page data in LRU cache. These are application level cache, i.e. if the Next.js application is scaled up, each instance of the application has its own cache.
Since none of the process notify each other of revalidation changes, it requires a bare minimum of n
requests where n
is equal to the number of instances, before all instances have the same cache (assuming no revalidation has triggered, and the data is constant across requests).
Due to the way most load balancers work, it's more than certain that the requests will not goto the same instance always. Example:
// 0s --- Request from Client A => Instance A (ISR logic kicks in, and a page generation is triggered)
// 10s --- Request from Client A => Instance B (Still serving from stale cache)
// 20s --- Request from Client A => Instance A (new data, page updated)
// 30s --- Request from Client A => Instance B (Still serving from stale cache)
Of course the above example is very trivial, but it explains the issue easily.
What's the solution you ask? There isn't. There is a workaround (like with all issues mentioned in this post), Don't use Incremental Static Generation with multi-process deployments, use getServerSideProps
and defer caching to CDN.
Which means, no static optimization! To be honest, Next.js' ISR APIs is similar to SSR+CDN cache, with an additional benefit of a pre-warmed cache (files created during build).
I don't know how Vercel does the scaling. Considering the fact that Vercel is built for Next.js, I won't be surprised if Next.js team decides to not do anything about this issue.
Routing and Shared Layouts
Typical Client Side Routers like react-router
has the ability to create routes for nested pages.
<App>
<Header />
<Router-Outlet />
<Footer />
</App>
The Header
and Footer
layouts are shared across all the pages in the app. When changing the route, it's just the components inside Router
which re-renders and not the entire App
.
That's not the case with Next.js
There are several examples of how to layout, but this implementation is the only one I could find which does not re-render the whole component tree:
export default function Layout({ children }) {
return (
<>
<Header />
<main>{children}</main>
<Footer />
</>
)
}
import Layout from '../component/layout'
const PageOne = () => {
return <div>This is Page One</div>
}
PageOne.Layout = Layout
export default PageOne
// pages/page-two.jsx
const PageTwo = () => {
return <div>This is Page Two</div>
}
PageTwo.Layout = Layout
export default PageTwo
import App from 'next/app'
const Noop = ({ children }) => children
export default class MyApp extends App {
render() {
const { Component, pageProps } = this.props
const Layout = Component.Layout || Noop
return (
<Layout data={/*...*/}>
<Component {...pageProps} />
</Layout>
)
}
}
Now the issue is we need to add an extra static property to the components which needs to share layouts. Comparing that to the example using react-router
, it feels...odd. While this is a valid solution provided by Next.js team, this isn't a great developer experience, especially considering most developers who have worked with React used React Router as a de-facto routing library. And this concept of file based routing involves a tremendous learning curve and sometimes a big limitation for complex nested routing.
Custom Server
Next.js provides an option to run a Custom Server, when it's default API routes and redirect handlers don't work for your use-case. We can basically have any Node based server like express, Hapi etc.
Before deciding to use a custom server please keep in mind that it should only be used when the integrated router of Next.js can't meet your app requirements. A custom server will remove important performance optimizations, like serverless functions and Automatic Static Optimization.
I believe you are seeing a pattern here why I'm frustrated with Next.js at times.
The issue I had here is sending data to the client side code from a custom server, while maintaining static rendering.
Following along the lines of this example:
const express = require('express')
const next = require('next')
const { parse } = require('url')
const port = parseInt(process.env.PORT, 10) || 3000
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare().then(() => {
const server = express()
server.all('*', (req, res) => {
const parsedUrl = parse(req.url, true)
const { pathname, query } = parsedUrl
req.query = { ...req.query, env: process.env.SECRECT_FOR_CLIENT }
return handle(req, res)
})
server.listen(port, (err) => {
if (err) throw err
console.log(`> Ready on http://localhost:${port}`)
})
})
then in the pages/_app.js
import '../styles/globals.css'
import { withRouter } from 'next/router'
function MyApp({ Component, pageProps, query }) {
return <Component {...pageProps} env={query.env} />
}
MyApp.getInitialProps = ({
ctx: {
req: { query },
},
}) => {
console.log('Query', query)
return { query }
}
export default withRouter(MyApp)
While this is a viable solution, using getInitialProps
was a no-go, because... Automatic Static Optimization.
Environment Variables
Environment variables are used for application wide constants which can be swapped, and restarting the service picks up the new environment variable.
Unfortunately, that's not the case for client-side environment variables. Next.js supports accessing client side variables by prefixing the key with NEXT_PUBLIC_
.
The environment variables are embedded during the build time. Since Next.js produces a static HTML/CSS/JS bundle, it can’t possibly read them at runtime. But what about ISR pages? Since technically, they are server generated at runtime on a request basis.
If you need to swap out an env variable out of the client, you need to rebuild the whole application.
Yes, there is Runtime Configuration, but...
Generally you'll want to use build-time environment variables to provide your configuration. The reason for this is that runtime configuration adds rendering / initialization overhead and is incompatible with Automatic Static Optimization. - Docs
Another complaint I have is that, one cannot use NODE_ENV
, for specifying which environment your application must run, because, Setting a non-standard NODE_ENV
value may cause dependencies to behave unexpectedly, or worse, break dead code elimination .
So now you have the overhead of maintaining two env variables!
Next.js supports .env.test
but NODE_ENV=test next build
fails. Why? Because it's a non-standard value.
And this gets worse for client side variables because, there is no way to build an app once and deploy everywhere if we cannot supply client side environment variables during SSR.
As a workaround, we can send the variables as props
from the getStaticProps
method, and access it on the client. This works, but is a clumsy solution because one has to resolve to things like Context APIs and props drilling in order to pass around variables to non-page components, increasing the app complexity.
This workaround only supports ISR and SSR pages for obvious reasons. For build time, the default solution provided by Next.js works.
If you use a custom server, you'd probably need access to env vars before the Next.js process kicks in. (logging, proxy etc. ). To use env var on the server, independent of the Next.js build process we can leverage the internal @next/env
package or trust the handy dotenv
module.
require('dotenv').config({
path: `./config/env.${process.env.APP_ENV || 'local'}`,
})
// Append NEXT_PUBLIC_ to client facing keys for easier identification and filters
const PUBLIC_KEYS = ['CLIENT_KEY', 'ANOTHER_CLIENT_KEY', 'ONE_MORE_CLIENT_KEY']
PUBLIC_KEYS.forEach((key) => {
process.env[`NEXT_PUBLIC_${key}`] = process.env[key]
})
OR
import { loadEnvConfig } from '@next/env'
export default async () => {
loadEnvConfig(`./config/env.${process.env.APP_ENV || 'local'}`)
}
and then using getStaticProps
supply the values to required pages.
An easier option would be to create an API Route like /api/config/
, and fetch on app load or whichever page needs it. I went with this one because this was straightforward to implement without the need additional modules.
In Conclusion
This was my experience using Next.js. Next.js provides some good features out-of-the-box, and some really PITA configurations and issues. There are a lot of "workarounds" one needs to implement for trivial use-cases, and some not-so-great hacks. Overall impression of this framework has mostly been neutral. Do I recommend Next.js? Sure, the benefits outweigh the drawbacks, and If you don't care about Static Site Optimization as much as I do, then by all means use Next.js.
On this page