NextJS App Router Migration with Sanity Hero Image

NextJS App Router Migration with Sanity - Part 1

Learn how we migrated our NextJS Sanity app to the App Router


If you’re like many other app owners out there, you’ve probably waited until recently to start migrating your NextJS app from the Pages Router to the new App Router. Maybe you’re on the fence about migrating to the App Router because you’ve heard of others reporting bugs regarding “On-Demand” Revalidation. Or you’ve just been putting it off because of the amount of effort and time it would take for your team to migrate your app. Operation Nation recently decided it was time to finally migrate our NextJS Sanity App over to the App Router. We decided this for a few reasons. First, is because NextJS announced it reached a “stable” version. Second, they state in their docs that they recommend anyone building a new NextJS app should opt for the App Router. And third, we wanted to leverage React Server Components and improve the speed and performance of the websites we build for our clients.

Operation Nation maintains our own internal NextJS Sanity starter kit called the “ONE Builder”. It comes with all the components, modules, plugins, and infrastructure that any top-tier website would need for production. Recently, we just finished phase 1 of upgrading the ONE Builder and we want to share our process and steps with you so you can more easily migrate your own NextJS and Sanity application to the App Router.

What is Phase 2 of the migration you might ask? Good question. We use ChakraUI for our styles and component library. As you probably already know, Chakra is built on top of Emotion and compiles its styles during run time. This requires you to utilize use client in any component in your app that uses a Chakra component. This isn’t ideal with the App router as it negatively impacts performance. So, in Part 2 of this migration guide, we’ll go over how we migrated our app from ChakraUI to ParkUI.

Without going into too much detail, ParkUI is built on top of ArkUI and PandaCSS. This tool set is built by the creators and contributors of ChakraUI. It’s a solution that allows you to keep using the familiar syntax of styled-system that we all know and love in ChakraUI. PandaCSS is a CSS-in-JS with build time generated styles, RSC compatible, multi-variant support, and best-in-class developer experience.

So without further ado, let’s dive into Phase 1 of the migration: Pages -> App Router

App Router Migration Steps:

The migration from the Pages to the App Router can include a lot of refactoring. It's a complete paradigm shift to using React Server Components. Luckily, the NextJS team has made it so you can do an incremental migration. The Pages and App Router directories can exist alongside each other.

I’m not going to go into great detail about each migration step because the NextJS Docs do an excellent job of outlining all the steps. So the following steps in this article will cover what the significant tasks were in the context of our app using Sanity.

The migration steps can be categorized into a couple of different sections. First, you need to complete all the steps that would be required regardless of whether you are using Sanity or not. Next, is updating all of your Sanity related code when it comes to using the official Sanity NextJS tool kit next-sanity

1: Update NextJS, React, ESLint, and Node

Create the new /app directory and update to the latest NextJS and React versions. You must use a minimum Node.js version of v18.17.0.

npm install next@latest react@latest react-dom@latest

Upgrade ESLint version

npm install -D eslint-config-next@latest

2: Migrate Internationalization

Yes, our NextJS Sanity Kit has localization built right in. This is one of the reasons we had to wait so long to migrate. With the release of the new App Router, NextJS has removed internationalized routing as a built-in feature 😔. So, we had to wait until there was another solution available. We ended up using the next-i18n-router to have internationalized routing.


npm install next-i18n-router

First, we nested all pages and layouts inside of a dynamic segment named [locale] in the app dir:

└── app
    └── [locale]
        ├── layout.js 
        └── page.js

Then we created a file called i18nConfig.ts at the root of our project to store our config:

import { Config } from 'next-i18n-router/dist/types';

const i18nConfig: Config = {
  locales: ['en', 'nl', 'es'],
  defaultLocale: 'en'

export default i18nConfig;

Then we updated our middleware.ts file at the root of our project where the i18nRouter will be used to provide internationalized redirects and rewrites:

import { i18nRouter } from 'next-i18n-router';
import i18nConfig from './i18nConfig';

export function middleware(request) {
  return i18nRouter(request, i18nConfig);

// only applies this middleware to files in the app directory
export const config = {
  matcher: '/((?!api|static|.*\\..*|_next).*)'

We also created a handy hook to make the variables reusable in @utils/useLocale.ts

'use client';
import { usePathname } from 'next/navigation';
import { useCurrentLocale } from 'next-i18n-router/client';
import i18nConfig from '../i18nConfig';
export const UseLocale = () => {
  const locale = useCurrentLocale(i18nConfig);
  const { defaultLocale, locales } = i18nConfig;
  const pathname = usePathname();
  const asPath = pathname || '/';
  const getLocale = locale !== undefined ? locale : 'en';
  const getDefLocale = defaultLocale !== undefined ? defaultLocale : 'en';
  return {

3: Install and Implement Google Tag Manager

Install @next/third-parties to integrate third-party libraries such as GTM(Google Tag Manager, Map, Youtube, etc). Full instructions for this package can be found on NextJS Docs.

npm install @next/third-parties@latest

4: Create Root Layout File

Create a layout.tsx in the /app directory. The Root layout file is where we initialize Google Tag Manager and use generateStaticParams to build our pages with all the configured locales.

import { GoogleTagManager } from '@next/third-parties/google';
import Script from 'next/script';
import { getCookie } from 'cookies-next';
import i18nConfig from 'i18nConfig';
import '@styles/index.css';

const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID as string;

export function generateStaticParams() {
  return => ({ locale }));

export default function Root({
}: {
  children: React.ReactNode;
  params: { locale: string };
}) {

  const consent = getCookie('localConsent');

  return (
    <html lang={params.locale}>
     <GoogleTagManager gtmId={GTM_ID} />
          __html: `
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
             consent !== true
               ? `gtag('consent', 'default', {
                  'analytics_storage': 'denied',
                  'functionality_storage': 'denied',
                  'security_storage': 'denied'
               : `gtag('consent', 'update', {
                  'analytics_storage': 'granted',
                  'functionality_storage': 'granted',
                  'security_storage': 'granted'

5: Migrate Pages and Create Root Home Page

Here are some things to keep in mind as you’re migrating your pages.

  • Pages in the /app directory are Server Components by default. This is different from the Pages router because in there, all the pages were Client Components.
  • The /app directory uses nested folders to define routes and a special page.js file to make a route segment publically accessible.
  • Keep your data fetching in the Server components and any client interactions in your Client components.
// src/app/page.tsx

import type { Metadata } from 'next';
import { draftMode } from 'next/headers';
import { notFound } from 'next/navigation';
import { loadHomePage} from '@loader/loadQuery';
import HomePageLayout from '@components/pageLayouts/HomePageLayout';
import HomePagePreviewLayout from '@components/pageLayouts/HomePagePreviewLayout';
import filterDataToSingleItem from '@utils/FilterDataToSingleItem';
import createMetadata from '@utils/createMetadata';

export default async function IndexPage() {
  const preview = draftMode().isEnabled;
  const [homePageInitial] = await Promise.all([
  const { data } = homePageInitial;
  if (preview) {
    return (
 	<HomePagePreviewLayout homePageInitial={homePageInitial} />
  if (!homePage) {
  return <HomePageLayout data={data} />;

export async function generateMetadata(): Promise<Metadata> {
  const metadata = await createMetadata('homePage');
  return metadata;

6. Migrate next/head with Dynamic Metadata Function

In the App Router, next/head package is no longer used. It’s been replaced with Metadata component.

import { draftMode } from 'next/headers';
import filterDataToSingleItem from '@utils/FilterDataToSingleItem';
import { loadSeo } from '@loader/loadQuery';
import createOgImage from '@utils/createOgImage';

const createMetadata = async (docType: string, slug?: string) => {
  const { data: seoData } = await loadSeo(docType, slug);
  const preview = draftMode().isEnabled;
  const data = filterDataToSingleItem(seoData, preview);
  const domain = data?.setting?.domain?.replace(/\/$/, '');
  const ogImage = createOgImage(
  return {
    metadataBase: domain ? new URL(domain) : undefined,
    icons: {
      icon: data?.setting?.favicon,
      shortcut: data?.setting?.favicon,
      apple: data?.setting?.favicon,
      other: {
        rel: 'apple-touch-icon-precomposed',
        url: data?.setting?.favicon
    alternates: {
      canonical: '/',
      languages: {
        'en-US': '/',
        'nl-NL': '/nl',
        'es-ES': '/es'
    title: data?.seo?.seoTitle || 'Untitled',
    description: data?.seo?.metaDescription,
    openGraph: {
      images: ogImage

export default createMetadata;


export async function generateMetadata({
 params: { slug }
}: {
 params: { slug: string };
}): Promise<Metadata> {
 const metadata = await createMetadata('page', slug);
 return metadata;

7: Create Dynamic Page Component

Next, you can create another page component in app/[slug]/page.tsx . This time we have passed slug as params. Following this structure, you can create any nested routes in the app dir.

import type { Metadata } from 'next';
import { draftMode } from 'next/headers';
import { notFound } from 'next/navigation';
import { loadPage, loadSettings, loadUnderConstruction } from '@loader/loadQuery';
import dynamic from 'next/dynamic';
import PageLayout from '@components/pageLayouts/PageLayout';
import PagePreviewLayout from '@components/pageLayouts/PagePreviewLayout';
import filterDataToSingleItem from '@utils/FilterDataToSingleItem';
import { generateStaticSlugs } from '@loader/generateStaticSlugs';
import createMetadata from '@utils/createMetadata';

const UnderConstruction = dynamic(() => import('@components/underConstruction/UnderConstruction'));

const Page = async ({ params: { slug } }: { params: { slug: string } }) => {
 const [settingInitial, pageInitial, underConstructionInitial] = await Promise.all([
 const { data: settingData } = settingInitial;
 const { data: pageData } = pageInitial;
 const { data: underConstructionData } = underConstructionInitial;
 const preview = draftMode().isEnabled;
 const setting = filterDataToSingleItem(settingData, preview);
 const page = filterDataToSingleItem(pageData, preview);
 const initial = {, setting };
 const underConstruction = filterDataToSingleItem(underConstructionData, preview);
 if (setting?.enableUnderConstruction && underConstruction) {
   return <UnderConstruction setting={setting} underConstructionData={underConstructionData} />;
 if (preview) {
   return (
     <PagePreviewLayout slug={slug} pageInitial={pageInitial} settingInitial={settingInitial} />

 if (!page && !setting?.enableUnderConstruction) {
 return <PageLayout data={initial} />;

export default Page;

export function generateStaticParams() {
 return generateStaticSlugs('page');

export async function generateMetadata({
 params: { slug }
}: {
 params: { slug: string };
}): Promise<Metadata> {
 const metadata = await createMetadata('page', slug);
 return metadata;

The generateStaticSlugs function is what’s used to generate static paths for the dynamic page.

// src/loaders/generateStaticSlugs.ts
import 'server-only';

import { groq } from 'next-sanity';

import { client } from '@lib/client';
import { token } from '@lib/token';

// Used in `generateStaticParams`
export function generateStaticSlugs(type: string) {
 // Not using loadQuery as it's optimized for fetching in the RSC lifecycle
 return client
     perspective: 'published',
     useCdn: false,
     stega: false
   .fetch<string[]>(groq`*[_type == $type && defined(slug.current)]{"slug": slug.current}`, {

8. Create client.ts and token.ts in lib folder

// lib/client.ts
import { createClient } from '@sanity/client/stega';

import { apiVersion, dataset, projectId, revalidateSecret, studioUrl } from './api';

export const client = createClient({
  // If webhook revalidation is setup we want the freshest content, if not then it's best to use the speedy CDN
  useCdn: !revalidateSecret,
  perspective: 'published',
  stega: {
    enabled: false,
// lib/token.ts
import 'server-only';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { experimental_taintUniqueValue } from 'react';

export const token = process.env.SANITY_READ_TOKEN;

if (!token) {
  throw new Error('Missing SANITY_READ_TOKEN');

  'Do not pass the sanity API read token to the client.',

9. Embedding Sanity Studio

Install or update the next-sanity package. This is Sanity’s official toolkit for building NextJS applications with Sanity.

npm install next-sanity@latest

Create a folder called studio in the root of the app directory /app/studio

then add another folder called [[...index]] inside the studio

Then create page.tsx and Studio.tsx component

// ./app/studio/[[...index]]/page.tsx
import { Studio } from './Studio'

// Ensures the Studio route is statically generated
export const dynamic = 'force-static'

// Set the right `viewport`, `robots` and `referer` meta tags
export { metadata } from 'next-sanity/studio/metadata'
export { viewport } from 'next-sanity/studio/viewport'

export default function StudioPage() {
  return <Studio />

// ./app/studio/[[...index]]/Studio.tsx
'use client'

import { NextStudio } from 'next-sanity/studio'

import config from 'sanity.config'

export function Studio() {
  //  Supports the same props as `import {Studio} from 'sanity'`, `config` is required
  return <NextStudio config={config} />

10. On-Demand Revalidation with tags and webhook

ISR (Incremental Static Revalidation) in the Pages Router allows you to update static pages on a per-page basis without the need to rebuild your entire site. The equivalent of that in the App Router is on-demand revalidation with tags, paths, or time-based methods.

The next-sanity toolkit fully supports the NextJS revalidation features. Tag-based validation gives you fine-grained control for when you want to revalidate content

Note: Remember to set up a webhook in Sanity

We implement a loadQuery function to handle fetching data with tagRevalidation.

export const loadQuery = ((query, params = {}, options = {}) => {
 const { perspective = draftMode().isEnabled ? 'previewDrafts' : 'published' } = options;
 // Don't cache by default
 let revalidate: NextFetchRequestConfig['revalidate'] = 0;
 // If `next.tags` is set, and we're not using the CDN, then it's safe to cache
 if (!usingCdn && Array.isArray( {
   revalidate = false;
 } else if (usingCdn) {
   revalidate = 60;
 return queryStore.loadQuery(query, params, {
   next: {
     ...( || {})
}) as typeof queryStore.loadQuery;


export function loadHomePage() {
 return loadQuery<HomePagePayload | null>(homePageQuery, {}, { next: { tags: ['homePage'] } });

Now, for the revalidateTag to work properly you have to set up an API route in your NextJS app that handles incoming requests, typically made by a GROQ Powered Webhook.

“The code example below uses the built-in parseBody function to validate that the request comes from your Sanity project (using a shared secret + looking at the request headers). Then it looks at the document type information in the webhook payload and matches that against the revalidation tags in your app:”

* This code is responsible for revalidating queries as the dataset is updated.
* It is set up to receive a validated GROQ-powered Webhook from
* 1. Go to the API section of your Sanity project on or run `npx sanity hook create`
* 2. Click "Create webhook"
* 3. Set the URL to https://YOUR_NEXTJS_SITE_URL/api/revalidate
* 4. Dataset: Choose desired dataset or leave at default "all datasets"
* 5. Trigger on: "Create", "Update", and "Delete"
* 6. Filter: Leave empty
* 7. Projection: {_type, "slug": slug.current}
* 8. Status: Enable webhook
* 9. HTTP method: POST
* 10. HTTP Headers: Leave empty
* 11. API version: v2021-03-25
* 12. Include drafts: No
* 13. Secret: Set to the same value as SANITY_REVALIDATE_SECRET (create a random secret if you haven't yet, for example by running `Math.random().toString(36).slice(2)` in your console)
* 14. Save the configuration
* 15. Add the secret to Vercel: `npx vercel env add SANITY_REVALIDATE_SECRET`
* 16. Redeploy with `npx vercel --prod` to apply the new environment variable

import { revalidateTag } from 'next/cache';
import { type NextRequest, NextResponse } from 'next/server';
import { parseBody } from 'next-sanity/webhook';

import { revalidateSecret } from '@lib/api';

export async function POST(req: NextRequest) {
 try {
   const { body, isValidSignature } = await parseBody<{
     _type: string;
     slug?: string | undefined;
   }>(req, revalidateSecret);
   if (!isValidSignature) {
     const message = 'Invalid signature';
     return new Response(message, { status: 401 });

   if (!body?._type) {
     return new Response('Bad Request', { status: 400 });

   if (body.slug) {
   return NextResponse.json({
     status: 200,
     revalidated: true,
 } catch (err: any) {
   return new Response(err.message, { status: 500 });

If you still have questions about implementing tag revalidation with NextJS and Sanity, read this in-depth article by Rudderstack.

Wrapping Up:

Updating from the Pages to the App Router in NextJS wasn't too painful. Tools like the Sanity NextJS toolkit make it easier and provide great documentation on how to do it. 

The fact that you can do it incrementally while the Pages and App Router live side by side also makes it less of a daunting task so you don’t have to do it all in 1 sprint.

Since we were using ChakraUI for our component library and it’s not designed to work with React Server Components, we took quite a performance hit. Almost every front-end file has to utilize use client because of Chakra. But stay tuned, because phase 2 of our migration will cover our migration from ChakraUI to ParkUI!

If your website is a NextJS app using the Pages Router and you want to migrate over to the App Router but don’t have the time or resources to do it yourself, reach out and see how we can help! Thanks for reading 🍻

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


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