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.ts file

  • Introduce 2 new files:

    1. /src/app/i18n/request.ts

      import {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
        };
      });
    2. /src/app/i18n/routing.ts

      import { 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);
      
    3. Change /src/middleware.ts

      import 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|.*\\..*).*)"],
      };
      
    4. Change /src/app/api/preview/route.ts file 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();