You are building a real, production-ready MVP that I will host on my own server.
-
Hosting / runtime
- Self-hosted on my Linux home server (Node.js).
- Must run with:
npm installnpm run buildnpm run start
- No Vercel-specific features (no Edge runtime, no
@vercel/*APIs, no middleware that only works on Vercel).
-
Framework & stack
- Next.js 14 – App Router (TypeScript).
- Supabase:
- Postgres (SQL schema).
- Storage bucket (private) for images.
pg_cronfor scheduled cleanup.
- Stripe Connect Express for creator payouts.
- Tailwind CSS for styling.
- Sharp for server-side image processing / blur.
-
Auth
- No traditional auth system (no full signup/login, no OAuth for buyers or creators).
- Use secret edit links (UUID) for creators.
- Use an
ADMIN_MODERATION_KEYenv var to guard the simple moderation dashboard.
-
Moderation
- Build hooks to call an external AI LLM Moderation service over HTTP:
- URL:
MODERATION_API_URL - Optional
MODERATION_API_KEY
- URL:
- This moderation service runs on my own home server and is not part of this repo.
- You provide only the integration code and types, not the model itself.
- Build hooks to call an external AI LLM Moderation service over HTTP:
A pay-to-reveal SaaS for Instagram creators:
- Creator uploads an image.
- Sets a price (minimum $5).
- Connects Stripe Connect Express for payouts.
- Platform generates a public link to place in an Instagram Story.
- Followers tap the link:
- See a blurred preview.
- Pay via Stripe Checkout.
- Instantly see the full image.
- Content is ephemeral:
- Post and its images auto-expire after 24 hours.
- Fits Instagram Stories lifecycle.
Positioning tagline:
“Drop a secret on your Story.
Get paid instantly.
Gone in 24h.”
Typical use case:
“I want to share a formula to grow hair, next photo I show my hair, before and after.
‘Do you want to know what I did? Pay $X. Receive in seconds.’”
- Minimum price: $5 (500 cents).
- Platform fee: 25% of each transaction.
- Stripe fee assumed: 2.9% + $0.30.
- No subscriptions. No long-term accounts. Pure one-off impulse buys.
Example revenue splits:
-
$5 sale
- Gross: $5.00
- Stripe: $0.45
- Platform (25% of $5): $1.25
- Approx creator take-home: $5.00 − $0.45 − $1.25 = $3.30
(You can keep the more exact $3.41 example in the copy.)
-
$10 sale
- Gross: $10.00
- Stripe: ~$0.59
- Platform: $2.50
- Creator: ~$6.91
-
$20 sale
- Gross: $20.00
- Stripe: ~$0.88
- Platform: $5.00
- Creator: ~$14.12
(Exact math in UI can be simplified; it’s mostly for creator expectations.)
This product is an abuse magnet if moderation is weak:
- Anonymous buyers.
- Anyone can upload.
- Money + hidden content.
We must minimize Stripe risk (chargebacks, TOS violations) and abuse:
- Sexual content, nudity, pornography, fetish content.
- Any content involving minors (sexual or not, if sexual context implied).
- Hate, harassment, extremism.
- Blackmail, doxxing, revenge content.
- Scams, fraud, “pay to see illegal stuff”.
- Self-harm or suicide content.
- Secret tips (“how I grew my hair back”).
- Before/after transformations (fitness, hair, makeup).
- Exclusive behind-the-scenes content.
- Secret announcements.
- “Guess what happened” type reveals within policy.
- Next.js 14 (App Router)
- TypeScript
- Node runtime only.
- Supabase
- Postgres database.
- Private Storage bucket for images.
pg_cronextension for scheduled cleanup of expired posts.
- Stripe
- Stripe Checkout (
checkout.sessions). - Stripe Connect Express accounts.
- Webhooks for
checkout.session.completed.
- Stripe Checkout (
- Other
- Tailwind CSS for UI.
- Sharp for image validation, resizing, and blur.
- Basic HTTP server integration with external moderation service.
Provide .env.local.example with all variables (placeholders only):
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_BASE_URL= # e.g. https://mydomain.com
# AI Moderation (external service on my server)
MODERATION_API_URL= # e.g. http://127.0.0.1:5005/moderate
MODERATION_API_KEY= # optional API key for local LLM service
# Simple admin gating (no full auth)
ADMIN_MODERATION_KEY= # shared secret for /admin/moderationUse SQL/migrations equivalent to the following.
CREATE TABLE posts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
secret_key UUID DEFAULT gen_random_uuid(), -- for edit link
original_image_path TEXT NOT NULL,
blurred_image_path TEXT NOT NULL,
price INTEGER NOT NULL, -- cents, min 500 = $5
stripe_account_id TEXT, -- Stripe Connect account for creator payouts
view_count INTEGER DEFAULT 0,
purchase_count INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE DEFAULT (NOW() + INTERVAL '24 hours'),
-- Moderation
moderation_status TEXT DEFAULT 'pending', -- 'pending' | 'approved' | 'rejected' | 'flagged'
moderation_reason TEXT,
ai_risk_score NUMERIC(5,2),
ai_risk_labels TEXT -- JSON string of labels, e.g. '["adult","blackmail"]'
);Indexes:
CREATE INDEX idx_posts_secret_key ON posts(secret_key);
CREATE INDEX idx_posts_expires_at ON posts(expires_at);
CREATE INDEX idx_posts_moderation_status ON posts(moderation_status);CREATE TABLE purchases (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
session_id TEXT UNIQUE NOT NULL, -- Stripe Checkout session id
paid_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_purchases_post_id ON purchases(post_id);
CREATE INDEX idx_purchases_session_id ON purchases(session_id);CREATE TABLE reports (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
reason TEXT,
status TEXT DEFAULT 'open', -- 'open' | 'reviewed'
ai_risk_score NUMERIC(5,2),
ai_risk_labels TEXT,
ai_action TEXT -- 'no_action' | 'hide' | 'freeze_stripe' | 'needs_manual_review'
);
CREATE INDEX idx_reports_post_id ON reports(post_id);
CREATE INDEX idx_reports_status ON reports(status);Use pg_cron to delete expired posts from DB regularly:
SELECT cron.schedule(
'delete-expired-posts',
'0 * * * *', -- hourly
$$DELETE FROM posts WHERE expires_at < NOW();$$
);Optionally add a trigger to log deleted images for storage cleanup.
The AI moderation service runs separately on my home server and is exposed via MODERATION_API_URL. You only implement HTTP calls and type definitions.
export type ModerationInput =
| {
type: 'post_create';
description: string;
price: number;
meta?: {
width?: number;
height?: number;
sizeBytes?: number;
};
}
| {
type: 'report';
reason: string;
postId: string;
};
export type ModerationOutput = {
decision: 'approve' | 'reject' | 'flag';
riskScore: number; // 0–100
labels: string[]; // e.g. ['adult', 'blackmail']
reason: string;
suggestedAction?: 'hide' | 'freeze_stripe' | 'needs_manual_review';
};- Implement a helper like:
export async function callModerationService(
input: ModerationInput
): Promise<ModerationOutput | null> {
// Server-side only, never expose MODERATION_API_URL/KEY to client.
// If service is unreachable, return null.
}-
On post create:
- Call moderation with
{ type: 'post_create', description, price, meta }. - Map
ModerationOutput.decisiontoposts.moderation_status:approve→approvedreject→rejectedflag→flagged
- Save
ai_risk_score,ai_risk_labels,moderation_reason. - If service is down (null):
- Set
moderation_status = 'pending'. - Allow post creation, but show “pending review” state in UI.
- Set
- Call moderation with
-
On report:
- Insert row in
reports. - Call moderation with
{ type: 'report', reason, postId }. - Update report row with
ai_risk_score,ai_risk_labels,ai_action. - If
ai_action === 'hide':- Set
posts.moderation_status = 'flagged'. - New purchases should be blocked for flagged posts (but existing buyers still see content).
- Set
- If
ai_action === 'freeze_stripe':- Don’t implement API calls to Stripe here; just mark this clearly in DB (for manual follow-up).
- Insert row in
Use a structure like:
/app
page.tsx # Landing + create form
/p/[id]/page.tsx # Public page (blurred + pay + report)
/p/[id]/success/page.tsx # Post-payment reveal
/edit/[key]/page.tsx # Creator edit page (secret_key)
/admin/moderation/page.tsx # Simple admin moderation dashboard
/app/api
/create/route.ts # Create post + upload image + initial moderation
/checkout/route.ts # Create Stripe Checkout session
/webhook/route.ts # Stripe webhook (checkout.session.completed)
/connect/route.ts # Stripe Connect onboarding link
/connect/callback/route.ts # Save stripe_account_id for a post/creator
/image/[id]/route.ts # Serve blurred/full images with access checks
/report/route.ts # Buyers submit reports
/cleanup/route.ts # Optional storage cleanup endpoint
/lib
supabase.ts # Supabase client
stripe.ts # Stripe client
image-processor.ts # Image validation, resize, blur (Sharp)
moderation.ts # Moderation types + HTTP client
time.ts # Helpers like “expires in X hours”Frontend (landing page):
- Form fields:
- Image upload (JPG/PNG, max 10MB).
- Price in USD (min $5).
- Optional text
description(used for moderation).
- Show a short policy banner:
“No sexual content, nudity, minors, hate, violence, scams, blackmail, or self-harm. Violations will be blocked.”
Backend (/api/create):
- Validate:
- Only
image/jpegorimage/png. - Size ≤ 10MB.
- Use Sharp to ensure min 800×800 px.
- Price ≥ 500 (cents).
- Only
- Process:
- Use Sharp to generate:
- Optimized original (good balance of size & quality).
- Strongly blurred preview (smaller size).
- Upload both to a private Supabase Storage bucket (e.g.
images). - Call moderation service (
type: 'post_create'). - Insert a row into
postswith:- Paths to original and blurred images.
- Price,
secret_key,expires_at. - Moderation fields from the AI output.
- Use Sharp to generate:
- Response behavior:
- If
moderation_status = 'rejected':- Do not create a usable post.
- Show user a clear error reason and no public link.
- If
approved,flagged, orpending:- Return:
- Public URL:
/p/[id] - Secret edit URL:
/edit/[secret_key] expires_attimestamp.
- Public URL:
- Return:
- If
- Fetch post by
id. - If not found: 404.
- If expired (
expires_at < now): show “This secret has expired.” - If
moderation_status = 'rejected': show “This content is not available.” - If
moderation_status = 'flagged': show a subtle banner like “This content is under review”. - Display:
- Blurred image via
/api/image/[id]?type=blur. - Price and “Unlock for $X” button.
- Time remaining (countdown).
- If
stripe_account_idis missing: disable payment and show “Creator hasn’t connected payments yet.”
- Blurred image via
- Include a “Report” button:
- Opens a small form (textarea for
reason). - POST to
/api/report.
- Opens a small form (textarea for
- Accept query param
type=blur|fulland optionallysession_idfor full. - Steps:
- Fetch post by
id. - If not found → 404.
- If expired → 410 Gone.
- If
moderation_status = 'rejected'→ return 451 (policy).
- Fetch post by
- For
type=blur:- Always allowed when not expired & not rejected.
- Use Supabase signed URL or stream image bytes.
- Cacheable (e.g.
Cache-Control: public, max-age=3600).
- For
type=full:- Require
session_id. - Check
purchasesfor matchingpost_idandsession_id. - If no match → 403 Forbidden.
- If match:
- Serve original image via signed URL or stream.
- Private / no-cache headers.
- Require
- Input:
post_id. - Validate:
- Post exists.
- Not expired.
moderation_statusis notrejected.- Price ≥ 500.
stripe_account_idpresent.
- Create a Stripe Checkout session:
mode: 'payment'.- Line item for the post’s price.
- Application fee = 25% of
unit_amount. transfer_data.destination = stripe_account_id.success_url = NEXT_PUBLIC_BASE_URL/p/[id]/success?session_id={CHECKOUT_SESSION_ID}.cancel_url = NEXT_PUBLIC_BASE_URL/p/[id].metadata.post_id = id.
- Return
session.urlto frontend.
- Read
session_idfrom URL. - Server-side:
- Fetch post by
id. - If not found → show error.
- Retrieve Stripe Checkout session with
session_id. - Confirm:
payment_status === 'paid'.session.metadata.post_id === id.
- Fetch post by
- If verification fails:
- Redirect back to
/p/[id].
- Redirect back to
- If verified:
- Show full image via
/api/image/[id]?type=full&session_id=.... - Optional: “Download” button.
- Show full image via
- Verify Stripe signature using
STRIPE_WEBHOOK_SECRET. - Handle
checkout.session.completed:- Extract
session.metadata.post_idandsession.id. - Insert row into
purchases:post_id,session_id,paid_at.
- Increment
purchase_countfor that post (use SQL or RPC).
- Extract
- Return 200 JSON
{ received: true }.
- Use
secret_keyfrom URL. - Fetch post by
secret_key. - If not found: show “Invalid or expired link”.
- If expired: show “This content has expired”.
- Show:
- Blurred preview.
- Price (editable).
- Moderation state badge (
pending,approved,flagged,rejected). - Stats: views, purchases, estimated revenue.
- Stripe connection status.
- Public link (copy button).
- Time remaining until expiry.
- Allow:
- Update price (revalidate min $5).
- Delete post with confirmation.
- Access controlled by
ADMIN_MODERATION_KEY:- For simplicity, accept it as a query param or header and compare to env var.
- Display:
- List of posts where
moderation_status IN ('pending', 'flagged'). - For each post:
- Basic info, AI risk score, labels.
- Recent reports with report AI outputs (
ai_action,reason).
- List of posts where
- Allow simple actions:
- Set
moderation_statustoapprovedorrejected.
- Set
- Secret edit links use unguessable UUIDs.
- All image files are stored in private Supabase Storage.
- Signed URLs or backend streaming for image access.
- Server-side blur via Sharp (no CSS blur tricks).
- Verify Stripe Checkout sessions server-side before showing full image.
- Verify Stripe webhook signatures.
- Enforce min price $5.
- Validate file type, size, and dimensions on upload.
- Check
expires_aton all relevant endpoints (public page, image, etc.). - Expired posts return “expired” state and cannot be purchased.
- Honor purchases even if post later expires (buyer with valid session_id can still see).
- Moderation logic:
- AI integration on create + report.
- Ability to flag/hide posts via
moderation_status.
- Content policy clearly visible to creators on the upload page.
- Admin moderation dashboard protected by
ADMIN_MODERATION_KEY.
- Content auto-deletes after 24 hours → “self-cleaning” system.
- Matches Instagram Stories behavior.
- Reduces long-term exposure of harmful content (but does not remove need for moderation).
- No complex account recovery flows needed (links are short-lived).
- Creates urgency and FOMO: “Expires in X hours”.
Implement or at least ensure support for these tests:
- Upload a 10MB image → should succeed.
- Upload an 11MB image → should be rejected.
- Upload a
.txtfile → should be rejected. - Set price $4 → should be rejected.
- Set price $5 → should succeed.
- CSS blur tricks (in browser devtools) → should not reveal the real image (because real image is on a secure endpoint and blurred version is a separate file).
- Attempt direct image URL (bypass API) → should fail due to private bucket.
- Invalid
session_idon success page → should redirect back to public page. - Webhook without valid signature → should be rejected.
- Access expired post → “expired” UI.
- Access success page after expiry with a valid purchase → should still show full image.
-
pg_cronjob deletes expired posts from DB. - AI moderation:
- Create a post with obviously disallowed text (e.g. “this is blackmail/sexual content”) → AI should reject/flag and post should not be available.
- Submit a report with “this is a scam” → AI should flag post and set
moderation_statusappropriately.
- Admin moderation dashboard only accessible with correct
ADMIN_MODERATION_KEY.
- Complete Next.js 14 project:
- All pages and API routes described above.
- TypeScript types for moderation integration.
- Database setup:
- SQL or migration scripts for
posts,purchases,reports, indexes, andpg_cronjob.
- SQL or migration scripts for
- Supabase configuration instructions in
README.md:- How to create private Storage bucket (e.g.
images). - How to enable
pg_cron.
- How to create private Storage bucket (e.g.
- Stripe configuration instructions:
- Setup for Stripe Connect Express.
- Creating application fee (25%).
- Webhook endpoint and
STRIPE_WEBHOOK_SECRET.
- Moderation integration instructions:
- How to set
MODERATION_API_URLandMODERATION_API_KEY. - Request/response contract for the external LLM service.
- How to set
.env.local.examplefile with all required env keys (no secrets).- Notes for running on a home server behind a reverse proxy (e.g. Nginx/Caddy):
- Standard
npm run build && npm run start. - Health check route if needed.
- Standard