I built a no-login color palette app - turns out RLS policies alone don't work
I wanted a place to find and publish color palettes without making anyone sign up. Not "sign up but it's fast," actually zero accounts. Turns out that's a more interesting engineering problem than it sounds. The core id
I wanted a place to find and publish color palettes without making anyone sign up. Not "sign up but it's fast," actually zero accounts. Turns out that's a more interesting engineering problem than it sounds.
The core idea
Every visitor gets a random device ID generated once with crypto.randomUUID() and stored in localStorage. That ID goes to Supabase whenever someone likes a palette. No email, no password, nothing tied to a real identity - just "this browser liked this palette." Likes and counts are global and live for everyone, but only your own browser sees which ones you've liked.
It's not a new idea, but building it properly taught me a few things I didn't expect.
Grants aren't the same as RLS policies
I set up Row Level Security policies thinking that was the whole story. It's not. Postgres also needs an explicit GRANT on the table for the anon role before RLS even gets evaluated - otherwise every request 401s with a permission error, and the RLS policies you wrote never even get a chance to run. Two separate layers, both required.
The URL sync race condition that took me a while to catch
I sync app state to the URL and back so every view is shareable. The bug: clicking a palette from a tagged view would open the detail page for a split second, then snap back to the homepage. Turns out my URLβstore sync effect was calling the same "set active tags" action that the UI uses when a person clicks a tag - and that action also resets the current view as a side effect. So opening a palette would correctly set the view to "detail," and then the very next line in the same effect would silently reset it back to "new" because of a stale tag comparison. Fixed it by splitting that into two actions: one for real user clicks (which should change the view), and one for URL sync (which shouldn't).
Making the loading bar actually mean something
Every state change (switching tags, opening a palette, searching) runs through a small loader animation. Originally the actual data updated instantly and the loading bar was just decorative, running after the fact. Now the state update is deferred until the loader animation completes, using a token so a stale update from an old click can't clobber a newer one. Small detail, but it's the difference between a loading bar that means something and one that's just visual noise.
Stack
React, TypeScript, Zustand, Supabase (Postgres, no auth), Tailwind. No backend server - Supabase's REST layer directly from the client, secured entirely through RLS.
It's free, open source, and live: PaletteSnap. Repo's here if you want to see how the anonymous-likes/URL-sync stuff is wired: Github Repo
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.