DEV Community

Cover image for Building a production-ready Rails 8 API + Vite/React/TanStack monorepo starter
Juan Florville
Juan Florville

Posted on

Building a production-ready Rails 8 API + Vite/React/TanStack monorepo starter

Building a production-ready Rails 8 API + Vite/React/TanStack monorepo starter

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.

So I built rails-tanstack-starter — an open source monorepo template that has all of that done before you write your first line of product code.

This post explains the decisions behind it.


The stack

Layer Technology
Backend Ruby on Rails 8 (API mode)
Frontend Vite + React 19 + TypeScript
Routing TanStack Router
Data TanStack Query
UI Tailwind CSS v4 + shadcn/ui
Database PostgreSQL
Jobs Solid Queue
Deploy Kamal 2 → single VPS
CI GitHub Actions

Not a trendy stack — a boring, productive one. Every piece has been around long enough to have its rough edges solved.


Decision 1: Session cookies, not JWT

This is the most common question I get, so let me address it upfront.

The Rails API uses HttpOnly session cookies for auth. No JWT. No token storage on the client. No refresh logic.

Real logout. 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.

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

Rails 8 supports it natively. The new authentication generator handles session creation, cookie signing, and the Current.user pattern. No gems needed.

The frontend setup is one line — credentials: 'include' on every request:

// src/lib/api-client.ts
const response = await fetch(`/api/v1${path}`, {
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  ...options,
});
Enter fullscreen mode Exit fullscreen mode

CORS is configured with an explicit origin and credentials: true — no wildcards, which would break cookie auth anyway.


Decision 2: Monorepo, but loosely coupled

apps/web and apps/api are completely independent applications. They share nothing but HTTP.

apps/
├── web/   # Vite + React — its own package.json, Dockerfile, deploy config
└── api/   # Rails 8 — its own Gemfile, Dockerfile, deploy config
Enter fullscreen mode Exit fullscreen mode

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.

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.


Decision 3: Thin controllers, service objects

Controllers are deliberately thin:

# app/controllers/api/v1/users_controller.rb
def create
  result = Users::CreateService.new(params: user_params).call

  if result.success?
    start_new_session_for(result.data)
    render json: UserSerializer.render(result.data), status: :created
  else
    render json: { errors: result.errors }, status: :unprocessable_content
  end
end
Enter fullscreen mode Exit fullscreen mode

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

# spec/services/users/create_service_spec.rb
it "creates the user and returns a success result" do
  result = Users::CreateService.new(
    params: { email_address: "new@example.com", password: "password123" }
  ).call

  expect(result).to be_success
  expect(result.data).to be_persisted
end
Enter fullscreen mode Exit fullscreen mode

Decision 4: Centralized error handling in BaseController

Every API controller inherits from BaseController, which handles the most common exceptions so individual controllers don't have to:

# app/controllers/api/v1/base_controller.rb
class BaseController < ApplicationController
  rescue_from ActiveRecord::RecordNotFound,       with: :not_found
  rescue_from ActionController::ParameterMissing, with: :bad_request
  rescue_from ActiveRecord::RecordInvalid,        with: :unprocessable

  private

    def not_found(e)
      render json: { error: e.message }, status: :not_found
    end

    def bad_request(e)
      render json: { error: e.message }, status: :bad_request
    end

    def unprocessable(e)
      render json: { errors: e.record.errors }, status: :unprocessable_content
    end
end
Enter fullscreen mode Exit fullscreen mode

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


Decision 5: TanStack Router over React Router

Two features made the difference:

Type-safe route params. No more params.id returning string | undefined. Route params are fully typed based on your route definitions.

Integrated loader pattern. Data fetching co-located with the route definition, not scattered across useEffect calls in components.

Combined with TanStack Query for server state, the data layer is solid:

// src/features/auth/api.ts
export function useMe() {
  return useQuery({
    queryKey: ['me'],
    queryFn: () => apiClient.get<User>('/me'),
    retry: false,
  });
}
Enter fullscreen mode Exit fullscreen mode

Decision 6: Kamal 2 for deployment

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.

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.

# Deploy API
cd apps/api && kamal deploy

# Deploy frontend (from repo root)
kamal deploy -c apps/web/config/deploy.yml
Enter fullscreen mode Exit fullscreen mode

Database migrations run automatically on every deploy via the Docker entrypoint — no manual migration step, no deploy scripts to maintain.


What's included out of the box

Authentication — fully wired end to end

  • Sign up, sign in, sign out
  • Password reset via time-limited email tokens
  • Current.user pattern throughout the API

CI/CD via GitHub Actions

  • Backend: RuboCop · Brakeman · bundler-audit · RSpec
  • Frontend: ESLint · Prettier · TypeScript · Vitest
  • Two parallel jobs on every push and PR

Developer experience

  • docker compose up starts everything: Postgres, API with hot reload, frontend with HMR
  • Pre-push git hook lints only changed files — fast local feedback without running the full suite

Getting started

# Use the GitHub template button, or clone directly
git clone https://github.com/jcflorville/rails-tanstack-starter.git my-app
cd my-app

# Replace placeholders: {{PROJECT_NAME}}, {{WEB_DOMAIN}}, {{API_DOMAIN}}, etc.

# Set up git hooks
git config core.hooksPath .githooks

# Copy env files and start
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env
docker compose up
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:5173. Sign in with the seed user (test@example.com / password) and start building.

The repo is at github.com/jcflorville/rails-tanstack-starter — set up as a GitHub template so you can hit "Use this template" without forking.

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

Top comments (0)