At 2am on a Friday night, I was staring at a broken checkout flow on a retail project for a client in Dubai. The culprit? A deeply nested dynamic route in their old Pages Router that just… stopped working after a minor dependency bump. That’s when I realized: we couldn’t put this off any longer. The time had come to bite the bullet and migrate to the App Router.
Why This Migration Was Not Optional
This wasn’t just some theoretical upgrade. The client’s site was built on Next.js 12 with the Pages Router, and their conversion funnel was tanking — 17% bounce rate on product pages. They were losing real AED on mobile traffic alone. Meanwhile, their competitors in Abu Dhabi and Riyadh were already leveraging modern SSR patterns to deliver faster loads.
Migrating to the App Router (v14.0.3, specifically) was the obvious path forward, but nobody talked about the messy parts:
- •URLs with Arabic slugs breaking in edge cases
- •Legacy API routes that relied on
getInitialProps - •Client expectations about zero downtime during the switch
I’d done three smaller migrations before, but this was by far the largest: 22 pages, 8 layouts, and 14 API routes. Let’s get real about what it took.
Setting Up the Project Structure
First, I created a new app directory alongside pages. This let me test things incrementally. I started by copying our global layout into app/layout.tsx — no big deal until I hit CSS conflicts. The old global.scss file had over 1,200 lines, many from a pre-Tailwind era.
Speaking of Tailwind, we’d recently upgraded to v4 (huge win for reduced CSS file sizes), but the migration revealed some ugly inheritance issues. For example, our .btn-primary class was overriding Tailwind’s text-sm in some nested components during Server Components. Fixed that by rewriting atomic utility-first classes where needed.
Lesson for the day? Don’t assume your CSS will behave the same when moving rendering to Server Components.
Rebuilding Routes: One Slug at a Time
The hardest part wasn’t the code — it was the URL structure. Our client’s product URLs looked like /products/[id]/details/[ar-SA|en-US], which made zero sense for parallel routes.
After three false starts, I created a mapping file like this:
// app/product/[id]/[locale]/page.tsx
const ProductPage = ({
params
}: {
params: { id: string; locale: 'ar-SA' | 'en-US' }
}) => { ... }Then, I configured next.config.mjs to use i18n:
// next.config.mjs
export default {
i18n: {
locales: ['en-US', 'ar-SA'],
defaultLocale: 'en-US'
}
}But here’s the kicker — we had legacy SEO links that needed redirecting from old [id]/[lang] patterns to [lang]/products/[id]. For that, I wrote a serverless function on AWS Lambda (pages/api/redirect/[...slug].ts) to handle 301s during the switchover.
Data Fetching: From `getServerSideProps` to Suspense Hell
If you’ve ever moved from getServerSideProps to the App Router’s data fetching model, you know the pain. I tried three different approaches:
asyncawait in Server Components ✗ too slow for dynamic contentSuspensewithstartTransition✗ caused layout shifts- Hydration with
React Query(v5.18.3) ✓ finally usable on the server
I landed on server-side prefetching with queryClient.prefetchQuery() inside the layout.tsx file. This reduced our First Contentful Paint from 4.7s → 2.1s across 16 product pages.
Here’s the setup:
// app/layout.tsx
const queryClient = new QueryClient();
export default function RootLayout({ children }) {
return (
<html>
<body>
<QueryClientProvider client={queryClient}>
{children}
</QueueClientProvider>
</body>
</html>
)
}The hardest part wasn’t the code — it was explaining to the client why their "fast site" took 2 days of development to actually get faster.
The Unexpected Arabic Slug Nightmare
One morning, I noticed some URLs in the search console were 404ing – specifically those with Arabic slugs like /products/كتاب/details/en-US.
Why? Because the normalizeLocalePath utility from next/router doesn’t automatically decode URI-encoded locale segments in the App Router. It took me 8 hours of debugging, three Slack threads, and finally discovering a bug report on GitHub before I found the fix:
// utils/normalizeSlug.js
export const decodeSlug = (slug: string) => {
return decodeURIComponent(slug.replace(/%20/g, ' '));
}Applied this in both the route handler and the redirect API function. Never again.
Performance Wins That Actually Mattered
After shipping, we saw real changes:
- •Largest Contentful Paint dropped 3.2s → 1.8s
- •Cumulative Layout Shift improved from 0.34 → 0.11
- •Bounce rate on product pages dropped back to 9.7%
We also added incremental static regeneration (ISR) to category pages, which shaved off an extra 400ms on repeat visits.
FAQs From the Trenches
I’m using App Router v13 — should I upgrade to v14 for my UAE project?
If you’re supporting Arabic right-to-left layouts, upgrading to Next.js 14.0.3 is worth it — the built-in dir="rtl" handling improves rendering consistency in bilingual projects like Tawasul Limo’s booking site.
How long did the migration take in real terms?
Total dev time: 9 days. Half of that was fighting with legacy redirects and the Arabic slug issue mentioned above.
Should I use `app` or `pages` for a new retail site in Dubai?
Unless you have a legacy code base, always start with app — the future of Next.js is moving toward App Router-first development.
What’s the biggest gotcha with localization in App Router?
Don’t assume /en/my-page means the same thing as [lang]/my-page in route files. If you’re serving both Arabic and English content, explicitly define your locales in next.config.mjs.
If you’re thinking about upgrading an older Next.js project — especially one that needs bilingual support or has complex URL structures — don’t underestimate the hidden complexity. I’ve helped Dubai startups and Abu Dhabi enterprises through similar migrations. Let's talk about getting your next upgrade right the first time.