Home

Handling domain specific locale in next.js

MK

Martin Kulvedrøsten Myhre

06:30-8/29/2024

Next.js
i18n

When we deployed https://inmeta.no we decided to add a swedish (.se) page but we wanted to completly divide them when it comes to content.
This meant we would not be doing the traditional /[locale]/...content also called i18n routing.

Turned out that this was not straight forward in Next.js or with any popular package like next-intl.

This is how we solved it:

text

// SPEC or PACKAGES
next-intl: ^3.15.3
next: 14.2.5

Firstly we setup the middleware:

typescript

import { applySetCookie, getLocale } from '@/lib/cookies';
import { cookies } from 'next/headers';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';

export async function middleware(request: NextRequest) {
 const response = NextResponse.next();
 const locale = getLocale(request);

 const localeCookie = cookies().get('NEXT_LOCALE')?.value;
 const hostname = cookies().get('HOSTNAME')?.value;

 if (!localeCookie || !hostname) {
  response.cookies.set('NEXT_LOCALE', locale);
  response.cookies.set('HOSTNAME', request.nextUrl.hostname);

  applySetCookie(request, response);
 }

 return response;
}

export const config = {
 matcher: ['/((?!api|static|.*\\..*|_next|favicon.ico|robots.txt).*)'],
};

The biggest issue is that we dont have access to the request object or the HOSTNAME so we need to get this in the middleware and store it somewhere we can access this. Best place to my knowledge is in cookies. Since you always can access next/headers serverside and window.document.cookie.

If you wonder why we use applySetCookie a custom function and not just cookies().set you can read about it here:

Why we use applySetCookie

Purpose: It ensures that cookies set in the response are immediately available for server-side rendering, without waiting for the client to send them back.

2. Improvements:

This implementation is necessary because in the middleware, you’re working with both the incoming request and the outgoing response. By applying the response cookies back to the request, you’re ensuring that any subsequent server-side operations (like Server Components or API routes) will see the updated cookie values immediately.

The function:

typescript

// src/lib/cookies.ts
import { RequestCookies, ResponseCookies } from 'next/dist/server/web/spec-extension/cookies';
import { type NextRequest, NextResponse } from 'next/server';

/**
 * Copy cookies from the Set-Cookie header of the response to the Cookie header of the request,
 * so that it will appear to SSR/RSC as if the user already has the new cookies.
 */
export function applySetCookie(req: NextRequest, res: NextResponse): void {
 // parse the outgoing Set-Cookie header
 const setCookies = new ResponseCookies(res.headers);
 // Build a new Cookie header for the request by adding the setCookies
 const newReqHeaders = new Headers(req.headers);
 const newReqCookies = new RequestCookies(newReqHeaders);
 // biome-ignore lint/complexity/noForEach: <explanation>
 setCookies.getAll().forEach((cookie) => newReqCookies.set(cookie));
 // set “request header overrides” on the outgoing response
 NextResponse.next({
  request: { headers: newReqHeaders },
 }).headers.forEach((value, key) => {
  if (key === 'x-middleware-override-headers' || key.startsWith('x-middleware-request-')) {
   res.headers.set(key, value);
  }
 });
}

export function getLocale(req: NextRequest) {
 const domain = req.nextUrl.hostname;
  // our custom handler for checking if domain should return locale
 if (INMETA_CONFIG.i18n.domains.swedish.some((d) => domain.includes(d))) {
  return 'sv';
 }

 return 'nb';
}

/* Here is the domains if you are wondering
domains: {
   swedish: ['.se', '.sv', 'inmeta-test-sv.vercel.app'],
  },
*/

[root]/i18n.ts

typescript

import { getRequestConfig } from 'next-intl/server';
import { cookies } from 'next/headers';
import INMETA_CONFIG from '../config.inmeta';

export default getRequestConfig(async () => {
 // Provide a static locale, fetch a user setting,
 // read from `cookies()`, `headers()`, etc.
 const locale = cookies().get('NEXT_LOCALE')?.value || INMETA_CONFIG.i18n.fallbackLanguage; // no

 return {
  locale,
  messages: (await import(`../messages/${locale}.json`)).default,
 };
});

src/lib/locale.ts

typescript

import 'server-only';
import axios from 'axios';
import { cookies } from 'next/headers';
import INMETA_CONFIG from '../../config.inmeta';

// const SwedishDomains = ['sv.inmeta.no', 'localhost', '.sv'];

export const getLocale = async () => {
 const val = cookies().get('NEXT_LOCALE');
 const locale = val?.value;

 if (locale) return locale;
 return INMETA_CONFIG.i18n.fallbackLanguage;
};

export const getHostname = async () => {
 const val = cookies().get('HOSTNAME');
 const hostname = val?.value;
 if (hostname) return hostname;
 return INMETA_CONFIG.fallbackHostName;
};

export const getServerRequest = async () => { };

That’s pretty much it :)
Next up, I am just going to link some usage of it so that you get the gist of how it works.

layout.tsx

tsx

export default async function RootLayout({ children }: { children: ReactNode }) {
 const locale = await getLocale();
 const messages = await getMessages();

 const lang = INMETA_CONFIG.i18n.languages.find((l) => l.value === locale) || INMETA_CONFIG.i18n.languages[0];

 return (
  <html lang={lang.htmlLocaleValue}>
   <NextIntlClientProvider messages={messages}>
     {children}
   </NextIntlClientProvider>
  </html>
 );
}