Mejorando el manejo multisitio y por mercado con Sitecore JSS y Next.js
Introducción
Al construir un sitio web multisitio y multiidioma con Sitecore JSS y Next.js, gestionar de forma eficiente el contenido específico de cada mercado, tanto para los editores como para los usuarios, plantea retos particulares. Aunque Sitecore ofrece de serie funciones potentes para manejar múltiples idiomas y sitios, ampliar esas capacidades para una configuración multimercado exige una implementación a medida si se quiere garantizar una experiencia fluida.
En este artículo veremos cómo funciona el manejo de multisitio y multiidioma que Sitecore trae por defecto, y cómo construimos sobre él para cubrir los requisitos de mercado de Uniworld. Repasaremos los plugins y las configuraciones clave que modificamos, y el razonamiento detrás de cada cambio.
Manejo por defecto de multisitio e idioma en Sitecore
El Next.js Multisite Add-on de Sitecore incluye un amplio conjunto de funciones pensadas para simplificar las configuraciones multisitio y multiidioma:
- Multisite Middleware: Reescribe las peticiones hacia el sitio correcto en función del hostname entrante.
- Site Resolver: Usa GraphQL para obtener la información del sitio en tiempo de compilación, incluidos los ajustes propios de cada sitio como el idioma por defecto.
- Internationalized Routing: Usa el i18n de Next.js para gestionar distintos idiomas según la ruta de la petición, recurriendo a los valores por defecto cuando hace falta.
Por defecto, Sitecore resuelve el enrutamiento por idioma y por sitio con un patrón del tipo /_site_/. Esto asegura que el contenido quede correctamente acotado entre los distintos sitios alojados bajo la misma instancia de Next.js.
Comportamiento del idioma por defecto en Sitecore
En la configuración por defecto, Sitecore y Next.js usan el hostname y la ruta de la URL para determinar qué sitio y qué idioma servir. Si hay un prefijo de idioma en la URL (por ejemplo, /en-us), Sitecore sirve el contenido en ese idioma. Si no se especifica el idioma, usa el idioma por defecto del sitio en cuestión.
Sin embargo, este comportamiento por defecto no contempla de forma natural las redirecciones específicas por mercado, y hace falta personalizarlo para poder:
- Servir contenido específico de cada mercado según el país o las preferencias del usuario.
- Dar soporte al Experience Editor y a la vista previa sin conflictos.
- Integrar el manejo de mercados dentro de la configuración multisitio.
Retos del manejo multisitio y de mercados
Nos encontramos con varios retos al crear una solución multimercado:
- Servir el contenido correcto por mercado: Asegurar que se apuntaba al mercado correcto según la ubicación o las preferencias del usuario.
- Compatibilidad con el Experience Editor: Asegurar que los editores pudieran previsualizar y editar contenido sin redirecciones indebidas.
- Integración multisitio escalable: Integrar sin fricción el manejo por mercado con las capacidades multisitio de Sitecore.
Mapeo de idiomas y mercados
Nuestro objetivo era crear una experiencia multimercado que entregara contenido localizado y ajustado a la ubicación geográfica o a las preferencias de cada usuario. Así fue como mapeamos los mercados y sus locales correspondientes:
- Asia-Pacífico (AP): en-PH para el inglés hablado en Asia.
- Australia (AU): en-AU.
- Canadá (CA): en-CA.
- Unión Europea (EU): en-IE.
- Nueva Zelanda (NZ): en-NZ.
- Reino Unido (UK): en-GB.
- Estados Unidos (US): en-US.
- Sudáfrica (ZA): en-ZA.
El objetivo era garantizar que los usuarios de estos mercados recibieran contenido localizado para su mercado concreto, y queríamos resolverlo de forma eficiente a nivel de middleware con Next.js.
Implementación a medida: los pasos que dimos
Para cumplir estos requisitos introdujimos varios cambios clave en el middleware y la configuración de Next.js, además de en los plugins de Sitecore. A continuación, el detalle de las actualizaciones.
1. Market Plugin para el middleware
El Market Plugin resuelve el manejo de contenido específico por mercado apoyándose en el middleware de Next.js. Se asegura de que los usuarios sean redirigidos a la URL del mercado adecuado, con una detección y un manejo correctos del locale.
class MarketPlugin implements MiddlewarePlugin {
order = 0;
constructor() {
console.log('[MarketPlugin] Initializing MarketPlugin...');
}
async exec(req: NextRequest, res?: NextResponse): Promise<NextResponse> {
const { pathname } = req.nextUrl;
// Exclude assets and other unnecessary routes
if (this.excludeRoute(pathname)) {
console.log(`[MarketPlugin] Skipping route: ${pathname}`);
return res || NextResponse.next();
}
console.log(`[MarketPlugin] Executing MarketPlugin for request: ${req.url}`);
const cookieLocale = req.cookies.get('NEXT_LOCALE');
const country = req.geo?.country?.toLowerCase() || 'us';
let market: string;
let locale: string;
const urlMarket = pathname.split('/')[1];
if (validMarkets.includes(urlMarket)) {
console.log(`[MarketPlugin] URL market detected: ${urlMarket}`);
market = urlMarket;
} else if (cookieLocale) {
console.log(`[MarketPlugin] Locale cookie found: ${cookieLocale.value}`);
market = localeToMarketMap[cookieLocale.value.toLowerCase()] || 'us';
} else {
console.log(`[MarketPlugin] No valid market found, using country: ${country}`);
market = determineMarketFromCountry(country);
}
locale = determineLanguageFromMarket(market);
console.log(`[MarketPlugin] Determined locale from market: ${locale}`);
if (cookieLocale?.value !== locale) {
console.log(`[MarketPlugin] Setting locale cookie to: ${locale}`);
res = res || NextResponse.next();
res.cookies.set('NEXT_LOCALE', locale, {
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 days
});
}
if (!pathname.startsWith(`/${market}`)) {
const newUrl = req.nextUrl.clone();
newUrl.pathname = `/${market}${pathname === '/' ? '' : pathname}`;
console.log(`[MarketPlugin] Redirecting to market-specific path: ${newUrl}`);
res = NextResponse.redirect(newUrl);
}
console.log(`[MarketPlugin] Finished MarketPlugin execution for request: ${req.url}`);
return res || NextResponse.next();
}
private excludeRoute(pathname: string): boolean {
const isExcluded =
pathname.includes('.') || // Skip files (e.g., .css, .js, .png)
pathname.startsWith('/api/') || // Skip Next.js API routes
pathname.startsWith('/sitecore/'); // Skip Sitecore API routes
console.log(`[MarketPlugin] Checking if route should be excluded: ${pathname} - Excluded: ${isExcluded}`);
return isExcluded;
}
}
export const marketPlugin = new MarketPlugin();
Por qué lo modificamos:
- Detección y exclusión de rutas: La función excludeRoute se implementó para saltarse rutas innecesarias como los assets, las peticiones a la API y las peticiones a los servicios de Sitecore.
- Detección del mercado: El plugin comprueba primero si existe un prefijo de mercado válido en la URL. Si no lo hay, intenta usar una cookie (NEXT_LOCALE) para determinar el mercado. Si la cookie no está disponible, recurre al código de país de las cabeceras geo de la petición.
- Cookies y redirecciones: Si el mercado de la URL no coincide con el mercado detectado o preferido, se redirige al usuario a la ruta de mercado adecuada y se fija la cookie de locale para mantener un comportamiento consistente.
2. next.config.js actualizado para la gestión de mercado y locale
const jssConfig = require('./src/temp/config');
module.exports = {
i18n: {
locales: [
'en', 'en-PH', 'en-AU', 'en-CA', 'en-IE', 'en-NZ', 'en-ZA', 'en-GB', 'en-US',
],
defaultLocale: jssConfig.defaultLanguage,
localeDetection: false, // Disabled locale detection since we handle it through middleware
},
};
Por qué lo modificamos:
Desactivamos la detección automática de locale (localeDetection: false) porque nuestro middleware a medida ya se encargaba de la lógica para determinar el locale y el mercado correctos. Esto nos dio un control mucho más fino sobre qué mercado debe ver cada usuario.
3. Actualización de page-props.ts para incluir el mercado
Para soportar los nuevos cambios, se actualizó la interfaz SitecorePageProps para incluir la propiedad market.
export type SitecorePageProps = {
site: SiteInfo;
locale: string;
dictionary: DictionaryPhrases;
componentProps: ComponentPropsCollection;
notFound: boolean;
layoutData: LayoutServiceData;
headLinks: HTMLLink[];
market: string; // New property for storing market information
};
Cambios clave:
- Propiedad market: Se añadió market para almacenar el contexto de mercado de cada petición.
4. Plugin Page Props Factory actualizado
class SitePlugin implements Plugin {
order = 0;
async exec(props: SitecorePageProps, context: GetServerSidePropsContext | GetStaticPropsContext) {
if (context.preview) {
console.log('[SitePlugin] Preview mode detected. Skipping site resolution.');
return props;
}
const path = this.getNormalizedPath(context);
console.log(`[SitePlugin] Resolving site for path: ${path}`);
const siteData = getSiteRewriteData(path, config.sitecoreSiteName);
console.log(`[SitePlugin] Site data extracted from path:`, siteData);
const market = this.getMarketFromParams(context);
const locale = determineLanguageFromMarket(market);
context.locale = locale;
props.site = siteResolver.getByName(siteData.siteName);
props.locale = locale;
props.market = market;
console.log(`[SitePlugin] Props:`, props);
console.log(`[SitePlugin] Context:`, context);
return props;
}
private getMarketFromParams(context?: GetServerSidePropsContext | GetStaticPropsContext): string {
if (context?.params && Array.isArray(context.params.path) && context.params.path.length > 1) {
const [, secondSegment] = context.params.path;
if (validMarkets.includes(secondSegment)) {
context.params.path.splice(1, 1);
return secondSegment;
}
}
return '';
}
private getNormalizedPath(context: GetServerSidePropsContext | GetStaticPropsContext): string {
if (!context.params) return '/';
return Array.isArray(context.params.path) ? context.params.path.join('/') : context.params.path ?? '/';
}
}
export const sitePlugin = new SitePlugin();
Por qué lo modificamos:
El SitePlugin ayuda a resolver el contexto del sitio actual a partir de la petición, y se modificó para incluir tanto el mercado como el locale. Esto garantiza que el resto del proceso de construcción de la página conozca el contexto específico del mercado.
Cambios clave:
- Manejo de mercado y locale: Se actualizó el SitePlugin para extraer la información de mercado de la URL y fijarla dentro del contexto, garantizando la consistencia a lo largo del proceso de renderizado.
5. Actualizaciones del Preview Mode Plugin
import { GetServerSidePropsContext, GetStaticPropsContext } from 'next';
import {
SiteInfo,
personalizeLayout,
getGroomedVariantIds,
} from '@sitecore-jss/sitecore-jss-nextjs';
import {
editingDataService,
isEditingMetadataPreviewData,
} from '@sitecore-jss/sitecore-jss-nextjs/editing';
import { SitecorePageProps } from 'lib/page-props';
import { graphQLEditingService } from 'lib/graphql-editing-service';
import { Plugin } from '..';
import { determineMarketFromLocale } from 'lib/helpers/marketHelper';
class PreviewModePlugin implements Plugin {
order = 1;
async exec(props: SitecorePageProps, context: GetServerSidePropsContext | GetStaticPropsContext) {
if (!context.preview) {
console.log('[PreviewModePlugin] Not in preview mode. Skipping preview logic.');
return props;
}
console.log('[PreviewModePlugin] Preview mode detected.');
// If we're in Pages preview (editing) Metadata Edit Mode, prefetch the editing data
if (isEditingMetadataPreviewData(context.previewData)) {
console.log('[PreviewModePlugin] Detected Pages preview in Metadata Edit Mode.');
const { site, itemId, language, version, variantIds, layoutKind } = context.previewData;
console.log(`[PreviewModePlugin] Preview data received:`, context.previewData);
try {
const data = await graphQLEditingService.fetchEditingData({
siteName: site,
itemId,
language,
version,
layoutKind,
});
if (!data) {
throw new Error(
`Unable to fetch editing data for preview ${JSON.stringify(context.previewData)}`
);
}
console.log('[PreviewModePlugin] Editing data successfully fetched:', data);
const locale = context.previewData.language;
const market = determineMarketFromLocale(locale);
props.site = data.layoutData.sitecore.context.site as SiteInfo;
props.layoutData = data.layoutData;
props.dictionary = data.dictionary;
props.headLinks = [];
props.locale = locale;
props.market = market;
console.log(`[PreviewModePlugin] Props:`, props);
console.log(`[PreviewModePlugin] Context:`, context);
const personalizeData = getGroomedVariantIds(variantIds);
console.log('[PreviewModePlugin] Groomed variant IDs:', personalizeData);
personalizeLayout(
props.layoutData,
personalizeData.variantId,
personalizeData.componentVariantIds
);
console.log('[PreviewModePlugin] Personalized layout data applied.');
} catch (error) {
console.error('[PreviewModePlugin] Error fetching editing data:', error);
throw error;
}
return props;
}
// If we're in preview (editing) Chromes Edit Mode, use data already sent along with the editing request
console.log(
'[PreviewModePlugin] Detected preview mode using Experience Editor (Chromes Edit Mode).'
);
try {
const data = await editingDataService.getEditingData(context.previewData);
if (!data) {
throw new Error(
`Unable to get editing data for preview ${JSON.stringify(context.previewData)}`
);
}
console.log(
'[PreviewModePlugin] Editing data successfully retrieved from Experience Editor:',
data
);
const locale = data.language;
const market = determineMarketFromLocale(locale);
props.site = data.layoutData.sitecore.context.site as SiteInfo;
props.layoutData = data.layoutData;
props.dictionary = data.dictionary;
props.headLinks = [];
props.locale = locale;
props.market = market;
console.log(`[PreviewModePlugin] Props:`, props);
console.log(`[PreviewModePlugin] Context:`, context);
} catch (error) {
console.error('[PreviewModePlugin] Error getting editing data:', error);
throw error;
}
return props;
}
}
export const previewModePlugin = new PreviewModePlugin();
Por qué lo modificamos:
Modificamos el PreviewModePlugin para fijar la información específica de mercado durante el proceso de vista previa. Así, los editores que usaran el Experience Editor verían la variante de contenido correcta para el mercado indicado.
Cambios clave:
- Manejo de la personalización: El plugin gestiona el mercado y el locale durante la vista previa para ofrecer a los editores una previsualización coherente con lo que verá el usuario final.
Reflexiones finales
Montar un sitio web multisitio y multiidioma para Uniworld con Sitecore XM Cloud y Next.js implicó superar retos en torno a la segmentación de contenido por mercado y a la experiencia del editor. Este es el resumen de los pasos clave que dimos:
- Middleware MarketPlugin: Se encargó de la redirección por ruta de mercado y de la gestión de cookies para asegurar que se sirviera el contenido correcto a cada usuario.
- next.config.js actualizado: Configuramos los locales y las reescrituras (rewrites), y desactivamos la detección de locale para tener más control.
- Plugins de Page Props: Actualizamos site.ts y previewMode.ts para manejar correctamente la información de mercado y de locale, manteniéndola consistente entre los distintos estados de la página.
- Page Props actualizado: Añadimos el contexto de mercado a SitecorePageProps para poder acceder a él con facilidad.
Esta solución a medida permite que Sitecore XM Cloud entregue el contenido adecuado a cada usuario, garantizando una experiencia óptima tanto para los visitantes como para los editores de contenido. Es modular y fácil de extender, lo que la convierte en una base sólida para crecer.
Referencias
-
A Full Guide to Creating Multi-Language Sites with Sitecore XM Cloud and Next.js
https://blogs.perficient.com/2023/12/08/a-full-guide-to-creating-a-multi-language-sites-with-sitecore-xm-cloud-and-next-js/ -
Internationalization in the JSS Sample App for Next.js
https://doc.sitecore.com/xmc/en/developers/jss/latest/jss-xmc/internationalization-in-the-jss-sample-app-for-next-js.html -
Guide to Implementing Multilingual Support in Sitecore XM Cloud
https://www.getfishtank.com/blog/guide-to-implementing-multilingual-support-in-sitecore-xm-cloud -
The Next.js Multisite Add-On
https://doc.sitecore.com/xmc/en/developers/jss/latest/jss-xmc/the-next-js-multisite-add-on.html -
Multisite Support with Sitecore Next.js and Edge Middleware
https://miguelminoldo.fr/2022/07/22/multisite-support-sitecore-next-edge-middleware/
