Skip to main content

Command Palette

Search for a command to run...

Designing a Storefront API Layer for a Shopify Bundle Builder

Updated
•11 min read•View as Markdown
Designing a Storefront API Layer for a Shopify Bundle Builder

After building the individual APIs for my Shopify Bundle Builder & BYOB app, I started looking at the bigger picture.

At first, the endpoints looked like separate pieces:

/bundle-config
/bundles-list
/analytics

But they are actually solving three parts of the same problem.

The storefront needs to:

  1. Discover available bundles.

  2. Load the configuration for a specific bundle.

  3. Tell the backend what customers are doing.

Once I looked at it that way, the API architecture became much easier to reason about.

In this final article of the series, I want to walk through how I structured the storefront API layer, why I separated the endpoints, and what I would consider improving as the application grows.

The problem I was trying to solve

A bundle builder isn't just a product page with a different Add to Cart button.

The customer can potentially:

Choose products
Choose variants
Move through steps
Apply bundle rules
Change quantities
See pricing
Build a final bundle
Add it to cart

The storefront therefore needs application data, not just Liquid-rendered product information.

I wanted the frontend to communicate with the backend through a small, predictable API layer.

The architecture I ended up with is:

Shopify Storefront
       ↓
App Proxy
       ↓
Bundle Builder API
       ↓
Database

The API layer sits between the storefront and the application's data.

The three core endpoints

The storefront API currently has three main responsibilities.

1. Bundle configuration

GET /apps/bundle-builder/api/bundle-config?bundleId=

This endpoint loads the full configuration for one live bundle.

The response can contain:

Bundle
Steps
Rules
Design

This is the endpoint the bundle builder needs when initializing a specific bundle.

2. Bundle listing

GET /apps/bundle-builder/api/bundles-list?limit=&offset=

This endpoint returns a paginated list of live bundles.

I added pagination because I didn't want the storefront to request every bundle in a single response.

There is also a maximum of 50 bundles per request.

3. Analytics

POST /apps/bundle-builder/api/analytics

This endpoint records storefront events.

The initial events are:

view
add_to_cart
purchase

The data is written to BundleAnalytics.

Together, these three endpoints form the basic storefront API layer.

Why I separated the endpoints

It would have been possible to create one endpoint:

/apps/bundle-builder/api

and make it handle everything.

But I don't think that would make the application easier to maintain.

The operations have different purposes.

bundle-config
    → Read one bundle

bundles-list
    → Read many bundles

analytics
    → Write events

Keeping those responsibilities separate makes the API easier to understand.

If I need to change pagination, I don't need to worry about breaking analytics.

If I add a new analytics event, I don't need to modify bundle configuration logic.

That's a small architectural decision, but it makes a difference as the codebase grows.

The storefront flow

The typical customer journey looks something like this:

Customer opens bundle page
          ↓
Storefront requests bundle configuration
          ↓
Bundle Builder initializes
          ↓
Customer selects products
          ↓
Customer completes bundle
          ↓
Bundle added to cart
          ↓
Analytics event recorded

If the storefront needs to discover available bundles first, the list endpoint fits into the beginning:

Load bundle list
      ↓
Customer selects bundle
      ↓
Load bundle configuration
      ↓
Build bundle
      ↓
Add to cart
      ↓
Record analytics

Each endpoint has one job in this flow.

The API as a contract

One of the biggest benefits of this architecture is that the API becomes a contract between the frontend and backend.

The frontend doesn't need to know how the database is structured.

It doesn't need to know which tables store steps.

It doesn't need to know how rules are represented internally.

It just needs to know:

"Give me this bundle."
"Give me available bundles."
"Record this event."

For example:

const response = await fetch(
  `/apps/bundle-builder/api/bundle-config?bundleId=${bundleId}`
);

const config = await response.json();

The frontend works with the API response instead of reaching into application internals.

Database structure versus API structure

This separation becomes especially useful when the database structure gets complicated.

Internally, the application might store:

Bundle
 ├── Steps
 │    ├── Products
 │    └── Rules
 ├── Design
 └── Settings

But the storefront doesn't necessarily need that exact structure.

The API can transform the data into something that makes more sense for the JavaScript application:

{
  "bundle": {},
  "steps": [],
  "rules": [],
  "design": {}
}

This gives me freedom to change the database implementation later.

As long as the API contract remains stable, the storefront doesn't necessarily need to change.

Keeping the storefront payload under control

One thing I tried to avoid was sending more data than the storefront actually needs.

This is especially relevant for the bundle list endpoint.

If I have 30 bundles, I don't necessarily need the complete configuration of all 30 bundles.

The list endpoint can return lightweight information.

For example:

{
  "bundles": [
    {
      "id": "101",
      "title": "Build Your Own Box"
    }
  ]
}

Then, when the customer selects a specific bundle:

bundleId = 101
       ↓
bundle-config
       ↓
full configuration

This is much more efficient than loading everything up front.

Pagination is part of the architecture

Pagination isn't just an implementation detail of bundles-list.

It also protects the storefront from unnecessarily large responses.

The endpoint accepts:

limit
offset

with a maximum limit of:

50

So even if a client sends:

?limit=5000

the backend can cap it.

This gives the API a predictable maximum response size.

For a bundle catalog, that is a useful boundary.

Rate limiting analytics

The analytics endpoint has a different concern.

It's a write endpoint exposed through the storefront, so I added an in-memory rate limit:

60 requests per minute per IP

The reason is straightforward.

I don't want an accidental frontend loop or abusive client to generate thousands of analytics requests.

The basic flow is:

Analytics request
      ↓
Identify IP
      ↓
Check current request window
      ↓
Count request
      ↓
Under 60?
   ↙       ↘
 Yes       No
  ↓         ↓
Write      429
event

It's intentionally simple.

For the current application, I didn't want to introduce an external rate-limiting service just for this endpoint.

Why in-memory rate limiting has limitations

There is an important trade-off here.

An in-memory rate limiter works well as a lightweight protection mechanism, but it isn't a distributed rate limiter.

If an application is running across several independent instances, each instance can have its own memory.

That means the effective limit isn't necessarily globally shared.

For a larger production environment, I'd consider moving the rate-limit state into a shared system.

For example:

Redis
Distributed cache
Platform-level rate limiting

But I prefer starting with the simplest solution that meets the current requirement.

Treating analytics as non-critical

Another architectural decision was making analytics secondary to the customer experience.

Imagine this:

Customer clicks Add to Cart
        ↓
Bundle added successfully
        ↓
Analytics request fails

The customer should still have a working cart.

I don't want a temporary analytics failure to prevent the actual business operation.

So analytics can be treated as best effort from the storefront.

For example:

trackBundleEvent('add_to_cart', bundleId)
  .catch(error => {
    console.warn(
      'Unable to record analytics event',
      error
    );
  });

The bundle builder remains functional.

Keeping shop data isolated

Because this is a Shopify app, shop isolation is another important part of the architecture.

The same API endpoints can be used by many different Shopify stores.

For example:

Store A
  ↓
App Proxy
  ↓
API
  ↓
Store A data

and:

Store B
  ↓
App Proxy
  ↓
API
  ↓
Store B data

The backend uses the requesting shop context when accessing bundle and analytics data.

That means the frontend doesn't need to pass an arbitrary shop identifier and have the backend blindly trust it.

Why App Proxy fits the architecture

Shopify App Proxy gives the storefront a Shopify-facing URL while forwarding the request to the application.

That creates a useful boundary:

Customer-facing URL
        ↓
Shopify
        ↓
App Proxy
        ↓
Application API

The storefront JavaScript can use paths such as:

/apps/bundle-builder/api/bundle-config
/apps/bundle-builder/api/bundles-list
/apps/bundle-builder/api/analytics

while the application keeps the actual business logic on the backend.

For this type of Shopify app, that separation makes the overall system much cleaner.

The three backend files

I also kept the route implementations separate:

api.bundle-config.jsx
api.bundles-list.jsx
api.analytics.jsx

Each file maps to one responsibility.

api.bundle-config.jsx

Responsible for:

Reading bundle ID
Finding the bundle
Checking availability
Returning configuration

api.bundles-list.jsx

Responsible for:

Reading pagination parameters
Finding live bundles
Applying limit/offset
Returning bundle summaries

api.analytics.jsx

Responsible for:

Reading event data
Validating event type
Checking rate limit
Writing BundleAnalytics

This structure makes the codebase easier to navigate.

What happens when something goes wrong?

I also wanted the API to return useful HTTP responses instead of making every failure look the same.

For example:

400
Invalid request

404
Bundle not found

429
Rate limit exceeded

500
Unexpected server error

The frontend can then react appropriately.

For example:

if (response.status === 404) {
  showBundleUnavailable();
}

or:

if (response.status === 429) {
  // Handle rate limiting
}

Good error boundaries make frontend debugging much easier.

Keeping the API simple

One thing I kept reminding myself while building this was:

Don't turn a small storefront API into a giant framework.

The current requirements are fairly focused.

I need to:

Load a bundle
List bundles
Record events

That's it.

There is always a temptation to build abstractions for every possible future requirement.

But that can make the code harder to understand before those requirements even exist.

I'd rather have three simple routes with clear responsibilities than one over-engineered API system.

What I would improve later

The current architecture gives me a good foundation, but there are several things I would consider as the app grows.

Cursor-based pagination

The current bundle list uses:

limit + offset

For larger datasets, cursor-based pagination could be worth considering.

Distributed rate limiting

The analytics endpoint currently uses in-memory rate limiting.

A shared rate limiter would make more sense across multiple application instances.

More analytics events

Depending on what merchants need, I could eventually add events such as:

bundle_started
step_view
bundle_completed
discount_applied

But I'd only add them when they answer a real reporting question.

Response caching

Some bundle configuration data may be suitable for caching depending on how frequently merchants update it.

That could reduce repeated backend work for popular bundles.

API versioning

If the response structure changes significantly in the future, versioning could help prevent older storefront code from breaking.

For example:

/api/v1/...
/api/v2/...

I don't need that complexity yet, but it's something I'd keep in mind.

The architecture in one diagram

After putting everything together, the architecture looks like this:

                 Shopify Storefront
                         │
                         ▼
                Bundle Builder JS
                         │
                         ▼
                Shopify App Proxy
                         │
             ┌───────────┼───────────┐
             │           │           │
             ▼           ▼           ▼
       bundle-config  bundles-list  analytics
             │           │           │
             │           │           ▼
             │           │     BundleAnalytics
             │           │
             └─────┬─────┘
                   ▼
              Bundle Data
                   │
                   ▼
                Database

The important part isn't the number of boxes.

It's the separation of responsibilities.

What I learned from building this

The biggest lesson for me was that a good API isn't necessarily a large API.

The three endpoints cover a surprisingly large part of the storefront requirements because each one has a very clear job.

bundle-config
"Give me this bundle."

bundles-list
"Show me available bundles."

analytics
"Record what happened."

That simplicity makes the frontend easier to work with.

It also gives the backend room to evolve without forcing the storefront to understand how everything works internally.

Final thoughts

Building the Bundle Builder made me think more about the boundary between Shopify's storefront and an app's backend.

Liquid is great for server-rendered storefront content.

JavaScript is useful for interactive bundle-building behavior.

And an API provides the connection between the two.

For my implementation, Shopify App Proxy became that connection point.

The final result is a relatively small storefront API layer:

/apps/bundle-builder/api/bundle-config
/apps/bundle-builder/api/bundles-list
/apps/bundle-builder/api/analytics

Each endpoint solves a specific problem, and together they give the bundle builder everything it needs to discover bundles, load configuration, and record customer interactions.

There is definitely room to evolve the architecture as the application grows, especially around distributed rate limiting, caching, pagination, and analytics.

But for the current version, keeping the system small and predictable has been one of the better decisions I've made while building the app.

And that's probably the main takeaway from this whole API layer:

Start with clear boundaries, give each endpoint one responsibility, and add complexity only when the product actually needs it.