Most merchants hit the same wall: the hosted platform that got them to $200K ARR is now costing them $1,500/month in apps, theme hacks, and workarounds just to do things that should be table stakes. Building a custom storefront API isn't a moonshot reserved for enterprise teams. It's a decision I'd make at around 50K monthly sessions when platform constraints start eating into conversion rate.
This post is about the mechanics. Not the philosophy of headless commerce — you can find that anywhere. I'm talking about what you actually build, in what order, and what it costs in developer hours.
What a Custom Storefront API Actually Does
A storefront API sits between your frontend (React, Next.js, whatever) and your backend data sources — product catalog, inventory, cart state, checkout, customer accounts. It's not a replacement for your commerce engine. It's a translation layer you control.
The difference matters. When you use Shopify's Storefront API directly from the browser, you're bound by their rate limits (currently 2 requests/second for Storefront API, as of 2024), their schema, and their caching rules. When you build your own, you add a layer that:
- Aggregates data from multiple sources (PIM, ERP, custom inventory)
- Caches aggressively on your own infrastructure
- Returns only the fields your frontend needs (no over-fetching)
- Handles auth your way — JWTs, sessions, whatever fits your stack
That control is worth something. In my second store, moving to a custom API layer cut our average API response time from 380ms to 47ms by adding Redis caching in front of our Shopify calls. Conversion went up 1.8% in the following 30 days. That's not a rounding error.
Choosing Your API Architecture
Three realistic options for SMBs building custom storefront APIs:
| Approach | Setup Time | Monthly Infra Cost | Best For |
|---|---|---|---|
| REST (Express/Fastify) | 1-2 weeks | $20-80 (VPS/serverless) | Teams comfortable with HTTP, simple integrations |
| GraphQL (Apollo/Yoga) | 2-4 weeks | $30-120 | Complex frontends, multiple data sources, strong typing |
| tRPC | 1-2 weeks | $20-60 | Full-stack TypeScript shops, Next.js monorepos |
I'd skip GraphQL if your team hasn't used it before. The learning curve is real, and you'll spend two weeks on schema design instead of shipping. REST with a clean route structure gets you 80% of the benefits in half the time.
For most SMBs I'd recommend Fastify over Express. It's faster out of the box (benchmarks show ~30% higher throughput on identical hardware), has first-class TypeScript support, and the plugin system is cleaner. Express is fine but it's 2013 software.
The Core Endpoints You Need to Build First
Don't build everything. Build the critical path first — the routes a customer touches between landing and purchase.
Products
GET /products/:handle— single product with variants, images, metafieldsGET /products?collection=:handle&page=:n— paginated collection listingGET /products/search?q=:query— search (wire this to Algolia or Typesense, don't build your own)
Cart
POST /cart— create cartGET /cart/:id— fetch cartPATCH /cart/:id/lines— add/update/remove lines
Checkout
POST /checkout— create checkout session (usually a redirect to your commerce engine's hosted checkout, or Stripe if you're going fully custom)
Customer
POST /auth/login,POST /auth/logout,GET /customers/me
That's 10 endpoints. Get those working, tested, and cached before you touch anything else.
A Real Fastify Product Endpoint
Here's a stripped-down but functional product endpoint that wraps a Shopify Storefront API call with Redis caching. This is close to what I'd actually ship:
// routes/products.js
const CACHE_TTL = 300; // 5 minutes
async function productRoutes(fastify, options) {
fastify.get('/products/:handle', async (request, reply) => {
const { handle } = request.params;
const cacheKey = `product:${handle}`;
// Check Redis first
const cached = await fastify.redis.get(cacheKey);
if (cached) {
reply.header('X-Cache', 'HIT');
return JSON.parse(cached);
}
// Fetch from Shopify Storefront API
const query = `
query getProduct($handle: String!) {
product(handle: $handle) {
id
title
handle
descriptionHtml
priceRange {
minVariantPrice { amount currencyCode }
}
variants(first: 50) {
edges {
node {
id
title
availableForSale
price { amount currencyCode }
selectedOptions { name value }
}
}
}
images(first: 10) {
edges {
node { url altText width height }
}
}
}
}
`;
const response = await fetch(
`https://${process.env.SHOPIFY_DOMAIN}/api/2024-04/graphql.json`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': process.env.SHOPIFY_STOREFRONT_TOKEN,
},
body: JSON.stringify({ query, variables: { handle } }),
}
);
const { data, errors } = await response.json();
if (errors || !data.product) {
return reply.code(404).send({ error: 'Product not found' });
}
const product = data.product;
// Cache and return
await fastify.redis.setex(cacheKey, CACHE_TTL, JSON.stringify(product));
reply.header('X-Cache', 'MISS');
return product;
});
}
module.exports = productRoutes;
A few things worth noting here. The X-Cache header is just for your own debugging — you'll thank yourself later when you're trying to figure out why a product update isn't showing. The 5-minute TTL is a starting point; drop it to 60 seconds for inventory-sensitive products. And yes, you should add a cache invalidation webhook from Shopify that hits a DELETE /cache/product/:handle route whenever a product updates.
Authentication and Session Management
This is where most DIY storefront APIs get sloppy. Don't store cart IDs or customer tokens in localStorage. Use httpOnly cookies. A customer's cart ID leaking via XSS is a support nightmare.
For guest carts, I use a signed cookie with the cart ID — no auth required, just a secret-signed value so it can't be tampered with. For logged-in customers, I issue a short-lived JWT (15 minutes) plus a refresh token stored in an httpOnly cookie. The refresh token rotates on every use.
// Simplified auth middleware
async function authenticate(request, reply) {
const token = request.cookies.access_token;
if (!token) return; // Guest — that's fine
try {
request.customer = fastify.jwt.verify(token);
} catch (err) {
// Try refresh
reply.clearCookie('access_token');
// Trigger refresh flow
}
}
Don't build your own password hashing. Use bcrypt with a work factor of 12 minimum, or better yet, offload auth entirely to something like Clerk or Auth.js and just store the customer reference ID in your own database.
Caching Strategy That Actually Works
Three layers, in order of speed:
- In-memory (Node.js process) — for near-static data like navigation menus, footer content, site settings. Cache for 10 minutes, invalidate on deploy.
- Redis — for product data, collection listings, search results. TTL varies by data volatility.
- CDN edge cache — for fully public, non-personalized responses. Set
Cache-Control: public, s-maxage=300and let Cloudflare or Fastly handle it.
The mistake I see most often: people cache everything in Redis and skip the CDN layer entirely. A product page that gets 500 requests/minute will hammer Redis unnecessarily. If the response is the same for every anonymous visitor, push it to the edge.
For cart and customer endpoints, set Cache-Control: private, no-store. Never cache personalized data at the CDN layer.
Deployment: Don't Over-Engineer It
You don't need Kubernetes for this. Seriously. A $24/month Hetzner VPS (CX21, 4GB RAM) running your Fastify app behind nginx reverse proxy setup Ubuntu 22.04 as a reverse proxy handles roughly 800 concurrent connections without breaking a sweat. Add a managed Redis instance — Upstash has a free tier that covers up to 10,000 commands/day, paid starts at $0.20 per 100K commands.
For zero-downtime deploys, use PM2 with cluster mode and pm2 reload instead of pm2 restart. That's it. You don't need a container orchestrator until you're doing millions of requests per day.
If you'd rather go serverless, Vercel's Edge Functions or Cloudflare Workers work well for the stateless routes (product fetching, search). Just be aware that Workers have a 128MB memory limit and no persistent connections, so Redis access goes through Upstash's REST API instead of a native client — adds ~10-20ms per call.
For teams already running on Shopify who want to understand the tradeoffs before going fully headless, my earlier post on Shopify headless architecture tradeoffs covers the decision framework in detail. And if you're evaluating whether to keep Shopify as the backend at all, this guide on Shopify alternatives for growing stores lays out what the realistic options look like at different revenue levels.
Common Mistakes Worth Avoiding
Skipping rate limit handling. Your Shopify Storefront API token has limits. If you cache correctly, you'll rarely hit them — but add exponential backoff with jitter on 429 responses anyway. A thundering herd after a cache flush will take your storefront down otherwise.
Building checkout from scratch. Unless you have a specific reason (B2B pricing, complex bundles, regulatory requirements), use your commerce engine's hosted checkout. Stripe Checkout or Shopify Checkout handle PCI compliance, 3DS, Apple Pay, and a hundred edge cases you don't want to own. The conversion optimization alone — Shopify's checkout converts at 15%+ for most stores — is worth the tradeoff.
No request validation. Add Zod or Fastify's built-in JSON Schema validation on every route that accepts a body. An unvalidated quantity field that accepts -999 will ruin your day.
Ignoring observability from day one. Add structured logging (Pino is built into Fastify), trace IDs on every request, and at minimum a free Sentry instance for error tracking. You can't fix what you can't see.
Building Custom Storefront API: What to Do Tomorrow
If you're still reading, you're probably at the point where your current setup is costing you more in constraints than a custom layer would cost to build. Here's the honest answer on timeline: a competent Node.js developer can have a production-ready custom storefront API covering the critical path in 3-4 weeks. Add another 2 weeks for auth, caching, and observability. Budget $3,000-6,000 in developer time if you're hiring, or 80-120 hours if you're doing it yourself.
Start with the product and cart endpoints. Get those cached and tested. Deploy to a single VPS. Measure your response times before and after. The numbers will tell you whether to keep going.
Building a custom storefront API is not a religious commitment to headless commerce — it's an infrastructure decision. Make it when the math works, not before.