The Reverse Mullet: Rails, GraphQL and React Router at Leanpub

by Peter Armstrong, Founder / CEO

This post is adapted from a talk I gave in Silicon Valley about how Leanpub is built. If you scanned the QR code on the slide: hi. Ruboss is the company that builds Leanpub, and for the first time since 2019 we're taking on clients again.


You know what a mullet is. Business on the front. Party on the back.

A reverse mullet is the other way round. Party on the front: an interactive, stateful, fashionable user interface. Business on the back: Ruby on Rails and a SQL database. Rock solid. Boring on purpose.

At Leanpub, our load balancer sends a handful of path patterns to Rails: the API, the admin panel, a few webhooks. Everything else goes to a Node process running React Router, which talks to Rails using GraphQL. Two runtimes. One boundary. And the boundary is a URL routing rule.

Nineteen years of the same haircut

I first described the reverse mullet in 2007, in a book called Flexible Rails: Flex on the front, Rails on the back. In 2009 we started building them for clients.

Leanpub launched in 2010 on WordPress, then moved to plain Rails 3. In 2015 we rewrote the front end in React over a REST API on the same Rails app. In 2020 we started porting that front end to Remix, as a background task, while upgrading the Rails app and refactoring it into bounded contexts. By the time it shipped, Remix was called React Router 7 and the backend was on Rails 8.

Same Rails app. Three front ends. Two reverse mullets.

The number of runtimes never changed. The boundary changed three times: hand-written ActionScript classes over XML, then hand-written JavaScript models over REST, and now a GraphQL schema that generates the client. Each step removed a way to be wrong about the contract. None of them removed the contract.

The contract pipeline

A rake task dumps the GraphQL schema to a file. In development, a watcher reruns it whenever anything under app/graphql changes. Codegen turns that into TypeScript types and a typed SDK, and every .gql file next to a route becomes a typed function.

So you rename price in Ruby, and every line of TypeScript that still says price turns red, in files you haven't opened. Without that, you find out when a customer sees a blank price on the store page. With it, you find out, and they never do.

The same schema feeds the web app, the iOS app, the Android app, a set of public REST routes that are thin adapters, and an MCP endpoint. One contract. Five clients.

And the browser never talks to Rails directly. It sends an operation name to a React Router route, which only runs operations compiled into the SDK. That's a persisted-query allowlist, and we got it without ever deciding to have one.

Cacheability

The most important rule: never put user-specific data in server-rendered HTML. Every public page is one byte-identical response, whether you're logged in or not, so store pages come off the edge.

The price is that a signed-in user sees a logged-out header for a moment after the page loads. We call it the anonymous flash, and we accepted it in writing as the price of cacheability.

Leanpub speaks 42 languages, so the language goes into the URL and the cache key. That's 42 copies of every page, and 42 ways to miss. So a miss has to be cheap: Node asks a Redis cache of GraphQL responses, keyed on the query and not the language, before it ever asks Rails.

What the boundary costs

Two runtimes means two dependency treadmills, and a front-end framework that was renamed under us before the port even finished. It means more generated TypeScript than the GraphQL Ruby that produces it. It means hydration mismatches, errors that change class as they cross the boundary, and a WebSocket bridge that exists only because two processes can't share a cookie. And nothing is ever really gone: deleting a field becomes a deprecate, count the traffic, then delete cycle, because old bundles and pinned mobile builds keep calling it for months.

Mullet, or crew cut?

The all-Rails "crew cut" is much stronger in 2026 than it was in 2007. Rails 8, Turbo, the Solid trio and Hotwire Native cover most of what people mean by "interactive", with one language, one process and one deploy.

If Leanpub were a CRUD-heavy marketplace without a collaborative editor, a reader and native mobile apps, the crew cut would win outright.

The mullet wins the moment you need the same contract from a second client. Leanpub has five.

The rules that survived contact

  • Keep the back office plain Rails.
  • One byte-identical response for public pages. Accept the flash.
  • Never let the browser write a query.
  • Deprecate, count the traffic, then delete.
  • Generate the mirror. Never hand-write it.
  • Auth at the layout, not the route.

Would I do it again?

For Leanpub, yes. It was the right way to refactor: a new front end against the live Rails app, one piece at a time, with no stop-the-world rewrite.

For a greenfield app? Trade-offs. It would be so much simpler not to have the boundary at all. But then you don't get the typed contract, or one schema feeding every client.

The front end is where fashion lives. Ruby is where the money lives. And the boundary between them is the real product decision.


If you're facing that decision, or if you have a Rails app whose front end has aged badly, the Ruboss team would love to help. Email peter@ruboss.com.

Contact Ruboss

Tell us what you’re building, or what you’re stuck on. Every engagement starts with a short conversation with Peter Armstrong to understand the problem and whether the Ruboss team is the right fit.

Email peter@ruboss.com

More articles

Working Back and Forth

Developing great products and customers is a productive cycle that takes neverending attention to detail and seeing how things look from different perspectives.

Read more

Why is Ruboss Doing Consulting Again?

This blog post was written primarily for interested Leanpub authors, but if you're a potential Ruboss client it may also help you to understand some of our motivations.

Read more