InertiaJb

InertiaJb lets you declare Inertia.js props for your Rails frontend components inside view templates, using plain Ruby Hashes powered by jb.

It is built on top of the inertia-rails gem.

A *.html.inertia template is just Ruby code whose last expression is a Hash. That Hash is handed straight to InertiaRails::Renderer, so every Inertia protocol feature works out of the box — partial reloads (at any nesting depth), optional / always / deferred / scroll props, prop merging, shared data, and global key transforms.

# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  # Shared props are merged in automatically.
  inertia_share user: -> { Current.user }

  def show
    @message = Message.find(params[:id])
  end
end
# app/views/messages/show.html.inertia
{
  message: {
    content: @message.content,
    author: {
      name: @message.author.name,
      email: @message.author.email,
      url: url_for(@message.author, format: :json)
    },
    comments: render(partial: "comments/comment", collection: @message.comments)
  }
}
// app/javascript/pages/messages/show.jsx
export default function Message({ user, message }) {
  return (
    <div>
      <p>Hi, {user.name}</p>
      <p>{message.content}</p>
      <a href={message.author.url}>
        {message.author.name} &lt;{message.author.email}&gt;
      </a>
      {message.comments.map((c) => <Comment key={c.id} comment={c} />)}
    </div>
  );
}

Why jb?

Inertia's server side is fundamentally about producing a Hash of props that inertia-rails then resolves and serializes. jb templates are plain Ruby Hashes, so there is zero impedance mismatch: no DSL to learn, no intermediate representation, and the full power of Ruby for building collections and conditionals.

Installation

Add the gem to your Gemfile:

gem "inertia_jb"

Then, in your inertia-rails initializer, disable default_render so that a normal implicit render falls through to your .html.inertia template:

# config/initializers/inertia_rails.rb
InertiaRails.configure do |config|
  config.default_render = false
end

You do not need to call use_inertia_instance_props.

Layout

On an initial (non-XHR) page load the data-page root element is wrapped in a layout; Inertia (XHR) visits always return a bare JSON body with no layout.

The layout is chosen from inertia-rails' config.layout, matching InertiaRails::Renderer's own semantics.

Templates and partials

  • Page templates live at app/views/<controller>/<action>.html.inertia and must return a Hash (your Inertia props).
  • Partials are ordinary jb partials named _name.html.jb. They return a Hash, and compose naturally:
# app/views/messages/show.html.inertia
{
  author:   render(partial: "authors/author", object: @message.author),
  comments: render(partial: "comments/comment", collection: @message.comments)
}
# app/views/authors/_author.html.jb
{ id: author.id, name: author.name }

render(partial:, collection:) returns an array of Hashes (thanks to jb), which you embed directly. Don't name partials .html.inertia — that extension triggers the Inertia response wrapper and is only for top-level page templates.

Sharing a partial with a plain JSON API

An Inertia page and a plain JSON endpoint are both, in the end, just a Hash, so a single jb partial can back both. Name the partial without a format (_message.jb, not _message.html.jb) so it resolves for the html format Inertia uses and the json format a normal API request uses:

# app/views/messages/_message.jb
{
  id:      message.id,
  content: message.content,
  author:  render(partial: "authors/author", object: message.author)
}
# app/views/messages/show.html.inertia   — the Inertia page
{ **render(partial: "messages/message", object: @message) }
# app/views/messages/index.html.inertia   — nested under a key
{ messages: render(partial: "messages/message", collection: @messages, as: :message) }
# app/views/messages/show.json.jb        — a plain JSON endpoint
render(partial: "messages/message", object: @message)

Note the asymmetry: the JSON endpoint can return that Array at the top level, but the Inertia page must nest it under a key ({ posts: … }) — Inertia props must be an object, never a top-level Array.

Gotcha — wrap the page template in a Hash literal. A .html.inertia page must not be a bare top-level render(partial: …):

# ❌ props get misread as the component name
render(partial: "messages/message", object: @message)

# ✅ spread into a real Hash literal
{ **render(partial: "messages/message", object: @message) }

jb's render(partial:) returns a Jb::TemplateResult (a delegator), not a true Hash. inertia-rails decides "is this props or a component name?" with component.is_a?(Hash), so a bare partial result is taken for a component name and your props end up in the component field. Wrapping it in a literal { **… } — or nesting it under a key, e.g. { message: render(…) } — makes the top-level value a genuine Hash, which inertia-rails reads as props. A .json.jb endpoint never hits this, because jb serializes its top-level result with to_json directly.

If you'd rather keep a format-specific partial (_message.json.jb), borrow the :json variant from the Inertia side with formats::

# app/views/messages/show.html.inertia
{ **render(partial: "messages/message", object: @message, formats: [:json]) }

Inertia prop types

Because props are just a Hash, Inertia's special prop types are plain values you drop in. Inside a .html.inertia template you can use the short helpers (optional, always, defer, scroll, merge, deep_merge, once, cache) or the full InertiaRails.* methods.

{
  id:    @post.id,
  title: @post.title,

  # Only fetched when explicitly requested in a partial reload.
  stats: optional { @post.expensive_stats },

  # Always fetched, even if not requested in a partial reload.
  settings: always { current_settings },

  # Excluded from the initial load; fetched in a follow-up request.
  comments: defer(group: :comments) {
    render(partial: "comments/comment", collection: @post.comments)
  },

  # Infinite scrolling (accepts a paginator or explicit metadata).
  feed: scroll(@pagy) {
    render(partial: "feed/item", collection: @items)
  },

  # Sent once and cached client-side; skipped on later visits until reset.
  flash: once { session.delete(:flash) },

  # Server-side cached via Rails.cache; the block's JSON output is reused
  # across requests until the cache entry expires.
  report: cache("posts/#{@post.id}/report", expires_in: 5.minutes) {
    render(partial: "reports/report", object: @post.report)
  }
}

See the inertia-rails docs for partial reloads, deferred props, infinite scroll, once props, and prop caching.

camelCase keys

If you configure inertia-rails' prop_transformer to camelize keys, it applies at every nesting depth — because your props reach inertia-rails as a real Hash:

# config/initializers/inertia_rails.rb
InertiaRails.configure do |config|
  config.default_render = false
  config.prop_transformer = ->(props:) { props.deep_transform_keys { |k| k.to_s.camelize(:lower) } }
end

Alternatively, just write camelCase keys directly in your templates.

Notes

  • Inertia props must be an object, so page templates should return a Hash (not a top-level Array).
  • Don't install this alongside inertia-builder; both register an :inertia template handler.

Development

bundle install
bundle exec rake test

License

MIT. See LICENSE.