Add monorail analytics proxy as virtual resource route
Fixes: https://github.com/Shopify/hydrogen-internal/issues/254
Summary
Adds /.well-known/shopify/monorail/unstable/produce_batch as a virtual resource route that proxies Shopify analytics requests to the configured Shopify store domain, eliminating runtime errors in Multipass/Hybrid setups where users navigate between Hydrogen and Shopify theme subdomains.
Why
Problem: In Multipass/Hybrid setups, Shopify theme analytics scripts persist after subdomain navigation and POST to the Hydrogen domain, causing runtime errors:
POST /.well-known/shopify/monorail/unstable/produce_batch → 404 Not Found
Error: "did not provide an action for route routes/($locale).$"
Affected setups: Any Hydrogen + Multipass configuration with subdomain redirects to Shopify themes (e.g., xyz.com)
Impact:
- Runtime error noise in logs
- Potential analytics data loss
- User confusion from errors
What
Implementation
Virtual Resource Route:
Request Flow:
┌──────────────────┐
│ Theme Scripts │ (trekkie.js, consent-tracking-api.js)
│ POST analytics │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Hydrogen Domain │
│ /.well-known/... │ (xyz.com)
└────────┬─────────┘
│
▼ (proxy)
┌──────────────────┐
│ Shopify Theme │ (xyz.myshopify.com)
│ /.well-known/... │
└────────┬─────────┘
│
▼
200 OK / 400
Implementation Details
Resource Route (no default export):
- Returns raw responses without React Router layout wrapping
- Uses
response.bodystream to preserve Shopify's exact response - Forwards all headers (CORS, x-request-id, x-robots-tag, etc.)
export async function action({request, context}: ActionFunctionArgs) {
const shopifyStoreDomain = context?.env?.SHOPIFY_STORE_DOMAIN;
if (!shopifyStoreDomain) {
return new Response('', {status: 204});
}
const response = await fetch(
`https://${shopifyStoreDomain}${url.pathname}`,
{method: 'POST', headers: {...}, body}
);
return new Response(response.body, {
status: response.status,
headers: respHeaders,
});
}
Registration:
// packages/hydrogen/src/vite/get-virtual-routes.ts
{
id: 'vite/virtual-routes/routes/[.]well-known.shopify.monorail.unstable.produce_batch',
path: '.well-known/shopify/monorail/unstable/produce_batch',
file: '[.]well-known.shopify.monorail.unstable.produce_batch.jsx',
index: false,
}
Key Features
| Feature | Implementation | Benefit |
|---|---|---|
| Resource Route | No default export | Returns raw responses (no HTML layout) |
| Conditional Proxy | Checks SHOPIFY_STORE_DOMAIN env var |
Works out-of-box when domain set, silent fallback when not |
| Header Forwarding | Copies all Shopify response headers | Preserves CORS, validation messages, Shopify metadata |
| Stream Response | Uses response.body not await response.text() |
Avoids serialization, exact Shopify response |
| Error Handling | Try-catch with 204 fallback | Graceful degradation if proxy fails |
robots.txt SEO Fix
Removed: Disallow: /.well-known/shopify/monorail
Rationale:
- Shopify's endpoint returns
x-robots-tag: noindexheader - Google best practice: Don't use both robots.txt Disallow AND noindex headers
- With robots.txt blocking, crawlers never see the noindex header
- Proper approach: Let
x-robots-tag: noindexheader prevent indexing
Reference: Google - Block Search Indexing with noindex
"For the noindex rule to be effective, the page or resource must not be blocked by a robots.txt file."
🎩 Top Hat
Prerequisites
- [ ] Hydrogen project (2025.7.0+)
- [ ] Multipass setup with theme subdomain (optional - route works without)
- [ ]
SHOPIFY_STORE_DOMAINenvironment variable (optional)
Testing Steps
Test 1: Verify Route Exists and Returns Raw Response
# Start dev server
cd templates/skeleton
npm run dev
# In another terminal - test POST
curl -X POST http://localhost:3000/.well-known/shopify/monorail/unstable/produce_batch \
-H "Content-Type: text/plain" \
-d '{"events":[]}' \
-i
# Expected: HTTP 200 or 400, NO HTML in response body
# With SHOPIFY_STORE_DOMAIN set: Proxies to Shopify, returns their response
# Without SHOPIFY_STORE_DOMAIN: Returns 204 No Content
# Test GET (should reject)
curl http://localhost:3000/.well-known/shopify/monorail/unstable/produce_batch
# Expected: 405 Method Not Allowed
Test 2: Verify Proxy Functionality with Valid Payload
# Set environment variable
export SHOPIFY_STORE_DOMAIN=hydrogen-preview.myshopify.com
# Post valid analytics payload
curl -X POST http://localhost:3000/.well-known/shopify/monorail/unstable/produce_batch \
-H "Content-Type: text/plain" \
-d '{
"events": [{
"schema_id": "trekkie_storefront_page_view/1.4",
"payload": {
"shopId": 1,
"currency": "USD",
"uniqToken": "test-token",
"visitToken": "test-visit",
"microSessionId": "test-session",
"microSessionCount": 1,
"url": "https://shop.com",
"path": "/",
"search": "",
"referrer": "",
"title": "Home",
"appClientId": "12875497473",
"isMerchantRequest": false,
"hydrogenSubchannelId": "0",
"isPersistentCookie": true,
"contentLanguage": "en"
},
"metadata": {"event_created_at_ms": 1759436400000}
}],
"metadata": {"event_sent_at_ms": 1759436400000}
}' -i
# Expected: HTTP 200 OK with Shopify analytics response
# Should see CORS headers from Shopify
Test 3: Verify robots.txt Fix
curl http://localhost:3000/robots.txt | grep "monorail"
# Expected: NO output (Disallow line removed)
# Allows crawlers to see x-robots-tag: noindex header
Test 4: Run Unit Tests
cd packages/hydrogen
npm test -- "monorail"
# Expected: 7/7 tests passing
# - Proxies POST to Shopify domain
# - Returns 405 for GET
# - Returns 204 fallback when no domain
# - Handles proxy errors
# - Forwards headers correctly
# - Handles large payloads
# - Proxies valid Hydrogen analytics payloads
Edge Cases to Test
- [ ] Route works without
SHOPIFY_STORE_DOMAIN(returns 204) - [ ] Route works with
SHOPIFY_STORE_DOMAINset (proxies correctly) - [ ] Invalid analytics payloads return 400 from Shopify
- [ ] Valid analytics payloads return 200 from Shopify
- [ ] Network errors to Shopify gracefully fallback to 204
- [ ] GET requests properly rejected with 405
- [ ] Large analytics payloads handled without issues
- [ ] No HTML layout wrapping in responses
- [ ] All Shopify headers preserved (CORS, x-*, etc.)
Validation Checklist
- [ ] All tests pass:
npm test(445/445) - [ ] No TypeScript errors:
npm run typecheck - [ ] Monorail route returns raw responses (no HTML)
- [ ] Proxy forwards to correct Shopify domain
- [ ] robots.txt no longer blocks monorail endpoint
- [ ] Works in both dev and production (tested on skeleton.hydrogen.shop)
- [ ] No breaking changes to existing analytics (monorail-edge.shopifysvc.com still works)