NextJS App Router Migration Part 2 Hero

NextJS App Router Migration with Sanity - Part 2 (styles and performance)

Learn how to migrate your design system and optimize for performance when migrating from the Pages Router to App Router in NextJS

TL;DR:

In Part 1 of our "NextJS App Router Migration with Sanity" series, we walked you through the initial phase of migrating our NextJS Sanity app from the Pages Router to the App Router. We discussed the motivations behind our decision, the preparation steps, and the technical details involved, including updates to NextJS, handling internationalization, and setting up Google Tag Manager. We also highlighted the challenges and solutions specific to our use of Sanity and shared the importance of incremental migration. Now, in Part 2, we will delve into our migration from ChakraUI to ParkUI, addressing performance issues and further enhancing our app's capabilities.

Migrating Styles: ChakraUI to ParkUI

We loved the ChakraUI library and were very sad to have to migrate off of it. Not to mention just the sheer amount of time it takes to migrate an entire component library. But unfortunately, ChakraUI v2 is not supporting the NextJS App Router, at least not in an optimal way without having to make every component a client component using ‘use client’. Now if you have to make every component a client component, it kind of defeats the purpose of using the App Router to leverage React Server Components. After researching various UI libraries we identified ParkUI as a solid choice. This was for multiple reasons that included:

  • The author, , is a contributor to ChakraUI and is working with the Chakra team on ArkUI. Shout out to Christian 💪 🙌
  • ParkUI supports many of the popular Javascript frameworks including React, Vue, Solid and also plans to support Svelte in the near future.
  • Working with ParkUI feels very much like working with Chakra because it’s built on top of PandaCSS and ArkUI.
    • PandaCSS is “CSS-in-JS with build time generated styles, RSC compatible, multi-variant support, and best-in-class developer experience”. Writing styles in PandaCSS allows you to write styles like you’re using styled-props from ChakraUI. Plus it comes with a bunch of extra amazing features. Did I mention that Sage, the creator of Chakra, is also the creator of PandaCSS (and ArkUI)?! 😏
    • ArkUI is a headless library for building reusable, scalable Design Systems that works for a wide range of JS frameworks. And it’s also built by the Chakra team.
  • ParkUI is kinda like Shadcn but using PandaCSS and ArkUI instead of Tailwind and Radix. We like this way of managing dependencies because it gives you more control. You can use the CLI to install all the components or just copy and paste the ones you want.

Now, in between the time we decided to use ParkUI and writing this article, I have discovered that the Chakra team is working on ChakraUI v3 which will officially support NextJS App Router and React Server Components. 🤯 😅 But I still think they have a ways to go before v3 is ready.

HOWEVER, Chakra v3 is basically using the same tech stack ParkUI is … PandaCSS, ArkUI, and ZagJS for the state machine. So I’m not totally bummed that we won’t be using Chakra v3. But I’m curious to see the differences once they release it.

Style Migration Process

Now that we had made our decision on a Style Design System, it was time to get to work. And with the help of Cursor AI, it made the whole process go a lot faster. Since the syntax of ChakraUI and ParkUI has a lot of similarities/compatibility, it also made the migration process smoother. The first step was to obviously install Park UI and dependencies. You can follow the installation instructions using their Setup Guide, or you can follow along here.

ArkUI website



1. Install Ark UI:

“The first step is to install Ark UI. Ark UI is a headless component library that forms the foundation for most components. To install Ark UI, execute the following command in your project's root directory:”

npm install @ark-ui/react

2. Install Panda Presets:

“The next package you will need is @park-ui/panda-preset. This package contains all the recipes and tokens explicitly built for Ark UI's headless components.”

run npm install @park-ui/panda-preset -D

After you've installed the presets, you'll need to add it to your Panda configuration file along with your preferred jsxFramework like shown below. The presets are what gives the components their styles. These can be overridden to customize your own look and feel of the component library.

import { defineConfig } from '@pandacss/dev'

export default defineConfig({
  preflight: true,
  presets: ['@pandacss/preset-base', '@park-ui/panda-preset'],
  include: ['./src/**/*.{js,jsx,ts,tsx}'],
  exclude: [],
  jsxFramework: 'react', // or 'solid' or 'vue'
  outdir: 'styled-system',
})

After you've added the presets, ensure you run panda codegen after adding the presets to your Panda configuration file.

This command is very important because it generates the styled-system otherwise the app will failed to load PandaCSS.

3. Path Aliases:

To simplify integrating code snippets without changing import statements, set up path aliases by modifying your tsconig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "~/*": ["./src/*"]
    }
  },
  "include": ["src", "styled-system"]
}

4. Add Components to your Project

ParkUI recommends storing your components in the ~/components/ui directory. Here's an example how a project structure might look like:

➜ /src/components/ui/
  |-- styled/
  |   |-- utils/
  |   |   `-- create-style-context.ts
  |   `-- button.tsx
  `-- button.tsx

You can do this manually, but we recommend you save time by using the ParkUI CLI for the job. It allows you to add components 1 by 1 or you can just install all of them like so: 

npx @park-ui/cli components add --all

After you have installed all the ParkUI components you need, it’s just a matter of replacing their ChakraUI equivalents one by one throughout your code base. Hopefully it’s mostly just updating your import statements to using the ParkUI component in your codebase instead of the corresponding Chakra component.

5. Add your custom PandaCSS Recipes

Next is adding custom PandaCSS recipes that will override the style presets that are coming from ParkUI. This is so the ParkUI components match your style guide. Learn how you can customize the ParkUI present UI in their docs.

Here is a `badge` recipe we customized as an example.

import { defineRecipe } from '@pandacss/dev';

export const badge = defineRecipe({
  className: 'badge',
  base: {
    alignItems: 'center',
    display: 'inline-flex',
    fontWeight: 'medium',
    userSelect: 'none',
    whiteSpace: 'nowrap',
    borderRadius: '3px'
  },
  variants: {
    variant: {
      light: {
        bg: 'primary.soft',
        color: 'primary.default'
      },
      primary: {
        bg: 'primary.default',
        color: 'white'
      }
    },
    size: {
      sm: {
        fontSize: 'sm',
        px: 4, // <-- px is short for paddingLeft and paddingRight
        py: 4 // <-- py is short for paddingTop and paddingBottom
      },
      md: {
        borderRadius: '3px',
        fontSize: 'md',
        px: 4, // <-- these values are tokens from the design system
        py: 2 // <-- these values are tokens from the design system
      }
    }
  },
  defaultVariants: {
    size: 'md',
    variant: 'primary'
  }
});

Optimizing your NextJS App Router for Performance

Ok, now that you have all your components migrated over to ParkUI, you’re ready to go! Right? Not so fast! Don’t forget to audit your website performance by checking its scores in PageSpeed Insights.

Just because you are using the App Route and React Server components doesn’t mean you are automatically going to see performance gains. There is a lot of other moving parts to take into account around performance. And the App Router has a million different ways you can manage caching and data fetching.

So if you see your speed score drop significantly and then you want to pull your hair out ....

Confused - WUT


Don't worry, you'll get there. 😉 Just make sure you study and read all the NextJS documentation around caching and data fetching to make sure you understand how it all works.

Pro Tip 💡: Also make sure to check your data usage in Sanity to analyze how many API requests you are making on average per day and also how many requests you are making to the API CDN for assets. Based on the amount of daily traffic you get, does the number of API requests made to Sanity make sense to you? If it’s WAY over what you should be expecting, then chances are you may have configured your fetch calls to Sanity incorrectly, you aren’t caching your requests correctly to Vercel through NextJS, or you have some runaway rebellious React components that are re-rendering too many times causing too many requests.

Here are some of your top tips for optimizing your NextJS app using the App Router. Let’s dive in! 🚀

Optimize your Data Queries to Sanity:

Now, if your Sanity site is using a Page Builder set up in the Sanity Studio (like ours is), then you know that your groq queries to Sanity can easily get out of hand. This depends on how many “Modules” you have in your Page Builder library. And also how many components you are listing on a landing page.

There are limits on how much data can be retrieved from Vercel in a single fetch call. 4.5 MB to be exact. So just be aware of that with how much data you are trying to fetch in a single request. After analyzing our groq queries, the first step was to remove queries for page builder modules that were not set on a particular page. We came upon this very useful article from the Sanity team “Simplifying GROQ queries for complex “Page Builder”.

This article was very helpful and inspired our refactor of how we structure our page builder queries (Shout out to the Sanity team). Instead of doing one large static query to fetch all the possible data on a page from the page builder, you should split up your groq query and make it more dynamic so you are only fetching the data you need. Here is how our Page Builder queries look.

First, fetch the module types that are set for a particular page.

export const getPageQueryTypes = (pageType: string, slug: string = '') => {

return groq`*[_type == "${pageType}" ${slug && `&& slug.current == "${slug}"`}][0]{
   _id,
   "moduleTypes": pageBuilder{
     "modules": module[]{
       _key,
       _type
     }
   }
 }`;
};

Now that you have an array of the module types that are set on a page, you can build a dynamic query to fetch the data for each module. 

import { MODULE_QUERIES } from './pageBuilder';

interface Module {
 _type: string;
}


const buildPageBuilderQuery = (modules: Module[]): string => {
 if (!modules) return '';
 const moduleTypes = modules.map(mod => mod._type).filter(type => type);
 const pageBuilderQuery = moduleTypes
   .map(type => (MODULE_QUERIES[type] ? `_type == "${type}" => ${MODULE_QUERIES[type]}` : ''))
   .filter(query => query)
   .join(',');
 return pageBuilderQuery;
};


export default buildPageBuilderQuery;

// Here is how you use it. 
const pageBuilderQuery = buildPageBuilderQuery(pageModules?.moduleTypes?.modules);

And here is what MODULE_QUERIES looks like. (This is just an example).

import accordion from '@data/modules/accordion';
import cardGrid from '@data/modules/cardGrid';
import contactForm from '@data/modules/contactForm';
import ctaModule from '@data/modules/ctaModule';


export const MODULE_QUERIES = {
 accordion,
 cardGrid: `{
   ${cardGrid}
 }`,
 contactForm,
 ctaModule,
 featuredArticles,
 imageSplitText,
 logoCloud,
 pricePackages,
 processes: process,
 richText,
 services: service,
};

Here is example of one of these groq queries…

const accordion = `accordion{
 faqLayout,
 collapsedByDefault,
 accordion[]->{
   _id,
   title,
   faqGroup[]->{
    _id,
    question,
    answer
   }
 }
}`;

Ok, now that you have set up your page builder query to be dynamic. Now we can assemble the final query for all the data on a page. Pass your pageBuilderQuery into the a function that assembles the entire page query for Sanity. 

import { groq } from 'next-sanity';
import hero from './modules/hero';
import moduleHeader from './shared/moduleHeader';


const buildSlugPageScopedQuery = pageBuidlerQuery => {
 return groq`*[_type == "page" && slug.current == $slug][0]
 {
   seo,
   slug,
   title,
   enableHero,
   publishStatus,
   ${hero},
   pageBuilder{
     "modules": module[] {
         _key,
         _type,
         moduleId,
         disabled,
         modulePresentation{
           enableCircleAnimation,
           bgColor,
           bgImage,
           topPadding,
           bottomPadding,
           borderImage,
           borderImagePostion,
           enableBorderImage
         },
         ${moduleHeader},
         ${pageBuidlerQuery}
     }
   }
 }`;
};

And there you have it! Just these optimizations alone gave us a 15 - 20 point Performance boost in Lighthouse.

Pro Tip 💡: for schema naming conventions. Give your Sanity Schema name field the same value as you want it to be on the front-end for the React component. So for example, if you prefer your React components to have a uppercase first letter, you would name it like so … 

const accordion = defineType({
 name: 'Accordion',
 type: 'object',
 title: 'Accordion',
 icon: TbLayoutNavbar,
 fields: [
   moduleId,
   disabled,
....

This will make your life much easier when you go to map the data to React components on the front-end.

Ok, that could have been a blog article in of itself. I could go into more detail, but I’ll save that for another day. Next item for optimization is an easy win but often overlooked or forgotten, which is optimizing your images.

Image Optimization with Sanity and NextJS

Always always always optimize your images to be the appropriate size and resolution for your use-case. Don’t be uploading an image to your Sanity Media library that is 3000 x 2000 pixels with a resolution of 300 dpi if you only need to use it on a website where its max width will only ever be 600px. Sanity and NextJS provide us with tools for image cropping and image sizing. The full docs for optimizing images with NextJS can be found here. But let’s go over some of the highlights real quick.

The Next.js Image component extends the HTML <img> element with features for automatic image optimization:

  • Size Optimization: Automatically serve correctly sized images for each device, using modern image formats like WebP and AVIF.
  • Visual Stability: Prevent layout shift automatically when images are loading.
  • Faster Page Loads: Images are only loaded when they enter the viewport using native browser lazy loading, with optional blur-up placeholders.
  • Asset Flexibility: On-demand image resizing, even for images stored on remote servers

Pro Tip to keep your Sanity API CDN usage lower 💡: If you have any images that will barely ever change, don’t load them from the Sanity CMS. Load them from NextJS locally 😉

Prefetching Data in the NextJS App Router

The router intelligently prefetches resources for linked pages, preemptively loading assets to minimize latency and improve perceived performance.

While prefetching can enhance performance, excessive prefetching can lead to unnecessary resource consumption.

Use prefetching strategically for critical navigation paths. Prefetch can be disabled by passing prefetch={false}. When prefetch is set to false, prefetching will still occur on hover in the pages router but not in the app router. Pages will preload JSON files with the data for faster page transitions. Prefetching is only enabled in production.

Create a super-optimized link to prefetch link on hover in the app router:

"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { type ComponentPropsWithRef } from "react";

export const SuperLink = (props: ComponentPropsWithRef<typeof Link>) => {
  const router = useRouter();
  const strHref = typeof props.href === "string" ? props.href : props.href.href;

  const conditionalPrefetch = () => {
    if (strHref) {
      void router.prefetch(strHref);
    }
  };

  return (
    <Link
      {...props}
      prefetch={false}
      onMouseEnter={(e) => {
        conditionalPrefetch();
        return props.onMouseEnter?.(e);
      }}
      onPointerEnter={(e) => {
        conditionalPrefetch();
        return props.onPointerEnter?.(e);
      }}
      onTouchStart={(e) => {
        conditionalPrefetch();
        return props.onTouchStart?.(e);
      }}
      onFocus={(e) => {
        conditionalPrefetch();
        return props.onFocus?.(e);
      }}
    />
  );
};

Replace React Icons with Lucide React

Because of the way react-icons exports their icons, the package contains massive amounts of barrel files. This significantly slows down dev mode compilation during local development providing a negative impact on the developer experience. But it also ends up leaving a TON of code in your js bundles. This happens often when you have a dynamic icon picker in your headless CMS.

According to the Vercle docs, adding the following config to your nextjs config is supposed to solve the problem. But it didn’t for us. 🙅‍♂️

module.exports = {
  experimental: {
    optimizePackageImports: ["my-lib"]
  }
}

However, switching over lucide-react has resulted in significant gains in compilation time during development and reduced js bundles.

Check out this article from Vercel for more technical details on this problem.

Lazy Load Components with Dynamic Loading

If you have lots of data being fetched and UI being rendered but many of the UI modules are further below the fold, you can take into consideration lazy loading these components. Here is how you could do it with a Page Builder set up.

import type { PageBuilderType } from '@src/types/pagebuilderType';
import { Box } from 'styled-system/jsx';
import dynamic from 'next/dynamic';
import LazyLoad from 'react-lazyload';
import Module from './Module';

const DynamicModule = dynamic(() => import('./Module'), {
  ssr: false
});

type Props = {
  pageBuilder: PageBuilderType;
  hasHero: boolean;
};

const PageBuilder = ({ pageBuilder, hasHero }: Props) => {
  return (
    <Box className="sections-container">
      {pageBuilder.modules.map((module, index) => {
        if (module?.disabled) {
          return null;
        }
        if (index === 0) {
          return <Module key={module._key} module={module} index={index} hasHero={hasHero} />;
        }
        if (!hasHero && index < 3) {
          return <Module key={module._key} module={module} index={index} hasHero={hasHero} />;
        }
        return (
          <LazyLoad key={module._key} offset={48}>
            <DynamicModule module={module} index={index} hasHero={hasHero} />
          </LazyLoad>
        );
      })}
    </Box>
  );
};

export default PageBuilder;

This allows you to asynchronously load components in the UI once it calls for them.

Conclusion

Wow, ok we know that was a lot. We know Style migration and Performance Optimization could have been separated into 2 different blog articles. But I wanted to give you the full scope of what you can expect to do during an app migration from the Pages Router to the App Router in NextJS. And hopefully you are already following all the best practices when it comes to many of the performance steps we discussed. Alright, so let’s do a quick recap:

Style Migration:

Take it step by step and use Cursor to help you speed up the process. install all the ParkUI components and then start replacing the import statements from Chakra. Update any component styles so they are mirroring your previous UI.

Performance Optimization:

Besides some of the standard optimization methods such as:

  • Image Optimization
  • Lazy Loading and Dynamic Imports
  • Reduce initial CSS loading to prevent render blocking style resources

Some unique optimizations to Sanity and the App Router include:

  • Optimizing your data queries to Sanity when using a Page Builder in your headless CMS
  • Replacing react-icons with lucide-react

We hope you found this guide helpful and valuable to your own migration. Migrating a large NextJS app from the Pages Router to the App Router can be a daunting task. But as long as you break it down into smaller more manageable chunks, it can be done!

Let us know if you have any questions or if you notice we missed some crucial steps!

And if you’re interested in a consultation for your own NextJS migration, feel free to schedule a free 30 minute consultation call by clicking the button below.



Cheers!



Eric Nation profile picture

Eric Nation

|

Co-Founder & Head of Engineering

Eric Nation, co-founder and partner at Operation Nation, spearheads the Product and Web Development division. Boasting over a decade of expertise as a professional software engineer, Eric thrives on crafting immersive web applications and sites. Away from the keyboard, you can find him at the gym, surfing, skateboarding, or on a motorcycle adventure somewhere.

Other Articles You May like:

SEO Case Study for Organic Growth

SEO

Achieving 566% Organic Growth: A Case Study

This case study explores a successful journey of achieving a staggering 566% growth in organic traffic and a significant increase in conversions for a client by employing a comprehensive SEO and content strategy.

Josien Nation profile picture

Josien Nation

08 March 2024