I shipped a collaborative todo app where anyone with a link can view or edit a shared list, no account required. Rails 8 , no React, no SPA, no Node build step. Wanted to share two patterns that took real effort to get right.
The app: Simple Todo - create a list, get a link, share it. The other person opens it and starts working. No signup, no install. The guest lists are deleted after 30 days if not reclaimed from an account.
Stack: Rails 8.1 / Ruby 3.4, Hotwire (Turbo + Stimulus), Tailwind via importmaps, PostgreSQL 17 + Redis, Solid Queue, Devise + AASM, Stripe, Kamal 2 on a Hetzner VPS. Under $10/mo all-in.
Pattern 1: Guest-to-user ownership transfer
This is where I burned a lot time to tune it. Guests get a session-based identity (UUID v7 primary keys, 30-day persistence). They can create lists, share them, use the app fully. When they decide to register, all their lists need to transfer to their new account atomically, with an audit trail.
I ended up with an AASM state machine on GuestSession, a dedicated service that handles the transfer inside a transaction, and a polymorphic model that tracks previous owner, new owner, and timing. The service went through three rewrites before the state transitions work. First version was a fat controller method. Second was a service without states. Third attempt, I sketched the state machine on paper before writing code, and it clicked.
The key insight: Devise doesn't know about any of this. The conversion hooks into Devise's registration flow, but the ownership transfer is entirely its own domain. Trying to shoehorn it into Devise callbacks was my first mistake.
Pattern 2: Sharing permissions via state machine
Same link, two modes. The list has a sharing_state column managed by AASM. The toggle_permission action in the controller uses the state machine transitions (enable_editing! / disable_editing!) rather than toggling a boolean. The editable_by? method on the model switches on sharing_state to decide what a given viewer can do.
This replaced an earlier version that was just a public_edit_allowed boolean, and the state machine version handles edge cases (what if someone toggles permissions while a list is being transferred?) that the boolean never could.
What Hotwire actually does here (and doesn't):
Turbo Streams handle all the CRUD: create, update, toggle, destroy. Four .turbo_stream.erb templates, server-rendered HTML fragments that replace DOM elements on each request. There's no ActionCable, no WebSocket broadcasting, no real-time sync. If two people have the same list open and one adds an item, the other sees it on their next page load, not instantly. For shared todo lists, that's fine. Real-time would add complexity without adding value here.
Stimulus handles the interactive bits. The inline editing for todo items is a 358-line Stimulus controller, not Turbo Frames. There are 19 Stimulus controllers total across the app. It's more JS than "zero custom JS," but it's all vanilla Stimulus, no framework on top.
What I'd do again: Hotwire over React for collaborative CRUD. Kamal over managed platforms. PostgreSQL + Redis + Solid Queue for a boring stack with zero surprises.
What's your experience with Devise + non-standard auth flows? Guest sessions, magic links, multi-tenant. I'm curious how others have handled the boundary between Devise's world and custom auth logic.
Top comments (0)