Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

27 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

unshutter

unshutter (v.): to open the shutters; to let the light in.

A stock-media storefront an AI agent can take from git clone to a running, verified store — media, database, payments — without a single human signup. Every platform in the stack provisions claim-later environments (npx @cloudinary/cloud, npx neon-new, stripe sandbox create), so the human touches move to where they belong: claiming something that already works. Paste the Agent quick start into your agent and watch.

Built on Next.js 16 + Payload CMS + Neon + Stripe + Vercel, with Cloudinary as the media layer. Sibling to kickoff-cards (Cloudinary + Supabase).

The unshutter gallery — watermarked previews on a warm gallery wall, originals locked behind the media layer

Three product shapes:

  • Photos — browse watermarked, heavily compressed Previews; paying unshutters the image with a time-limited signed link to the full-size Original
  • Videos — an AI-selected 5-second watermarked, low-res preview clip (Cloudinary e_preview) plus six auto-extracted watermarked stills; purchase downloads the original video file
  • Photosets — a whole folder of images (50+) sold as one product behind a handful of curated watermarked previews, delivered as a single zip of Originals

The core idea: the paywall is enforced by the media layer itself, not by hiding URLs. Originals live in Cloudinary as restricted assets (type: private for images, type: authenticated for video) that the CDN refuses to deliver unsigned. There is no user database, no entitlement table, no "secret" link that leaks — Stripe's payment record is the entitlement, and Cloudinary's signature requirement is the lock.

The provisioning story has names: Cloudinary Claimable Clouds, Neon Claimable Postgres, Stripe sandboxes — and Payload rounds out the stack as a code-first CMS an agent can read end to end. The agent's run stops at a verified local site; claiming and deploying stay yours. Under the hood, unshutter is also a real-world Cloudinary integration with some depth to it — restricted assets, strict transformations, AI video previews, signed archives (see What it demonstrates) — and the repo itself was built AI-assisted against live services, shipping the guardrails that made that work (see Developing with AI assistance).


What it demonstrates

  • Restricted assets as a paywall — image Originals upload as type: private, video Originals as type: authenticated; Cloudinary will not deliver the original bytes without a signed URL, full stop. (Video uses authenticated because it requires a signature on every rendition, original and derived — the stronger guarantee for paywalled video, and one that depends on no strict-mode configuration at all; the app's URL builders supply the signatures)
  • Strict Transformations + named transformations — with strict mode ON, only the allowlisted t_preview / t_og transformations of a private asset resolve publicly; nobody can request the un-watermarked variant
  • AI video previews with e_preview — Cloudinary's deep-learning excerpt picker cuts a 5-second highlight reel per video; chained with the watermark + q_auto:low, it is the merchandising. e_preview can't generate on the fly (HTTP 423), so it's eager-generated at upload with eager_async + a signature-verified webhook that flips the catalog's previewReady flag
  • Frame extraction as a filmstrip — six watermarked stills per video from so_<N>p percentage offsets, eager-generated alongside the preview; the first doubles as player poster, Stripe thumbnail, and OG image
  • Zip archives as a deliverable — a purchased photoset downloads as one signed, expiring generate_archive zip of 50+ private Originals, assembled on the fly; member public_ids encode source filenames so the zip unpacks with human-readable names
  • Signed, expiring downloads — purchase mints a ~10-minute private_download_url (or archive URL) per item, re-minted fresh on every visit to the durable download page
  • Degradation as the product — Previews use q_auto:low + a watermark overlay + a size cap; the compression isn't a cost to minimize, it's the merchandising
  • AI tagging + captioning on ingest — photo uploads request google_tagging + captioning; tags and caption (used as alt text) write back to the catalog automatically. On clouds without the add-ons registered, ingestion degrades gracefully: the upload retries untagged instead of failing. Videos and photosets are tagged manually by design (built-in video tagging is dialogue-only; per-file AI across a 50-image set buys nothing)
  • Generative social crops — per-photo OG images from one t_og transformation (w_1200,h_630,c_auto,g_auto)
  • f_auto delivery under strict mode — previews render through next-cloudinary's CldImage with strictTransformations, emitting exactly the allowlisted t_preview/f_auto chain so WebP/AVIF negotiation survives strict mode; Cloudinary owns all optimization
  • Stripe as the entitlement system/download?session_id=… verifies payment_status === 'paid' live against the Stripe API; the webhook only records Orders for reporting and grants nothing
  • Payload as the embedded catalog — admin at /admin in the same Next.js app; collection hooks upload each Original to Cloudinary on save and destroy it on delete (deleting a photoset cascade-deletes its members and their assets)
A video product page — AI-picked 5-second preview clip with a six-frame watermarked filmstrip A photoset product page — a folder of originals behind curated watermarked previews, sold as one zip

Features

  • One gallery mixing photos, videos, and photosets — always watermarked Previews, never an Original
  • Video cards hover-play their muted, looping 5-second preview; video pages add a player and a six-frame watermarked filmstrip
  • Photoset cards carry a photo-count badge; set pages show the curated watermarked previews (isPreview per member, picked in the admin)
  • Mixed cart across all three product kinds (client state, localStorage) with live header badge — prices always re-derived server-side
  • Guest checkout via Stripe Checkout; no accounts anywhere
  • Durable download page: bookmark it, revisit any time, get fresh signed URLs each render — per-file downloads for photos/videos, one zip per photoset
  • Purchased items clear from the cart automatically after checkout
  • Photographer admin: create/curate the catalog, set pricing, watch Orders arrive; bulk-ingest a photoset folder with one command (pnpm payload run scripts/ingest-photoset.ts <folder> "<title>" <priceCents>)
  • Per-item OG/social images; AI-generated tags and alt text for photos, manual tags for videos and sets

Stack

Framework Next.js 16 (App Router, TypeScript)
CMS Payload 3 (embedded, code-first)
Database Neon (Postgres, via @payloadcms/db-postgres)
Payments Stripe (hosted Checkout, test mode)
Media Cloudinary (next-cloudinary in the storefront, Node SDK v2 server-side)
Deploy Vercel

Agent quick start

The entire site — services included — can be stood up locally by an AI agent with no human intervention: no accounts, no dashboards, no hand-edited .env. Deploying is deliberately not part of it; that's yours to do once you've seen it working. Paste this into your agent:

Clone https://github.com/cloudinary-devs/unshutter and get the storefront running locally, end to end. Don't ask me for credentials — provision claimable environments. Follow the agent quick start in the README, plus AGENTS.md and docs/AGENT-PROVISIONING.md. Verify a full test purchase against the local site. Don't deploy anywhere. When you're done, give me the local URL and the claim links.

If you are the agent, this is your runbook:

  1. Clone + installgit clone https://github.com/cloudinary-devs/unshutter && cd unshutter && pnpm install && cp .env.example .env
  2. Provision — no credentials needed:
    npx @cloudinary/cloud     # → CLOUDINARY_URL in .env + claim URL (24h)
    npx neon-new --yes        # → connection string (copy to DATABASE_URI) + claim URL (72h)
    stripe sandbox create     # → sk_test_... for STRIPE_SECRET_KEY
    Fill the rest of .env: NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME from the provisioned cloud name, PAYLOAD_SECRET from openssl rand -hex 32, NEXT_PUBLIC_SERVER_URL=http://localhost:3000. Record every claim URL and expiry — they are the handover.
  3. Configure Cloudinary via the Admin API — create the named transformations and allowlist entries listed in Required Cloudinary console setup (exact recipes: src/lib/cloudinary/README.md). Then verify, don't assume: request an un-allowlisted derived URL of a private asset and expect 401. If it resolves, Strict Transformations is off — the one console toggle you must flag to your human.
  4. Bring uppnpm payload migrate, then stripe listen --forward-to localhost:3000/api/stripe/webhook in the background (write the printed whsec_... to STRIPE_WEBHOOK_SECRET), then pnpm dev.
  5. Seed + verify end to end — create the first admin user with a Payload Local API script, upload a photo, then drive the storefront: add to cart → Stripe Checkout with 4242 4242 4242 4242/download delivers the un-watermarked Original via signed URL, and the unsigned original URL 401s.
  6. Stop at local. Hand over — report the local URL, the admin credentials you seeded, and every claim URL with its expiry (Cloudinary 24h, Neon 72h). Do not deploy — hosting is the human's step, and the Cloudinary cloud must be claimed before a deployed site can serve media to anyone but this machine. Point them at docs/AGENT-PROVISIONING.md, Phases 5–6, when they're ready.

Quick start

Requirements: Node 20+, pnpm, the Stripe CLI. Accounts are optional — every service below can be provisioned claim-later (next section).

git clone https://github.com/cloudinary-devs/unshutter
cd unshutter
pnpm install
cp .env.example .env
# fill in .env — see the file's comments for each value
pnpm dev

No accounts? Provision the stack in three commands

If you (or your agent) don't have credentials yet, provision throwaway-until-claimed environments — no signup, no email verification, no dashboard:

npx @cloudinary/cloud     # Claimable Cloud → writes CLOUDINARY_URL to .env, prints a claim URL (24h)
npx neon-new --yes        # Claimable Postgres → connection string in .env, claim URL (72h)
stripe sandbox create     # anonymous Stripe sandbox with working test keys

Everything works immediately. When you like what you've built, open the claim URLs to keep the Cloudinary environment (free account, assets and credentials intact) and the Neon database. Until it's claimed, a Claimable Cloud locks media delivery to your IP — fine for local dev, claim before sharing a deployed URL. Full agent walkthrough: docs/AGENT-PROVISIONING.md; Cloudinary's docs: Claimable Cloud provisioning.

Otherwise, use an existing free Cloudinary account, any Postgres database (Neon free tier works), and Stripe test keys.

Then in a second terminal:

stripe listen --forward-to localhost:3000/api/stripe/webhook
# copy the printed whsec_... into .env as STRIPE_WEBHOOK_SECRET

Open http://localhost:3000/admin, create the first admin user, and upload a few Photos — each save ingests the Original into Cloudinary privately and pulls back AI tags + caption. The storefront is at /. Check out with test card 4242 4242 4242 4242.

Videos: upload through the same Media collection (add tags manually — the built-in video tagger only understands dialogue). The watermarked preview clip and stills generate asynchronously; in production a Cloudinary webhook flips the video live, in local dev tick its Preview Ready checkbox after a minute (Cloudinary can't POST to localhost).

Photosets: ingest a whole folder as one product —

pnpm payload run scripts/ingest-photoset.ts ./my-folder "Golden Hour Beach Set" 4900 "beach,sunset"

Every file uploads as a private set member; the first four are flagged as public previews (re-pick in the admin via each member's Is Preview checkbox).

Required Cloudinary console setup

The security model depends on console configuration that code cannot create — do this before Previews will resolve:

  1. Strict Transformations: ON (Settings → Security). Without it, derived versions of private assets are publicly addressable and the watermark is bypassable.
  2. Named transformation preview (allowed_for_strict: true): watermark overlay + q_auto:low + c_limit,w_1200.
  3. Named transformation og (allowed_for_strict: true): w_1200,h_630,c_auto,g_auto.
  4. Allowlist the chained variants t_preview/f_auto and t_og/f_auto (f_auto can't live inside a named transformation).
  5. Named transformation video_preview: e_preview:duration_5c_limit,w_640 → the same watermark overlay → q_auto:low.
  6. Named transformation video_still: c_limit,w_800 → the same watermark overlay → q_auto:low.

Videos don't depend on strict mode: they upload as type: authenticated, so every video URL the app emits — original and derived — is signed. Photoset zips need no console setup.

Full details and rationale: src/lib/cloudinary/README.md.

Make it yours

The storefront is built to be rebranded from exactly two files:

  • src/site.config.ts — every brand-voiced string: site name, header wordmark (including which leading segment gets the accent color), the gallery tagline, and the <meta> description.
  • src/styles/unshutter-tokens.css — the entire visual identity as CSS custom properties: palette, the three font roles (display serif / UI sans / technical mono), spacing, radii, shadows, motion. The storefront stylesheet references only these tokens, so swapping values here restyles every page consistently.

No component edits needed for a rebrand. The gallery lays itself out from your content: cards keep each photo's natural aspect ratio and the masonry wall balances columns automatically, so it looks intentional whatever you upload.

Documentation

Developing with AI assistance

The stack was chosen so an AI coding agent can provision real services, work against them, and hand them over — instead of guessing against mocks or waiting on a human to fill in .env:

  • CloudinaryClaimable Clouds: npx @cloudinary/cloud gives an agent a live environment with zero human steps; agent docs, MCP servers + skills, and llms.txt let it inspect assets, test transformation chains, and verify private-asset behavior empirically
  • Neon — Claimable Postgres (npx neon-new): instant database, claim URL for handover; MCP server for schema inspection
  • Stripe — anonymous sandboxes (stripe sandbox create) with working test keys; CLI for triggering and tailing test webhooks; agent toolkit for the API
  • Vercel — CLI/MCP for deployments, env vars, logs, once you decide to host it (deploying is outside the agent's unattended scope — see docs/AGENT-PROVISIONING.md, Phase 6)
  • Payload — no dashboard state to reverse-engineer; the whole CMS is TypeScript config in the repo

The pattern across all four platforms is the same and it's the interesting part: provision anonymously, build, verify, then hand the human a claim link. The human touches move from before the work (signup forms, key copy-pasting) to after it (claiming something that already works). docs/AGENT-PROVISIONING.md walks the whole flow for this app.

AGENTS.md encodes the working agreements that kept generated code on the rails (where calls live, what's non-negotiable in the security model, how models were delegated), and docs/VERIFIED.md records the "verify against current docs before building" research — both worth reading if you build with agents on fast-moving APIs.

Running tests

pnpm test:int   # Vitest unit + integration tests (colocated in src/lib/)
pnpm lint       # ESLint
pnpm build      # TypeScript check + production build

The end-to-end purchase flow (browse → cart → Stripe test checkout → webhook → signed download) is exercised manually per the test plan in the spec.

License

MIT

About

Photography paywall storefront. An open-source showcase demonstrating Cloudinary in a modern full-stack app - Cloudinary + Next.js 16 + Payload CMS + Neon + Stripe + Vercel

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages