DEV Community

Digital dev
Digital dev

Posted on

Migrating a Vite i18n App to Next.js Without Breaking Everything

The Challenge of Framework-Specific i18n

Internationalization (i18n) is one of those features that feels simple at first—just a few JSON files and a hook—until you decide to switch your underlying meta-framework. If you've been building a project with Vite and react-i18next, you've likely enjoyed the speed of HMR and the simplicity of client-side translation loading.

However, moving that same logic to Next.js introduces a massive paradigm shift: Moving from Client-Side Rendering (CSR) to Server-Side Rendering (SSR) and the App Router's directory structure. If you aren't careful, you'll end up with "hydration mismatch" errors or SEO-unfriendly blank pages while the browser waits for translation files to load.

In this guide, we will walk through the strategic steps to migrate your i18n layer without rewriting your entire component library.

1. Understanding the Architecture Shift

In a standard Vite app, translations are usually loaded via an i18n.ts config file that initializes i18next and wraps the app in an I18nextProvider. This happens entirely in the browser.

Next.js (specifically the App Router) handles things differently:

  1. Language Detection: Instead of checking localStorage in a useEffect, Next.js typically uses the URL structure (e.g., /en/about or /fr/about) or middleware to detect the locale.
  2. Server Components: You cannot use hooks like useTranslation() directly in Server Components. You need a way to fetch translations on the server and pass them down or use a library that supports React Server Components (RSC).

2. Setting Up the Middleware

The first step in Next.js is ensuring your app knows which language to serve before the page even renders. We do this with a middleware.ts file in the root directory.

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const locales = ['en', 'es', 'de'];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const pathnameHasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameHasLocale) return;

  const locale = 'en'; // Default or detect from headers
  request.nextUrl.pathname = `/${locale}${pathname}`;
  return NextResponse.redirect(request.nextUrl);
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
Enter fullscreen mode Exit fullscreen mode

3. Organizing Translation Files

In Vite, you might have kept your JSON files in public/locales. In Next.js, it’s often more efficient to keep them in a dedicated locales folder within your src or root directory so they can be imported directly by Server Components without making internal fetch requests.

/src
  /locales
    /en
      common.json
    /es
      common.json
Enter fullscreen mode Exit fullscreen mode

4. Bridging Client and Server Components

This is where most migrations get messy. You likely have hundreds of client-side components using useTranslation(). To avoid breaking them, you should use a library like next-intl or a custom implementation of i18next for the App Router.

For those looking for a faster transition, using an automated migration assistant like ViteToNext.AI can help map your existing Vite folder structures and dependency patterns to the equivalent Next.js App Router conventions automatically.

The Server Implementation

You'll want a utility function to get translations on the server:

// src/lib/get-dictionary.ts
const dictionaries = {
  en: () => import('@/locales/en/common.json').then((module) => module.default),
  es: () => import('@/locales/es/common.json').then((module) => module.default),
};

export const getDictionary = async (locale: 'en' | 'es') => dictionaries[locale]();
Enter fullscreen mode Exit fullscreen mode

5. Handling Client-Side Hooks

Keep your existing UI components as Client Components (add 'use client' at the top). You will need to wrap your layout in a provider that initializes the i18n state with the data fetched from the server. This prevents the "flash of untranslated text."

// src/app/[lang]/layout.tsx
export default async function LocaleLayout({
  children,
  params: { lang },
}: { children: React.ReactNode, params: { lang: string } }) {
  const dictionary = await getDictionary(lang);

  return (
    <html lang={lang}>
      <body>
        <I18nProvider dictionary={dictionary} locale={lang}>
          {children}
        </I18nProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

6. Common Pitfalls to Avoid

  • Window is not defined: In Vite, you might have accessed window.location. In Next.js, this will break your build during SSR. Use usePathname or useRouter from next/navigation instead.
  • Dynamic Routes: Ensure your generateStaticParams is set up if you want to export your i18n site as a static build (output: 'export').
  • SEO: Don't forget to update your metadata dynamically using the locale parameter. Next.js makes this easy with generateMetadata functions.

Conclusion

Migrating i18n logic from Vite to Next.js isn't just about moving files; it's about shifting from a browser-first mindset to a server-first mindset. By leveraging middleware for routing and keeping your JSON structures consistent, you can maintain your translation workflow while gaining the performance benefits of Next.js.

Further reading: Automatically migrate your Vite project to Next.js with ViteToNext.AI

Top comments (0)