Headless and API first Solidus: the prerequisite for a mobile app

TL;DR

Difficulty: Advanced

Headless means the storefront is a client of the shop rather than the shop itself. For most shops the right build is a hybrid: one Rails application with a server rendered storefront and a first class JSON API calling the same rules. Solidus ships its API with the core, with checkout, payments, stock, image sizes, token authentication and an event bus. You still design customer sign in, versioning, rate limits, caching, search and idempotency keys. App stores charge no commission on physical goods, but Apple rejects apps that are just a repackaged website, so the app needs the API underneath.

Sooner or later somebody in a growing online shop says the word app, or marketplace, or second storefront, and the answer to how hard that is was decided before the shop was built. In this article I explain what headless actually means, when it pays and when it does not, the one application hybrid I build on Solidus, what the current Solidus API really ships with, what you still design yourself, and what Apple and Google charge a shop that sells physical goods in an app.

A shop that can only talk to browsers is the expensive kind to extend

Sooner or later somebody in a growing online shop says the word app. Or marketplace. Or a tablet for the showroom, or a second brand selling the same stock from a different looking site. The owner then asks how hard it would be, and the honest answer depends almost entirely on a decision taken before the shop was built: was it built as a website that happens to sell things, or as a selling system that happens to have a website in front of it?

That distinction is what people mean by headless, when they mean anything at all by it. This article explains what it actually means, when it is worth the trouble and when it is not, what Solidus gives you today (checked against the current source, version 4.8.11), what you still have to design yourself, and what the app stores will and will not charge you. I made the general case for the platform in the Solidus comparison piece. This one is narrower and more technical.

What headless means, in one sentence

The storefront is a client of the shop. It is not the shop.

In a conventional shop, the code that renders the product page is the same code that knows what the product costs, whether it is in stock, which promotion applies and what happens when someone presses add to basket. The rules and the pixels live together. That works until you want a second set of pixels, at which point you find the rules tangled into the first set, and you either duplicate them or pull them apart.

In a headless shop, the rules live in one place and are exposed through an interface, almost always a JSON API over HTTP. The website asks that interface. So does the app, the marketplace feed, the showroom tablet and the warehouse tool. None of them knows anything about pricing, stock or promotions. They ask, and they are told.

If your bank's website and its app always show the same balance, you have used such a system. The balance is not in the app. The app asks.

The marketing word versus the contract

Almost every platform now calls itself headless, because almost every platform has some API. In the marketing sense the word has stopped meaning anything. What matters is whether the API is the contract every client goes through, or a side door next to the real logic. Three questions separate the two.

First, does the same logic serve the website and the API? If the website takes a privileged path and the API is a translation of it, the API will always lag. A promotion configured in the admin works on the web and silently fails in the app.

Second, can you do through the API what your business actually needs? Not "most things", but the specific operations your app, feed or warehouse tool will need. Solidus is a good illustration of why you check rather than assume: its API covers products, orders, checkout, payments, shipments and stock, but promotions are read only apart from applying and removing coupon codes on an order, and there is no endpoint for refunds as such (payments have a credit action instead)2.

Third, who owns the API? If it belongs to a vendor, the vendor decides its rate limits, versions, deprecations and price. If it lives in your codebase on your server, it changes when you change it. With Solidus the third answer is unambiguous: it is an open source Rails framework, the API engine is part of it, and it runs inside your own application.

When a separate front end is worth it

"Flexibility" is not a business case, so here are the concrete ones.

You want a mobile app. An app is a second storefront that runs on a phone. It needs the catalogue, basket, checkout, account and order history, and it needs them to behave exactly as the website does, because the same customer uses both. Without an API you build all of that twice, and the copies drift apart.

You sell through marketplaces or partners. Amazon, a trade portal, a retailer who lists your range. Each wants products and stock out and orders back in. That is an API in both directions, not a nightly spreadsheet export that is wrong for most of the day.

You run a second brand on the same catalogue. A trade site and a consumer site, or a UK shop and an EU shop with different prices and legal texts. If the rules live in one place, the second storefront is a design job. If they live in the templates, it is a second shop.

You sell in person. A tablet at a market stall or in a showroom that sells from the same stock, at the same prices, and puts the order into the same queue for the warehouse.

Your systems need to talk. ERP, warehouse management, accounting, a CRM, a mail tool that wants abandoned baskets. Each integration is easier with one documented way in and out.

If two or more of these are genuinely on the roadmap, rather than mentioned once in a meeting, the API is not optional.

When it is not worth it

A single storefront and no plans. One website, no app, no marketplaces, integrations limited to a payment provider and a mail tool. A fully decoupled build here means two applications to deploy, monitor and debug, and two hosting bills, for flexibility you will not use.

A small team. A JavaScript front end talking to a Ruby back end means two skill sets, two build pipelines and two places for a bug to hide. A team of one or two is better served by one application.

A large catalogue that lives on search traffic. If revenue depends on Google finding thousands of product pages, those pages need to arrive as fast, complete HTML with correct structured data. A JavaScript front end can do that with a server rendering framework, but then you have added a rendering layer to solve a problem a Rails app never had, and every migration of URLs and templates is a risk to rankings you have already earned.

So the answer is rarely "fully headless or not". It is the version that gives you the contract without the cost of running two applications.

The hybrid I build: one application, two front doors

Every Solidus shop I build has the same shape. There is one Rails application. It contains the Solidus core, which owns products, variants, prices, stock, promotions, orders, payments, shipments and taxes. It contains a server rendered storefront: ordinary Rails controllers and views producing HTML, which customers see and Google indexes. And it contains the JSON API, a second set of controllers exposing the same models to anything that is not a browser.

The important part is what those two sets of controllers share, which is everything below them.

Tap or click to open full screen and move around

When a customer on the website adds something to the basket, the storefront controller calls the same code the API controller calls when an app does it. In Solidus that common path is the order's contents object: the API's line item endpoint calls @order.contents.add with the variant, quantity and options3, and the cart controller of the official starter storefront does the same4. The same promotion rules run, the same stock check happens, and the same comparison decides whether this is a new line or more of an existing one.

I call this API first even though the storefront does not talk to the API over HTTP. The API is designed as a first class citizen from the start, the rules stay out of the views, and nothing the storefront can do is impossible through the API. The result is one application to deploy, monitor, secure and test, server rendered pages for search and speed, and an answer to "can we have an app?" that begins with "the API is already there".

What Solidus ships with today

Solidus is distributed as a set of gems, and the solidus gem you install depends on solidus_api alongside the core and the admin5. The API is not an optional extension. Here is what it gives you, checked against the source.

Catalogue and stock. Products, variants, option types, taxonomies and taxons, product properties, images, stock locations, stock items and stock movements2. List endpoints are paginated with a per_page parameter and accept search filters.

Basket and checkout. Orders, line items, addresses, shipments with shipping rate selection, payments with authorise, capture, purchase, void and credit actions, coupon codes, returns and customer returns2. The checkout endpoints drive the same order state machine as the web checkout, so a custom checkout step added to the order exists in the API too.

Authentication. Requests carry a user's API key as Authorization: Bearer <key>, and guests carry the order's guest token in the X-Spree-Order-Token header6. Two defaults matter for an app. API keys are generated for admins only unless you change that, and you have to implement a sign in strategy that hands a key to a customer's app yourself6. And every endpoint requires authentication unless you switch the requires_authentication preference off for the ones that should be public, such as the product list7.

Images in several sizes. Every image in the API response comes with one URL per configured style, and the defaults are mini (48 pixels), small (400), product (680) and large (1200), stored through Active Storage8. That is a better starting point than most people expect. What you still decide is the CDN in front of them and whether the formats suit an app.

An event bus. Solidus publishes events through Spree::Bus, built on the Omnes publish and subscribe library9. The core registers events such as order_finalized, order_canceled, order_emptied, order_recalculated, order_short_shipped, carton_shipped, reimbursement_reimbursed and reimbursement_errored10. There is no core event for a failed payment and no built in outbound webhook manager. A subscriber that posts JSON to another system when order_finalized fires is a small class you register in an initialiser, and you can publish your own events on the same bus.

Two things it does not give you. There is an official GraphQL extension, but its README lists support for Solidus 2.11 and 3.x and the repository has had no commits since October 202311. On Solidus 4 I would not build an app on it. Plan for REST. And the storefront is not a gem any more: the official solidus_starter_frontend is generated into your application with bin/rails generate solidus:install --frontend=starter4, so the storefront code is yours to change, which is exactly what the hybrid needs.

A checkout over the API, step by step

The Solidus API documentation describes the full checkout as a sequence of calls12. This is the order an app goes through, and it is the same order the web checkout goes through.

Tap or click to open full screen and move around

Adding an item as a guest looks like this. The order number, token and variant id are placeholders.

curl -X POST https://shop.example/api/checkouts/R123456789/line_items \
  -H "Content-Type: application/json" \
  -H "X-Spree-Order-Token: GUEST_TOKEN" \
  -d '{"line_item": {"variant_id": 42, "quantity": 1}}'

And completing it, with the total the customer saw on the confirmation screen:

curl -X PUT "https://shop.example/api/checkouts/R123456789/complete?expected_total=60.00" \
  -H "X-Spree-Order-Token: GUEST_TOKEN"

That expected_total parameter is worth knowing about. If the order total has changed since the customer looked at it, because a price or promotion changed in between, the endpoint refuses with a 400 instead of charging a different amount13.

What you still have to design yourself

Versioning. Once an app is in the stores, you cannot change an API response without breaking phones you do not control, because someone will still run the version from eight months ago. Solidus does not version its API for you. Decide before the first release: a version in the URL, a header, or a rule that responses only ever gain fields and never lose them. I use the last, with a deliberate version bump for the rare change that cannot be additive.

Rate limits. A public API is hit by things that are not your app: scrapers, a partner's integration stuck in a retry loop. Rack::Attack, a maintained Rack middleware, lets you throttle per key or per address and answer with a proper 429 rather than falling over14.

Caching. Product data is read constantly and changes rarely. HTTP caching headers, conditional requests and a cache in front of the read heavy endpoints stop an app from making your server work harder than the website does.

A customer sign in for the app. As above, Solidus gives admins API keys and leaves the customer sign in flow to you. A mobile app usually wants short lived tokens with refresh, which you build on top.

Search. The list endpoints filter by name, taxon and price. That is enough for a category screen, not for a search box with typo tolerance and results in under a tenth of a second. That needs an index kept in sync with the catalogue.

Documentation. The core API has an OpenAPI description in the repository12. Your own endpoints need the same, ideally generated from the tests so it cannot go stale. I cover that in my piece on testing APIs.

Idempotency. This one gets its own section.

Retries, idempotency and the double charge

On a website, a payment happens once because the browser submits a form once. On a phone, connections drop in tunnels and lifts, and an app that sends "complete this order" and hears nothing back will, if written naively, send it again.

Solidus already protects the most dangerous call better than many platforms. The checkout and line item actions take a lock on the order while they run, and the state machine will not complete an order that is already complete13. So a repeated complete call does not complete the order twice. What it does produce is an error for a purchase that actually succeeded, and an app that shows "payment failed" to someone who has just paid. The calls that create things are the bigger risk: a retried POST /api/orders creates a second order, and a retried payment creation adds a second payment to the order.

The standard fix is an idempotency key. The app generates a unique key per attempt and sends it with the request, and the server stores the first response under that key and returns it again for any retry. Stripe works this way: it saves the status and body of the first request with a key and returns them for retries, and it can prune keys after 24 hours15. The IETF has an active draft standardising an Idempotency-Key HTTP header for exactly this16. Solidus does not implement it, so I add a small table of seen keys and a check in front of every endpoint with a side effect: order creation, payments, completion, anything that sends a message or moves stock. It is an afternoon of work, and it is the best value insurance a shop with an app can buy.

The Gift Bow: what exists today and what an app would need

The Gift Bow is a gift and hamper demo shop I am building on Solidus. It is a work in progress and not open to visitors yet, but it already shows the discipline in practice. Here is what it does today. It is bilingual in English and German. It sells from five explicit price lists, in pounds, euros and US, Australian and New Zealand dollars, with its own prices in each rather than a converted figure. It has a build your own hamper feature: The Sweet Spot is a fixed £60 for exactly six treats, The Showstopper a fixed £100 for exactly eight with at most two from wine and spirits, and Free Range is priced as you fill it. Teas and coffees can be bought subscribe and save at 10% off, delivered every two, four or eight weeks. The homepage cards are records managed in the admin with start and end dates, which is how a festive collection can be scheduled to appear on 1 November without anyone touching the site.

Each of those features was built as a rule on the order, the line item or the product, not as a view. The hamper's chosen contents ride on the line item as structured data, and a comparison rule in the model tells the basket that two hampers with different contents are two lines, not a quantity of two. The subscription interval is a line item attribute with its own comparison rule. The currency is a pricing option on the order.

What an app would need is honest to state. The standard endpoints work today, with an API key, as on any Solidus shop. The hamper builder does not yet have its own JSON endpoint, because nothing needs one: today it is a storefront form in front of the hamper service. An app would need that endpoint added, accepting a container and a list of picks and calling the same service the form calls, plus the customer sign in, versioning and idempotency from the sections above. It would not need the business rules extracted from templates, because they were never in the templates. That missing phase is the point.

The cost of doing it later, as arithmetic

"Build it right the first time" is advice everyone gives and nobody costs. A simple expected value comparison does it. Keeping the rules out of the views from the start costs a known amount now. Retrofitting an API onto a shop where the rules have grown into the views costs a larger amount, but only if the second client ever arrives. Doing it now is worth it when

p×R>Up \times R > U

where pp is the probability that an app, marketplace feed or second storefront arrives during the shop's life, RR is the cost of the retrofit when it does, and UU is the extra cost of building the discipline in from the start. The expected value section of the Foundations explains why multiplying a cost by its probability is the fair comparison.

A worked example with an invented shop, harbourhampers.example in Bristol, and assumptions stated rather than claimed. The build costs £60,000. The discipline (service objects instead of logic in controllers, an API test beside each web test, image and versioning decisions taken early) adds £4,000. A retrofit, which means finding every rule in the views and moving it while the shop stays live, is assumed at £20,000 before a line of the app is written. The break even probability is

p∗=UR=400020000=0.2p^{*} = \frac{U}{R} = \frac{4000}{20000} = 0.2

where p∗p^{*} is the probability above which building it in is the better bet, UU is the £4,000 now and RR the £20,000 later. If there is better than a one in five chance that the shop will want an app, a marketplace feed or a second storefront in its lifetime, the discipline pays for itself on expected cost alone, before counting the months of delay and the risk of breaking a working shop during a retrofit. Change the assumptions to your own quotes; the method stays the same.

What the app stores charge a shop

Two facts change the business case for a shop app more than any architecture choice, and both are better than people fear.

First, the commission. Apple's App Store Review Guidelines say that an app selling physical goods or services consumed outside the app must use purchase methods other than in app purchase, such as Apple Pay or card entry17. Google Play lists purchases of physical goods among those its billing system does not cover18. So a shop app taking card payments for hampers through your own payment provider pays no app store commission on those orders. The store fees are the developer accounts: the Apple Developer Program costs 99 US dollars a year19, and a Google Play developer account a one off 25 US dollars20.

Second, the review. Apple's guideline 4.2 says an app should include features, content and UI that elevate it beyond a repackaged website17. A web shop wrapped in an app shell risks rejection. A native app that talks to a proper API, with a saved basket, order tracking and notifications for repeat buyers, is what the guideline asks for, and it needs the API underneath.

Whether a shop should have an app at all is a separate question. Repeat purchase and subscription businesses, where the customer comes back monthly and values notifications and a saved basket, are the natural candidates. A shop that sells someone a sofa every seven years usually is not. When an app is right, I build it in React Native, one codebase for iOS and Android, against the same API as everything else.

API ready from day one, as a checklist

This is what the phrase means on a build, in the order I do it.

  1. Business rules live in services and models, never in views or controllers. A controller takes input, calls something and renders.
  2. Every feature the web storefront offers gets an API test beside its web test, proving the endpoint does the same thing. This is what stops drift.
  3. Responses only ever gain fields. Renaming or removing one is a deliberate version bump.
  4. Every endpoint with a side effect accepts an idempotency key, and the app sends expected_total on completion.
  5. Images are served in named sizes through a CDN.
  6. There is a rate limiter and a way to see who is calling what.
  7. Customer sign in for API clients is designed, not improvised.
  8. The contract is written down and generated from the tests.

None of that is exotic, and it costs little if you start that way.

Where this leaves you

If you are building a new shop, ask for the hybrid: one application, a server rendered storefront, a first class API, rules in one place. Ask to see the API tests next to the web tests, and ask what happens when the app retries a payment.

If you already have a shop and the app question has arrived, have someone look at where the rules live before anyone quotes for the app. The app quote is only half the number.

On the shops I build, the bespoke Solidus code becomes yours on full payment, so the API, the services behind it and the tests are your asset, not a licence. If you want to see what this looks like with real features, I am happy to walk through the approach with you in a free workshop, with the first two weeks of work on me.

If you are wondering whether your shop could carry an app, a marketplace feed or a second storefront without a rebuild, I run a free workshop and the first two weeks of work are on me. I will tell you honestly whether the foundation is there, and what it would take if it is not.

Sources

  1. 1.

    Solidus, release v4.8.1, GitHub, 5 November 2026, https://github.com/solidusio/solidus/releases/tag/v4.8.1

  2. 2.

    Solidus, API routes, api/config/routes.rb, GitHub, https://github.com/solidusio/solidus/blob/main/api/config/routes.rb

  3. 3.
  4. 4.

    Solidus, solidus_starter_frontend README, GitHub, https://github.com/solidusio/solidus_starter_frontend

  5. 5.

    Solidus, solidus.gemspec (dependencies on solidus_api, solidus_backend and solidus_core), GitHub, https://github.com/solidusio/solidus/blob/main/solidus.gemspec

  6. 6.

    Solidus, API authentication, api/openapi/authentication.md, GitHub, https://github.com/solidusio/solidus/blob/main/api/openapi/authentication.md

  7. 7.

    Solidus, Spree::ApiConfiguration (requires_authentication defaults to true), GitHub, https://github.com/solidusio/solidus/blob/main/api/lib/spree/api_configuration.rb

  8. 8.

    Solidus, Spree::AppConfiguration, product_image_styles and image_attachment_module, GitHub, https://github.com/solidusio/solidus/blob/main/core/lib/spree/app_configuration.rb

  9. 9.
  10. 10.

    Solidus, core engine, events registered on Spree::Bus, GitHub, https://github.com/solidusio/solidus/blob/main/core/lib/spree/core/engine.rb

  11. 11.

    Solidus, solidus_graphql_api README and commit history, GitHub, https://github.com/solidusio/solidus_graphql_api

  12. 12.

    Solidus, Checkout flow, api/openapi/checkout-flow.md, GitHub, https://github.com/solidusio/solidus/blob/main/api/openapi/checkout-flow.md

  13. 13.

    Solidus, Spree::Api::CheckoutsController (lock_order and expected_total), GitHub, https://github.com/solidusio/solidus/blob/main/api/app/controllers/spree/api/checkouts_controller.rb

  14. 14.

    Rack::Attack, README, GitHub, https://github.com/rack/rack-attack

  15. 15.

    Stripe, Idempotent requests, API reference, https://docs.stripe.com/api/idempotent_requests

  16. 16.

    Jayadeba Jena and others, The Idempotency-Key HTTP Header Field, IETF httpapi working group, Internet Draft, https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/

  17. 17.

    Apple, App Store Review Guidelines, 3.1.3(e) Goods and Services Outside of the App and 4.2 Minimum Functionality, https://developer.apple.com/app-store/review/guidelines/

  18. 18.

    Google, Understanding Google Play's Payments policy, Play Console Help, https://support.google.com/googleplay/android-developer/answer/10281818

  19. 19.

    Apple, Apple Developer Program, https://developer.apple.com/programs/

  20. 20.

    Google, Register for a Google Play Console developer account, Play Console Help, https://support.google.com/googleplay/android-developer/answer/6112435