Chris.luChris.lu header image, displaying an 80s style landscape and sunset

The road to React 19 and Next.js 15

a futuristic city with two signs "react" and "next.js"

This post is based on research I did a while ago when I first heard about Server Components. I wanted to understand what makes them different from what I was using at that time. I have kept track of the React and Next.js releases, which are the first few chapters, and then I had some discussions with other devs about the need or not to migrate. This is why I wrote the "do I need to migrate all my code today?" chapter, and finally, the last chapters are about finding out what the major changes between the Next.js pages directory and app directory are

I will try to keep this document up to date as things evolve continuously. If you find mistakes, please use the issues on GitHub to report them, and if you want to discuss something related to the article, feel free to open post it in the discussions on GitHub

React 18.x and beyond

React updates recap:

React 19

New features in Next.js before v13

Since version v10.2.1 Next.js enabled Incremental type checking, making builds much faster and then in version 12. Next.js typescript compilation got another speed boost, because Next.js started using "SWC" a compiler written in Rust. For a bit of history and more in-depth information, check out their Next.js "SWC compiler" documentation. Also in version 12 Next.js they started experimenting with React Server Components (alpha version). This version of Next.js did NOT include the app directory yet.

Next.js v13

In May 2022, the next.js team released the Layouts RFC and sketched out their ideas for server components, but also improved data fetching and things like nested layouts. Alongside the RFC posted on their blog, they opened a github discussion regarding the layouts RFC

Next.js 13 releases:

Next.js 14

Next.js 15

do I need to migrate all my code today?

Do I need to move all my code from pages to the app directory, and do I need to use Server Components and/or Server Actions?

TLDR: NO!

The app directory and server-side React are features you can opt into when ready, but nobody forces you to use them. The pages directory still exists in Next.js 13 and 14, and React still has all the client components features it had before. Yes they added Server Components and Server Actions but they also add new client side features like (client) Actions.

This means you can still start a new project today and use the pages directory (but I don't recommend it as I think projects can be much more powerful if they use the app router). If you have an existing codebase, you don't need to migrate everything at once from pages to the app directory. You can do it bit by bit. If, however, you start a new project from scratch, then I recommend that you consider using the app directory as well as Server Components and, eventually, Server Actions because those technologies are more future-proof and also include a lot of great features the pages router or React client side only projects do not have

If you are already using a previous version of Next.js and are migrating to one of the latest versions then I highly recommend you first check out the Next.js "codemods" documentation as these might save you a lot of time, another good read is the Next.js app router migration guide

pages VS app router

This is an introduction to the differences between the Next.js app and the pages router.

routing in pages VS app

For some time Next.js only had the pages directory, but since the first release of Next.js 13, Next.js now has two directories to choose from the pages and app directories, which correspond to the pages router and the app router, both are file-based routers but how files are being used differs:

When using app router (/app directory), each URL segment corresponds to a directory, which means we can put more than just pages in a directory. One of those routing files, for example, is a layout.tsx file, which allows us to build nested layouts, another one is the loading.tsx file to add a loader that gets displayed while the content of the page is getting fetched

The app router supports features you already know from the pages router, like dynamic or catch-all route segments, but it also makes routing more flexible, and even more new features got added since the first release, like the parallel routes that got introduced in Next.js 13.3

layout(s) in pages VS app

With the pages router layouts, you have the possibility to wrap the children prop in your _app.tsx with a layout component which then gets applied to every page, if however you want to use more than one layout, then you have to write custom code to make it work, for example by adding custom code inside of your _app.tsx, you could check which route you are on and, based on that, switch to another layout, or you could import different layout components in each page file

With the app router layouts, it is much easier to have different layouts for different segments of your website, all you need to do is add a layout.tsx file (or a layout.jsx or layout.mdx file, depending on what language you use) into a directory and it will apply to the segment it is in as well as all segments nested below that, so not only is it easy to have multiple layouts it is also easy to create a cascade of nested layouts

So, even the app router layout system does not have many new features (the page layout system is already quite feature-rich), in my opinion, the DX has improved greatly, and hence, it is easier to manage complex scenarios.

Data fetching and rendering

This is an introduction to the differences between fetching and rendering when using the pages router compared to fetching and rendering when using the new app router.

using the pages router

Here is a little recap of the 1st and 2nd generation of data fetching features using the pages router (/pages directory):

using the app router

To do data fetching in the app Router, you do NOT use the getServerSideProps anymore. Instead, you use Server Components that fetch data on the server and then send it to the client, together with your pre-rendered client components and compiled CSS.

In the app router (in my opinion), getting data from an API is easier than using the getServerSideProps in pages (as there is less framework-specific syntax you need to remember). A regular fetch will do (fetch in Next.js got extended with framework-specific features like a caching mechanism, but in the end, it still works like a regular fetch). Here is a straightforward example:

export default async function MyPage() {
    const data = await fetch('https://api.example.com/...')
    return (<>{data.foo}</>)
}

When getting data in the Server Component and calling an API endpoint, it is highly recommended to use fetch as not only does it fetch data but also allows you to use the caching layer to make sure you reduce the amount of fetches by re-using cached data. You can fine-tune the caching of requests by, for example, defining when the cache should expire by setting the revalidate value manually:

export default async function MyPage() {
    // revalidate if cache older than an hour
    const data = await fetch(
        'https://api.example.com/...',
        { next: { revalidate: 3600 } }
    )
    return (<>{data.foo}</>)
}

It is also possible to use tags, they are useful when you need to revalidated multiple cached fetches, all at once based on their common tag:

page.tsx
export default async function MyPage() {
    // revalidate if cache older than an hour
    const data = await fetch(
        'https://api.example.com/...',
        { next: { tags: ['my_custom_tag'] } }
    )
    return (<>{data.foo}</>)
}
action.ts
'use server'
 
import { revalidateTag } from 'next/cache'
 
export default async function action() {
    revalidateTag('my_custom_tag')
}
Tip

I highly recommend doing a deep dive in the Next.js "Data Fetching, Caching, and Revalidating" documentation to better understand how this works and what options you have.

Fetch is not the only way to get data in server components. You can also directly use a database client packages or even an ORM, but in that case, you need to set up the caching yourself by, for example, using react cache. There is more information in the Next.js documentation about fetching data on the server with third-party libraries.

import { cache } from 'react'
import { client } from 'third_party_database_client'
 
export const getData = cache(async (id) => {
    const data = await client.queryById({ id })
    return data
})
 
export default async function MyPage({ params: { id } }) {
    const data = await getData(id)
    return (<>{data.foo}</>)
}

Another option for caching database requests was introduced in Next.js 14, where it was marked as unstable (so be aware that the API might change in the future). The new next/cache feature is called unstable_cache. It is the same caching mechanism used by fetch by for one's own queries using the database client you prefer:

import { unstable_cache } from 'next/cache'
import { client } from 'third_party_database_client'
 
const getData = unstable_cache(async (id) => {
    const data = await client.queryById({ id })
    return data
}, ['my_custom_tag'])
 
export default async function MyPage({ params: { id } }) {
    const data = await getData(id)
    return (<>{data.foo}</>)
}

In version 14.1, Next.js got some improvements for custom cache handlers, which got released as a stable version (most useful when not deploying your project on Vercel and when using your own custom cache storage), the two configuration options are now called cacheHandler and cacheMaxMemorySize:

next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
    cacheHandler: require.resolve('./MY_CUSTOM_CACHE_HANDLER.js'),
    cacheMaxMemorySize: 0,
}

In version 14.2, the Next.js team added a new experimental option called staleTimes to let developers set custom revalidation times for the Router Cache:

next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
    experimental: {
        staleTimes: {
            dynamic: 30,
            static: 180,
        },
    },
}

In version 15, Next.js changed caching to be disabled by default, meaning you now need to opt-in caching manually

Server components and server actions

When using the app directory, Next.js will assume that every component is a Server Component by default, meaning if you don't specify anything, then a component (or page/layout) with no directive (either 'use client' or 'use server') is automatically considered being a Server Component. There are things you can't do in a Server Component, like using useEffect or listening for an onClick event. If you want to use these, you must add the 'use client' directive on top of your component to turn it into a client component.

It is recommended that as few components be turned into client components as possible. Next.js always renders client components on the server, then sends the HTML to the client, and finally performs a hydration step to make the page interactive. However, server components use an SSR technique called React Server Component Payload (RSC Payload), which results in less client code and reduced page load times.

It is also recommended that you do all data fetching in Server Components. This allows you to import packages that will only be used on the Server, meaning they will not be bundled with other client-side code, hence reducing the payload that is being sent to the client. Another benefit is that you can use environment variables like an API key, and those will stay secret and not be exposed to the client when you use them in Server Components.

Server Actions allow us to mutate data on the server (from within a client component) without, for example, calling an API endpoint. By using the directive 'use server' inside a client component, you turn a part of your client component into a Server Component. That code will get executed on the server but will reside inside the client component. Server Actions have the potential to simplify our code. For example, using Server Actions you can update or insert data into a database next to the client code that handles the onSubmit of a form, keeping all the logic of your data flow in one place. Server Actions also make it easy to progressively enhance forms, meaning a form will be usable even before JavaScript is done loading.

'use client'
 
import { client } from 'my-database-lib'
 
export default function MyPage() {
    async function formAction(formData) {
        'use server'
        client.insert({ name: formData.get('username') })
    }
    return (<form action={formAction} method="POST">
        <input type="text" id="username">
    </form>)
}

Below are links to documents I think will help to fully understand the potential of App router data fetching (and caching), Server Components, and Server Actions: