Our marketplace is organized around multiple country sites. Each country gets its own subdomain, while the apex is the cross-country portal. A single Go application serves the portal and country catalogs.
The Middleware Approach
Instead of path-based routing (/cr/en/vehicles), we use host-based resolution. A Gin middleware extracts the country from the Host header before any route handler fires:
func CountryHostMiddleware(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
if slug, ok := helper.CountrySlugFromHost(c.Request.Host, cfg); ok {
c.Set(string(CountryContextKey), slug)
c.Set(OnCountryHostContextKey, true)
}
c.Next()
}
}
CountrySlugFromHost strips the port, lowercases the host, and accepts only a one-label subdomain in the country allowlist. If cr.example.com comes in, the context gets country=cr. Edge routing sends shop hosts to a different application; this middleware must not treat an arbitrary subdomain as a country.
A later locale middleware derives language preferences and puts both country and language into the request context. Currency is resolved separately because a visitor may choose a display currency that differs from the country's default.
URL Structure: Language in Path, Country in Host
Each country host serves localized URLs such as /{lang}/vehicles and /{lang}/real-estate. The host's default-language homepage lives at /; requesting the redundant default-language homepage returns a 301 to /.
This design gives us clean hreflang tags for Google:
<link rel="alternate" hreflang="en-CR" href="https://cr.example.com/en/vehicles" />
<link rel="alternate" hreflang="es-CR" href="https://cr.example.com/es/vehiculos" />
<link rel="alternate" hreflang="x-default" href="https://cr.example.com/es/vehiculos" />
The Cookie Problem
Authentication is shared across the platform's apex and country subdomains. The session cookie uses Domain=example.com, so the browser sends it to both example.com and its subdomains. A leading dot is accepted by browsers but ignored by modern cookie semantics, so Domain=.example.com does not create a different scope.
That scope is also a security boundary. A compromised sibling subdomain may be able to shadow or overwrite a domain cookie, and SameSite does not isolate sibling subdomains because they are the same site. We therefore share the cookie only with trusted platform hosts and set Secure, HttpOnly, and an appropriate SameSite value. Tenant-owned custom domains cannot share this cookie.
If cross-subdomain login is unnecessary, a host-only cookie is safer and can use the __Host- prefix. That prefix is incompatible with a Domain attribute.
Legacy URL Migration
We originally had country paths on the apex: example.com/cr/en/vehicles. When we moved to subdomains, we needed 301 redirects:
func LegacyCountryRedirectMiddleware(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
if helper.OnCountryHost(c, cfg) {
c.Next()
return
}
if target := helper.LegacyCountryPathRedirect(
c.Request.URL.Path, c.Request.URL.RawQuery, cfg,
); target != "" {
c.Redirect(http.StatusMovedPermanently, target)
c.Abort()
return
}
c.Next()
}
}
Old links such as example.com/cr/en/vehicles now redirect permanently to cr.example.com/en/vehicles. We also preserve the query string deliberately and test malformed country/language combinations so the redirect cannot become an open redirect.
The Result
One binary and one router serve the portal and multiple country catalogs. Adding a country still requires registry, locale, currency, SEO, and data changes, but it does not require a new service architecture.
This article is based on lessons from building Towami, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.
Top comments (0)