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!