paint-brush
Next.js v15 — What’s new under the hoodby@vordgi
339 reads
339 reads

Next.js v15 — What’s new under the hood

by AlexanderNovember 15th, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Each next.js release is a set of new, interesting, and controversial features. This version will be no exception. However, new version is interesting not so much for its new functionality, but for the change in priorities and organization in next.js. And yes, as you may have guessed from the title, a significant part of this release is valuable for reflecting on previous mistakes.
featured image - Next.js v15 — What’s new under the hood
Alexander HackerNoon profile picture

Hey! This is another article about next.js. And finally, about the new version! Each release is a set of new, interesting, and controversial features. This version will be no exception. However, new version is interesting not so much for its new functionality, but for the change in priorities and organization in next.js. And yes, as you may have guessed from the title, a significant part of this release is valuable for reflecting on previous mistakes.


I've been working with next.js since around version 8. All this time I've been watching its development with interest (sometimes not without disappointment). Recently, I've published a series of articles about struggling with the new App Router - "Next.js App Router. A path to the future or a wrong turn", "Next.js caching. A gift or a curse", "More libraries for the god of libraries or how I rethought i18n". All of these were a result of very weak development of ideas and capabilities in previous versions of next.js. And because of this, my interest in the new version has only grown. Along with that, there's a desire to understand the vector of changes in the framework.


In this article, I won't dwell on what App Router or server components are - these are described in detail in previous articles. We'll focus only on the new version and only on the new changes.


Note: The article reflects the most interesting changes from the author’s perspective. Commits and PRs to the next.js core, developer messages and tasks are analyzed here, so the article reflects more changes than are officially presented.

Next.js v15 Release

First, a bit about changes in the internal development processes of next.js. For the first time, the framework team has published a release candidate (RC version). Obviously, they did this due to the React.js team's decision to publish React v19 RC.


Usually, the next.js team in their stable releases calmly uses react from the "Canary" release branch (this branch is considered stable and recommended for use by frameworks). This time, however, they decided to do things differently (spoiler alert - not in vain).


The plan for both teams was simple - publish a pre-release version, let the community check for issues, and in a couple of weeks publish a full release.

Tweet from React.js core-team developer https://x.com/acdlite/status/1797668537349328923


It's been over six months since the release candidate of React.js was released, but the stable version still hasn't been published. The delay in releasing the stable version of React.js has impacted next.js's plans as well. Therefore, contrary to tradition, they published a total of 15 additional patch versions while already working on the 15th version (usually 3-5 patches and then a release). What's noteworthy here is that these patch versions didn't include all accumulated changes, but only addressed critical issues, which also deviates from next.js's usual processes.


The basic release process in next.js is that everything merges into the canary branch, and then, at some point, this branch is published as a stable release.


However, as a result, the next.js team decided to decouple from the React.js release and publish a stable version of the framework before the stable version of React.js is released.

Documentation Versioning

Another very useful organizational change. Finally, it's possible to view different versions of the documentation. Here's why this is so important:


Firstly, updating next.js can often be quite a challenging task due to major changes. In fact, this is why there are still over 2 million downloads for version 12 and over 4 million for version 13 monthly (to be fair, version 14 has over 20 million downloads).


Consequently, users of previous versions need documentation specific to their version, as the new one might be rewritten for a half.


Next.js documentation versioning - nextjs.org/docs


Another problem is that Next.js essentially uses a single channel. Documentation changes are also made to it. Therefore, descriptions of changes from canary versions immediately appeared in the main documentation. Now they are displayed under the "canary" section.

Using React

At the beginning, I mentioned that Next.js is currently using the RC version of React.js. But in reality, this is not quite true, or rather not entirely true. In fact, Next.js is currently using two React.js configurations: the 19th canary version for App Router and the 18th version for Pages Router.


Interestingly, at one moment they wanted to include the 19th version for Pages Router as well, but then rolled back these changes. Now, full support for React.js version 19 is promised after the release of its stable version.


Along with this, the new version will have several useful improvements for server actions functions (yes, the React team renamed them):

  • Optimization of weight and performance;
  • Improved error handling;
  • Fixed revalidation and redirects from server functions.


I suppose I'll include Next.js's new feature in this section as well - the Form component. Overall, it's the familiar form from react-dom, but with some improvements. This component is primarily needed if successful form submission involves navigating to another page. For the next page, the loading.tsx and layout.tsx abstractions will be pre-loaded.

import Form from 'next/form'
 
export default function Page() {
  return (
    <Form action="/search">
      {/* On submission, the input value will be appended to 
          the URL, e.g. /search?query=abc */}
      <input name="query" />;
      <button type="submit">Submit</button>;
    </Form>;
  )
}

Developer Experience (DX)

When talking about Next.js, we can't ignore the developer experience. In addition to the standard "Faster, Higher, Stronger" (which we'll also discuss, but a bit later), several useful improvements have been released.


Long-awaited support for the latest ESLint. Next.js didn't support ESLint v9 until now. This is despite the fact that both eslint itself (v8) and some of its subdependencies are already marked as deprecated. This resulted in an unpleasant situation where projects were essentially forced to keep deprecated packages.


The error interface has been slightly improved (which in Next.js is already clear and convenient):

  • Added a button to copy the stack trace;
  • Added the ability to open the error source in the editor at a specific line.

Example of copying the error stack in next.js


A "Static Indicator" has been added - an element in the corner of the page showing that the page has been built in static mode. Overall, it's a minor thing, but it's amusing that they included it in the key changes as something new. The indicator for a "pre-built" page has been around since roughly version 8 (2019) and here, essentially, they've just slightly updated it and adapted it for the App Router.


A directory with debugging information has also been added - .next/diagnostics. It will contain information about the build process and all errors that occur. It's not yet clear if this will be useful in daily use, but it will certainly be used when troubleshooting issues with Vercel devrels (yes, they sometimes help to solve problems).

Next.js team's response to a tweet about slow project build

Changes in the Build Process

After discussing DX, it's worth talking about the build process. And along with it, Turbopack.

Turbopack

And the biggest news in this area. Turbopack is now fully completed for development mode! "100% of existing tests passed without errors with Turbopack"


Now the Turbo team is working on the production version, gradually going through the tests and refining them (currently about 96% complete)

Example changelog section in next.js

Turbopack also adds new capabilities:

  • Setting a memory limit for builds with Turbopack;
  • Tree Shaking (removal of unused code).
const nextConfig = {
  experimental: {
    turbo: {
      treeShaking: true,
      memoryLimit: 1024 * 1024 * 512 // in bytes / 512MB
    },
  },
}

These and other improvements in Turbopack "reduced memory usage by 25-30%" and also "accelerated the build of heavy pages by 30-50%".

Other

Significant style issues have been fixed. In version 14, situations often arose where the order of styles was broken during navigation, causing style A to become higher than style B, than vice versa. This changed their priority and consequently, elements looked different.


The next long-awaited improvement. Now the configuration file can be written in TypeScript - next.config.ts

import type { NextConfig } from 'next';
 
const nextConfig: NextConfig = {
  /* config options here */
};
 
export default nextConfig;


Another interesting update is retrying attempts for static page builds. This means if a page fails at build time (for example, due to internet problems) - it will try to build again.

const nextConfig = {
  experimental: {
    staticGenerationRetryCount: 3,
  },
}


And to conclude this section, a functionality highly desired by the community - the ability to specify the path to additional files for building. With this option, you can, for example, specify that files are located not in the app directory, but in directories like modules/main, modules/invoices.


However, at the moment, they have only added it for internal team purposes. And in this version, they definitely won't present it. Going forward, it will either be used for Vercel needs, or they will test it and present it in the next release.

Changes in the Framework API

The most painful part of Next.js updates - API changes. And in this version, there are also breaking updates.


Several internal framework APIs have become asynchronous - cookies, headers, params and searchParams (so-called Dynamic APIs).

import { cookies } from 'next/headers';
 
export async function AdminPanel() {
  const cookieStore = await cookies();
  const token = cookieStore.get('token');

  // ...
}


It's a major change, but the Next.js team promises that all this functionality can be updated automatically by calling their codemod:

npx @next/codemod@canary next-async-request-api .


Another change, but probably not relevant to many. The keys geo and ip have been removed from NextRequest (used in middleware and API routes). Essentially, this functionality only worked in Vercel, while in other places developers made their own methods. For Vercel, this functionality will be moved to the @vercel/functions package


And a few more updates:

  • In revalidateTag, you can now pass multiple tags at once;
  • Keys images.remotePatterns.search and images.localPatterns have been added to the configuration for next/image. These allow better control over address restrictions for image compression.
const nextConfig = {
  images: {
    localPatterns: [
      {
        pathname: '/assets/images/**',
        search: 'v=1',
      },
    ],
  },
}

Caching

In my personal opinion, this is where the most important changes for Next.js have occurred. And the biggest news is - Caching is now disabled by default! I won't go into detail about caching problems, this was largely covered in the article "Next.js Caching. Gift or Curse".


Let's go through all the main changes in caching:

  • Specifically, fetch now uses the no-store value by default instead of force-cache;
  • API routes now work in force-dynamic mode by default (previously the default was force-static, meaning they were compiled into a static response during build time [if dynamic APIs were not used on the page]);
  • Caching in the client router has also been disabled. Previously, if a client visited a page within a route - it was cached on the client and remained in that state until the page was reloaded. Now, the current page will be loaded each time. This functionality can be reconfigured through next.config.js:
const nextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 30 // defaults to 0
    },
  },
}
  • Moreover, even if client-side caching is enabled - it will apparently be updated at the right moment. Specifically, if the cache of an enabled page on the server expires.
  • Server components are now cached in development mode. This makes updates in development happen faster. The cache can be cleared simply by reloading the page. You can also completely disable this functionality through next.config.js:
const nextConfig = {
  experimental: {
    serverComponentsHmrCache: false, // defaults to true
  },
}
  • You can now manage the "Cache-Control" header. Previously, it was always rigidly overwritten with Next.js's internal values. This caused artifacts with caching through CDN;
  • next/dynamic caches modules and reuses them, rather than loading them again each time;


That's regarding the "historical misunderstandings". New APIs will also appear in Next.js. Namely, the so-called Dynamic I/O. It hasn't been written about anywhere yet, so the following will be the author's guesses based on the changes.


Dynamic I/O appears to be an advanced mode of dynamic building. Something like PPR (Partial Prerendering), or more precisely, its complement. In short, Partial Prerendering is a page building mode where most elements are built at build time and cached, while individual elements are built for each request.


So, dynamic I/O [probably] finalizes the architecture for this logic. It expands caching capabilities so that it can be enabled and disabled precisely depending on the mode and place of use (whether in a "dynamic" block or not).

const nextConfig = {
  experimental: {
    dynamicIO: true, // defaults to false
  },
}


Along with this, the "use cache" directive is added. It will be available in nodejs and edge runtimes and, apparently, in all server segments and abstractions. By specifying this directive at the top of a function or a module exporting a function - its result will be cached. The directive will only be available when dynamicIO is enabled.

async function loadAndFormatData(page) {
  "use cache"
  ...
}


Also, specifically for use cache, methods cacheLife and cacheTag are added

export { unstable_cacheLife } from 'next/cache'
export { unstable_cacheTag } from 'next/cache'

async function loadAndFormatData(page) {
  "use cache"
  unstable_cacheLife('frequent');
  // or
  unstable_cacheTag(page, 'pages');
  ...
}


cacheTag will be used for revalidation using revalidateTag, and cacheLife will set the cache lifetime. For the cacheLife value, you'll need to use one of the preset values. Several options will be available out of the box ("seconds", "minutes", "hours", "days", "weeks", "max"), additional ones can be specified in next.config.js:

const nextConfig = {
  experimental: {
    cacheLife?: {
      [profile: string]: {
        // How long the client can cache a value without checking with the server.
        stale?: number
        // How frequently you want the cache to refresh on the server.
        // Stale values may be served while revalidating.
        revalidate?: number
        // In the worst case scenario, where you haven't had traffic in a while,
        // how stale can a value be until you prefer deopting to dynamic.
        // Must be longer than revalidate.
        expire?: number
      }
    }
  }
}

Partial Prerendering (PPR)

Probably the main feature of the next release. As mentioned earlier, PPR is a page building mode where most elements are assembled at build time and cached, while individual elements are assembled for each request. At the same time, the pre-built part is immediately sent to the client, while the rest is loaded dynamically.

How Partial Prerendering works

The functionality itself was introduced six months ago in the release candidate as an experimental API. This API will remain in this state, and we will likely see it as stable only in version 16 (which is good, as major functionality often transitioned to stable within six months to a year).


Regarding the changes. As mentioned earlier, it primarily updated the working principles. However, from the perspective of using PPR, this hardly affected anything. At the same time, it received several improvements:


Previously, there was just a flag in the config, but now to enable PPR, you need to specify 'incremental'. This is apparently done to make the logic more transparent - content can be cached by developers even in PPR, and to update it, you need to call revalidate methods.

const nextConfig = {
  experimental: {
    ppr: 'incremental',
  },
}


Also, previously PPR was launched for the entire project, but now it needs to be enabled for each segment (layout or page):

export const experimental_ppr = true


Another change is Partial Fallback Prerendering (PFPR). It's precisely due to this improvement that the pre-built part is immediately sent to the client, while the rest is loaded dynamically. In place of dynamic elements, a callback component is shown during this time.

import { Suspense } from "react"
import { StaticComponent, DynamicComponent } from "@/app/ui"
 
export const experimental_ppr = true
 
export default function Page() {
  return {
     <>
	     <StaticComponent />
	     <Suspense fallback={...}>
		     <DynamicComponent />
	     </Suspense>
     </>
  };
}

Instrumentation

Instrumentation is marked as a stable API. The instrumentation file allows users to hook into the lifecycle of the Next.js server. It works across the entire application (including all segments of Pages Router and App Router).


Currently, instrumentation supports the following hooks:


register - called once when initializing the Next.js server. It can be used for integration with observability libraries (OpenTelemetry, datadog) or for project-specific tasks.


onRequestError - a new hook that is called for all server errors. It can be used for integrations with error tracking libraries (Sentry).


export async function onRequestError(err, request, context) {
  await fetch('https://...', {
    method: 'POST',
    body: JSON.stringify({ message: err.message, request, context }),
    headers: { 'Content-Type': 'application/json' },
  });
}
 
export async function register() {
  // init your favorite observability provider SDK
}

Interceptor

Interceptor, also known as route-level middleware. It's something like a full-fledged [already existing] middleware, but unlike the latter:

  • It can work in the Node.js runtime;
  • It works on the server (which means it has access to the environment and unified cache);
  • It can be added multiple times and is inherited in nesting (similar to how middleware worked when it was in beta version);
  • It also works for server functions.


Moreover, when creating an interceptor file, all pages below in the tree become dynamic.

import { auth } from '@/auth';
import { redirect } from 'next/navigation';

const signInPathname = '/dashboard/sign-in';

export default async function intercept(request: NextRequest): Promise<void> {
  // This will also seed React's cache, so that the session is already
  // available when the `auth` function is called in server components.
  const session = await auth();

  if (!session && request.nextUrl.pathname !== signInPathname) {
    redirect(signInPathname);
  }
}

// lib/auth.ts
import { cache } from 'react';

export const auth = cache(async () => {
  // read session cookie from `cookies()`
  // use session cookie to read user from database
})


Speaking of Vercel, middleware will now be effective as a primary simple check at the CDN level (thus, for example, immediately returning redirects if the request is not allowed), while interceptors will work on the server, performing full-fledged checks and complex operations.


In self-hosting, however, such a division will apparently be less effective (since both abstractions work on the server). It may be sufficient to use only interceptors.

Conclusions

Overwriting fetch, aggressive caching, numerous bugs, and ignoring community requests. The Next.js team made erroneous decisions, rushed releases, and held onto their views despite community feedback. It took almost a year to recognize the problems. And only now, finally, there's a sense that the framework is once again addressing community issues.


On the other hand, there are other frameworks. A year ago, at the React.js presentation, it seemed that all frameworks would soon be on par with Next.js. React started mentioning Next.js less frequently as the main tool, frameworks were showcasing upcoming build systems, support for server components and functions, and a series of global changes and integrations. Time has passed, and essentially, none of them have reached that point yet.


Of course, final conclusions can only be drawn after some time, but for now, it feels like the changes in React.js, instead of the expected leveling of frameworks, have led to even greater dominance of Next.js and a wider divergence between frameworks (since the implementation of server components and actions was left to the discretion of the frameworks).


At the same time, OpenAI switched to Remix ("due to its greater stability and convenience"):

Remix usage in ChatGPT

And apparently they started before significant changes in Next.js

Tweet about ChatGPT switching to Remix from August, 2024


In general, in the next stateofjs and stackoverflow surveys, we are likely to see significant reshuffling.


Credits

Code examples or their foundations are taken from next.js documentation, as well as from commits, PRs, and the next.js core;


Postscript

If you need a tool for generating documentation based on MD files - take a look at robindoc.com, if you work with next.js - you might find something useful in the solutions at nimpl.tech.