<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Juan Florville</title>
    <description>The latest articles on Forem by Juan Florville (@juan_florville_4469653226).</description>
    <link>https://forem.com/juan_florville_4469653226</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2681973%2F8c34b721-3c01-4d22-9066-2f17fbedabd7.jpg</url>
      <title>Forem: Juan Florville</title>
      <link>https://forem.com/juan_florville_4469653226</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/juan_florville_4469653226"/>
    <language>en</language>
    <item>
      <title>Building a production-ready Rails 8 API + Vite/React/TanStack monorepo starter</title>
      <dc:creator>Juan Florville</dc:creator>
      <pubDate>Wed, 06 May 2026 13:01:49 +0000</pubDate>
      <link>https://forem.com/juan_florville_4469653226/building-a-production-ready-rails-8-api-vitereacttanstack-monorepo-starter-18n1</link>
      <guid>https://forem.com/juan_florville_4469653226/building-a-production-ready-rails-8-api-vitereacttanstack-monorepo-starter-18n1</guid>
      <description>&lt;h1&gt;
  
  
  Building a production-ready Rails 8 API + Vite/React/TanStack monorepo starter
&lt;/h1&gt;

&lt;p&gt;I've started more SaaS projects than I'd like to admit, and every single one began the same way: two days wiring up auth, configuring CORS, setting up Docker, writing the same CI pipeline, and making the same architectural decisions I'd already made three times before.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/jcflorville/rails-tanstack-starter" rel="noopener noreferrer"&gt;rails-tanstack-starter&lt;/a&gt; — an open source monorepo template that has all of that done before you write your first line of product code.&lt;/p&gt;

&lt;p&gt;This post explains the decisions behind it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Ruby on Rails 8 (API mode)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;Vite + React 19 + TypeScript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Routing&lt;/td&gt;
&lt;td&gt;TanStack Router&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;TanStack Query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;Tailwind CSS v4 + shadcn/ui&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jobs&lt;/td&gt;
&lt;td&gt;Solid Queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy&lt;/td&gt;
&lt;td&gt;Kamal 2 → single VPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI&lt;/td&gt;
&lt;td&gt;GitHub Actions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Not a trendy stack — a boring, productive one. Every piece has been around long enough to have its rough edges solved.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision 1: Session cookies, not JWT
&lt;/h2&gt;

&lt;p&gt;This is the most common question I get, so let me address it upfront.&lt;/p&gt;

&lt;p&gt;The Rails API uses HttpOnly session cookies for auth. No JWT. No token storage on the client. No refresh logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real logout.&lt;/strong&gt; With JWT, you can't truly invalidate a token before it expires unless you maintain a blocklist — which defeats the "stateless" benefit entirely. With server-side sessions, destroying the session is a genuine logout. Instantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;XSS-proof by default.&lt;/strong&gt; HttpOnly cookies are inaccessible to JavaScript. A compromised dependency can't &lt;code&gt;document.cookie&lt;/code&gt; your auth token out of the browser. JWT stored in &lt;code&gt;localStorage&lt;/code&gt; has no such protection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rails 8 supports it natively.&lt;/strong&gt; The new authentication generator handles session creation, cookie signing, and the &lt;code&gt;Current.user&lt;/code&gt; pattern. No gems needed.&lt;/p&gt;

&lt;p&gt;The frontend setup is one line — &lt;code&gt;credentials: 'include'&lt;/code&gt; on every request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/api-client.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/v1&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;include&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CORS is configured with an explicit origin and &lt;code&gt;credentials: true&lt;/code&gt; — no wildcards, which would break cookie auth anyway.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision 2: Monorepo, but loosely coupled
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;apps/web&lt;/code&gt; and &lt;code&gt;apps/api&lt;/code&gt; are completely independent applications. They share nothing but HTTP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apps/
├── web/   # Vite + React — its own package.json, Dockerfile, deploy config
└── api/   # Rails 8 — its own Gemfile, Dockerfile, deploy config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is intentional. Shared packages in a monorepo sound appealing until you spend an afternoon debugging a TypeScript version mismatch between workspaces. For a two-app setup, the overhead isn't worth it.&lt;/p&gt;

&lt;p&gt;The benefit of keeping them together: one repo, one PR, one CI run, shared git history. When an API change breaks the frontend, you see it in the same commit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision 3: Thin controllers, service objects
&lt;/h2&gt;

&lt;p&gt;Controllers are deliberately thin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/api/v1/users_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
  &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Users&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CreateService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;params: &lt;/span&gt;&lt;span class="n"&gt;user_params&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success?&lt;/span&gt;
    &lt;span class="n"&gt;start_new_session_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="no"&gt;UserSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="ss"&gt;status: :created&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;errors: &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;errors&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="ss"&gt;status: :unprocessable_content&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All business logic lives in &lt;code&gt;app/services/&lt;/code&gt;. Every service returns a &lt;code&gt;ServiceResult&lt;/code&gt; — success or failure, with data or errors. This makes testing business logic trivial without touching HTTP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/services/users/create_service_spec.rb&lt;/span&gt;
&lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"creates the user and returns a success result"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Users&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CreateService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="ss"&gt;params: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;email_address: &lt;/span&gt;&lt;span class="s2"&gt;"new@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;password: &lt;/span&gt;&lt;span class="s2"&gt;"password123"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;

  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;be_success&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;be_persisted&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Decision 4: Centralized error handling in BaseController
&lt;/h2&gt;

&lt;p&gt;Every API controller inherits from &lt;code&gt;BaseController&lt;/code&gt;, which handles the most common exceptions so individual controllers don't have to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/api/v1/base_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BaseController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="n"&gt;rescue_from&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RecordNotFound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="ss"&gt;with: :not_found&lt;/span&gt;
  &lt;span class="n"&gt;rescue_from&lt;/span&gt; &lt;span class="no"&gt;ActionController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ParameterMissing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;with: :bad_request&lt;/span&gt;
  &lt;span class="n"&gt;rescue_from&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RecordInvalid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="ss"&gt;with: :unprocessable&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;not_found&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;error: &lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="ss"&gt;status: :not_found&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;bad_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;error: &lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="ss"&gt;status: :bad_request&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;unprocessable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;errors: &lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;errors&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="ss"&gt;status: :unprocessable_content&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, a &lt;code&gt;User.find(params[:id])&lt;/code&gt; that fails returns an HTML 500 error page from Rails — the worst possible response from a JSON API.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision 5: TanStack Router over React Router
&lt;/h2&gt;

&lt;p&gt;Two features made the difference:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type-safe route params.&lt;/strong&gt; No more &lt;code&gt;params.id&lt;/code&gt; returning &lt;code&gt;string | undefined&lt;/code&gt;. Route params are fully typed based on your route definitions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integrated loader pattern.&lt;/strong&gt; Data fetching co-located with the route definition, not scattered across &lt;code&gt;useEffect&lt;/code&gt; calls in components.&lt;/p&gt;

&lt;p&gt;Combined with TanStack Query for server state, the data layer is solid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/features/auth/api.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useMe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;me&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;apiClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/me&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Decision 6: Kamal 2 for deployment
&lt;/h2&gt;

&lt;p&gt;Kubernetes is overkill for a new SaaS. Heroku gets expensive fast past the hobby tier. Kamal 2 hits a sweet spot: Docker-based deploys to a plain VPS with zero infrastructure to manage.&lt;/p&gt;

&lt;p&gt;Both apps deploy as separate containers on the same server. The frontend (Nginx) serves the static React build. The API (Rails + Thruster) handles requests. SSL via Let's Encrypt is automatic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Deploy API&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;apps/api &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; kamal deploy

&lt;span class="c"&gt;# Deploy frontend (from repo root)&lt;/span&gt;
kamal deploy &lt;span class="nt"&gt;-c&lt;/span&gt; apps/web/config/deploy.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Database migrations run automatically on every deploy via the Docker entrypoint — no manual migration step, no deploy scripts to maintain.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's included out of the box
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Authentication — fully wired end to end&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sign up, sign in, sign out&lt;/li&gt;
&lt;li&gt;Password reset via time-limited email tokens&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Current.user&lt;/code&gt; pattern throughout the API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;CI/CD via GitHub Actions&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Backend: RuboCop · Brakeman · bundler-audit · RSpec&lt;/li&gt;
&lt;li&gt;Frontend: ESLint · Prettier · TypeScript · Vitest&lt;/li&gt;
&lt;li&gt;Two parallel jobs on every push and PR&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Developer experience&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;docker compose up&lt;/code&gt; starts everything: Postgres, API with hot reload, frontend with HMR&lt;/li&gt;
&lt;li&gt;Pre-push git hook lints only changed files — fast local feedback without running the full suite&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Use the GitHub template button, or clone directly&lt;/span&gt;
git clone https://github.com/jcflorville/rails-tanstack-starter.git my-app
&lt;span class="nb"&gt;cd &lt;/span&gt;my-app

&lt;span class="c"&gt;# Replace placeholders: {{PROJECT_NAME}}, {{WEB_DOMAIN}}, {{API_DOMAIN}}, etc.&lt;/span&gt;

&lt;span class="c"&gt;# Set up git hooks&lt;/span&gt;
git config core.hooksPath .githooks

&lt;span class="c"&gt;# Copy env files and start&lt;/span&gt;
&lt;span class="nb"&gt;cp &lt;/span&gt;apps/api/.env.example apps/api/.env
&lt;span class="nb"&gt;cp &lt;/span&gt;apps/web/.env.example apps/web/.env
docker compose up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visit &lt;code&gt;http://localhost:5173&lt;/code&gt;. Sign in with the seed user (&lt;code&gt;test@example.com&lt;/code&gt; / &lt;code&gt;password&lt;/code&gt;) and start building.&lt;/p&gt;

&lt;p&gt;The repo is at &lt;strong&gt;&lt;a href="https://github.com/jcflorville/rails-tanstack-starter" rel="noopener noreferrer"&gt;github.com/jcflorville/rails-tanstack-starter&lt;/a&gt;&lt;/strong&gt; — set up as a GitHub template so you can hit "Use this template" without forking.&lt;/p&gt;

&lt;p&gt;If you end up using it or have questions about any of the decisions, I'd love to hear from you in the comments.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>react</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
