Project Description
A high-performance headless Shopify storefront built with Hydrogen and React Router 7, featuring edge deployment and advanced caching for sub-second speeds.
Building a production Shopify storefront used to mean choosing between two paths: use Hydrogen on Oxygen and accept Shopify's hosting constraints, or use plain Remix and lose Hydrogen's commerce primitives. Neither felt right for a modern e-commerce stack that needs edge deployment, sub-second SSR, and the flexibility of a non-Shopify hosting provider.
I built this storefront to prove there's a third path — Shopify Hydrogen with React Router 7, deployed on Vercel using a custom Redis caching layer. The result is a headless storefront that streams HTML before the database queries finish, serves stale content from cache while fetching fresh data, and costs roughly the same as a static site.
Critical vs. Deferred — The Two-Phase Loader
The architecture's most important decision lives in app/root.tsx. The loader is split into two phases:
export async function loader({ request, context }: LoaderFunctionArgs) {
const criticalData = await loadCriticalData({ request, context });
const deferredData = loadDeferredData({ request, context });
return defer({
...criticalData,
deferredData: deferredData,
});
}
loadCriticalData fetches the Shopify HEADER_QUERY (navigation menu + shop info) synchronously — without it the header cannot render and the page has no navigation. loadDeferredData wraps the footer query, cart state, and login status in promises that resolve after the initial paint.
In practice, this means the browser receives the page shell with the nav bar and main content, while the cart badge, footer links, and user avatar stream in milliseconds later. The Shopify Storefront API round-trip for the footer query becomes a non-blocking background operation.
async function loadDeferredData({ context }: LoaderArgs) {
const { storefront, cart, customerAccount } = context;
return {
footer: storefront.query(FOOTER_QUERY, {
variables: { headerMenu, footerMenu },
cache: storefront.CacheLong(),
}),
cart: cart.get(),
isLoggedIn: customerAccount.isLoggedIn(),
};
}
React Router's <Await> component with Suspense boundaries handles the fallback UI — skeleton placeholders for recommended products, a static footer skeleton while the real footer loads.
Vercel Without Oxygen — The Redis Cache Layer
Oxygen (Shopify's hosting platform) provides the caches.open('hydrogen') API through Cloudflare Workers. Deploying on Vercel means losing that API. Rather than accepting the limitation, the project implements a full replacement using Upstash Redis.
The app/lib/redis-cache.ts module implements the Web Cache interface:
export class UpstashCache implements Cache {
async match(request: Request): Promise<Response | undefined> {
const key = this.buildKey(request.url);
const cached = await redis.get<CacheEntry>(key);
if (!cached) return undefined;
// serve stale while revalidating
if (isStale(cached) && !isExpired(cached)) {
queueRevalidation(key, request);
}
if (isExpired(cached)) return undefined;
return rebuildResponse(cached);
}
}
The stale-while-revalidate pattern is critical. If a cached page is slightly stale (past the TTL but within the grace window), the cache serves it immediately and triggers a background revalidation. Subsequent requests get fresh data. This means the median response time is a Redis read (~5ms) rather than a Storefront API call (~200ms), even during cache expiration windows.
The cache key prefix hydrogen: ensures compatibility with existing Hydrogen cache tags for granular invalidation from Shopify webhooks.
SEO at the Edge
The robots.txt generator is unusually deliberate. Rather than a static file, app/routes/[robots.txt].tsx produces different crawl rules per bot:
const crawlerRules: Record<string, CrawlerRule> = {
'AhrefsBot': { 'Crawl-Delay': '10' },
'MJ12bot': { 'Crawl-Delay': '10' },
'Pinterestbot': { 'Crawl-Delay': '1' },
'Nutch': { disallow: ['/'] },
'Adsbot-Google': { disallow: ['/cart', '/account', '/search'] },
};
Aggressive crawlers get a 10-second crawl delay. Full disallow for Nutch. Shopping-related paths blocked from ad bots. This is paired with Shopify-standard disallow patterns for + and %2B in collection/blog URLs (which create infinite crawl space), and a blanket disallow of sort_by parameter inflation.
The sitemap handles three locales (EN-US, EN-CA, FR-CA) with locale-prefixed URLs. Each product, collection, page, blog, and policy generates a locale-aware entry in the paginated sitemap.
The Dual-Mode Search System
Search operates in two modes from a single route. When the URL includes ?predictive, the route returns typeahead suggestions (products, collections, pages, articles). Without it, full search results with pagination.
export async function loader({ request, context }: LoaderFunctionArgs) {
const searchParams = new URL(request.url).searchParams;
const isPredictive = searchParams.has('predictive');
if (isPredictive) {
const searchResults = await storefront.query(PREDICTIVE_SEARCH_QUERY, {
variables: { query, ... },
});
return { type: 'predictive', term, result: searchResults };
}
const searchResults = await storefront.query(SEARCH_QUERY, {
variables: { query, ... },
});
return { type: 'regular', term, result: searchResults };
}
The component tree mirrors this split with render props — SearchResultsPredictive and SearchResults share sub-components (.Products, .Collections, .Pages, .Articles) but use different query types. The predictive version also attaches Shopify tracking parameters via urlWithTrackingParams() for search analytics.
Interactive Shopping — Hotspots and Filters
The Hotspot component turns any product image into an interactive shoppable lookbook. Each hotspot stores x/y coordinates as percentages of the container, and clicking opens a Radix Popover showing the product card:
type HotspotProps = {
x: number; // percentage
y: number; // percentage
product: ProductItemFragment;
};
Positioned absolutely within the image container, hotspots are responsive by default — percentage coordinates adapt to any viewport. This enables editorial-style product discovery without custom development per image.
The Filter system goes beyond simple collection filtering. It uses URL-based state management with a FILTER_URL_PREFIX:
- Standard filters (product type, vendor, tag) via
filter.vendor,filter.productType - Price range with
filter.price.minandfilter.price.max - Sort menu (Featured, Price ascending/descending, Best Selling, Newest, Alphabetical)
PriceRangeFilteruses 500ms debounced URL updates
Applied filters render as removable chips, each clearing its specific parameter without a full page reload.
The Discount URL Handler
A small but elegant feature: visiting /discount/FREESHIPPING?redirect=/collections/all applies the discount code to the cart and 303-redirects to the target page:
if (redirectTo) {
const url = new URL(redirectTo, request.url);
if (url.hostname !== requestUrl.hostname || url.pathname.includes('//')) {
throw new Error('Phishing redirect blocked');
}
}
The phishing check ensures the redirect target is on the same domain and doesn't contain // path manipulation. The discount application happens server-side via the Storefront API's CartDiscountCodesUpdate mutation.
Session Management Without a Database
Sessions are pure cookies with no backend storage. app/lib/session.ts implements HydrogenSession using React Router's createCookieSessionStorage:
export class AppSession implements HydrogenSession {
constructor(private session: SessionStorage) {}
async get(): Promise<CookieSession> { ... }
async set(key: string, value: string): Promise<void> { ... }
async clean(): Promise<void> { ... }
get isPending(): boolean {
return this.#isPending;
}
}
The isPending flag is critical. After every request, server.ts checks this flag and conditionally sets the Set-Cookie header. No Set-Cookie on read-only requests. No unnecessary cookie churn. The session cookie itself is httpOnly with lax same-site — secure by default.
The server.ts acts as the orchestrator:
const handleRequest = createRequestHandler({
build: reactRouterBuild,
mode: process.env.NODE_ENV,
});
const response = await handleRequest(request);
if (hydrogenContext.session.isPending) {
response.headers.set(
'Set-Cookie',
await hydrogenContext.session.commit()
);
}
What I Learned
This project challenged the assumption that Shopify Hydrogen requires Oxygen hosting. The custom Redis cache layer with stale-while-revalidate delivers comparable performance to Cloudflare's cache API, and the createHydrogenContext factory from @shopify/hydrogen is cleanly abstracted — swapping the cache backend requires implementing only the Cache interface.
The critical/deferred data pattern proved more impactful than I expected. By deferring footer, cart, and login status, the median Time to First Byte dropped because the server doesn't wait for Shopify API responses that aren't needed for the initial viewport. Suspense boundaries with skeleton placeholders make the deferred content invisible to the user — they see a fully rendered page immediately, with the footer appearing imperceptibly after.
Three decisions I'd make again:
-
stale-while-revalidate over TTL-only caching — During the grace window, the median response time drops from ~200ms to ~5ms. The background revalidation ensures data is never more than one TTL window stale.
-
Per-bot robots.txt rules — Aggressive crawlers like Ahrefs consumed significant crawl budget on parameterized URLs. Crawl-Delay headers reduced the crawl rate by roughly 60% without affecting Googlebot's indexing.
-
URL-based filter state — Storing filter and sort state in URLSearchParams instead of React state makes every filter combination shareable, bookmarkable, and browser-back-button-safe. The
FILTER_URL_PREFIXconvention keeps filter parameters organized and prevents collisions.
The storefront is deployed on Vercel with edge runtime support, serving as both a production-ready skeleton for bespoke e-commerce and a reference implementation for Hydrogen-on-Vercel architecture.


