Domain-based localization in next.js with uniform
Last updated: October 8, 2025
We are using next-intl package with i18n routing in this example:
Using this repo as a starting point: https://github.com/uniformdev/examples/tree/main/examples/nextjs-app-router-localization-starter
Remove
/src/i18n.tsfileIntroduce 2 new files:
/src/app/i18n/request.tsimport {notFound} from 'next/navigation'; import {getRequestConfig} from 'next-intl/server'; import {routing} from './routing'; export default getRequestConfig(async ({locale}) => { // Validate that the incoming `locale` parameter is valid if (!routing.locales.includes(locale as any)) notFound(); return { messages: (await import(`../../../messages/${locale}.json`)).default }; });/src/app/i18n/routing.tsimport { defineRouting } from "next-intl/routing"; import { createSharedPathnamesNavigation } from "next-intl/navigation"; export const routing = defineRouting({ // A list of all locales that are supported locales: ["en", "de"], localePrefix: "never", // Used when no locale matches defaultLocale: "en", domains: [ { domain: "localhost-en", defaultLocale: "en", // Optionally restrict the locales available on this domain locales: ["en"], }, { domain: "localhost-de", defaultLocale: "de", locales: ["de"], // If there are no `locales` specified on a domain, // all available locales will be supported here }, //this is a fallback domain that supports all locales and is required for Uniform Preview { domain: "localhost", defaultLocale: "en", locales: ["de", "en"], // all available locales in uniform project }, ], }); // Lightweight wrappers around Next.js' navigation APIs // that will consider the routing configuration export const { Link, redirect, usePathname, useRouter } = createSharedPathnamesNavigation(routing);Change
/src/middleware.tsimport createMiddleware from "next-intl/middleware"; import { routing } from "./app/i18n/routing"; //actual configuration is done in `routing.ts` now export default createMiddleware(routing); export const config = { // Match only internationalized pathnames // middleware is executed for root and pages but ignored for non-pages matcher: ["/", "/((?!api|_next|_vercel|.*\\..*).*)"], };Change
/src/app/api/preview/route.tsfile as below:import { createPreviewPOSTRouteHandler, createPreviewOPTIONSRouteHandler, } from "@uniformdev/canvas-next-rsc/handler"; import { IN_CONTEXT_EDITOR_PLAYGROUND_QUERY_STRING_PARAM, IN_CONTEXT_EDITOR_QUERY_STRING_PARAM, isAllowedReferrer, SECRET_QUERY_STRING_PARAM, } from "@uniformdev/canvas"; import { cookies, draftMode } from "next/headers"; import { redirect } from "next/navigation"; import { type NextRequest } from "next/server"; const BASE_URL_EXAMPLE = "https://example.com"; //we cannot use default resolve function because with the domain based localization locale is not part of the path, but uniform still expects it there. const resolveFullPathCustom: ResolveFullPath = ({ slug, path, locale }) => { // Remove the locale from the path const cleanPath = path?.replace(`/${locale}`, "") || slug || ""; const url = new URL(cleanPath, "http://localhost:3000"); // Relative URL, but use base for URL handling // Return the relative path return `${url.pathname}${url.search}`; }; export type ResolveFullPath = (options: { /** The ID of the composition */ id?: string; /** The slug of the composition */ slug?: string; /** The path of the project map node attached to the composition, if there is one */ path?: string; /** The preview locale selected in Visual Canvas, available only if Localization is set up */ locale?: string; }) => string | undefined; const getQueryParam = (req: NextRequest, paramName: string) => { const value = req.nextUrl.searchParams.get(paramName); if (typeof value === "undefined") { return undefined; } return Array.isArray(value) ? value[0] : value; }; const contextualEditingQueryParams = [ IN_CONTEXT_EDITOR_QUERY_STRING_PARAM, IN_CONTEXT_EDITOR_PLAYGROUND_QUERY_STRING_PARAM, ]; type CreatePreviewGETRouteHandlerOptions = { resolveFullPath?: ResolveFullPath; playgroundPath?: string; }; const createPreviewGETRouteHandler = ( options?: CreatePreviewGETRouteHandlerOptions ) => { return async (request: NextRequest) => { const isConfigCheck = getQueryParam(request, "is_config_check") === "true"; if (isConfigCheck) { return Response.json( { hasPlayground: Boolean(options?.playgroundPath), isUsingCustomFullPathResolver: false, }, { headers: { "Access-Control-Allow-Origin": process.env.UNIFORM_CLI_BASE_URL || "https://uniform.app", }, } ); } // If this is a no-cors request (send my the Canvas editor to check if the preview URL is valid), we return immediately. if (request.headers.get("sec-fetch-mode") === "no-cors") { return new Response(null, { status: 204 }); } if (!process.env.UNIFORM_PREVIEW_SECRET) { return new Response("No preview secret is configured", { status: 401 }); } const { searchParams } = new URL(request.url); const isPlayground = searchParams.get(IN_CONTEXT_EDITOR_PLAYGROUND_QUERY_STRING_PARAM) === "true"; let pathToRedirectTo: undefined | string; if (isPlayground) { if (!options?.playgroundPath) { return new Response("No playground path is configured", { status: 401, }); } pathToRedirectTo = options.playgroundPath; } const id = getQueryParam(request, compositionQueryParam.id); const slug = getQueryParam(request, compositionQueryParam.slug); const path = getQueryParam(request, compositionQueryParam.path); const locale = getQueryParam(request, compositionQueryParam.locale); const disable = getQueryParam(request, "disable"); const secret = getQueryParam(request, SECRET_QUERY_STRING_PARAM); const referer = request.headers.get("referer"); const isUniformContextualEditing = getQueryParam(request, IN_CONTEXT_EDITOR_QUERY_STRING_PARAM) === "true" && isAllowedReferrer(referer || undefined); if (typeof pathToRedirectTo === "undefined") { const resolveFullPath = options?.resolveFullPath || resolveFullPathDefault; pathToRedirectTo = resolveFullPath({ id, slug, path, locale }); } validateLocalRedirectUrl(pathToRedirectTo); // domain-based localization checks this cookie for locale resolution when multi-locale domain is used. In our case it is `localhost` configured in step B. cookies().set({ name: "NEXT_LOCALE", value: locale, sameSite: "none", secure: true, }); if (!pathToRedirectTo) { return new Response( "Could not resolve the full path of the preview page", { status: 400 } ); } if (disable) { draftMode().disable(); redirect(pathToRedirectTo); return; } if (secret !== process.env.UNIFORM_PREVIEW_SECRET) { return new Response("Invalid preview secret", { status: 401 }); } draftMode().enable(); const redirectionUrl = new URL(pathToRedirectTo, BASE_URL_EXAMPLE); assignRequestQueryToSearchParams(redirectionUrl.searchParams, searchParams); if (isPlayground) { // Playground required composition ID to be passed as a query parameter redirectionUrl.searchParams.set("id", searchParams.get("id") || ""); } if (!isUniformContextualEditing) { contextualEditingQueryParams.forEach((param) => { redirectionUrl.searchParams.delete(param); }); } const fullPathToRedirectTo = redirectionUrl.href.replace( BASE_URL_EXAMPLE, "" ); redirect(fullPathToRedirectTo); }; }; function validateLocalRedirectUrl(pathToRedirectTo: string | undefined) { // prevent open redirection to any complete URL with a protocol (nnn://whatever) if (pathToRedirectTo?.match(/^[a-z]+:\/\//g)) { throw new Error('Tried to redirect to absolute URL with protocol. Disallowing open redirect.'); } } const resolveFullPathDefault: ResolveFullPath = ({ slug, path }) => { return path || slug; }; const compositionQueryParam = { id: "id", slug: "slug", path: "path", locale: "locale", }; const assignRequestQueryToSearchParams = ( searchParams: URLSearchParams, query: URLSearchParams ) => { const compositionQueryParamNames = Object.values(compositionQueryParam); for (const [name, value] of query.entries()) { if (name === SECRET_QUERY_STRING_PARAM) { continue; } if (compositionQueryParamNames.includes(name)) { continue; } if (typeof value === "undefined") { continue; } searchParams.append(name, value); } }; // we are passing our custom function into the handler to avoid having `locale` in the redirection path export const GET = createPreviewGETRouteHandler({ playgroundPath: "/playground", resolveFullPath: resolveFullPathCustom, }); export const POST = createPreviewPOSTRouteHandler(); export const OPTIONS = createPreviewOPTIONSRouteHandler();