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
Top comments (1)
The grants-versus-policies point is the one most people learn the hard way, so good call putting it up front. One thing about the device ID though, because it changes what RLS can actually promise you here.
crypto.randomUUID()in localStorage is a client-generated, client-supplied value. With no auth, theanonrole has no verified identity, so a policy has nothing trustworthy to compare against — whatever filter you write is comparing the row to a value the caller chose. That means "only your own browser sees which ones you've liked" is enforced by your client sending its own ID, not by the database refusing to send anyone else's. Someone hitting the REST endpoint directly can pass any device_id they like: read another device's likes if they have the UUID, or write likes as that device.For palette likes the confidentiality side is not dramatic. The write side is the part I would care about — nothing stops a script inserting unlimited rows under invented device IDs, and your global counts are the thing that gets distorted.
Supabase has a primitive built for exactly this shape: anonymous sign-in.
From the user's point of view it is still zero accounts — no email, no password, no prompt. But you now get a stable
auth.uid()the client cannot forge, so the boundary becomes something the database can actually enforce:The
with checkon insert is the half that stops the impersonation, and it is the half that is easy to leave off —usingalone does not constrain what a client is allowed to write.Two smaller things while you are in there:
to anon/to authenticatedon every policy explicitly. Without aTOclause a policy is evaluated for every role, which is both slower and rarely what you meant.unique (device_id, palette_id)constraint is worth having regardless of the auth question — it makes double-likes impossible at the storage layer rather than in the handler.Anonymous sign-in keeps the whole premise of the project intact. It just moves the identity from something the browser asserts to something the server issued, which is the difference between a filter and a boundary.
Related failure shape if it is useful — a policy that exists, passes its tests, and still returns other people's rows: github.com/cekuu35/supabase-rls-le...