ClipSync: Building a Secure, Real-Time Clipboard Manager Without a Custom Backend
Syncing clipboard items, links, and files across devices instantly using PostgreSQL, RLS, and Next.js.
My workaround for moving content between devices was messaging myself on WhatsApp. It worked, technically. But it buried every link and snippet inside a thread with no organization, no useful search, and no way to tell "I'll need this next week" from "I needed this once and forgot to delete it." ClipSync is what I built instead.
The Architecture Decision That Shapes Everything
Most web apps have a custom API server between the browser and the database. The browser calls the server, the server validates the request, checks permissions, and talks to the database. That chain is familiar, but for a solo project it also means writing, deploying, and maintaining an entire service that mostly exists to proxy queries.
ClipSync takes a different approach: the browser talks directly to Supabase over HTTPS. There is no custom application server in the request path. The front end, a Next.js 15 App Router app deployed on Vercel, holds a single Supabase client instance configured with the project URL and the publishable key. All reads and writes go from the browser to Supabase directly, authenticated by the user's JWT.
That sounds like it should be insecure. It is not, because authorization is handled at the right layer.
The publishable key only allows making requests. What those requests can actually read or write is controlled entirely by Postgres Row Level Security, which is enforced at the database before any data is returned or modified. The service role key, which can bypass that security, is never shipped to the client and is not used by the application at all.
Security Without a Server in the Middle
Row Level Security is a Postgres feature where access policies are attached to table rows. Every table in ClipSync carries a user_id column. Every policy checks auth.uid() = user_id for select, insert, update, and delete.
When a request arrives, Postgres evaluates the policy before returning or modifying any data. Even a direct request to the Supabase endpoint with a valid JWT can only touch rows where the authenticated user id matches.
This is the real security boundary, not the UI. The client-side route guard in auth-provider.tsx redirects unauthenticated users to the login page. That is a UX concern. If it were removed or bypassed, the database policies would still hold. A user cannot read or modify another user's rows regardless of what the client sends.
The practical consequence: authorization comes free at the data layer. There is no permission-checking middleware to write, no server route to forget, and no code path that might accidentally expose one user's data to another.
Four tables, one security model:
Stores clipboard content snippet and files metadata.
Saved articles, URLs, and pages for read-later.
User notes and organized textual snippets.
Shared system config. No user-specific data; read-only for authenticated users.
Keeping Files Private
Files live in a private Supabase Storage bucket named clips-files. Every upload goes to a path namespaced by user id: {userId}/{timestamp}-{sanitizedFileName}. The bucket's RLS policy requires auth.uid() = owner for insert, select, and delete. The bucket itself is not public, so there is no URL to guess or enumerate.
To display or download a file, the app calls createSignedUrl, which returns a time-limited URL granting temporary access to one specific storage object.
Access by content type:
Image clips
Render in an img element pointed at a signed URL with a longer expiry.
PDF clips
Render in an iframe using the browser's native PDF viewer, served through the same URL type.
Downloads
Use a short-lived signed URL (about one minute) with the download option so the browser saves the file with its original name.
Security Configuration: The Content Security Policy in next.config.ts was widened to allow images and frames from the Supabase domain (img-src and frame-src) and connections for Realtime (connect-src), while keeping all other sources locked down.
Real-Time Sync Across Devices
There is no device pairing step in ClipSync. Identity is just the signed-in user. Signing in on a second device with the same account gives the same user id, and Row Level Security returns the same rows. Cross-device identity is authentication, nothing more.
For live updates, the useClips and useSavedPages hooks subscribe to Postgres change events via Supabase Realtime, filtered to the current user's user_id.
Insert, update, and delete events are applied to the local Zustand store directly, without a full refetch. A clip added on a phone appears on a signed-in laptop within about a second. Because Realtime also respects Row Level Security, a device only receives events for its own user's data. The subscription is set up inside each hook alongside the initial data fetch, and unsubscribed on unmount to prevent leaked channels.
Three Bugs Worth Talking About
1. The Shared-File Problem
When you save a clipboard clip into a saved page, ClipSync creates a new row in saved_notes. For file clips, the initial implementation copied the storage path from the original clip row into the new saved note row. Both records now pointed at the same underlying file.
That looks fine until you delete the original clip. Deleting a clip removes its associated storage object. The saved note row still exists, but it now references a path with no file behind it. The note has a filename and no content.
Lesson Learned: This was a classic issue of shared resource ownership. When two separate database records point to the same file lifecycle, one deleting itself will silently corrupt the other. The fix was to physically copy the storage file during the save action.
2. How to Design a Trash Bin
Adding a trash bin for saved notes came down to a schema choice: a deleted_at timestamp column on the existing table, or a separate saved_notes_trash table.
Soft-delete won. Here is why. Active queries filter on deleted_at is null. The trash view filters on deleted_at is not null within the 7-day retention window. Restoring a note is a single update setting deleted_at back to null. The underlying file in storage is never touched during delete or restore, so the file is always present when the user comes back.
A separate table would have meant moving rows on delete, moving them back on restore, and deciding what to do with the storage object during each transition. That is more state to keep synchronized and more places to create a mismatch between the database row and the storage object. The simplest design that is still correct is usually the right one, especially when recovery from error is straightforward.
The purge logic runs when the saved page opens: it queries for notes past the retention window and deletes them along with their storage objects.
3. Drag-and-Drop Had Two Separate Bugs
Reordering saved notes with dnd-kit introduced two distinct problems that looked related but were not:
Bug A: Clicks stopped working on the note cards
The drag listeners were attached to the entire card element. This intercepted pointer events, so clicking delete or any other button on the card did nothing because the drag handler saw it first.
Fix: Moved listeners to a dedicated drag handle and added a minimum drag distance constraint.
Bug B: The order did not persist after reordering
Saving the new position used an upsert. Postgres executes the insert path of an upsert first, before checking for a conflict. The rows were missing NOT NULL columns that exist on the original insert, so Postgres errored before it ever reached the conflict resolution step.
Fix: Replaced the upsert with individual update statements targeting each row's position.
Multi-Tenancy From a Single-User Start
The project originally shipped with a single-user lock: a config table row recording the first registered email address, checked on every login. Anyone other than that email was denied, at the application level.
Removing it required no schema changes. Row Level Security was already in place and already isolated each user's data. Lifting the gate did not weaken data separation because the gate was never the isolation mechanism. It only controlled who could get a JWT. Once gone, anyone can sign up and receive their own private workspace.
The distinction matters: application-level gating and database-level multi-tenancy are different things. RLS provides the isolation. The lock was a restriction on top of it. Once I understood that, removing it was a small change. The dead lock functions were deleted afterward to prevent leaving dead code.
Email That Actually Arrives
Supabase's default mailer is rate-limited and intended for testing. In production, magic links need to reliably reach arbitrary email addresses. The solution was to configure Resend as custom SMTP in Supabase Auth.
The sending domain is a subdomain of my portfolio domain, not the root domain. Reputation isolation: if the app's transactional email ever has deliverability problems, it does not affect email sent from the root domain. That is a simple DNS decision worth making early.
Declares which mail servers are authorized to send email on behalf of the subdomain.
Adds a digital signature to emails, allowing receiving servers to verify they were sent by the domain owner.
Defines how the receiver should handle emails that fail SPF or DKIM checks, providing reports back.
What I'd Do Differently next time
Add Tests first
Write end-to-end and integration tests prior to adding features to catch lifecycle bugs like the shared-file issue.
Public Landing Page
Build a public-facing static landing page with an app explanation and Sign In CTA, rather than putting the entire domain behind authentication.
Syntax Highlighting
Utilize lightweight highlighting libraries like Shiki to improve readability of code snippets inside text clips.
Database Functions
Migrate reordering calculations to a database RPC function to prevent multiple sequential client updates and preserve atomicity.