Around in May 2023, Next.js witnessed some big changes with the introduction of the new App Router.

The routing system received a complete overhaul. Just prior to that, I had created a video series showing how to build a headless blog with Next.js as the frontend and WordPress as the API. It was based on the previous pages router, where you define all the routes within the /pages directory.

Many people started asking how to make the project work with the new app router. So I rebuilt the entire project using app router. If you want to check that out, you can find the code in my GitHub repository.

I thought I would share the key things I learned while performing the migration, so that it will make the job easier for you as well. I will also make another video showing how I did the migration.

With that let’s start.

1. Pages Router is Still Supported

The first thing I want to tell you is that the old Pages Router still works. Next.js hasn’t yet removed support for it in the latest versions. You can continue developing in the old way as if nothing has happened. If you have apps in production that uses Pages Router, no need to switch in a hurry.

However, it looks like App Router is the way forward.

Also /pages and /app can co-exist within the same project. Some pages can be within the old /pages directory, while some can be within the /app directory. So you can move pages incrementally. However, the same page cannot exist inside /pages and /app simultaneously.

2. File-based Routing to Folder-based Routing

The Pages Router followed a file and folder based routing. For instance, if you have a route location example.com/members/list, then you define it inside /pages/members/list.js.

That has changed with the new App Router. All routes are identified with the name of the folder in which it exists.

So the same example becomes: /app/members/list/page.js. Note that list is a folder which identifies the route path, within which we define the file page.js.

Here, page.js is a reserved file name in the Next.js App Router, which is used to export the page component for any route.

For instance if you have another route like example.com/blog/my-post, you can define it as: /app/blog/[slug]/page.js.

In effect, you end up having a page.js inside each route folder. So if your app has 10 routes, then you will have 10 page.js files nested within the /app directory.

That’s one thing I don’t quite like with the new approach – with more than one file with identical file name, the /app directory can look quite a mess if your app has a few dozen routes.

The problem exacerbates when considering that page.js is not the only reserved file name. You can also have nested layout.js files, route.js files for defining API routes, and so on.

3. _app.js and _document.js gone

With /pages, you can use _app.js and _document.js files to define custom app and custom document structures.

Moving to the App Router, both of these are replaced by layout files – layout.js (.jsx or .tsx).

As I already mentioned, layout is another reserved file name. Layouts can be placed alongside page.js files inside route files. You can also have multiple levels of nested layouts, which means there can be a parent layout file in the root directory with sub-layouts inside route folders.

Want to import global stylesheets? You can do that within the root layout.

Also, layout takes the inner layouts and pages as its children. So the root layout file (/app/layout.js) is a must-have.

// an example of a RootLayout - /app/layout.js

import '../styles/main.css';

export default function RootLayout({ children }) {
 return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

I think this move has simplified the layout arrangement when compared to the previous _app and _document based arrangement.

4. Data Fetching

Another notable change is with regards to how you fetch external data within pages and API routes.

In the Pages Router, there were two functions:

  1. getStaticProps(), and
  2. getServerSideProps()

Both functions run on the server during build time, or during server-side rendering, depending on whether you’re using SSG or SSR respectively. Within these functions, you could perform fetch requests and return the results. And the returned values would be available to the default export function as props.

Now, both these functions are completely gone in App Router.

Instead, you can directly use the Fetch API – fetch() – that we’re already used to. No need to put then inside special functions like getStaticProps() or getServerSideProps().

export async function fetchProduct() {
    const productData = await fetch("https://example.com/api/product", {
        headers: {
            'Accept': 'application/json',
        }
    })
    return productData
}
export default async function Product({ params }) {
    const productData = await fetchProduct(params.slug)

    return (
        <h1>{{ productData.title }}</h1>
        <div>{{ productData.price }}</div>
    )
}

Since all components are server-side by default in the App router (more on that below), you can rest assured that the fetch() happens on the server and not on the client.

5. Static Site Generation

In Pages Router, when you want to statically generate dynamic routes, you could return an array of route path values from the getStaticPaths() function.

// define this inside /pages/products/[productSlug].js

export async function getStaticPaths() {
    const productSlugs = await getProductSlugs();

    const paths = productSlugs.map((s) => (
        {
            params: { productSlug: s.slug }
        }
    ));

    return {
        paths: paths,
        fallback: 'blocking'
    }
}

Now this function name is changed to generateStaticParams(). Next.js says that this function name looks more meaningful.

// define this inside /app/products/[productSlug]/page.js

export async function generateStaticParams() {
    const productSlugs = await getProductSlugs();

    const paths = productSlugs.map((s) => (
        { productSlug: s.slug }
    ));

    return paths
}

Also note that the structure of the return object is also simplified. No need for the fallback property either.

6. SEO

In my opinion, SEO was a bit of a mess in the pages router. You had to fetch and return all seo tags inside getStaticProps() or getServerSideProps(), then insert it inside <Head /> components appropriately.

Instead of that, App Router has built-in support for SEO.

  1. you can export a const object called metadata, which contains the title, description, open graph tags, and so on
  2. or if you want to generate these values dynamically, define a function called generateMetadata(), which then fetches all the values and returns the object
// define this within page.js files

async function getSeo(slug) {
    /**
    fetch the seo data
    **/
    return seoData
}
export async function generateMetadata({ params }) {

    const seoData = await getSeo(params.productSlug)

    return {
        title: seoData.title,
        description: seoData.metaDesc,
        openGraph: {
            title: seoData.opengraphTitle,
            description: seoData.metaDesc,
            images: [seoData.opengraphImage.mediaItemUrl],
            url: seoData.opengraphImage.mediaItemUrl,
            locale: 'en_IN',
            type: seoData.opengraphType,
            siteName: seoData.opengraphSiteName,
        // ...add more tags if required
        }
    }
}

Next.js will automatically insert these metadata values within the <head> tag during build or render time.

7. API Routes

In the /pages routes, all API routes were defined within the /api/ folder. And each API route file exported a function named handler(req, res).

Now, there are no restrictions like that. You can create API routes in any folder, not just the /api/ folder.

Make sure to name the API route files as route.js. That’s how they differ from other page.js routes and layout.js files. Here note that route.js is another reserved file name.

The handler function has also changed. Now you can denote the request method by the function name – like:

  • export function GET(request)
  • export function POST(request)
  • export function PUT(request)

…etc.

Next.js has also made available the NextRequest and NextResponse objects, which are extended from the native Request and Response objects available with the JavaScript Fetch API.

// sample API route that handles a form submission POST request

import { NextResponse } from "next/server";

export async function POST(request) {

    const formData = await request.json()

    const data = {
        firstName: formData?.firstName,
        email: formData?.email,
        message: formData?.message,
    }

    if( !data.firstName || !data.email || !data.message ) {
        return NextResponse.json(
            { message: "all fields required" }, 
            { status: 400 }
        )
    }

    return NextResponse.json({ message: "form submitted successfully" })
}

Overall, the API feature has become more powerful with the new /app/ router.

8. Revalidation & Caching Behaviour

In the Pages Router, you could set the revalidate property inside getStaticProps() to tell Next.js how often to fetch the latest data from the source.

Else if you wanted to fetch the data on every request, getServerSideProps() should be used instead of getStaticProps().

There was also a fallback property alongside revalidate that dictates when to run the getStaticProps() function.

Now all these confusions are gone!

  • All data fetches are cached by default, when used with the fetch() function. The cache persists infinitely if no other options are used.
  • If you want to completely opt-out of cache, add the option { cache: 'no-store' } to fetch(). This makes it equivalent to using getServerSideProps() – fresh data is fetched on every request.
// don't store the data in the cache, fetch on every request

export async function fetchProduct(slug) {
    const productData = await fetch("https://example.com/api/product", {
        headers: {
            'Accept': 'application/json',
        },
        cache: 'no-store'
    })
    return productData
}
export default async function Product({ params }) {
    // ...define the component
}
  • Data can also be re-fetched periodically – add the revalidate option. For instance { revalidate: 3600 } fetches fresh data at most every one hour.
// revalidates every one hour

export async function fetchProduct(slug) {
    const productData = await fetch("https://example.com/api/product", {
        headers: {
            'Accept': 'application/json',
        },
        next: {
            revalidate: 3600
        }
    })
    return productData
}
export default async function Product({ params }) {
    // ...define the component
}

9. Server Components & Client Components

The new React Server Components is what everyone is talking about now. At first, I couldn’t wrap my head around it.

Next.js already renders React components on the server, right? Then how does it make sense? That was my initial thought.

Anyways, here is how it goes:

  • When you use React without any frameworks like Next.js, the server sends only the HTML wrapper element along with the whole React JavaScript code. Then this React JavaScript code does its work and renders all the JSX elements into the DOM on the user’s browser, creates a virtual DOM and make things interactive.
  • On the other hand, when React is run with Next.js, the component JSX is rendered into HTML on the server and then sent to the client browser. So the HTML is almost complete – good for SEO. But in addition to that, almost the whole JavaScript (except any server-only modules) is also sent to the client. There React runs again and re-renders the components to make everything interactive like a normal React app. This is called hydration.
  • Now with Server Components, this hydration step is omitted. Next.js runs and renders components on the server, then sends the HTML back. The JavaScript components are not sent to the client browser. Still you might find some JavaScript sent along but it is not for hydrating the components.

So what does that imply?

  • Reduced JavaScript bundle size sent to the browser.
  • Tasks like data fetching and API requests are performed fully on the server.
  • Secret keys and API keys can be safely used inside server components without getting exposed to clients.

Downsides?

  • Server Components are not interactive. Things like useStateuseEffectonClick etc won’t work.
/**
- /app/product/[productSlug]/page.js
- this is a Server Component
**/

export async function fetchProduct(slug) { /** fetch product and return **/ }
export async function generateStaticParams() { /** return productSlug list **/ }
export async function generateMetaData({ params }) { /** return metadata **/ }

export default async function Product({ params }) {
    const productData = await fetchProduct(params.slug)

    return (
        <h1>{{ productData.title }}</h1>
        <div>{{ productData.price }}</div>

        // this button's onClick wont trigger
        <button onClick={console.log('clicked')}>Send</button>
    )
}

So, how to make things interactive?

That’s where you can use Client Components. They render on the server and hydrates on the client as usual.

But how to create a client component?

As I mentioned, all top-level route components in the App Router are Server Components by default.

So when you want interactivity, move it to a child component, then switch to ‘client mode’ by adding the use client directive at the top. Make sure to add the directive at the top of the file.

The use client directive marks all the components in that file as Client Components. Also if you import components into a client component, they will also function as client components.

So the above example becomes:

  • define the Button component in a separate client component:
/** 
- /app/product/[productSlug]/SendButton.js
- this is a Client Component
**/

'use client'

export default function SendButton() {
    return (
        <button onClick={console.log('clicked')}>Send</button>
    )
}
  • then import it inside the parent server component
/**
- /app/product/[productSlug]/page.js
- this is a Server Component
- but it can import client components like <SendButton />
**/

import SendButton from './SendButton'

export async function fetchProduct(slug) { /** fetch product and return **/ }
export async function generateStaticParams() { /** return productSlug list **/ }
export async function generateMetaData({ params }) { /** return metadata **/ }

export default async function Product({ params }) {
    const productData = await fetchProduct(params.slug)

    return (
        <h1>{{ productData.title }}</h1>
        <div>{{ productData.price }}</div>

        // now the button is interactive
        <SendButton />
    )
}

However, the opposite is not possible. You can’t import a server component into a client component.

Some features, like the SEO metadata we discussed above, can only be used inside a server component. Such components can’t be imported within a client component.

Conclusion

I’m still learning about the new features in Next.js. And here I’ve shared the points that I’ve discovered. Hope it helps you too.