Turbo Frames from Scratch: Interactive Rails Without an SPA

Turbo Frames from scratch

In Rails, you can build an interactive interface without a separate JSON API, global client-side state, or a React application. The server keeps rendering HTML, and the browser replaces only the fragment that needs to change.

Recently I redesigned wishlist selection in an online auto-parts store. The user clicks Save, sees their lists, checks several boxes, can create a new list right there, and confirms the selection. The page does not reload.

This small scenario exposed almost every fundamental property of Turbo Frames: frame boundaries, lazy loading, stable DOM IDs, cooperation with Turbo Streams, and state that exists only in the browser.

What problem does Turbo solve?

A classic server-rendered website is simple:

  1. The browser requests a URL.
  2. The server reads the database and builds HTML.
  3. The browser replaces the entire page.

This architecture has one source of truth and a clear data flow. But even a small action, such as renaming a list, causes a full navigation and loses local page state: scroll position, an open dialog, or input focus.

An SPA usually solves this differently:

  1. JavaScript requests JSON.
  2. It stores state in the browser.
  3. React, Vue, or another framework turns that state into DOM.

The interface becomes responsive, but a second rendering system appears. The server knows the business rules and data, the client knows how to assemble the screen, and the JSON contract has to keep these two worlds in sync.

Hotwire offers a third formulation: send not data for the interface, but HTML that is already ready to render. The server remains the owner of the view, while a small universal JavaScript runtime knows which part of the document to replace.

The whole model can be reduced to one flow:

flowchart LR
    B[Browser] -->|HTTP request| S[Rails server]
    S -->|ready-made HTML| B
    B -->|Turbo Drive: whole body| D[Full navigation]
    B -->|Turbo Frame: area by ID| F[Partial navigation]
    B -->|Turbo Stream: DOM command| T[Targeted change]
    F --> J[Stimulus: local behavior]
    T --> J

Turbo does not turn server-rendered HTML into JSON and does not introduce separate client-side state by itself. It only chooses which part of the response to apply to the DOM.

Four mechanisms matter for an ordinary web interface:

  • Turbo Drive speeds up regular links and forms by replacing <body> without a full page reload;
  • Turbo Frames create separate navigation areas inside a page;
  • Turbo Streams perform targeted DOM operations such as replace, prepend, and remove;
  • Stimulus adds the JavaScript that still belongs to browser behavior: focus, drag-and-drop, opening a widget, or integrating an external library.

Strictly speaking, Drive, Frames, and Streams are Turbo mechanisms. Stimulus is a separate part of Hotwire, and the family also includes Hotwire Native for mobile applications.

A mental model for Turbo Frames

A Turbo Frame is an HTML element with a unique id:

<turbo-frame id="wishlist_picker">
  <a href="/wishlists">Choose a list</a>
</turbo-frame>

The link is inside the frame, so Turbo intercepts the click and makes a regular HTTP request in the background. In the response, it looks for a frame with the same ID:

<turbo-frame id="wishlist_picker">
  <form action="/wishlist_items" method="post">
    <label><input type="checkbox" name="wishlist_ids[]" value="1"> Volvo</label>
    <button>Done</button>
  </form>
</turbo-frame>

It then replaces the contents of the old wishlist_picker with the contents of the new one. The rest of the page stays untouched.

The response can be a complete HTML document. Turbo will still extract only the matching frame from it. This lets one Rails action serve both modes:

  • a regular navigation without Turbo shows a standalone page;
  • a request from a frame updates only the area inside it.

In Rails, the markup is usually created with a helper:

<%= turbo_frame_tag "wishlist_picker" do %>
  <%= link_to "Choose a list", wishlists_path %>
<% end %>

The important point is that a frame defines a navigation context, not a visual component. By default, everything inside it loads responses back into that frame. The boundary should match the area that you are willing to replace as a whole.

Lazy loading: HTML on demand

A frame can load its contents through src:

<turbo-frame
  id="wishlist_picker"
  src="/wishlists/selector?variant_id=42"
  loading="lazy"
>
  <span>Loading...</span>
</turbo-frame>

According to the Turbo Frames handbook, loading="lazy" delays the request until the element enters the viewport. This is especially useful for a modal, a tab, or a block below the first screen.

In my case, the product card renders only the button and the shell of a bottom panel right away. The user's lists load when the panel opens. This has two advantages:

  • the main product listing does not run an expensive list query for every card;
  • the user gets fresh data exactly when they start choosing.

The lazy-loading flow looks like this:

sequenceDiagram
    participant V as Widget
    participant F as Turbo Frame
    participant S as Rails
    participant D as DOM

    V->>F: element enters the viewport
    F->>S: GET /wishlists/selector
    S-->>F: HTML with the same id
    F->>D: replace only the frame contents

This leads to an easy-to-miss rule: a GET endpoint must be safe, meaning that it must not change data. A safe GET is also idempotent: repeating the request creates no new effects. Turbo can prefetch links, browsers repeat requests, and search crawlers visit URLs. Creating a default list when the selector opens would be a mistake. The branch creates it only after an explicit PUT from the Done button.

One real interface, three kinds of state

A wishlist selector looks like one widget, but its state lives in three places.

State Where it lives Example
Persisted database on the server the product already belongs to lists 2 and 5
Draft current browser DOM the user unchecked 2 and checked 7 but has not clicked Done
Visual JavaScript and Bootstrap the bottom panel is open and the input has focus

This distinction shaped the architecture more than the visual design did.

It helps to draw the boundaries of responsibility separately:

flowchart TB
    DB[(Database)] -->|persisted membership| R[Rails HTML]
    R -->|renders| DOM[Browser DOM]
    DOM -->|draft selection| DOM
    ST[Stimulus + Bootstrap] -->|focus and offcanvas| DOM
    TF[Turbo Frame] -->|replaces area by ID| DOM
    TS[Turbo Stream] -->|prepend / replace / remove| DOM

If an operation affects only the DOM, the server does not need to know about it. If data must survive the next request, it has to be explicitly submitted and persisted.

The simplified page structure looks like this:

<div data-controller="wishlist-selector">
  <turbo-frame id="wishlist_button_variant_42">
    <button>♡ Save</button>
  </turbo-frame>

  <div class="offcanvas">
    <form id="wishlist_button_variant_42_form" action="/wishlist_items/sync_memberships" method="post">
      <input type="hidden" name="_method" value="put">
    </form>

    <turbo-frame
      id="wishlist_button_variant_42_wishlists"
      src="/wishlists/selector?variant_id=42"
      loading="lazy"
    >
      Loading...
    </turbo-frame>

    <button type="submit" form="wishlist_button_variant_42_form">
      Done
    </button>
  </div>
</div>

There are two different frames here:

  1. The small button frame lets the empty heart become a filled heart after saving.
  2. The list frame independently loads the current checkboxes.

Visually this is one bottom panel. From the perspective of updates, it consists of two areas with different lifetimes.

Why you should not simply redraw the whole list

The user opens the selector and changes several checkboxes. Those changes have not reached the server yet. Then they click New list, enter a name, and save it.

A naive implementation renders the whole frame again after creating the list:

render partial: "wishlist_selector_list"

The server knows the persisted state, but it knows nothing about the checkbox changes that have not been submitted. Replacing the whole frame silently resets them to their old values.

The problem is not Turbo. We simply redrew the area that contained the user's draft.

The solution in this branch is to return two targeted Turbo Stream operations:

render turbo_stream: [
  turbo_stream.prepend(
    "wishlist_button_variant_42_wishlists_items",
    partial: "wishlist_selector_row",
    locals: { wishlist: @wishlist, checked: true }
  ),
  turbo_stream.replace(
    "wishlist_button_variant_42_wishlists_quick_create",
    partial: "wishlist_selector_quick_create"
  )
]

The new row is added at the beginning of the list, the create form is cleared, and existing DOM nodes with changed checkboxes stay where they are.

This is one of the main lessons of a server-driven UI: the size of a response must account not only for server data, but also for unsaved browser state.

Turbo Frame and Turbo Stream are not the same thing

They are often mixed up because both update parts of a page.

Turbo Frame Turbo Stream
Main idea separate navigation context command that changes the DOM
How the target is selected matching <turbo-frame id> target with a DOM ID or targets with a CSS selector
Typical source link, form, or frame src form response, WebSocket, or SSE
Typical action replace the frame contents append, prepend, replace, update, remove, and more
Does the target need <turbo-frame>? yes no, any regular HTML element works
When to use it an area loads and replaces itself one response should change several points on the page

After the user clicks Done, the server in my example changes two independent areas at once:

  • the heart for the specific product;
  • the list counter in the navigation.

This is a natural Turbo Stream response with two replace operations. There is no need to wrap the navigation counter in a frame just to update it through a stream. The Turbo Streams handbook makes the same point.

A short rule:

  • if a link or form "travels" inside its own area, start with a Frame;
  • if the server needs to send several targeted changes, use a Stream;
  • if only a local browser effect is needed, such as focusing an input, use Stimulus.

Why Stimulus is still needed here

Turbo does not try to replace all JavaScript. It takes care of network interaction and rendering, but it does not have to manage Bootstrap components.

In the current branch, Stimulus does three small things:

  1. It moves the offcanvas into <body> so the product card's z-index does not trap the panel inside a new stacking context.
  2. On every open, it calls frame.reload() to show the current lists.
  3. Another controller focuses the input after the Bootstrap collapse animation finishes.

Business state is not duplicated in JavaScript. Only DOM behavior and the lifecycle of the third-party UI component remain there.

There is also a subtle detail: Stimulus targets are searched for inside the controller element. If a target is moved out of that subtree into <body>, it can no longer be treated as an ordinary dynamic target. That is why the DOM element is saved before moving it, and only the child panel is moved, not the controller's root. Otherwise you can create a disconnect / connect loop.

This is not the foundation of Turbo Frames, but it is a good example of an architectural boundary: Turbo handles HTML over the network, while Stimulus handles the lifecycle of elements in the browser.

Seven rules that save time

1. The ID is a contract

A request from turbo-frame#abc expects a turbo-frame#abc in the response. If there is no matching element, Turbo considers the response unsuitable for the frame and shows a Content missing error.

In Rails, it is convenient to build IDs with dom_id:

dom_id(variant, :wishlist_button)
# wishlist_button_variant_42

This keeps IDs stable and unique even with dozens of product cards on the page.

2. Choose the smallest replaceable area

If only the heart changes after an action, do not replace the entire product card. A larger area increases the chance of losing focus, form state, expanded elements, and connected JavaScript widgets.

3. GET creates nothing

Lazy loading and prefetch make background GET requests less predictable. Any creation, deletion, or change must happen through an explicit unsafe HTTP operation: POST, PUT, PATCH, or DELETE.

4. Do not confuse database state with DOM state

The server does not know about unsaved input. Before a replace, ask: does the target contain a text field, a checked checkbox, a scroll position, or an open element that exists only in the browser?

5. One partial must return the right shell

If an endpoint serves frame navigation, its response must include the expected <turbo-frame>. It is convenient to keep the tag in a partial that is used both for the first render and for updates.

6. A Stream target does not need a frame

Turbo Stream works with any element that has a suitable id. An unnecessary frame changes the behavior of nested links and forms, so adding one "just in case" is harmful.

7. Test behavior, not only the HTML response

A controller spec can check that the right stream actions are returned, but it will not catch the loss of unsaved checkboxes after replacing their parent. This scenario needs a browser test: change the selection, create a new list, and verify that the previous checks remain selected.

Isn't this just htmx?

It is very similar. htmx extends HTML with attributes that describe the request, event, target, and replacement method:

<button
  hx-post="/wishlist_items"
  hx-target="#wishlist_button_variant_42"
  hx-swap="outerHTML"
>
  Save
</button>

Turbo Frame expresses a narrower convention: links and forms inside a named area update that same area. htmx gives you more local control: a request can originate from almost any element, run on different events, and change an explicitly chosen target.

The practical difference:

  • Turbo feels especially natural in Rails: helpers, the stream MIME type, respond_to, broadcasting, and conventions are already part of the ecosystem;
  • htmx is not tied to a server framework and usually describes behavior more explicitly on the element itself;
  • Turbo has fewer attributes for the standard CRUD flow;
  • htmx makes it easier to perform a non-standard swap without switching to a separate Turbo Stream format.

The idea behind htmx is not new either. Its authors explicitly describe it as a continuation of intercooler.js, a jQuery library that added server interactions through HTML attributes.

"But PHP had this years ago"

Yes, the family of ideas is older than Hotwire.

In the mid-2000s, the PHP library xajax let JavaScript call a PHP function and change part of the page asynchronously. The repository carries a 2005 copyright notice. An xajax response was closer to a set of XML commands such as "assign this HTML to that element" than to today's REST navigation between HTML views, but the developer experience felt familiar: the server decided what to show without a large client application being written by hand.

ASP.NET Web Forms had an UpdatePanel: the server reran the page lifecycle, while the browser updated a selected area without a full postback. It looks similar from the outside, although the protocol, the heavy hidden page state, and the server-control model were different.

The modern PHP descendant of this line is Laravel Livewire. It renders Blade on the server, listens for browser events, and makes AJAX requests. But Livewire is a component system: together with HTML it stores a JSON snapshot of the PHP component's public state and hydrates it on the next request. A Turbo Frame is simpler and more stateless: a URL plus an HTML response with a matching ID.

Two more close relatives:

  • Unpoly adds fragment updates, layers, preload, and progressive enhancement to server applications regardless of language;
  • Phoenix LiveView starts with ordinary HTTP and HTML, then keeps a stateful process on the server and sends DOM diffs over a persistent connection.

All of them keep rendering closer to the server, but they answer the question "where does state live between actions?" differently.

What happens in React and Next.js?

It is important to distinguish three technologies that are often called SSR as if they were the same thing.

Classic SSR renders the initial HTML on the server. After loading, React hydrates the markup, and subsequent actions are usually handled by the client application. SSR by itself is not an equivalent of Turbo Frames.

React Server Components run before bundling in a server environment and do not enter the client bundle. They can read data close to its source and pass the result to client components. But this is not "return ordinary HTML and insert it by ID". The framework sends a special representation of the component tree.

Next.js App Router builds pages and layouts from Server Components by default. On a subsequent navigation, the server generates an RSC payload, and the client router merges it with the existing tree while preserving the state of shared layouts. Next.js adds prefetching, streaming, Suspense, and client-side transitions.

The goal is close to Turbo: less client code, server rendering, and partial updates. The mechanism is a different abstraction level:

Turbo Frames Next.js App Router + RSC
Composition unit HTML area with an ID React component tree and route segments
Update format ordinary HTML RSC payload plus HTML for the first response
Client model DOM and a universal Turbo runtime React runtime, reconciliation, and client components
Server anything capable of returning HTML a React framework with RSC support
Local interactivity Stimulus or ordinary JS Client Components, hooks, and state

So the phrase "Vercel has also returned to the server" is broadly true, but the technologies are not the same. Turbo develops the browser's hypermedia model. React moves the component execution boundary between server and client while keeping React as the interface model.

When Turbo Frames are a good fit

Turbo is especially convincing when:

  • the application already renders HTML on the server;
  • the interface mostly consists of forms, lists, cards, filters, and CRUD;
  • business rules and authorization live on the server;
  • progressive enhancement and simple HTTP flows matter;
  • the team wants to avoid duplicating types, APIs, and templates on both sides.

Do not force it onto an interface where:

  • most actions must work instantly without a network;
  • there is a complex graph of local state with undo/redo;
  • the application contains heavy client-side visualization, an editor, a canvas, or a game;
  • the application must work offline for a long time;
  • one screen continuously combines data from many independent backend APIs directly in the browser.

The boundary does not have to run through the entire application. A catalog, checkout, and settings can use Turbo, while a complex editor inside one page can be a React component.

What I took away from this branch

Before this task, Turbo Frames seemed to me like "an iframe without an iframe": the server returned a piece of HTML and the browser inserted it. The mechanics really are that simple, but the design revolves around a deeper question: who owns the state at each moment of an action?

In the wishlist selector, the answer was:

  • the database owns persisted membership in lists;
  • the DOM temporarily owns checkboxes that have not been confirmed;
  • Turbo owns delivery and replacement of server-rendered HTML;
  • Stimulus owns focus and the lifecycle of the Bootstrap offcanvas.

After separating those responsibilities, the decisions become almost mechanical:

  • load a fresh list - lazy Turbo Frame;
  • add one row without losing the draft - turbo_stream.prepend;
  • update the heart and navigation - two stream replace operations;
  • place the cursor in a field - six lines of Stimulus;
  • create a default list - only after an explicit PUT, never on GET.

Turbo does not eliminate client-side state. It lets you avoid creating a separate state architecture where the browser DOM is already a sufficient model.