This document covers multi-tenant binding architecture, canonical URL management, and hreflang handling in the VTEX Rewriter application.
📋 Table of Contents
- Multi-Binding Architecture
- Binding Resolution
- Canonical URL Management
- Cross-Binding Route Resolution
🌐 Multi-Binding Architecture
Binding Architecture
VTEX uses bindings to handle multi-tenant scenarios where a single account can serve multiple domains, languages, or currencies. Each binding represents a unique storefront configuration.
_10interface Binding {_10 id: string_10 canonicalBaseAddress: string_10 defaultLocale: string_10 supportedLocales: string[]_10 currencyCode: string_10 countryCode: string_10 salesChannel: string_10}
Binding Resolution Flow
🔗 Canonical URL Management
_35class CanonicalManager {_35 async getCanonicalUrl(path: string, binding: Binding): Promise<string> {_35 const route = await this.findRoute(path, binding.id)_35 _35 if (!route) {_35 // No specific route found, use binding's canonical base_35 return this.buildCanonical(path, binding.canonicalBaseAddress)_35 }_35 _35 // Check if route has specific canonical rules_35 if (route.canonical) {_35 return this.resolveCanonicalTemplate(route.canonical, route, binding)_35 }_35 _35 // Default: use current binding's canonical base_35 return this.buildCanonical(route.resolvedPath, binding.canonicalBaseAddress)_35 }_35 _35 private buildCanonical(path: string, baseAddress: string): string {_35 const normalizedPath = normalizePath(path)_35 return `https://$\{baseAddress\}$\{normalizedPath\}`_35 }_35 _35 private resolveCanonicalTemplate(_35 template: string, _35 route: ResolvedRoute, _35 binding: Binding_35 ): string {_35 return template_35 .replace('{baseAddress}', binding.canonicalBaseAddress)_35 .replace('{path}', route.resolvedPath)_35 .replace('{locale}', binding.defaultLocale)_35 .replace('{currency}', binding.currencyCode)_35 }_35}
Canonical Headers Implementation
_48async function setCanonicalHeaders(ctx: Context): Promise<void> {_48 const { binding, route } = ctx.state_48 _48 if (!route || route.type === 'redirect') {_48 return // No canonical for redirects_48 }_48 _48 // Build canonical URL from binding configuration_48 const canonicalUrl = `https://$\{binding.canonicalBaseAddress\}$\{ctx.path\}`_48 _48 // Set link header for SEO_48 ctx.set('Link', `<$\{canonicalUrl\}>; rel="canonical"`)_48 _48 // Add to response body for client-side rendering_48 ctx.state.canonical = canonicalUrl_48 _48 // Handle alternate language versions_48 if (binding.supportedLocales.length > 1) {_48 const alternates = await generateAlternateUrls(ctx.path, binding)_48 ctx.state.alternates = alternates_48 }_48}_48_48async function generateAlternateUrls(_48 path: string, _48 currentBinding: Binding_48): Promise<AlternateUrl[]> {_48 const alternates: AlternateUrl[] = []_48 _48 // Get all bindings for the same account_48 const allBindings = await getAccountBindings()_48 _48 for (const binding of allBindings) {_48 if (binding.id === currentBinding.id) continue_48 _48 // Check if route exists in this binding_48 const route = await findRoute(path, binding.id)_48 if (route) {_48 alternates.push({_48 href: `https://$\{binding.canonicalBaseAddress\}$\{route.resolvedPath\}`,_48 hreflang: binding.defaultLocale,_48 title: `$\{binding.countryCode\} - $\{binding.currencyCode\}`_48 })_48 }_48 }_48 _48 return alternates_48}
🔄 Cross-Binding Route Resolution
Multi-Binding Route Resolution
_36async function resolveRouteAcrossBindings(_36 path: string, _36 currentBinding: Binding,_36 allBindings: Binding[]_36): Promise<ResolvedRoute | null> {_36 _36 // 1. Try current binding first (highest priority)_36 let route = await findRoute(path, currentBinding.id)_36 if (route) {_36 return { ...route, binding: currentBinding, source: 'current' }_36 }_36 _36 // 2. Try other bindings with same language_36 const sameLanguageBindings = allBindings.filter(b => _36 b.defaultLocale === currentBinding.defaultLocale && _36 b.id !== currentBinding.id_36 )_36 _36 for (const binding of sameLanguageBindings) {_36 route = await findRoute(path, binding.id)_36 if (route) {_36 return { ...route, binding, source: 'same-language' }_36 }_36 }_36 _36 // 3. Try fallback binding (usually master)_36 const fallbackBinding = allBindings.find(b => b.id === 'master')_36 if (fallbackBinding && fallbackBinding.id !== currentBinding.id) {_36 route = await findRoute(path, fallbackBinding.id)_36 if (route) {_36 return { ...route, binding: fallbackBinding, source: 'fallback' }_36 }_36 }_36 _36 return null_36}
Cross-Binding Route Resolution Sequence
Binding-Specific Route Storage
_58class BindingAwareRoutes<T extends RouteBase> {_58 async save(route: T): Promise<void> {_58 // Store route with binding-specific key_58 const key = this.generateKey(route.from, route.binding)_58 await this.vbase.saveJSON(this.bucket, key, route)_58 _58 // Update cross-binding index for canonical resolution_58 await this.updateCrossBindingIndex(route)_58 }_58 _58 private async updateCrossBindingIndex(route: T): Promise<void> {_58 if (!route.entityId) return_58 _58 // Load existing index_58 const indexKey = `entities/$\{route.type\}/$\{route.entityId\}.json`_58 const index = await this.vbase.getJSON(this.bucket, indexKey) || {}_58 _58 // Add current binding mapping_58 index[route.binding] = {_58 path: route.from,_58 canonical: route.canonical || false,_58 lastModified: new Date().toISOString()_58 }_58 _58 // Save updated index_58 await this.vbase.saveJSON(this.bucket, indexKey, index)_58 }_58 _58 async findCanonicalForEntity(_58 entityType: string, _58 entityId: string_58 ): Promise<string | null> {_58 const indexKey = `entities/$\{entityType\}/$\{entityId\}.json`_58 const index = await this.vbase.getJSON(this.bucket, indexKey)_58 _58 if (!index) return null_58 _58 // Find canonical binding (marked as canonical: true)_58 const canonicalEntry = Object.entries(index).find(_58 ([_, data]: [string, any]) => data.canonical === true_58 )_58 _58 if (canonicalEntry) {_58 const [bindingId, data] = canonicalEntry_58 const binding = await getBinding(bindingId)_58 return `https://$\{binding.canonicalBaseAddress\}$\{data.path\}`_58 }_58 _58 // Fallback to master binding_58 const masterEntry = index['master']_58 if (masterEntry) {_58 const masterBinding = await getBinding('master')_58 return `https://$\{masterBinding.canonicalBaseAddress\}$\{masterEntry.path\}`_58 }_58 _58 return null_58 }_58}
Example: Multi-Region E-commerce Setup
_40// Example binding configuration for multi-region setup_40const bindings: Binding[] = [_40 {_40 id: 'us-en',_40 canonicalBaseAddress: 'shop.example.com',_40 defaultLocale: 'en-US',_40 supportedLocales: ['en-US'],_40 currencyCode: 'USD',_40 countryCode: 'US',_40 salesChannel: '1'_40 },_40 {_40 id: 'br-pt',_40 canonicalBaseAddress: 'loja.example.com.br',_40 defaultLocale: 'pt-BR',_40 supportedLocales: ['pt-BR'],_40 currencyCode: 'BRL',_40 countryCode: 'BR',_40 salesChannel: '2'_40 },_40 {_40 id: 'fr-fr',_40 canonicalBaseAddress: 'boutique.example.fr',_40 defaultLocale: 'fr-FR',_40 supportedLocales: ['fr-FR'],_40 currencyCode: 'EUR',_40 countryCode: 'FR',_40 salesChannel: '3'_40 }_40]_40_40// Route resolution example:_40// Path: /produto/smartphone-abc_40// Current binding: br-pt_40// _40// 1. Check route in br-pt binding ✓ Found_40// 2. Use canonical: https://loja.example.com.br/produto/smartphone-abc _40// 3. Build alternates from bindings:_40// - en-US: https://shop.example.com/product/smartphone-abc_40// - fr-FR: https://boutique.example.fr/produit/smartphone-abc