I play piano, and my sheet music lived everywhere: a Downloads folder on my laptop, email attachments on my phone, a USB stick somewhere. Every time I sat down to play, the piece I wanted was on the wrong device.
ownSheets is my fix: a small self-hosted web app where all my PDFs live in one place, tagged and searchable, readable from any device with a browser. I open it on a tablet at the piano, flip pages with arrow keys or a Bluetooth pedal, and never think about where the file actually is.
It runs entirely on free tiers (a static host + a free Supabase project), so it costs nothing to operate. You own the data, the storage, and the deployment.
Note
ownSheets was originally built for personal use and was later open-sourced. AI was used to help write portions of the codebase, but every change was reviewed by myself.
Built with React, TypeScript, Tailwind CSS, PDF.js, and Supabase.
The library
- Upload PDFs and tag them with title, composer, arranger, key, and a 1-10 difficulty
- Cover thumbnails, instant search, and tag filtering
- Setlists: ordered collections of sheets for a session or a gig
The viewer
- Full retina-resolution rendering with zoom
- Page turning via arrow keys, spacebar, swipe (on phones and tablets), or any Bluetooth pedal that emulates arrows (AirTurn, PageFlip...)
- Download any sheet straight from the viewer
Works on touch
- Responsive layout with a bottom nav bar on phones; on touch tablets, controls that normally appear on hover are always shown, since there is no cursor to hover with
- Swipe left/right in the viewer to flip pages; zoom with the on-screen controls and drag to pan when zoomed in
Built to be cheap and fast
- PDFs and thumbnails are cached locally after the first load, so repeat views cost zero Supabase egress and open instantly (the viewer even shows a "cached" badge)
- Loading a thumbnail pre-warms the full PDF cache: by the time you tap a sheet, it is usually already on your device
- Installable as a PWA; the app shell and cached sheets work offline
Sharing without giving up control
- You sign in with a single owner password, no account system to manage
- Hand out read-only access codes to friends; revoke them with one click
- Per-code usage stats: devices, downloads, total Supabase egress, last active
- A storage meter shows how much of the free 1 GB you have used
Your data stays yours
- One-click backup exports every PDF + all metadata + setlists into a single ZIP
- Import that ZIP into any other ownSheets instance and get an identical library back
You need Node.js 18+, a free Supabase account, and any static host (I use Vercel).
git clone https://github.com/hxpe-dev/ownsheets.git
cd ownsheets
npm install
cp .env.example .env # fill it in, see Configuration below
npm run devOn the Supabase side (one-time setup, ~5 minutes):
-
Create a new project at supabase.com.
-
Run the entire
supabase/schema.sqlin the SQL Editor. That single file creates every table, policy, function, and the storage bucket. It does not set your owner email, that is step 5 and the app will show an empty library until you do it. -
In Authentication -> Sign in / Providers -> Email: keep Email enabled, and enable Anonymous sign-ins (that is how guest access codes work). Leave "Allow new users to sign up" on, Supabase ties anonymous sign-ins to it, and the security model does not rely on it being off (see Security).
-
In Authentication -> Users: add yourself as a user. That email + password is your owner login.
-
Required, do not skip: tell the database which email is the owner. Run this in the SQL Editor, using the exact same email as the user in step 4 (and as
VITE_OWNER_EMAIL):insert into public.app_config (owner_email) values ('you@example.com');
This is what lets the database recognise you as the owner. Until you run it, even you will see an empty library, because no account matches the owner. If you ever change your owner email, update this row too:
update public.app_config set owner_email = 'new@example.com' where id = 1; -
In Authentication -> URL Configuration: set the Site URL to wherever you deploy (use
http://localhost:5173while developing).
To deploy, push the repo to GitHub and import it into Vercel (or Netlify, or Cloudflare Pages). It is a plain Vite app: build command npm run build, output dist/, plus the environment variables below. Every push redeploys automatically.
About the Supabase security advisor warnings
After running the schema you will see a few remaining warnings. The auth_allow_anonymous_sign_ins and authenticated_security_definer_function_executable warnings are intentional: anonymous sign-in is how guest access codes work, and guests are authenticated users by definition. The auth_leaked_password_protection warning requires a Pro plan. Everything else is resolved by the schema's explicit REVOKE statements.
Everything is configured through environment variables (in .env locally, or your host's dashboard in production):
| Variable | Required | What it is |
|---|---|---|
VITE_SUPABASE_URL |
yes | Your project URL, from Supabase -> Project Settings -> Data API |
VITE_SUPABASE_PUBLISHABLE_KEY |
yes | The publishable key, same page |
VITE_OWNER_EMAIL |
yes | The email of the owner account you created. The login screen only asks for a password; this fills in the email |
VITE_OWNER_NAME |
no | Your name. Turns the tab title and header into "Alice's Sheets" |
Go to Settings, create an access code, and send it to a friend. They type it on the login screen like a password and get read-only access: view, search, download, nothing else. Each code shows how many devices use it, how much bandwidth they consume, and when they were last active. Revoking a code kicks every device on it instantly.
Settings -> Export backup downloads a ZIP with every PDF and a manifest.json describing all metadata and setlists. Import backup on any instance rebuilds the library exactly, with setlist ordering intact. Importing next to existing sheets is safe; nothing gets overwritten.
The whole app trusts a single rule: the owner is the one non-anonymous account whose email matches app_config.owner_email. Everything follows from there.
- The owner is pinned by email, not just "is signed in with a password". The database stores your owner email in a locked
app_configtable (no API access, read only by theis_owner()function). So even though anyone can register an account, no other account is ever treated as the owner. - Guests are anonymous sessions. An access code does not create a Supabase account; it just flips an anonymous session's
is_validated_guestflag. Guests can read sheets and setlists, nothing else. - Every write is gated on
is_owner()at the database level (Row Level Security). The anon API key that ships in the frontend cannot insert, update, or delete anything, in any table or in storage. A raw API call with the public key gets rejected by Postgres, not just by the UI. A random self-registered account gets the same treatment: not the owner, not a validated guest, so it can read and write nothing. - The storage bucket is private. PDFs are served only through short-lived signed URLs, and only to the owner or a validated guest.
- Access codes are stored as SHA-256 hashes and are readable only by the owner.
Why not just disable signups? Because Supabase ties anonymous sign-ins to the signup setting, turning signups off would also lock out your guests. Pinning the owner by email is what keeps things safe regardless, so signups stay on and stray accounts simply have zero access.
If you ever suspect trouble, rotate your owner password in Supabase -> Authentication -> Users, and revoke any access code from the in-app Settings tab.
Already running an older version? The write policies and owner check were reworked after the first release. Run the migration block at the bottom of supabase/schema.sql once in the SQL Editor (it creates app_config, sets your owner email, and rebuilds the policies). A fresh install from the current schema already has everything.
Issues and PRs are welcome. The codebase is intentionally small: no state management library, no router, just React and a handful of files.
src/
├─ routes/ # Library (main shell), Setlists, Settings, Auth
├─ viewer/ # PDFViewer: rendering, zoom, keyboard, pedal
├─ components/ # SheetCard, modals, TagInput, DifficultyPicker
├─ hooks/ # useThumbnail (lazy load + cache)
└─ lib/ # supabase client, queries, auth, pdf cache, backup
supabase/
└─ schema.sql # the entire database, in one file
A few ground rules:
supabase/schema.sqlis the single source of truth for the database.- Run
npm run buildbefore opening a PR; it type-checks and builds. - Keep it dependency-light. If a feature needs a 50 kB package, it probably needs a rethink first.
Things I want to build next: per-page annotations, pinning sheets for guaranteed offline use, and audio previews. Pick one up if you are feeling brave :)
