Skip to main content

Generated Rails Response Types

React on Rails can generate TypeScript declarations for the JSON response shapes your Rails app exposes to client-side data consumers such as TanStack Query. Rails stays the source of truth for the data and authorization; the generated .d.ts file gives the React side importable response types instead of hand-written interfaces that drift.

This first supported path is an explicit Rails contract. Register static contracts from config/initializers/react_on_rails_response_types.rb or another non-reloadable boot path, then run a rake task to emit TypeScript. Keep helper methods or schema constants next to the serializer, query object, or controller when that keeps ownership clear, but avoid top-level define_response or define_type calls in reloadable app files because Rails development reloads can run those files again. The task depends on the Rails :environment task, so initializers run before declarations are generated. Keep initializer contracts literal or reference non-reloadable constants only; if a contract depends on reloadable app constants, load those constants explicitly before registration or move the registration to an app hook that runs after the constants are available.

Define Response Contracts​

Register reusable object types and endpoint response types from Rails:

# config/initializers/react_on_rails_response_types.rb
ReactOnRails::TypeScriptResponseTypes.define_type(
"Project",
fields: {
id: :number,
name: :string,
status: :string,
archived: :boolean
}
)

ReactOnRails::TypeScriptResponseTypes.define_response(
"projects.index",
type_name: "ProjectsIndexResponse",
fields: {
projects: { array: "Project" },
meta: {
fields: {
page: :number,
per_page: :number,
total: :number
}
}
}
)

Use the same shape in the Rails response:

# app/controllers/api/projects_controller.rb
def index
result = ProjectsQuery.from_params(Current.user.projects, params).result

render json: {
projects: result[:records].map { |project| ProjectSerializer.one(project) },
meta: result[:meta]
}
end

Generate the TypeScript File​

Run:

bundle exec rake react_on_rails:generate_response_types

By default, the task writes:

app/javascript/types/react_on_rails_response_types.d.ts

Override the destination when needed:

REACT_ON_RAILS_RESPONSE_TYPES_OUT=app/frontend/types/rails_response_types.d.ts \
bundle exec rake react_on_rails:generate_response_types

The override must resolve inside Rails.root; the task rejects absolute paths or traversal outside the app.

The generated file contains named interfaces and a response lookup map:

export interface Project {
id: number;
name: string;
status: string;
archived: boolean;
}

export interface ProjectsIndexResponse {
projects: Project[];
meta: {
page: number;
per_page: number;
total: number;
};
}

export interface RailsResponseTypes {
"projects.index": ProjectsIndexResponse;
}

export type RailsResponseTypeName = keyof RailsResponseTypes;
export type RailsResponseType<TName extends RailsResponseTypeName> = RailsResponseTypes[TName];

Use With TanStack Query​

Import either the concrete response type or the keyed lookup helper:

import { useQuery } from '@tanstack/react-query';
import type { RailsResponseType } from '../types/react_on_rails_response_types';
import { apiFetch } from '../lib/apiFetch';

type ProjectsIndexResponse = RailsResponseType<'projects.index'>;

const projectsQuery = useQuery({
queryKey: ['projects', status, page],
queryFn: () => apiFetch<ProjectsIndexResponse>(`/api/projects?status=${status}&page=${page}`),
});

The key string in RailsResponseTypes is intentionally independent of the route path. Use a stable controller/action-style name such as projects.index so later route changes do not churn client types.

Use With Typed Rails Actions​

For mutations, pair the generated response lookup with the CSRF-aware caller from react-on-rails/railsAction. The helper does not generate routes or validate payloads at runtime; Rails still owns the route, strong parameters, authorization, and JSON rendering. The generated type only connects the client call site to the response contract you declared in Rails:

ReactOnRails::TypeScriptResponseTypes.define_response(
"projects.create",
type_name: "ProjectsCreateResponse",
fields: {
project: "Project"
}
)
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createRailsAction, RailsActionRequestError } from 'react-on-rails/railsAction';
import type { RailsResponseType } from '../generated/react_on_rails_response_types';

type ProjectFormValues = {
name: string;
status: string;
};

type CreateProjectVariables = {
project: ProjectFormValues;
};

type CreateProjectResponse = RailsResponseType<'projects.create'>;

const createProject = createRailsAction<CreateProjectVariables, CreateProjectResponse>({
path: '/api/projects',
});

function NewProjectButton() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createProject,
onSuccess: ({ project }) => {
queryClient.setQueryData(['project', String(project.id)], { project });
queryClient.invalidateQueries({ queryKey: ['projects'] });
},
onError: (error) => {
if (error instanceof RailsActionRequestError && error.response.status === 422) {
// Map your app's validation-error JSON into form state here.
}
},
});

return <button onClick={() => mutation.mutate({ project: { name: 'Apollo', status: 'active' } })}>Create</button>;
}

React on Rails Pro apps that depend on react-on-rails-pro can use the mirrored react-on-rails-pro/railsAction export.

Use body when the Rails endpoint expects a wrapper that differs from the variables passed to mutation.mutate, and use a path function for member actions:

const archiveProject = createRailsAction<{ id: number }, RailsResponseType<'projects.archive'>>({
method: 'PATCH',
path: ({ id }) => `/api/projects/${id}/archive`,
body: () => null,
});

Supported Field Specs​

Rails contract specTypeScript output
:stringstring
:number, :integer, :floatnumber
:boolean, :boolboolean
:datestring
:jsonJsonValue
:anyany
:unknownunknown
:nullnull
"Project"Project
{ raw: "Record<string, string>" }Record<string, string>
{ array: "Project" } or [:string]Project[] or string[]
{ fields: { id: :number } }{ id: number; }
{ type: :string, optional: true }field?: string
{ type: :string, nullable: true }field: string | null

Use symbols for built-in scalar aliases and strings for named TypeScript contract references. String references must match a registered contract type_name; unknown identifiers fail generation. Use { raw: "..." } for built-in or third-party JSON-safe TypeScript types that should be emitted verbatim, such as Record<string, string>.

When an object field itself uses option-like property names (type, array, fields, raw, nullable, or optional), wrap it in fields: so the contract is unambiguous:

fields: {
event: {
fields: {
type: :string,
optional: :boolean,
payload: :json
}
}
}

Boundary​

This task generates TypeScript declarations only. It does not validate payloads at runtime, infer schema from serializers, create routes, or generate route-specific callers. The optional react-on-rails/railsAction helper builds on the same RailsResponseTypes map by letting the client choose RailsResponseType<'projects.create'> for the response generic.