IN SHORT
- Server-rendered HTML with Hotwire covers most screens. React only where state is genuinely complex.
- Every third-party call lives in an idempotent background job.
- Database constraints, not just validations, protect correctness under load.
- Leave out services, GraphQL and caching until the app proves it needs them.
Every few years the “default” way to build a web application changes. What has not changed is the part clients actually pay for: a feature that works, ships this week, and is still cheap to change next quarter. The stack below is what I keep coming back to because it optimises for exactly that.
1. Server-rendered HTML first, Hotwire for the rest
Most screens in a product are forms, lists and detail pages. Rails renders those well on the server, and Turbo Drive makes navigation feel instant without a single-page application. When a screen needs to update in place, such as an inline edit, a live counter or a comment stream, Turbo Frames and Turbo Streams handle it with a partial instead of a client-side store.
I only add a React component when the interaction is genuinely stateful: a drag-and-drop board, a rich editor, a complex chart. Mixing the two is fine, but the default should be the cheaper option.
2. One place for background work
Anything that talks to a third party, whether mail, payments, PDF generation or webhooks, goes into a job. Two rules keep the queue healthy: jobs take identifiers rather than serialized objects, and every job is safe to run twice. Retries are only useful if the work is idempotent.
class SendInvoiceJob < ApplicationJob
queue_as :mailers
retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
def perform(invoice_id)
invoice = Invoice.find(invoice_id)
return if invoice.sent_at? # safe to run twice
InvoiceMailer.with(invoice:).deliver.deliver_now
invoice.update!(sent_at: Time.current)
end
end
3. Push the invariants into the database
Model validations are for humans; database constraints are for correctness. Unique indexes, foreign keys and not-null columns catch the race conditions that a validates_uniqueness_of quietly misses under load. It costs one extra line in a migration and saves a support ticket.
4. Utility CSS with a small component layer
Tailwind removes the “where do I put this style” question, and a handful of extracted component classes for buttons, cards and form fields keep the markup readable. The important part is having a design system at all: spacing, radius and colour tokens defined once, so a redesign is a variable change and not a rewrite.
5. Tests where the risk is
Request specs around the flows that make money, model specs around anything with real logic, and a couple of system specs to prove the happy path renders. Chasing coverage numbers is a distraction. A suite that runs in a minute and is trusted beats one that is thorough and skipped.
6. Boring deploys
A single command to deploy, structured logs, error tracking, and a performance monitor with alerts on the p95 of the slowest endpoints.
If a rollback needs a document, it is not a rollback.
What I deliberately leave out
No service extraction before the monolith hurts. No GraphQL layer for a single client. No caching until a query shows up in the slow log. Every one of those is a good tool with a real cost, and the cost lands on whoever maintains the app after you.
If you are starting something new and want a second opinion on the stack, say hello.
