🇮🇷 فارسی | 🇬🇧 English
A modern, private library for saving, organizing, searching, and reusing your best AI prompts.
Prompts · Categories · Images · Full-text search · Dark mode
Overview • Features • Architecture • Setup Guide • Security • FAQ
- About the Project
- Features
- Tech Stack
- Project Structure
- Getting Started
- Email Setup (Optional but Recommended)
- Testing
- Security
- Performance
- Known Limitations
- FAQ
- Contributing
- License
Prompt Saver is a private, per-user dashboard for anyone who works with AI regularly and is tired of losing their best prompts in scattered notes, chat histories, or sticky notes. It gives you one organized place to:
- Save prompts with titles, categories, and descriptions
- Attach up to 5 reference images per prompt
- Search and filter your entire library instantly
- Keep everything private and scoped only to your own account
It's built for developers, creators, marketers, and everyday AI power-users who want faster, more reliable access to the prompts that actually work for them.
Note
Build status: This project has been fully written, type-checked, linted, and built successfully, and its database logic has been verified with automated tests (68 passing unit tests, 26 passing SQL test cases against a real PostgreSQL instance — see supabase/test/). However, it has not yet been run end-to-end against a live Supabase project by its original developer. Treat your first real run after setup as the first live integration test, and please open an issue if anything doesn't behave as expected.
| Feature | Description |
|---|---|
| 🔐 Private per-user library | Every prompt, category, and image is scoped strictly to your account via Supabase Row Level Security. |
| 🗂️ Categories | Organize prompts into custom categories for quick retrieval. |
| 🖼️ Image attachments | Attach up to 5 images per prompt, automatically compressed client-side before upload. |
| 🔍 Full-text search | Instantly search and filter across your prompt library. |
| 📄 Pagination | Browse large libraries without performance issues. |
| ✏️ Full CRUD | Create, edit, and delete prompts and categories with ease. |
| ⭐ Favorites | Pin the prompts you use most and filter your dashboard to just those. |
| ⌨️ Copy shortcut | Cmd/Ctrl+C copies a prompt's text instantly from its details page. |
| 🖱️ Drag-and-drop image reorder | Reorder a prompt's images by dragging - the first one becomes its dashboard cover. |
| 🔗 Public sharing | Generate a read-only link anyone can view without an account; revoke it any time to instantly invalidate it. |
| 🌗 Light / Dark / System theme | Powered by next-themes, switches instantly and persists across sessions. |
| 🔒 Signed image URLs | Images are private by default; the app generates short-lived (1-hour) signed URLs server-side. |
| 🚦 Rate limiting | Lightweight in-memory rate limiting on login/register, layered on top of Supabase Auth's own protection. |
| 📧 Custom email templates | Ready-made, styled HTML templates for signup confirmation and password reset emails. |
| ⚡ Modern stack | Next.js 16 App Router, Server Actions, Turbopack, and React 19 Server Components. |
- Next.js 16 — App Router, Turbopack, Server Components + Server Actions
- React 19
- TypeScript
- Tailwind CSS v4
- shadcn/ui (new-york style) + Radix primitives
- Supabase — Postgres, Auth, Storage, Row Level Security
- Zod + React Hook Form conventions for validation
- nextjs-toploader for route-change progress
- next-themes for theme switching
- browser-image-compression for client-side image optimization
- @dnd-kit for accessible drag-and-drop image reordering
- Vitest for unit testing
flowchart LR
U[User] -- signs up / logs in --> AUTH[Supabase Auth]
U -- create / edit prompt --> SA[Server Actions]
SA -- validated by --> ZOD[Zod schemas]
SA -- writes --> DB[(Postgres + RLS)]
U -- uploads image --> COMP[Client-side compression]
COMP -- upload --> ST[Supabase Storage]
ST -- signed URL, 1h --> U
DB --> UI[Dashboard / Search / Categories]
app/
(public)/ → landing, login, register (unauthenticated routes)
(app)/ → dashboard, prompts/new, prompts/[id], prompts/[id]/edit
(protected — see proxy.ts)
shared/[token]/ → public, read-only shared-prompt page (no auth,
not in proxy.ts's protected list)
actions/ → Server Actions (create/update/delete for auth,
categories, prompts, images)
lib/
supabase/ → browser client, server client, session middleware
validations/ → Zod schemas (single source of truth for both
client-side and server-side validation)
queries/ → read-only data access (server-only)
rate-limit.ts → lightweight in-memory rate limiter
components/
ui/ → shadcn/ui components
prompts/, categories/, auth/ → feature components
types/database.types.ts → hand-written types matching the migrations
(regenerate with the Supabase CLI once linked)
proxy.ts → Next.js 16 middleware convention: refreshes
the Supabase session and protects routes
supabase/
migrations/ → SQL migrations (run these against your project)
test/ → local-only SQL test harness (not part of the app)
Follow these steps in order — from zero to a fully running local instance.
| Requirement | Notes |
|---|---|
| Node.js 18.18+ | Required to run and build the app |
| npm | Comes bundled with Node.js |
| A free Supabase account | Postgres, Auth, and Storage all in one |
| A free Vercel account | Optional — only needed for deployment |
| A free Resend account | Optional — only needed for custom email templates |
git clone https://github.com/imhamiddev/Prompt-saver.git
cd Prompt-saver- Go to supabase.com and create a new project (pick any region close to you).
- Wait for provisioning to finish, then open Project Settings → API. You'll need two values here:
- Project URL
- anon public key
- (Optional) Note the service_role key from the same page — but never expose it in
NEXT_PUBLIC_*variables or client-side code. This app does not require it for normal operation.
The SQL files in supabase/migrations/ create every table, index, trigger, RLS policy, and Storage bucket/policy the app needs, in this exact order:
| File | Creates |
|---|---|
20260916234100_helper_functions.sql |
Shared updated_at trigger function |
20260916234200_categories.sql |
categories table + RLS |
20260916234300_prompts.sql |
prompts table, full-text search, category-ownership trigger, RLS |
20260916234400_prompt_images.sql |
prompt_images table, 5-image cap trigger, RLS |
20260916234500_storage.sql |
the prompt-images Storage bucket + ownership-based Storage policies |
20260919083934_prompts_is_favorite.sql |
prompts.is_favorite column + partial index for the favorites filter |
20260919084810_fix_prompt_images_reorder.sql |
makes the image-order unique constraint deferrable + adds the reorder_prompt_images RPC that drag-and-drop reordering calls (see note below) |
20260919182936_prompt_sharing.sql |
prompts.share_token column + get_shared_prompt/get_shared_prompt_images RPCs powering public sharing (see note below) |
20260919234326_storage_shared_prompt_access.sql |
lets anonymous viewers resolve signed URLs for a shared prompt's images |
Why the reorder fix migration exists: the drag-and-drop image reordering feature (
components/prompts/image-grid.tsx) needs to reassign every image'sdisplay_orderfor a prompt in one go. The straightforward approach - updating each row'sdisplay_orderone at a time in a loop - runs into two constraints fighting each other: the unique(prompt_id, display_order)index rejects any intermediate state where two images briefly share a slot, and thedisplay_order between 0 and 4check rejects the "move everything to temporary negative slots first" workaround. This was caught by running the exact update sequence against a real Postgres instance (not just reasoning about it) - seesupabase/test/01_functional_tests.sql, tests 18-19. The fix makes the unique constraintDEFERRABLEand reassigns all rows atomically inside a single Postgres function, called viasupabase.rpc('reorder_prompt_images', ...)fromactions/prompt-images.ts.
Why public sharing is RPC-only, not a plain RLS policy for
anon: the first version of this migration granted theanonrole aSELECTpolicy onpromptsscoped toshare_token is not null. That's correct as written, but dangerous in practice - RLS restricts which rows a query can return, it doesn't add filters the query didn't ask for. Aselect * from promptsissued asanon(a future bug, a different code path, a raw REST call with no.eq("share_token", ...)filter) would return every currently-shared prompt across every user, not just the one the caller has a link to - turning "share one prompt" into "list everyone's shared prompts." This was caught by testing theanonrole's raw query behavior against a real Postgres instance, not by reasoning about the policy in the abstract (seesupabase/test/01_functional_tests.sql, tests 20-26). The fix:anonhas no table-level grant at all oncategories/prompts/prompt_images; the only read path isget_shared_prompt/get_shared_prompt_images,SECURITY DEFINERfunctions that structurally can only ever return the single row matching the token argument - there is no way to call them and get a list back. If you ever touch this area, preserve that property; don't reintroduce a broadanonSELECTpolicy on these tables.
npm install -g supabase
supabase login
supabase link --project-ref <your-project-ref> # find this in your project's URL/settings
supabase db pushThis applies every file in supabase/migrations/ in order automatically.
If you'd rather not install the CLI, open SQL Editor in your Supabase dashboard and run each file's contents in the exact order listed above — the trigger functions and categories table must exist before prompts is created, and so on.
Not sure whether every migration above has actually run against your project (e.g. if you started with an older version of this repo and are catching up)? Paste the contents of supabase/check_migration_status.sql into the SQL Editor and run it — it's a read-only query that checks for one distinctive artifact from each migration and reports t/f (true/false) for each. Any f means that file still needs to be run.
types/database.types.ts was hand-written to match these migrations exactly. Once your project is linked, regenerate it directly from the live schema so it never drifts:
npx supabase gen types typescript --project-id <your-project-ref> > types/database.types.tsThe password reset flow (and any Supabase email link, in general) redirects back to /auth/confirm in this app. Supabase blocks redirects to URLs it doesn't recognize, so you must allow it:
- In the Supabase dashboard, go to Authentication → URL Configuration.
- Under Redirect URLs, add:
http://localhost:3000/auth/confirm— for local developmenthttps://your-app.vercel.app/auth/confirm— once deployed (see step 6)
⚠️ Without this step, clicking a password-reset or signup-confirmation link will fail with an error from Supabase instead of reaching the app.
Copy the example file:
cp .env.example .env.localThen fill in the values from step 2:
NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY= # optional, leave blank unless you need itClick to expand the full variable list
| Variable | Source | Required |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Supabase → Project Settings → API | ✅ Yes |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Supabase → Project Settings → API | ✅ Yes |
SUPABASE_SERVICE_ROLE_KEY |
Supabase → Project Settings → API | ⛔ Optional |
💡 The anon key is safe to expose to the browser — every table has Row Level Security enabled, so Postgres itself enforces access control. The service role key bypasses RLS entirely and must never be exposed to the browser or committed to the repository.
npm install
npm run devOpen http://localhost:3000. You should be able to:
- ✅ Register a new account
- ✅ Log in
- ✅ Create a prompt
- ✅ Attach images
- ✅ Search, filter, and paginate
- ✅ Edit and delete prompts
...all backed live by your own Supabase project.
- Push this repository to GitHub (or GitLab / Bitbucket).
- In Vercel, click New Project and import the repository.
- Under Environment Variables, add the same three variables from
.env.local. - Click Deploy — Vercel auto-detects Next.js and uses the correct build command (
next build). - Once deployed, go back to your Supabase project's Authentication → URL Configuration and add your Vercel URL (e.g.
https://your-app.vercel.app) to both Site URL and Redirect URLs, so auth flows work correctly in production.
During original development, next/font/google (Geist) was swapped for the system font stack because the build sandbox had no network access to fonts.googleapis.com. Vercel's build environment has normal internet access, so if you'd like the original Geist font back, re-add this in app/layout.tsx:
import { Geist, Geist_Mono } from "next/font/google";This is purely cosmetic and has no effect on functionality.
By default, Supabase emails a confirmation link when someone registers. The user clicks it, gets verified, and lands on /dashboard. No extra configuration is required for this to work — it's the default behavior.
Both the signup confirmation and password-reset links share one setting: Authentication → Sign In / Providers → Email → Email OTP Expiration (in seconds; default 3600 = 1 hour). 15–30 minutes is a reasonable balance — very short expiry windows can cause legitimate clicks to fail due to email security scanners "prefetching" links.
Supabase's default email templates are plain. This repo includes styled, email-client-safe HTML templates:
supabase/email-templates/confirm-signup.html→ paste into Authentication → Emails → Confirm signupsupabase/email-templates/reset-password.html→ paste into Authentication → Emails → Reset password
⚠️ As of June 2026, Supabase free-tier projects using the default built-in email sender can no longer edit these templates directly — the fields are read-only unless you configure custom SMTP or upgrade to a paid plan. Projects created before June 3, 2026 are grandfathered in.
To unlock template editing with free custom SMTP via Resend (3,000 free emails/day):
-
Create a Resend account and generate an API key (Resend dashboard → API Keys).
-
In Supabase: Authentication → Emails → SMTP Settings, enable Custom SMTP, and fill in:
Field Value Host smtp.resend.comPort 465Username resendPassword your Resend API key Sender email / name whatever you'd like users to see -
Save — the template fields should now be editable. Paste in the HTML files above.
If you'd rather skip SMTP setup entirely, the app still works correctly — you'll just be stuck with Supabase's plain default email design (and, on the free tier, a 2–3 emails/hour cap) until configured.
reset-password.html's link points to /reset-password/start#confirm_url=... instead of directly to /auth/confirm. This is intentional — it prevents email security scanners from silently "prefetching" and consuming the single-use reset token before the real user clicks it. Do not change this back to a direct link — it will reintroduce that bug.
Two independent layers of testing were performed during development:
1. SQL functional tests (supabase/test/) — a local-only harness that stubs Supabase's platform schema so migrations can be tested against a real PostgreSQL instance. Not part of the app — you can safely ignore or delete this folder.
2. Unit tests (Vitest) — cover validation schemas and auth/route-protection middleware.
3. Static checks — should always pass cleanly.
| Command | Description |
|---|---|
npx vitest run |
Run the unit test suite |
npx tsc --noEmit |
Type-check the whole project |
npx eslint . |
Lint the codebase |
npm run build |
Production build |
- Row Level Security (RLS) is the primary access control mechanism — every table (
categories,prompts,prompt_images) has RLS enabled and forced, scoped toauth.uid(). - Cross-table ownership (e.g. a prompt's category must belong to the same user) is enforced by database triggers.
- The 5-image-per-prompt limit is enforced in three layers: the upload UI, the Server Action, and a database trigger as the ultimate backstop.
- Images are private — the Storage bucket is not public; short-lived (1-hour) signed URLs are generated server-side, and Storage-level RLS restricts access to each user's own folder.
- Public prompt sharing is deliberately narrow. Enabling it generates a random, unguessable token (
crypto.randomUUID()); anonymous viewers can only reach one prompt's data throughSECURITY DEFINERRPCs that require that exact token and structurally cannot return more than one row — there is noanon-roleSELECTgrant onprompts/prompt_images/categoriesat all, so there is no way for an unauthenticated request to list or browse anyone's data. Disabling sharing invalidates the link immediately. See the sharing migration's note in section 3 for the actual information-disclosure bug this design replaced. - Client-side image compression reduces large images before upload; the bucket's server-side file size/MIME type limits remain the real enforcement regardless of what the client sends.
- Generic error messages are shown to users; raw error details are only logged server-side.
- Rate limiting on login/register, layered with Supabase Auth's own protection (in-memory, per-instance — swap for Upstash Redis for a stricter guarantee).
- Security headers (
X-Frame-Options,X-Content-Type-Options,Referrer-Policy,Permissions-Policy) are set innext.config.ts. - Never commit
.env.local— only.env.example(with blank values) is tracked in git.
- Request-scoped caching (
React.cache()) on every data-fetching function (getCategories,getPromptById,getPromptImages,getSharedPrompt, etc.) — several pages call the same function from bothgenerateMetadata()and the page component itself; without this, that's two identical round-trips to Supabase per page view instead of one. - Parallel data fetching wherever two queries don't depend on each other's result — e.g. the prompt details page fetches the prompt and its images concurrently (
Promise.all) rather than one after the other, and the public share page's two RPC calls (prompt + images) do the same. - Code-split heavy client libraries out of the initial page bundle:
browser-image-compression(~860KB) is dynamicallyimport()-ed only when someone actually picks a file to upload, not loaded on every prompt-details page view.@dnd-kit(~2.4MB, used for drag-and-drop image reordering) is loaded vianext/dynamicwithssr: false, and skipped entirely for prompts with zero images.optimizePackageImportsinnext.config.tstree-shakes unused exports fromradix-uiand@dnd-kit's sub-packages (lucide-reactis optimized by Next.js's own default list already).
- Long-lived Storage cache headers — uploaded images get
cacheControl: "31536000"(1 year) rather than Supabase's 1-hour default, since each upload's storage path is a fresh random UUID that's never overwritten, so there's no staleness risk from caching it aggressively. next/link+ prefetch on pagination — the dashboard's page-number links were originally plain<a>tags (shadcn's defaultPaginationprimitives), which meant no Next.js prefetching and a full page reload on every click;components/prompts/prompts-pagination.tsxnow renders them throughnext/linkinstead.{ scroll: false }on the search/category/favorites filter navigations, so typing a search query or toggling a filter doesn't jerk the page back to the top if you've scrolled down through results.<link rel="preconnect">to the Supabase project's own domain in the root layout, so the browser can establish that connection while the page is still loading rather than only once the first image request needs it.
- Built and tested without a live Supabase connection — treat your first real run as the first integration test.
- The password-reset flow requires the redirect URL step in section 3 to work.
- The rate limiter is in-memory and per-instance (not shared across serverless function instances).
- Category deletion is blocked if the category still has prompts — no bulk-reassignment UI yet.
- Image reordering (drag-and-drop,
components/prompts/image-grid.tsx) uses thereorder_prompt_imagesPostgres function (see the migrations table above) rather than plainupdate()calls, for the constraint-ordering reason explained there — if you ever modify this flow, keep the reorder atomic (one RPC call) rather than reverting to a client-side loop of per-row updates. - Public sharing (
/shared/[token]) reads exclusively through theget_shared_prompt/get_shared_prompt_imagesRPCs (see the migrations table above) — if you ever touch this area, don't replace that with a directanonSELECTpolicy onprompts/prompt_images; see the note there for why that's a real information-disclosure risk, not just a style preference. A shared prompt's own copy button and public-page rendering were verified via HTTP with the app's own test schema rejecting malformed tokens correctly; the full round-trip against a live Supabase project (a valid token actually resolving prompt content) wasn't exercised in this sandboxed environment. - Theme persistence across reloads relies on browser
localStorageand wasn't exercised in a real browser during development — worth a quick manual check after deploying.
Why does the anon key get committed to .env.example while the service role key stays blank?
Because they serve completely different purposes. The anon key is designed to be public — it's what the browser uses, and Postgres Row Level Security is what actually stops unauthorized access. The service role key bypasses RLS entirely, so it must never be exposed or hardcoded anywhere.
Do I need the service role key at all?
No. Normal operation of the app — sign-up, login, creating prompts, uploading images — never touches it. It's only there for optional server-side admin scripts you might write yourself.
Why does the password reset link look so different from a normal Supabase link?
It's intentional — see Why the reset-password link looks unusual. It protects the single-use reset token from being silently consumed by email security scanners before the user clicks it themselves.
Can I use this without setting up custom SMTP?
Yes. The app works fully on Supabase's default email sender — you'll just get plain, unstyled emails and a lower hourly sending cap on the free tier.
Contributions, issues, and feature requests are welcome! Feel free to check the issues page or open a pull request.
- Fork the project
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the MIT License — you're free to use, copy, modify, merge, publish, distribute, and even sell copies of it, as long as the original copyright notice is included. See the LICENSE file for the full text.
Made with ❤️ by imhamiddev