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.