This guide is part three of a three-part series on building a modern single-page application with Ruby on Rails and Inertia.js.
In the previous article, we built out CRUD functionality and explored how Rails and Inertia handle data interactions through forms, validations, and smooth data flow.
In this final chapter, we’ll focus on refining our app by reviewing Inertia requests, implementing nested routing and actions, generating JS routes, and adding serializers. These additions will simplify routing and give us more control over data passed to the frontend, completing our Rails-Inertia setup.
Source code: https://github.com/greybutton/rails_inertia_blog
1. Review Inertia Requests
Network Requests Overview
Inertia handles requests for us, managing both server responses and client-side navigation without full page reloads. Here’s a look at how Inertia manages data with some examples:
1. Viewing the Article List
– First Load: The initial page load is a standard document
request where the server delivers a complete HTML document, similar to a traditional server-rendered app.
– Subsequent Requests: Once the app is loaded, Inertia intercepts navigation requests. When you click to navigate within the app (e.g., “New Article” or “List Articles”), Inertia sends requests to the server, requesting only the necessary data to update the view.
2. Opening the New Article Page
When you click “New Article”, the request Inertia sends to the server looks like this:
{
"component": "articles/New",
"props": {
"article": {
"id": null,
"title": null,
"body": null,
"created_at": null,
"updated_at": null
},
"web_articles_path": "/web/articles"
},
"url": "/web/articles/new",
"version": null
}
Explanation:
– Component: Inertia identifies that it needs the articles/New
component.
– Props: The props
object contains data passed from the server to initialize the view. Here, it includes an empty article object (id
, title
, and body
set to null
) and the web_articles_path for form submission.
– URL: The request URL shows that we’re navigating to /web/articles/new
.
Inertia loads the New component with these props, dynamically displaying the form for a new article.
3. Returning to the Article List
When you click “List Articles”, the request payload changes as follows:
{
"component": "articles/Index",
"props": {
"articles": [
{
"id": 1,
"title": "Hello Rails",
"body": "I am on Rails!",
"created_at": "2024-10-28T18:57:53.319Z",
"updated_at": "2024-10-28T18:57:53.319Z",
"web_article_path": "/web/articles/1"
}
],
"new_web_article_path": "/web/articles/new"
},
"url": "/web/articles",
"version": null
}
Explanation:
– Component: Now, Inertia is loading the articles/Index
component for the list view.
– Props: The props
object here contains a list of articles, each with its attributes like title
, body
, created_at
, and updated_at
. Additionally, it includes the web_article_path link for each article, enabling easy navigation.
– URL: The request is made to /web/articles
, matching the URL for the article list page.
4. Using the Browser Back Button
If you use the browser’s back button to return to the list, the page doesn’t reload. Inertia caches each view, allowing for smooth transitions without redundant requests to the server.
In Summary
Inertia abstracts away the complexity of requests, handling data fetching, page transitions, and URL updates automatically. We simply specify:
– Which component to load.
– What props to pass to the component for rendering.
Inertia then takes care of sending the appropriate data to the server, managing history states, and seamlessly updating the view without a full page reload. This keeps our application fast and reactive, combining the best of server-side rendering with the benefits of a SPA (Single Page Application) experience.
2. Nested Routing and Actions
To add comments to an article, we’ll set up nested routes, a comments controller, and a form for submitting comments. Instead of adding comments as a separate prop, we’ll include them in the article’s comments attribute to illustrate embedding related data.
1. Create ApplicationController for Articles Namespace
This controller provides a shared resource_article
method for finding the parent Article
by article_id
, which will be used in the comments
controller.
# app/controllers/web/articles/application_controller.rb
class Web::Articles::ApplicationController < Web::ApplicationController
def resource_article
@resource_article ||= Article.find(params[:article_id])
end
end
2. Create CommentsController for Articles
This controller handles creating comments for a specific article. The create action finds the parent article via resource_article
and then creates the comment.
# app/controllers/web/articles/comments_controller.rb
class Web::Articles::CommentsController < Web::Articles::ApplicationController
def create
@comment = resource_article.comments.create(comment_params)
redirect_to web_article_path(resource_article)
end
private
def comment_params
params.require(:comment).permit(:commenter, :body)
end
end
3. Update ArticlesController
to Include Comments in Show
Action
Instead of adding comments as a separate prop, we embed them within the article object by using as_json
with include: :comments
. This demonstrates how to structure an article’s prop with associated data. Additionally, by including web_article_comments_path: web_article_comments_path(@article)
in the props, we provide a convenient URL for submitting new comments directly linked to the specific article.
# app/controllers/web/articles_controller.rb
class Web::ArticlesController < Web::ApplicationController
def show
@article = Article.find(params[:id])
render inertia: "articles/Show", props: {
article: @article.as_json(include: :comments),
edit_web_article_path: edit_web_article_path(@article),
web_article_path: web_article_path,
web_article_comments_path: web_article_comments_path(@article)
}
end
end
4. Update the Show
Component to Display and Add Comments
The Show
component now renders the list of comments and includes a form to add new comments.
// app/javascript/packs/web/articles/Show.jsx
import React from "react";
import { Link } from "@inertiajs/react";
import CommentForm from "./comments/_Form";
const Article = ({ article, edit_web_article_path, web_article_path, web_article_comments_path }) => {
const handleDestroy = (e) => {
if (!confirm("Are you sure?")) {
e.preventDefault();
}
};
const handleCommentSubmit = (form, options) => {
form.post(web_article_comments_path, options);
};
return (
<>
<h1>{article.title}</h1>
<p>{article.body}</p>
<ul>
<li>
<Link href={edit_web_article_path}>Edit Article</Link>
</li>
<li>
<Link
href={web_article_path}
onClick={handleDestroy}
method="delete"
as="button"
type="button"
>
Delete Article
</Link>
</li>
</ul>
<h2>Comments</h2>
<ul>
{article.comments.map((comment) => (
<li key={comment.id}>
{comment.commenter}: {comment.body}
</li>
))}
</ul>
<h2>Add a comment:</h2>
<CommentForm onSubmit={handleCommentSubmit} submitText="Add Comment" />
</>
);
};
export default Article;
5. Create the CommentForm
Component
The _Form.jsx
component handles form submission for comments, including form validation and resetting the form upon successful submission.
// app/javascript/packs/web/articles/comments/_Form.jsx
import React from "react";
import { useForm } from "@inertiajs/react";
const Form = ({ comment = {}, onSubmit, submitText }) => {
const form = useForm({
commenter: comment.commenter || "",
body: comment.body || "",
});
const { data, setData, processing, errors, reset } = form;
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(form, { onSuccess: () => reset() });
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
type="text"
value={data.commenter}
onChange={(e) => setData("commenter", e.target.value)}
/>
{errors.commenter && <div>{errors.commenter}</div>}
</div>
<div>
<input
type="textarea"
value={data.body}
onChange={(e) => setData("body", e.target.value)}
/>
{errors.body && <div>{errors.body}</div>}
</div>
<button type="submit" disabled={processing}>
{submitText}
</button>
</form>
);
};
export default Form;
6. Update Routes to Include Nested Comments
Set up nested routes within the web/articles
namespace to make comments accessible under each article.
# config/routes.rb
namespace :web do
resources :articles do
scope module: :articles do
resources :comments
end
end
end
– Visit an article page to view existing comments.
– Use the “Add Comment” form to post a new comment and see it displayed immediately.
– To further explore, you can implement the delete functionality for comments as a next step.
This setup demonstrates how to nest resources in Inertia with Rails, passing data through props to maintain seamless interaction with the component hierarchy. Adding comments directly into the article
prop keeps the data structured, making it easier to work with associated models in the frontend.
3. Improvements
3.1 Generate JS Routes (Remove Route Passing)
To streamline the app and remove the need to manually pass routes from the backend, we’ll use the js-routes
gem to generate a single JavaScript file containing all Rails routes. This allows us to reference routes directly in the frontend without needing to modify backend actions every time we need a new route.
1. Add rake task lib/tasks/js_routes.rake
require "js-routes"
def generate_routes(root, **options)
routes_dir = Rails.root.join(root)
desc("Generate JS routes")
task(generate: :environment) do
FileUtils.mkdir_p(routes_dir)
routes_file_name = File.join(root, "routes.js")
JsRoutes.generate!(routes_file_name, **options)
end
end
namespace :js_routes do
generate_routes("packs/web", camel_case: true, include: [ /web/ ])
end
2. Install js-routes
gem
docker compose run --rm web bash -c "bundle add js-routes --version '~> 2.2'"
3. Generate routes.js
file
Use a custom Rake task to generate the routes.js file. This file will contain all Rails routes in a format suitable for JavaScript.
docker compose run --rm web bash -c "rake js_routes:generate"
This will generate app/javascript/packs/web/routes.js
, containing all routes with names in camelCase (following Rails naming conventions). Each route is defined as a function.
4. Refactor Frontend Components to Use JS Routes
After generating routes.js
, you can import the routes into your components and replace any previously passed paths.
Remove Route Props from Backend: In ArticlesController
, we can now eliminate all route-specific props from the show
, edit
, and other actions:
# app/controllers/web/articles_controller.rb
class Web::ArticlesController < Web::ApplicationController
def index
@articles = Article.all
render inertia: "articles/Index", props: {
articles: @articles,
}
end
def show
@article = Article.find(params[:id])
render inertia: "articles/Show", props: {
article: @article.as_json(include: :comments),
}
end
def new
@article = Article.new
render inertia: "articles/New", props: {
article: @article,
}
end
def edit
@article = Article.find(params[:id])
render inertia: "articles/Edit", props: {
article: @article,
}
end
end
Import Routes in Components: In your React components, import the generated routes instead of relying on backend-passed paths.
// app/javascript/packs/web/articles/Index.jsx
import React from "react";
import { Link } from "@inertiajs/react";
import { webArticlePath, newWebArticlePath } from "web/routes";
const Articles = ({ articles }) => (
<>
<h1>Articles</h1>
<ul>
{articles.map((article) => (
<li key={article.id}>
<Link href={webArticlePath(article)}>{article.title}</Link>
</li>
))}
</ul>
<Link href={newWebArticlePath()}>New Article</Link>
</>
);
// app/javascript/packs/web/articles/Show.jsx
import React from "react";
import { Link } from "@inertiajs/react";
import { editWebArticlePath, webArticlePath, webArticleCommentsPath } from "web/routes";
import CommentForm from "./comments/_Form";
const Article = ({ article }) => {
const handleDestroy = (e) => {
if (!confirm("Are you sure?")) {
e.preventDefault();
}
};
const handleCommentSubmit = (form, options) => {
form.post(webArticleCommentsPath(article), options);
};
return (
<>
<h1>{article.title}</h1>
<p>{article.body}</p>
<ul>
<li>
<Link href={editWebArticlePath(article)}>Edit Article</Link>
</li>
<li>
<Link
href={webArticlePath(article)}
onClick={handleDestroy}
method="delete"
as="button"
type="button"
>
Delete Article
</Link>
</li>
</ul>
<h2>Comments</h2>
<ul>
{article.comments.map((comment) => (
<li key={comment.id}>
{comment.commenter}: {comment.body}
</li>
))}
</ul>
<h2>Add a comment:</h2>
<CommentForm onSubmit={handleCommentSubmit} submitText="Add Comment" />
</>
);
};
export default Article;
// app/javascript/packs/web/articles/Edit.jsx
import React from "react";
import { webArticlePath } from "web/routes";
import Form from "./_Form";
const Edit = ({ article }) => {
const handleSubmit = (form) => {
form.put(webArticlePath(article));
};
return (
<>
<h1>Edit Article</h1>
<Form
article={article}
onSubmit={handleSubmit}
submitText="Update Article"
/>
</>
);
};
export default Edit;
// app/javascript/packs/web/articles/New.jsx
import React from "react";
import { Link } from "@inertiajs/react";
import { webArticlesPath } from "web/routes";
import Form from "./_Form";
const New = ({ article }) => {
const handleSubmit = (form) => {
form.post(webArticlesPath());
};
return (
<>
<h1>New Article</h1>
<Form
article={article}
onSubmit={handleSubmit}
submitText="Create Article"
/>
<Link href={webArticlesPath()}>List Articles</Link>
</>
);
};
export default New;
5. Regenerate Routes for New Paths
Whenever you add a new route in Rails, run rake js_routes:generate
to update routes.js
so the new routes become available in the frontend.
Why This Change Improves the Code
– Eliminates Redundant Path Passing: By centralizing route definitions in routes.js
, we no longer need to pass paths from backend actions, reducing repetition and improving readability.
– Keeps Routing Logic Consistent: Using Rails’ routes directly in JavaScript ensures the frontend adheres to backend routing conventions, even as routes change or expand.
– Reduces Controller Changes: This approach minimizes backend modifications whenever we add or change paths, making future maintenance more efficient.
This refactoring allows the frontend to directly access any route, significantly improving maintainability and scalability as the application grows.
3.2 Adding Serializers
To control which fields are sent in responses and simplify data structure, we’re introducing serializers with the active_model_serializers
gem. This allows us to customize responses for each view, selectively including only the necessary attributes and relationships.
Why Use Serializers?
Without serializers, our responses include all fields from the database, which may expose unnecessary data. With serializers, we can tailor each response, providing only the fields we want. For example, we’ll exclude fields like created_at
and updated_at
in certain views, streamlining the data sent to the frontend.
1. Add active_model_serializers
gem
docker compose run --rm web bash -c "bundle add active_model_serializers --version '~> 0.10.14'"
2. Create an Initializer for Serializers
Configure ActiveModelSerializers to use the json
adapter in config/initializers/active_model_serializers.rb:
# config/initializers/active_model_serializers.rb
require "active_model_serializers"
ActiveModelSerializers.config.adapter = :json
Restart the application to apply the serializer configuration changes: docker compose up
3. Generate Serializers
Generate serializers for Article, ArticleShow, and Comment to structure responses.
docker compose run --rm web bash
rails g serializer Application
rails g serializer Web::Application --parent=ApplicationSerializer
rails g serializer Web::ArticleShow --parent=Web::ApplicationSerializer
rails g serializer Web::Article --parent=Web::ApplicationSerializer
rails g serializer Web::ArticleShowSerializer::Comment --parent=Web::ApplicationSerializer
exit
4. Define Serializers
Customize each serializer to control which fields are included in the responses.
ApplicationSerializer (Base Serializer):
# app/serializers/application_serializer.rb
class ApplicationSerializer < ActiveModel::Serializer
end
Web::ApplicationSerializer (For CamelCase Conversion):
This serializer transforms attribute keys to camelCase, aligning with JavaScript naming conventions.
# app/serializers/web/application_serializer.rb
class Web::ApplicationSerializer < ApplicationSerializer
def attributes(*args)
hash = super(*args)
hash.transform_keys { |key| key.to_s.camelize(:lower) }
end
end
Web::ArticleSerializer (For List View):
Limits fields to id, title, and body for displaying articles in a list.
# app/serializers/web/article_serializer.rb
class Web::ArticleSerializer < Web::ApplicationSerializer
attributes :id, :title, :body
end
Web::ArticleShowSerializer (For Detailed View):
Includes comments for a detailed article view.
class Web::ArticleShowSerializer < Web::ApplicationSerializer
attributes :id, :title, :body
has_many :comments
end
Web::ArticleShowSerializer::CommentSerializer:
Defines the structure of a comment within an article view.
# app/serializers/web/article_show_serializer/comment_serializer.rb
class Web::ArticleShowSerializer::CommentSerializer < Web::ApplicationSerializer
attributes :id, :commenter, :body
end
5. Implement Serializers in Controllers
Use the new serialize
method in Web::ApplicationController
to apply the appropriate serializer for each action.
# app/controllers/web/application_controller.rb
class Web::ApplicationController < ApplicationController
def serialize(resource, options = {})
if resource.respond_to?(:length)
ActiveModel::Serializer::CollectionSerializer.new(resource, options)
else
options[:serializer].new(resource, options)
end
end
end
Update the index
and show
actions in ArticlesController
to use serializers:
# app/controllers/web/articles_controller.rb
class Web::ArticlesController < Web::ApplicationController
def index
@articles = Article.all
render inertia: "articles/Index", props: {
articles: serialize(@articles, serializer: Web::ArticleSerializer),
}
end
def show
@article = Article.find(params[:id])
render inertia: "articles/Show", props: {
article: serialize(@article, serializer: Web::ArticleShowSerializer),
}
end
end
5. Comparison: Before and After Serialization
Before: The response includes all fields, such as created_at
and updated_at
, which may not be necessary in every view.
{
"component": "articles/Index",
"props": {
"articles": [
{
"id": 1,
"title": "Hello Rails",
"body": "I am on Rails!",
"created_at": "2024-10-28T18:57:53.319Z",
"updated_at": "2024-10-28T18:57:53.319Z"
}
],
},
"url": "/web/articles",
"version": null
}
After: Only specified fields (id
, title
, body
) are included, keeping the response lean and focused.
{
"component": "articles/Index",
"props": {
"articles": [
{
"id": 1,
"title": "Hello Rails",
"body": "I am on Rails!"
}
],
},
"url": "/web/articles",
"version": null
}
Why This Change Improves the Code
1. Selective and Efficient Data Exposure: Serializers enable us to include only necessary fields in each response, enhancing privacy by reducing data exposure and improving performance by minimizing response payloads. This keeps data lean and secure.
2. Frontend Consistency: By transforming keys to camelCase, serializers ensure data aligns with JavaScript naming conventions, making it easier to work with on the frontend and improving code readability.
3. Centralized and Scalable Control: Serializers centralize data structure management, allowing us to modify response formats in one place. This keeps controllers clean and makes adjustments scalable as the app grows, enhancing maintainability.
Overall, serializers make our application more secure, efficient, and easier to maintain, ensuring that only relevant data is sent to the frontend in a format that aligns with JavaScript best practices.
4. Conclusion
In this series, we walked through building a basic Rails application with Inertia.js, covering the essential steps and concepts to get a modern single-page application up and running. Starting from project setup, we progressively enhanced our app by implementing core Inertia features, including:
– Pages and Responses: We created different pages and structured our Inertia responses to control the data passed from the backend to the frontend.
– Routing and Redirects: We set up routes without the need for a frontend router, using Rails routes to handle navigation and Inertia responses to manage state and URL updates.
– Links and Forms: We added dynamic links and forms, ensuring seamless user interactions with server-side validation and error handling.
– Validation and Serialization: By using serializers, we controlled the data format, minimized payloads, and structured responses to keep our frontend secure, efficient, and consistent.
This app demonstrates how Inertia.js allows us to build a single-page experience while retaining the simplicity and stability of server-side rendering in Rails. I aimed to keep the setup straightforward yet flexible, showing how even a basic Rails-Inertia setup can bring modern functionality to a Rails app.
5. What’s Next?
This series covered the basics of building a Rails app with Inertia.js. If you enjoyed this intro series, here’s what we could dive into next:
1. Backend Features: testing, search, sort, filter, pagination, authentication, file uploads, and flash messages.
2. Frontend Improvements: layouts, styling, shared data, i18n, client-side validation, and serialization.
These topics will deepen functionality and polish the user experience. Let me know if you’re interested in exploring further!