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,
});
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
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
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
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
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,
});
}
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
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.userpattern 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 upstarts 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
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)