Building Wishare: From Frustration to Full-Stack Rails App
My family has a gift problem.
Every birthday and Christmas, the same chaos. "What do you want?" texts flying around in three group chats across two languages. Duplicate gifts nobody admits to. My mother-in-law buying something my wife already ordered. The awkward returns. The forced smiles. The quiet resentment of someone who received their fourth scented candle.
Existing wishlist apps all had the same fatal flaw: they required everyone to create accounts. My father is not creating an account on a website he will visit twice a year. My mother will not download an app for this. The bar for adoption had to be zero.
I wanted something simple. Share a link. People see what you want. Someone marks an item as "getting this" so nobody else buys it. How hard could that be?
Four months hard.
September 7, 2025: rails new wishare
I ran the command and made my first commit. The stack was deliberate — Rails 8, PostgreSQL, Tailwind, Hotwire. Not because Rails is trendy (it is emphatically not trendy). Because I wanted to learn Hotwire properly, and the only way I learn anything properly is by building something real with it.
Rails 8 shipped with Solid Cache and Solid Queue, which replace Redis for caching and background jobs. One fewer dependency. One fewer thing to configure. One fewer thing to break at 2 AM.
The Devise Rabbit Hole
My second commit was Configure Devise authentication. What I thought would take an afternoon became a week of decisions.
Should I use Devise or build auth from scratch? How do I handle Google OAuth without making email-only users feel like second-class citizens? What about the invite flow — users need to see wishlists before signing up, or the whole "zero friction" thing falls apart?
I went with Devise plus Google OAuth. The invite system was trickier. I needed a Connection model that could exist in a pending state — you receive an invitation, you can browse the wishlist, and you only sign up when you want to create your own list. The friction gradient had to feel natural: viewing is free, contributing requires an account, creating lists requires full signup.
class Connection < ApplicationRecord
belongs_to :user
belongs_to :connected_user, class_name: 'User'
enum :status, { pending: 0, accepted: 1, blocked: 2 }
end
Simple model. Week of thinking to get to it.
Brazilian E-Commerce Nearly Broke Me
Here is where things got genuinely interesting.
A wishlist app needs link previews. You paste an Amazon URL, the app shows the product image, title, and price. Straightforward — fetch the OpenGraph tags. Every website has them. This should work.
It does work. For American websites.
Brazilian e-commerce sites are aggressive about blocking scrapers. Centauro detected my rotating user agents within three requests. Sephora Brasil served a full CAPTCHA wall. Casas Bahia returned blank HTML with a 200 status code — technically "success," just with no content. Magazine Luiza redirected to an app download page.
My primary market was the one market where link previews did not work.
I built a three-tier extraction system out of pure spite:
class UrlMetadataExtractor
def extract(url)
cached = GlobalMetadataCache.find_by(url: normalized_url(url))
return cached.metadata if cached&.fresh?
metadata = try_direct_fetch(url) ||
try_api_service(url) ||
minimal_metadata(url)
GlobalMetadataCache.upsert(url: normalized_url(url), metadata: metadata)
metadata
end
end
Direct fetch with rotating user agents first. API service fallback for the stubborn sites. And a minimal fallback that at least shows the URL title and domain, because showing nothing is worse than showing something.
The caching layer was the actual win. When one user adds a Casas Bahia link, every other user benefits from that cached metadata. My database became a shared knowledge base of Brazilian product information, built one frustrated user at a time.
Real-Time Because Users Expect It
The activity feed was the feature I was most excited about. See when friends add items, when someone marks a gift as "getting this," when a wishlist gets updated. Real-time. No refresh.
ActionCable made the initial implementation easy. Then mobile browsers made it complicated.
My first version created duplicate WebSocket connections every time the app came back from background on iOS Safari. Every. Single. Time. The activity feed would show each update twice, then three times, then the whole thing felt haunted.
class ActivityFeedChannel < ApplicationCable::Channel
def subscribed
return reject if rate_limited?
stream_for current_user
end
def self.broadcast_activity(user, activity)
user.friends.each do |friend|
broadcast_to(friend, activity.as_json)
end
end
end
Rate limiting the subscription was the fix. But the debugging process taught me more about WebSocket lifecycle on mobile browsers than I ever wanted to know.
The worst production bug came two weeks after deploy. Users reported infinite loading on the dashboard. The Railway logs showed PG::UndefinedTable: solid_cache_entries — Rails 8's new caching system needed a migration I missed because it was not in the default generator output. Three bug fixes in one week: missing Solid Cache table, ActionCable rate limiting, and inconsistent data between HTTP fallback and WebSocket feeds.
That week felt like drinking from a firehose. It was also the week I learned the most.
The Design System I Should Have Built First
Around commit 50, I stopped and looked at what I had built. Buttons had three different styles. Colors varied between pages. Dark mode was half-implemented — some screens looked great, some looked like a rendering error.
I stopped feature development cold and built a proper design system.
class UI::ButtonComponent < ViewComponent::Base
VARIANTS = {
primary: "bg-rose-500 hover:bg-rose-600 text-white",
secondary: "bg-dark-800 hover:bg-dark-700 text-white",
ghost: "bg-transparent hover:bg-dark-800 text-dark-300"
}
def initialize(variant: :primary, size: :md, **attrs)
@variant = variant
@size = size
@attrs = attrs
end
end
This refactor cost a full week. New features after that week looked consistent automatically. The lesson is obvious in retrospect and completely invisible when you are in the middle of building: design systems should come before the inconsistency becomes visible, not after.
Mobile: The Rails Way
Instead of building separate React Native apps — which would have doubled the codebase and tripled the maintenance — I used Hotwire Native. The Rails app stays the single source of truth. Thin Swift and Kotlin wrappers add native features where needed.
90% of the code is shared. Camera access and push notifications bridge through JavaScript. The app feels native enough on both platforms, which is a phrase that would make a mobile purist wince but accurately describes what users actually need.
Four Months of Lessons
Rails 8 is production-ready and it is not getting enough credit. Solid Cache, Solid Queue, and Hotwire make it a genuinely complete stack. I did not need Redis. I did not need a separate job processor. I did not need a JavaScript framework. Rails handles it all, and the developer experience is better than it has been in years.
URL extraction is a solved problem until you try it in Brazil. Every country has different anti-scraping measures. The "just fetch the OpenGraph tags" advice works for exactly the countries where most developers live and test.
Build the design system at commit 20, not commit 50. The cost of doing it early is one week. The cost of doing it late is one week plus the emotional damage of looking at your own inconsistent UI for a month.
Ship the smallest useful thing first. My first deploy was authentication plus basic wishlists. Everything else — real-time, URL previews, mobile apps — came after real users were already using the product. The features I thought were essential turned out to be nice-to-haves. The features users actually requested were ones I had not considered.
That last point is the one I keep relearning. Assumptions about what users want are almost always wrong. Ship fast. Ask questions. Adjust.