At my workplace, I worked on an ERP platform used by fashion businesses to manage customers, body measurements, products, orders, invoices, inventory, staff, and other day-to-day operations. Each business also had a public storefront where customers could browse products and check out.
The storefront started as a simple sharing feature. Businesses could publish products, copy a link, and send it to customers outside the main workspace. That worked well because the storefront was mostly a product catalogue, and most of the sales process still happened after the customer contacted the business.
As the platform evolved, the storefront became much more than a catalogue. Customers were discovering businesses through shared links, browsing products, placing an order, and tracking orders directly from the storefront. That introduced new technical requirements around branded storefronts, SEO, server-rendered metadata, public checkout, pricing, and analytics.
This article explores how I designed the storefront around wildcard subdomains, immutable shop identities, server-side shop resolution, and a scalable analytics pipeline.
Table of Contents
- Giving the Storefront Its Own Identity
- Business Names, Reserved Names, and Subdomains
- Resolving a Storefront
- Active and Inactive Storefronts
- Location and Currency
- Product Pages and Share Previews
- Storefront Event Ingestion
- Processing Raw Events
- Counting Unique Visitors With HyperLogLog
- Domain Routing and Local DNS
1. Giving the Storefront Its Own Identity
The original storefront was fairly simple. It was a React application that fetched a business and rendered its products. Beyond that, there wasn't much to it. There were no branded storefronts, analytics, subdomains, or even a separate identity beyond the business itself.
Supporting those capabilities meant the storefront needed its own data model. I introduced a dedicated shop entity to represent the public storefront.
The business remained the source of operational data such as customers, products, orders, subscriptions, staff, and settings. The shop entity became the source of everything related to the public-facing storefront.
shops
id
businessId
slug
subdomain
status
inactiveReason
createdAt
updatedAt
businessId, slug, and subdomain are all unique. This gives every storefront a stable internal identity, a human-readable public address, and an immutable fallback slug that continues to identify the storefront even if the business name or subdomain changes.
To support existing businesses, I backfilled the new table by generating an immutable slug and deriving a subdomain from each business name. If a derived subdomain was reserved or already in use, the migration failed instead of generating an alternative automatically. Since the business name determines the public storefront address, changing it behind the user's back would make the storefront feel unpredictable.
2. Business Names, Reserved Names, and Subdomains
Every storefront has two public addresses:
abdulrahmon.cuttoshape.com
abdulrahmon.cuttoshape.com/products/:productId
cuttoshape.com/shop/:slug
cuttoshape.com/shop/:slug/products/:productId
The subdomain is the primary storefront address that customers can easily remember and share. The slug is a permanent fallback.
Rather than asking businesses to choose a separate storefront name, the subdomain is derived from the business name. When a business is created or renamed, the name is normalized by converting it to lowercase, removing unsupported characters, replacing spaces with hyphens, collapsing repeated separators, and ensuring it fits within DNS label limits.
For example:
Adenuga Marts -> adenuga-marts
Since the business name determines the public storefront address, the same request also validates the platform's subdomain policy. Derived subdomains cannot conflict with existing storefronts or use reserved platform names such as api, admin, www, docs, or other names the platform may need in the future.
If validation fails, the business name update is rejected. The system does not generate alternatives such as adenuga-marts-2 because silently changing a business's public identity would make storefront URLs unpredictable.
The fallback slug serves a different purpose. It is generated by the backend, immutable, and never exposed for editing.
cuttoshape.com/shop/7Kxq9BvL2pQ8mNz4RcT6yWaDk9Lp2Qs8Vn3Tx5Zr
It isn't meant to be memorable but to permanently identify the storefront, even if the business name changes or the custom subdomain becomes unavailable.
3. Resolving a Storefront
Every public storefront request begins by resolving the incoming address to a storefront.
For subdomain requests, the storefront application extracts the tenant label from the request host and asks the backend to resolve it.
GET /api/public/storefronts/resolve?subdomain=adenuga-marts
For fallback URLs, the backend resolves the storefront by its immutable slug.
GET /api/public/storefronts/slug/:slug
Once resolved, every public operation works with the storefront's internal identifier. Products, checkout, and analytics no longer care whether the request came from a subdomain or a slug.
The backend also returns the storefront's canonical public URL. If the storefront's subdomain is active, the canonical URL is the subdomain. Otherwise, it is the slug-based URL. Clients simply use the returned value when generating links instead of rebuilding URLs from business names or environment-specific rules.
4. Active and Inactive Storefronts
A storefront that doesn't exist isn't the same as one that's unavailable, so I kept the model simple.
status: active | inactive
inactiveReason: string | null
If the storefront doesn't exist, the resolver returns a normal 404.
If it exists but is inactive, the storefront still resolves, but browsing and checkout are blocked. This lets the frontend show a proper unavailable page instead of treating it as a missing storefront.
The inactive reason is just a string. That makes it easy to introduce new reasons without updating every client.
5. Location and Currency
The storefront serves customers from different countries, so it couldn't display the same currency to everyone.
The product supported three markets:
Nigeria -> NGN
United Kingdom -> GBP
Everywhere else -> USD
I resolved the visitor's market from their request IP and cached the result for one day to reduce requests to the geolocation provider. If the location couldn't be determined, the storefront used the currency specified in the URL. If that wasn't present either, it fell back to the business's primary currency.
The resolved currency was then used throughout the storefront. Products already supported multiple currencies, including fabric-specific pricing, so customers saw prices that matched their market without requiring separate storefronts.
6. Product Pages and Share Previews
The original storefront lived inside the multi-tenant React dashboard. That worked well until product pages needed server-generated metadata for SEO and link previews.
The first approach was a server-rendered endpoint that generated the metadata and redirected customers back to the React storefront. It solved the metadata problem, but the public storefront URL still wasn't responsible for rendering the page.
I eventually split the public storefront from the dashboard into its own Next.js application. Storefronts and product pages now resolve their data, generate metadata, and render HTML from the same URL.
7. Storefront Event Ingestion
As the storefront grew, both businesses and the platform needed visibility into how storefronts were performing.
The storefront emits a small set of analytics events.
shop_view
product_view
product_search
add_to_cart
checkout_started
checkout_submitted
Analytics events are submitted asynchronously. If event collection fails, customers can still browse products, add items to their cart, and check out normally.
Not every event is captured the same way. Impression events such as shop_view and product_view are sent after a page has rendered, while interaction events such as add_to_cart, checkout_started, and checkout_submitted are sent directly from user actions. Search events are only recorded after the search has settled rather than on every keystroke.
Each event contains enough information for downstream processing.
{
"shopId": "shop_123",
"businessId": "business_123",
"productId": "product_456",
"eventType": "product_view",
"visitorId": "visitor_abc",
"sessionId": "session_xyz",
"clientEventId": "event_789",
"currency": "NGN",
"clientTimestamp": "2026-07-11T14:20:00Z"
}
The clientEventId uniquely identifies an analytics event. The backend enforces a unique constraint on (shopId, clientEventId), making retries safe without counting the same event twice.
8. Processing Raw Events
The backend doesn't update analytics counters directly from the public request.
Instead, every accepted event is stored as a raw event and queued for background processing.
Return 202
▲
│
Storefront ──► Store raw event
│
▼
Queue
│
▼
Analytics worker
A raw event represents a single analytics event before aggregation.
storefront_events
shopId
businessId
productId
eventType
visitorId
sessionId
clientEventId
currency
clientTimestamp
processedAt
When an event is first stored, processedAt is null. After the worker successfully processes it, processedAt is updated. This allows the worker to safely retry failed jobs without processing the same event twice.
The worker aggregates raw events into daily analytics tables for storefronts, products, and searches. These rollups power the analytics dashboard, while the raw events are retained for 90 days before a scheduled cleanup job removes processed records.
9. Counting Unique Visitors With HyperLogLog
Page views alone weren't enough because businesses also wanted to know how many unique visitors were reaching their storefronts.
Calculating distinct visitors from the raw events becomes more expensive as traffic grows because every visitor identifier has to be stored and queried.
I used Redis HyperLogLog for this part of the pipeline. Rather than storing every visitor identifier, HyperLogLog hashes each identifier and keeps only enough information to estimate how many unique values have been seen. The estimate isn't exact, but memory usage remains almost constant regardless of whether a storefront receives hundreds or millions of visitors.
As the analytics worker processes each event, it identifies the visitor using the persistent visitorId, falling back to the sessionId or request IP when necessary, and updates the daily HyperLogLog key for that storefront. Retrying the same event is safe because adding the same identifier again doesn't change the estimate.
The dashboard combines exact event totals from the SQL rollup tables with estimated unique visitor counts from HyperLogLog. Daily HyperLogLog keys can also be merged, allowing Redis to estimate distinct visitors across a selected period without counting returning visitors more than once.
Storefront analytics is intentionally limited to predefined reporting periods of up to one year. HyperLogLog keys are retained for 400 days, giving one-year reports a small retention buffer while preventing Redis memory from growing without bounds.
10. Domain Routing and Local DNS
The production setup uses a wildcard DNS record so any valid business subdomain resolves to the public storefront application.
*.cuttoshape.com
↓
Public storefront app
The application extracts the subdomain from the request host, resolves the corresponding storefront, and renders the page.
Slug routes follow a different path.
cuttoshape.com/shop/:slug
Instead of extracting a subdomain, the application resolves the storefront directly from its immutable slug before rendering the page.
I also wanted local development to behave like production, so I used dnsmasq to configure wildcard local DNS instead of relying on localhost.
address=/cuttoshape.local/127.0.0.1
That allowed me to test requests such as:
adenuga.cuttoshape.local
anything.cuttoshape.local
locally before deploying the wildcard DNS configuration.
Top comments (0)