Documentation
Feedback
Guides
VTEX IO Apps

VTEX IO Apps
Multi-Binding & Canonicals Architecture
vtex.rewriter
Version: 1.70.0
Latest version: 1.70.0

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


_10
interface 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


_35
class 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


_48
async 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
_48
async 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


_36
async 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


_58
class 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
_40
const 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

See also
Vtex.rewriter
VTEX IO Apps
VTEX App Store
VTEX IO Apps
Was this helpful?