From d27ac5bb221ec82ffd3da5f56e11e855f190dde9 Mon Sep 17 00:00:00 2001 From: burak-upstash Date: Thu, 30 Apr 2026 00:50:49 +0300 Subject: [PATCH 1/4] stripe-app --- .../.env.example | 11 ++ .../stripe-projects-url-shortener/.gitignore | 8 ++ .../stripe-projects-url-shortener/README.md | 95 +++++++++++++++ .../app/[slug]/route.ts | 29 +++++ .../app/actions.ts | 73 +++++++++++ .../app/auth/callback/route.ts | 14 +++ .../app/dashboard/create-link-form.tsx | 43 +++++++ .../app/dashboard/page.tsx | 113 ++++++++++++++++++ .../app/globals.css | 17 +++ .../app/layout.tsx | 21 ++++ .../app/login/page.tsx | 54 +++++++++ .../app/page.tsx | 11 ++ .../lib/redis.ts | 3 + .../lib/supabase/public.ts | 9 ++ .../lib/supabase/server.ts | 29 +++++ .../next-env.d.ts | 6 + .../next.config.ts | 5 + .../package.json | 31 +++++ .../postcss.config.mjs | 6 + .../migrations/20260430000000_links.sql | 21 ++++ .../tailwind.config.ts | 11 ++ .../tsconfig.json | 41 +++++++ 22 files changed, 651 insertions(+) create mode 100644 examples/stripe-projects-url-shortener/.env.example create mode 100644 examples/stripe-projects-url-shortener/.gitignore create mode 100644 examples/stripe-projects-url-shortener/README.md create mode 100644 examples/stripe-projects-url-shortener/app/[slug]/route.ts create mode 100644 examples/stripe-projects-url-shortener/app/actions.ts create mode 100644 examples/stripe-projects-url-shortener/app/auth/callback/route.ts create mode 100644 examples/stripe-projects-url-shortener/app/dashboard/create-link-form.tsx create mode 100644 examples/stripe-projects-url-shortener/app/dashboard/page.tsx create mode 100644 examples/stripe-projects-url-shortener/app/globals.css create mode 100644 examples/stripe-projects-url-shortener/app/layout.tsx create mode 100644 examples/stripe-projects-url-shortener/app/login/page.tsx create mode 100644 examples/stripe-projects-url-shortener/app/page.tsx create mode 100644 examples/stripe-projects-url-shortener/lib/redis.ts create mode 100644 examples/stripe-projects-url-shortener/lib/supabase/public.ts create mode 100644 examples/stripe-projects-url-shortener/lib/supabase/server.ts create mode 100644 examples/stripe-projects-url-shortener/next-env.d.ts create mode 100644 examples/stripe-projects-url-shortener/next.config.ts create mode 100644 examples/stripe-projects-url-shortener/package.json create mode 100644 examples/stripe-projects-url-shortener/postcss.config.mjs create mode 100644 examples/stripe-projects-url-shortener/supabase/migrations/20260430000000_links.sql create mode 100644 examples/stripe-projects-url-shortener/tailwind.config.ts create mode 100644 examples/stripe-projects-url-shortener/tsconfig.json diff --git a/examples/stripe-projects-url-shortener/.env.example b/examples/stripe-projects-url-shortener/.env.example new file mode 100644 index 0000000..49c3adb --- /dev/null +++ b/examples/stripe-projects-url-shortener/.env.example @@ -0,0 +1,11 @@ +# Populated automatically by `stripe projects add upstash/redis` +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= + +# Populated automatically by `stripe projects add supabase/postgres` +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# The deployed origin (used for absolute redirect URLs in magic-link emails). +# Set in Vercel; for local dev keep http://localhost:3000. +NEXT_PUBLIC_SITE_URL=http://localhost:3000 diff --git a/examples/stripe-projects-url-shortener/.gitignore b/examples/stripe-projects-url-shortener/.gitignore new file mode 100644 index 0000000..09a10b8 --- /dev/null +++ b/examples/stripe-projects-url-shortener/.gitignore @@ -0,0 +1,8 @@ +node_modules +.next +.env +.env.local +.vercel +*.log +.DS_Store +tsconfig.tsbuildinfo diff --git a/examples/stripe-projects-url-shortener/README.md b/examples/stripe-projects-url-shortener/README.md new file mode 100644 index 0000000..3680e79 --- /dev/null +++ b/examples/stripe-projects-url-shortener/README.md @@ -0,0 +1,95 @@ +# Shorty — URL shortener on Stripe Projects + +Companion code for the blog post **[Ship a Full-Stack App Without Opening a Dashboard](https://upstash.com/blog/ship-full-stack-app-stripe-projects)**. + +A URL shortener with live click counters. The whole stack — Postgres, Redis, hosting — is provisioned through one CLI: `stripe projects add`. + +## Stack + +- **Next.js 15** (App Router) on **Vercel** +- **Supabase** for auth and the `links` table +- **Upstash Redis** for the redirect cache, atomic click counters, and per-user rate limiting +- **Stripe Projects CLI** to provision all three from your terminal + +## Provision the stack + +```bash +stripe projects add supabase/postgres +stripe projects add upstash/redis +stripe projects add vercel/project +``` + +Each command provisions the resource and writes credentials to `.env`. After all three, your `.env` matches `.env.example`. + +## Run the database migration + +```bash +supabase db push +``` + +(or paste `supabase/migrations/20260430000000_links.sql` into the Supabase SQL editor.) + +## Develop + +```bash +npm install +npm run dev +``` + +Open http://localhost:3000, sign in with a magic link, and shorten something. + +## Deploy + +```bash +vercel deploy --prod +``` + +The Vercel project is already linked from `stripe projects add vercel/project`, and the env vars are already attached. There is no dashboard step. + +## Test + +```bash +SHORT=$(curl -s -X POST ...) # or just create one in the UI +for i in $(seq 1 100); do + curl -s -o /dev/null https://your-app.vercel.app/$SHORT +done +``` + +Refresh the dashboard. The counter says 100. None of those clicks touched Postgres. + +## Architecture + +``` +┌──────────────────────────┐ +│ Browser / curl │ +└────────┬─────────────────┘ + │ + │ GET /:slug + ▼ +┌──────────────────────────┐ ┌──────────────────────┐ +│ Next.js Route Handler │ ──────▶ │ Upstash Redis │ +│ (Vercel) │ GET │ link:{slug} │ +│ │ INCR │ clicks:{slug} │ +│ cache miss ─────┐ │ └──────────────────────┘ +└──────────────────┼───────┘ + │ + │ SELECT target_url + ▼ + ┌──────────────────┐ + │ Supabase (Postgres) │ + │ links table │ + └──────────────────────┘ +``` + +Hot path: Redis only. Cold path (first click after deploy): Postgres → Redis. + +## Files + +- `app/[slug]/route.ts` — redirect handler (Redis cache + Postgres fallback + INCR) +- `app/actions.ts` — server actions: create / delete a link, with rate limit +- `app/dashboard/page.tsx` — list links, show live click counts via `MGET` +- `app/login/page.tsx` — magic-link sign-in +- `lib/redis.ts` — `Redis.fromEnv()` singleton +- `lib/supabase/server.ts` — cookie-based Supabase client (auth-aware) +- `lib/supabase/public.ts` — anon Supabase client for the redirect path +- `supabase/migrations/20260430000000_links.sql` — schema + RLS policies diff --git a/examples/stripe-projects-url-shortener/app/[slug]/route.ts b/examples/stripe-projects-url-shortener/app/[slug]/route.ts new file mode 100644 index 0000000..2a9f36a --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/[slug]/route.ts @@ -0,0 +1,29 @@ +import { redis } from "@/lib/redis"; +import { publicSupabase } from "@/lib/supabase/public"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + + let target = await redis.get(`link:${slug}`); + + if (!target) { + const { data } = await publicSupabase + .from("links") + .select("target_url") + .eq("slug", slug) + .maybeSingle(); + + if (!data) return new Response("Not found", { status: 404 }); + + target = data.target_url as string; + await redis.set(`link:${slug}`, target, { ex: 60 * 60 * 24 }); + } + + // Fire-and-forget atomic increment. + redis.incr(`clicks:${slug}`).catch(() => {}); + + return Response.redirect(target, 302); +} diff --git a/examples/stripe-projects-url-shortener/app/actions.ts b/examples/stripe-projects-url-shortener/app/actions.ts new file mode 100644 index 0000000..d9caf77 --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/actions.ts @@ -0,0 +1,73 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { customAlphabet } from "nanoid"; +import { Ratelimit } from "@upstash/ratelimit"; +import { createClient } from "@/lib/supabase/server"; +import { redis } from "@/lib/redis"; + +const generateSlug = customAlphabet( + "abcdefghijklmnopqrstuvwxyz0123456789", + 6, +); + +const ratelimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(10, "1m"), + prefix: "shorty:create", +}); + +export async function createLink(formData: FormData) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) throw new Error("Not signed in."); + + const { success } = await ratelimit.limit(user.id); + if (!success) throw new Error("Slow down — 10 links per minute max."); + + const targetUrl = String(formData.get("url")); + try { + new URL(targetUrl); + } catch { + throw new Error("Invalid URL."); + } + + const slug = generateSlug(); + + const { error } = await supabase + .from("links") + .insert({ user_id: user.id, slug, target_url: targetUrl }); + + if (error) throw error; + + // Warm the cache so the first click is also fast. + await redis.set(`link:${slug}`, targetUrl, { ex: 60 * 60 * 24 }); + + revalidatePath("/dashboard"); + return slug; +} + +export async function deleteLink(slug: string) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) throw new Error("Not signed in."); + + const { error } = await supabase + .from("links") + .delete() + .eq("slug", slug) + .eq("user_id", user.id); + + if (error) throw error; + + await Promise.all([ + redis.del(`link:${slug}`), + redis.del(`clicks:${slug}`), + ]); + + revalidatePath("/dashboard"); +} diff --git a/examples/stripe-projects-url-shortener/app/auth/callback/route.ts b/examples/stripe-projects-url-shortener/app/auth/callback/route.ts new file mode 100644 index 0000000..fbad2d4 --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/auth/callback/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url); + const code = searchParams.get("code"); + + if (code) { + const supabase = await createClient(); + await supabase.auth.exchangeCodeForSession(code); + } + + return NextResponse.redirect(`${origin}/dashboard`); +} diff --git a/examples/stripe-projects-url-shortener/app/dashboard/create-link-form.tsx b/examples/stripe-projects-url-shortener/app/dashboard/create-link-form.tsx new file mode 100644 index 0000000..054731b --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/dashboard/create-link-form.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { createLink } from "@/app/actions"; + +export function CreateLinkForm() { + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + return ( +
{ + setError(null); + startTransition(async () => { + try { + await createLink(formData); + } catch (e) { + setError(e instanceof Error ? e.message : "Something went wrong."); + } + }); + }} + className="space-y-2" + > +
+ + +
+ {error &&

{error}

} +
+ ); +} diff --git a/examples/stripe-projects-url-shortener/app/dashboard/page.tsx b/examples/stripe-projects-url-shortener/app/dashboard/page.tsx new file mode 100644 index 0000000..a3f77de --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/dashboard/page.tsx @@ -0,0 +1,113 @@ +import { redirect } from "next/navigation"; +import { headers } from "next/headers"; +import { createClient } from "@/lib/supabase/server"; +import { redis } from "@/lib/redis"; +import { CreateLinkForm } from "./create-link-form"; +import { deleteLink } from "@/app/actions"; + +export const dynamic = "force-dynamic"; + +type Link = { + slug: string; + target_url: string; + created_at: string; +}; + +export default async function Dashboard() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect("/login"); + + const { data: links } = await supabase + .from("links") + .select("slug, target_url, created_at") + .eq("user_id", user.id) + .order("created_at", { ascending: false }); + + const rows: Link[] = links ?? []; + + const counts = rows.length + ? await redis.mget<(number | null)[]>( + ...rows.map((l) => `clicks:${l.slug}`), + ) + : []; + + const host = + process.env.NEXT_PUBLIC_SITE_URL ?? + `http://${(await headers()).get("host") ?? "localhost:3000"}`; + + async function signOut() { + "use server"; + const supabase = await createClient(); + await supabase.auth.signOut(); + redirect("/login"); + } + + return ( +
+
+

Your links

+
+ +
+
+ + + + {rows.length === 0 ? ( +

No links yet. Shorten one above.

+ ) : ( +
    + {rows.map((link, i) => { + const shortUrl = `${host}/${link.slug}`; + return ( +
  • +
    + + {shortUrl} + +

    + → {link.target_url} +

    +
    + + {counts[i] ?? 0} clicks + + +
  • + ); + })} +
+ )} +
+ ); +} + +function DeleteButton({ slug }: { slug: string }) { + async function action() { + "use server"; + await deleteLink(slug); + } + return ( +
+ +
+ ); +} diff --git a/examples/stripe-projects-url-shortener/app/globals.css b/examples/stripe-projects-url-shortener/app/globals.css new file mode 100644 index 0000000..450b1f9 --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/globals.css @@ -0,0 +1,17 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: light dark; +} + +body { + @apply bg-zinc-50 text-zinc-900 antialiased; +} + +@media (prefers-color-scheme: dark) { + body { + @apply bg-zinc-950 text-zinc-100; + } +} diff --git a/examples/stripe-projects-url-shortener/app/layout.tsx b/examples/stripe-projects-url-shortener/app/layout.tsx new file mode 100644 index 0000000..6b68f9e --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Shorty", + description: "URL shortener built with Stripe Projects, Supabase, and Upstash Redis.", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +
{children}
+ + + ); +} diff --git a/examples/stripe-projects-url-shortener/app/login/page.tsx b/examples/stripe-projects-url-shortener/app/login/page.tsx new file mode 100644 index 0000000..f78dc68 --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/login/page.tsx @@ -0,0 +1,54 @@ +import { redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; + +async function sendMagicLink(formData: FormData) { + "use server"; + const email = String(formData.get("email")); + const supabase = await createClient(); + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; + + const { error } = await supabase.auth.signInWithOtp({ + email, + options: { emailRedirectTo: `${siteUrl}/auth/callback` }, + }); + + if (error) throw error; + redirect("/login?sent=1"); +} + +export default async function Login({ + searchParams, +}: { + searchParams: Promise<{ sent?: string }>; +}) { + const { sent } = await searchParams; + + return ( +
+

Sign in

+

+ Enter your email and we'll send you a magic link. +

+
+ + +
+ {sent === "1" && ( +

+ Check your inbox for the sign-in link. +

+ )} +
+ ); +} diff --git a/examples/stripe-projects-url-shortener/app/page.tsx b/examples/stripe-projects-url-shortener/app/page.tsx new file mode 100644 index 0000000..a1af937 --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/page.tsx @@ -0,0 +1,11 @@ +import { redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; + +export default async function Home() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + redirect(user ? "/dashboard" : "/login"); +} diff --git a/examples/stripe-projects-url-shortener/lib/redis.ts b/examples/stripe-projects-url-shortener/lib/redis.ts new file mode 100644 index 0000000..2c7f79a --- /dev/null +++ b/examples/stripe-projects-url-shortener/lib/redis.ts @@ -0,0 +1,3 @@ +import { Redis } from "@upstash/redis"; + +export const redis = Redis.fromEnv(); diff --git a/examples/stripe-projects-url-shortener/lib/supabase/public.ts b/examples/stripe-projects-url-shortener/lib/supabase/public.ts new file mode 100644 index 0000000..f0e101d --- /dev/null +++ b/examples/stripe-projects-url-shortener/lib/supabase/public.ts @@ -0,0 +1,9 @@ +import { createClient } from "@supabase/supabase-js"; + +// Anon, cookie-free client for the public redirect path. +// Reads are allowed by the public-read RLS policy on `links`. +export const publicSupabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { auth: { persistSession: false } }, +); diff --git a/examples/stripe-projects-url-shortener/lib/supabase/server.ts b/examples/stripe-projects-url-shortener/lib/supabase/server.ts new file mode 100644 index 0000000..f9b0b43 --- /dev/null +++ b/examples/stripe-projects-url-shortener/lib/supabase/server.ts @@ -0,0 +1,29 @@ +import { createServerClient, type CookieOptions } from "@supabase/ssr"; +import { cookies } from "next/headers"; + +export async function createClient() { + const cookieStore = await cookies(); + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll( + cookiesToSet: { name: string; value: string; options: CookieOptions }[], + ) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options), + ); + } catch { + // setAll called from a Server Component — ignore. + } + }, + }, + }, + ); +} diff --git a/examples/stripe-projects-url-shortener/next-env.d.ts b/examples/stripe-projects-url-shortener/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/examples/stripe-projects-url-shortener/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/stripe-projects-url-shortener/next.config.ts b/examples/stripe-projects-url-shortener/next.config.ts new file mode 100644 index 0000000..cb651cd --- /dev/null +++ b/examples/stripe-projects-url-shortener/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/examples/stripe-projects-url-shortener/package.json b/examples/stripe-projects-url-shortener/package.json new file mode 100644 index 0000000..61b7561 --- /dev/null +++ b/examples/stripe-projects-url-shortener/package.json @@ -0,0 +1,31 @@ +{ + "name": "stripe-projects-url-shortener", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@supabase/ssr": "^0.5.2", + "@supabase/supabase-js": "^2.45.4", + "@upstash/ratelimit": "^2.0.3", + "@upstash/redis": "^1.34.3", + "nanoid": "^5.0.7", + "next": "^16.2.4", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.9.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3" + } +} diff --git a/examples/stripe-projects-url-shortener/postcss.config.mjs b/examples/stripe-projects-url-shortener/postcss.config.mjs new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/examples/stripe-projects-url-shortener/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/examples/stripe-projects-url-shortener/supabase/migrations/20260430000000_links.sql b/examples/stripe-projects-url-shortener/supabase/migrations/20260430000000_links.sql new file mode 100644 index 0000000..efa16de --- /dev/null +++ b/examples/stripe-projects-url-shortener/supabase/migrations/20260430000000_links.sql @@ -0,0 +1,21 @@ +create table links ( + id uuid primary key default gen_random_uuid(), + user_id uuid references auth.users(id) on delete cascade not null, + slug text unique not null, + target_url text not null, + created_at timestamptz default now() +); + +create index links_user_id_idx on links(user_id); + +alter table links enable row level security; + +-- Short URLs are public by design: anyone with the slug can resolve it. +create policy "public read links" + on links for select using (true); + +create policy "users insert their own links" + on links for insert with check (auth.uid() = user_id); + +create policy "users delete their own links" + on links for delete using (auth.uid() = user_id); diff --git a/examples/stripe-projects-url-shortener/tailwind.config.ts b/examples/stripe-projects-url-shortener/tailwind.config.ts new file mode 100644 index 0000000..eb7ad00 --- /dev/null +++ b/examples/stripe-projects-url-shortener/tailwind.config.ts @@ -0,0 +1,11 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: ["./app/**/*.{ts,tsx}"], + theme: { + extend: {}, + }, + plugins: [], +}; + +export default config; diff --git a/examples/stripe-projects-url-shortener/tsconfig.json b/examples/stripe-projects-url-shortener/tsconfig.json new file mode 100644 index 0000000..803331c --- /dev/null +++ b/examples/stripe-projects-url-shortener/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} From 8985c49336399a1911bc0a9238ac1a78684bf9d8 Mon Sep 17 00:00:00 2001 From: burak-upstash Date: Thu, 30 Apr 2026 01:19:21 +0300 Subject: [PATCH 2/4] shorty example: refactor to Redis-only via Stripe Projects Co-Authored-By: Claude Opus 4.7 (1M context) --- .../.env.example | 10 +- .../stripe-projects-url-shortener/.gitignore | 7 +- .../.projects/state.json | 23 ++++ .../.projects/state.local.json | 62 ++++++++++ .../stripe-projects-url-shortener/README.md | 84 +++---------- .../app/[slug]/route.ts | 18 +-- .../app/actions.ts | 58 ++++----- .../app/auth/callback/route.ts | 14 --- .../app/{dashboard => }/create-link-form.tsx | 2 +- .../app/dashboard/page.tsx | 113 ----------------- .../app/layout.tsx | 2 +- .../app/login/page.tsx | 54 -------- .../app/page.tsx | 116 ++++++++++++++++-- .../lib/redis.ts | 8 +- .../lib/supabase/public.ts | 9 -- .../lib/supabase/server.ts | 29 ----- .../package.json | 2 - .../migrations/20260430000000_links.sql | 21 ---- 18 files changed, 255 insertions(+), 377 deletions(-) create mode 100644 examples/stripe-projects-url-shortener/.projects/state.json create mode 100644 examples/stripe-projects-url-shortener/.projects/state.local.json delete mode 100644 examples/stripe-projects-url-shortener/app/auth/callback/route.ts rename examples/stripe-projects-url-shortener/app/{dashboard => }/create-link-form.tsx (96%) delete mode 100644 examples/stripe-projects-url-shortener/app/dashboard/page.tsx delete mode 100644 examples/stripe-projects-url-shortener/app/login/page.tsx delete mode 100644 examples/stripe-projects-url-shortener/lib/supabase/public.ts delete mode 100644 examples/stripe-projects-url-shortener/lib/supabase/server.ts delete mode 100644 examples/stripe-projects-url-shortener/supabase/migrations/20260430000000_links.sql diff --git a/examples/stripe-projects-url-shortener/.env.example b/examples/stripe-projects-url-shortener/.env.example index 49c3adb..80e2d84 100644 --- a/examples/stripe-projects-url-shortener/.env.example +++ b/examples/stripe-projects-url-shortener/.env.example @@ -1,11 +1,7 @@ # Populated automatically by `stripe projects add upstash/redis` -UPSTASH_REDIS_REST_URL= -UPSTASH_REDIS_REST_TOKEN= +UPSTASH_REST_URL= +UPSTASH_REST_TOKEN= -# Populated automatically by `stripe projects add supabase/postgres` -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= - -# The deployed origin (used for absolute redirect URLs in magic-link emails). +# Optional: deployed origin (used in the dashboard for displaying short URLs). # Set in Vercel; for local dev keep http://localhost:3000. NEXT_PUBLIC_SITE_URL=http://localhost:3000 diff --git a/examples/stripe-projects-url-shortener/.gitignore b/examples/stripe-projects-url-shortener/.gitignore index 09a10b8..2027100 100644 --- a/examples/stripe-projects-url-shortener/.gitignore +++ b/examples/stripe-projects-url-shortener/.gitignore @@ -1,8 +1,11 @@ node_modules .next -.env -.env.local .vercel *.log .DS_Store tsconfig.tsbuildinfo + +.projects/cache +.projects/vault +.env +.env.local diff --git a/examples/stripe-projects-url-shortener/.projects/state.json b/examples/stripe-projects-url-shortener/.projects/state.json new file mode 100644 index 0000000..b5e44a5 --- /dev/null +++ b/examples/stripe-projects-url-shortener/.projects/state.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "providers": { + "supabase": { + "name": "Supabase" + }, + "upstash": { + "name": "Upstash" + } + }, + "resources": { + "supabase-project": { + "name": "supabase-project", + "providerName": "Supabase", + "serviceId": "project" + }, + "upstash-redis": { + "name": "upstash-redis", + "providerName": "Upstash", + "serviceId": "redis" + } + } +} diff --git a/examples/stripe-projects-url-shortener/.projects/state.local.json b/examples/stripe-projects-url-shortener/.projects/state.local.json new file mode 100644 index 0000000..e604847 --- /dev/null +++ b/examples/stripe-projects-url-shortener/.projects/state.local.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "project": { + "initializedAt": "2026-04-29T21:56:46.683Z", + "merchantDisplayName": null, + "merchantId": "acct_1HoeMzF0KWKLiikN", + "projectId": "project_61Ub43SdnilEX7Q1t16P2SfsJCSQC1fRzkGTL2XIOJH6", + "projectName": "shorty" + }, + "providers": { + "supabase": { + "accountRequestId": "facctrq_61Ub4FP2wxznsCGHA16P2SfsJCSQC1fRzkGTL2XIO3ei", + "configuration": {}, + "linkedAt": "2026-04-29T22:09:17.484Z", + "providerId": "prvdr_61UIXYHdsyF69pZbG52n2", + "providerName": "Supabase", + "redirectUrl": null, + "requestedAt": "2026-04-29T22:09:17.484Z", + "status": "complete" + }, + "upstash": { + "accountRequestId": "facctrq_61Ub44VstjSfh6yXQ16P2SfsJCSQC1fRzkGTL2XIO4qG", + "configuration": {}, + "linkedAt": "2026-04-29T21:57:51.777Z", + "providerId": "prvdr_61UVa3kQyvSOEnWgd5Naa", + "providerName": "Upstash", + "redirectUrl": null, + "requestedAt": "2026-04-29T21:57:51.777Z", + "status": "complete" + } + }, + "resources": { + "supabase-project": { + "configuration": { + "name": "shorty", + "price": "free" + }, + "createdAt": "2026-04-29T22:09:43.857Z", + "lastRevealedAt": "2026-04-29T22:09:44.373Z", + "lastStatus": "complete", + "name": "supabase-project", + "providerId": "prvdr_61UIXYHdsyF69pZbG52n2", + "providerName": "Supabase", + "resourceId": "fres_61Ub4FahNk615F2Kg16P2SfsJCSQC1fRzkGTL2XIOB4i", + "serviceId": "project" + }, + "upstash-redis": { + "configuration": { + "name": "shorty", + "price": "free" + }, + "createdAt": "2026-04-29T21:57:53.606Z", + "lastRevealedAt": "2026-04-29T21:57:54.068Z", + "lastStatus": "complete", + "name": "upstash-redis", + "providerId": "prvdr_61UVa3kQyvSOEnWgd5Naa", + "providerName": "Upstash", + "resourceId": "fres_61Ub44WezWFH4L6Pq16P2SfsJCSQC1fRzkGTL2XIOUhs", + "serviceId": "redis" + } + } +} diff --git a/examples/stripe-projects-url-shortener/README.md b/examples/stripe-projects-url-shortener/README.md index 3680e79..cdb5480 100644 --- a/examples/stripe-projects-url-shortener/README.md +++ b/examples/stripe-projects-url-shortener/README.md @@ -1,95 +1,47 @@ # Shorty — URL shortener on Stripe Projects -Companion code for the blog post **[Ship a Full-Stack App Without Opening a Dashboard](https://upstash.com/blog/ship-full-stack-app-stripe-projects)**. +Companion code for the blog post **[Ship a URL Shortener Without Opening a Dashboard](https://upstash.com/blog/ship-full-stack-app-stripe-projects)**. -A URL shortener with live click counters. The whole stack — Postgres, Redis, hosting — is provisioned through one CLI: `stripe projects add`. +A public URL shortener with live click counters. The whole stack — data store and hosting — is provisioned through one CLI: `stripe projects add`. ## Stack -- **Next.js 15** (App Router) on **Vercel** -- **Supabase** for auth and the `links` table -- **Upstash Redis** for the redirect cache, atomic click counters, and per-user rate limiting -- **Stripe Projects CLI** to provision all three from your terminal +- **Next.js 16** (App Router) on **Vercel** +- **Upstash Redis** as the data layer — hash per link, sorted set for the recent list, atomic `INCR` for click counters, `@upstash/ratelimit` for abuse protection +- **Stripe Projects CLI** to provision both from your terminal -## Provision the stack +## Provision and deploy ```bash -stripe projects add supabase/postgres +stripe projects init shorty stripe projects add upstash/redis stripe projects add vercel/project ``` -Each command provisions the resource and writes credentials to `.env`. After all three, your `.env` matches `.env.example`. +Each command writes credentials to `.env` (locally) and attaches them to the Vercel project (in production). After the third command, your app is deployed. -## Run the database migration - -```bash -supabase db push -``` - -(or paste `supabase/migrations/20260430000000_links.sql` into the Supabase SQL editor.) - -## Develop +## Develop locally ```bash npm install npm run dev ``` -Open http://localhost:3000, sign in with a magic link, and shorten something. - -## Deploy - -```bash -vercel deploy --prod -``` - -The Vercel project is already linked from `stripe projects add vercel/project`, and the env vars are already attached. There is no dashboard step. +Open http://localhost:3000 and shorten something. ## Test ```bash -SHORT=$(curl -s -X POST ...) # or just create one in the UI -for i in $(seq 1 100); do - curl -s -o /dev/null https://your-app.vercel.app/$SHORT -done -``` - -Refresh the dashboard. The counter says 100. None of those clicks touched Postgres. - -## Architecture - -``` -┌──────────────────────────┐ -│ Browser / curl │ -└────────┬─────────────────┘ - │ - │ GET /:slug - ▼ -┌──────────────────────────┐ ┌──────────────────────┐ -│ Next.js Route Handler │ ──────▶ │ Upstash Redis │ -│ (Vercel) │ GET │ link:{slug} │ -│ │ INCR │ clicks:{slug} │ -│ cache miss ─────┐ │ └──────────────────────┘ -└──────────────────┼───────┘ - │ - │ SELECT target_url - ▼ - ┌──────────────────┐ - │ Supabase (Postgres) │ - │ links table │ - └──────────────────────┘ +SHORT=https://your-app.vercel.app/abc123 +for i in $(seq 1 100); do curl -s -o /dev/null $SHORT; done ``` -Hot path: Redis only. Cold path (first click after deploy): Postgres → Redis. +Refresh the home page. The counter says 100. ## Files -- `app/[slug]/route.ts` — redirect handler (Redis cache + Postgres fallback + INCR) -- `app/actions.ts` — server actions: create / delete a link, with rate limit -- `app/dashboard/page.tsx` — list links, show live click counts via `MGET` -- `app/login/page.tsx` — magic-link sign-in -- `lib/redis.ts` — `Redis.fromEnv()` singleton -- `lib/supabase/server.ts` — cookie-based Supabase client (auth-aware) -- `lib/supabase/public.ts` — anon Supabase client for the redirect path -- `supabase/migrations/20260430000000_links.sql` — schema + RLS policies +- `app/[slug]/route.ts` — redirect handler (Redis HGET + fire-and-forget INCR + 302) +- `app/actions.ts` — server actions: create / delete a link, IP-rate-limited +- `app/page.tsx` — recent links list with live click counts via `MGET` +- `app/create-link-form.tsx` — client form with optimistic state +- `lib/redis.ts` — Redis client (reads `UPSTASH_REST_URL` / `UPSTASH_REST_TOKEN` written by Stripe Projects) diff --git a/examples/stripe-projects-url-shortener/app/[slug]/route.ts b/examples/stripe-projects-url-shortener/app/[slug]/route.ts index 2a9f36a..49758af 100644 --- a/examples/stripe-projects-url-shortener/app/[slug]/route.ts +++ b/examples/stripe-projects-url-shortener/app/[slug]/route.ts @@ -1,5 +1,4 @@ import { redis } from "@/lib/redis"; -import { publicSupabase } from "@/lib/supabase/public"; export async function GET( _request: Request, @@ -7,22 +6,9 @@ export async function GET( ) { const { slug } = await params; - let target = await redis.get(`link:${slug}`); + const target = await redis.hget(`link:${slug}`, "target_url"); + if (!target) return new Response("Not found", { status: 404 }); - if (!target) { - const { data } = await publicSupabase - .from("links") - .select("target_url") - .eq("slug", slug) - .maybeSingle(); - - if (!data) return new Response("Not found", { status: 404 }); - - target = data.target_url as string; - await redis.set(`link:${slug}`, target, { ex: 60 * 60 * 24 }); - } - - // Fire-and-forget atomic increment. redis.incr(`clicks:${slug}`).catch(() => {}); return Response.redirect(target, 302); diff --git a/examples/stripe-projects-url-shortener/app/actions.ts b/examples/stripe-projects-url-shortener/app/actions.ts index d9caf77..beef528 100644 --- a/examples/stripe-projects-url-shortener/app/actions.ts +++ b/examples/stripe-projects-url-shortener/app/actions.ts @@ -1,9 +1,9 @@ "use server"; import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; import { customAlphabet } from "nanoid"; import { Ratelimit } from "@upstash/ratelimit"; -import { createClient } from "@/lib/supabase/server"; import { redis } from "@/lib/redis"; const generateSlug = customAlphabet( @@ -17,15 +17,19 @@ const ratelimit = new Ratelimit({ prefix: "shorty:create", }); -export async function createLink(formData: FormData) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) throw new Error("Not signed in."); +async function clientId() { + const h = await headers(); + return ( + h.get("x-forwarded-for")?.split(",")[0]?.trim() || + h.get("x-real-ip") || + "anonymous" + ); +} - const { success } = await ratelimit.limit(user.id); - if (!success) throw new Error("Slow down — 10 links per minute max."); +export async function createLink(formData: FormData) { + const ip = await clientId(); + const { success } = await ratelimit.limit(ip); + if (!success) throw new Error("Slow down — 10 links per minute per IP."); const targetUrl = String(formData.get("url")); try { @@ -35,39 +39,25 @@ export async function createLink(formData: FormData) { } const slug = generateSlug(); + const createdAt = Date.now(); - const { error } = await supabase - .from("links") - .insert({ user_id: user.id, slug, target_url: targetUrl }); - - if (error) throw error; - - // Warm the cache so the first click is also fast. - await redis.set(`link:${slug}`, targetUrl, { ex: 60 * 60 * 24 }); + await Promise.all([ + redis.hset(`link:${slug}`, { + target_url: targetUrl, + created_at: String(createdAt), + }), + redis.zadd("links:recent", { score: createdAt, member: slug }), + ]); - revalidatePath("/dashboard"); + revalidatePath("/"); return slug; } export async function deleteLink(slug: string) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) throw new Error("Not signed in."); - - const { error } = await supabase - .from("links") - .delete() - .eq("slug", slug) - .eq("user_id", user.id); - - if (error) throw error; - await Promise.all([ redis.del(`link:${slug}`), redis.del(`clicks:${slug}`), + redis.zrem("links:recent", slug), ]); - - revalidatePath("/dashboard"); + revalidatePath("/"); } diff --git a/examples/stripe-projects-url-shortener/app/auth/callback/route.ts b/examples/stripe-projects-url-shortener/app/auth/callback/route.ts deleted file mode 100644 index fbad2d4..0000000 --- a/examples/stripe-projects-url-shortener/app/auth/callback/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/server"; - -export async function GET(request: Request) { - const { searchParams, origin } = new URL(request.url); - const code = searchParams.get("code"); - - if (code) { - const supabase = await createClient(); - await supabase.auth.exchangeCodeForSession(code); - } - - return NextResponse.redirect(`${origin}/dashboard`); -} diff --git a/examples/stripe-projects-url-shortener/app/dashboard/create-link-form.tsx b/examples/stripe-projects-url-shortener/app/create-link-form.tsx similarity index 96% rename from examples/stripe-projects-url-shortener/app/dashboard/create-link-form.tsx rename to examples/stripe-projects-url-shortener/app/create-link-form.tsx index 054731b..6e17d68 100644 --- a/examples/stripe-projects-url-shortener/app/dashboard/create-link-form.tsx +++ b/examples/stripe-projects-url-shortener/app/create-link-form.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useTransition } from "react"; -import { createLink } from "@/app/actions"; +import { createLink } from "./actions"; export function CreateLinkForm() { const [error, setError] = useState(null); diff --git a/examples/stripe-projects-url-shortener/app/dashboard/page.tsx b/examples/stripe-projects-url-shortener/app/dashboard/page.tsx deleted file mode 100644 index a3f77de..0000000 --- a/examples/stripe-projects-url-shortener/app/dashboard/page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { redirect } from "next/navigation"; -import { headers } from "next/headers"; -import { createClient } from "@/lib/supabase/server"; -import { redis } from "@/lib/redis"; -import { CreateLinkForm } from "./create-link-form"; -import { deleteLink } from "@/app/actions"; - -export const dynamic = "force-dynamic"; - -type Link = { - slug: string; - target_url: string; - created_at: string; -}; - -export default async function Dashboard() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) redirect("/login"); - - const { data: links } = await supabase - .from("links") - .select("slug, target_url, created_at") - .eq("user_id", user.id) - .order("created_at", { ascending: false }); - - const rows: Link[] = links ?? []; - - const counts = rows.length - ? await redis.mget<(number | null)[]>( - ...rows.map((l) => `clicks:${l.slug}`), - ) - : []; - - const host = - process.env.NEXT_PUBLIC_SITE_URL ?? - `http://${(await headers()).get("host") ?? "localhost:3000"}`; - - async function signOut() { - "use server"; - const supabase = await createClient(); - await supabase.auth.signOut(); - redirect("/login"); - } - - return ( -
-
-

Your links

-
- -
-
- - - - {rows.length === 0 ? ( -

No links yet. Shorten one above.

- ) : ( -
    - {rows.map((link, i) => { - const shortUrl = `${host}/${link.slug}`; - return ( -
  • -
    - - {shortUrl} - -

    - → {link.target_url} -

    -
    - - {counts[i] ?? 0} clicks - - -
  • - ); - })} -
- )} -
- ); -} - -function DeleteButton({ slug }: { slug: string }) { - async function action() { - "use server"; - await deleteLink(slug); - } - return ( -
- -
- ); -} diff --git a/examples/stripe-projects-url-shortener/app/layout.tsx b/examples/stripe-projects-url-shortener/app/layout.tsx index 6b68f9e..9e8bc60 100644 --- a/examples/stripe-projects-url-shortener/app/layout.tsx +++ b/examples/stripe-projects-url-shortener/app/layout.tsx @@ -3,7 +3,7 @@ import "./globals.css"; export const metadata: Metadata = { title: "Shorty", - description: "URL shortener built with Stripe Projects, Supabase, and Upstash Redis.", + description: "URL shortener built with Stripe Projects + Upstash Redis.", }; export default function RootLayout({ diff --git a/examples/stripe-projects-url-shortener/app/login/page.tsx b/examples/stripe-projects-url-shortener/app/login/page.tsx deleted file mode 100644 index f78dc68..0000000 --- a/examples/stripe-projects-url-shortener/app/login/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; - -async function sendMagicLink(formData: FormData) { - "use server"; - const email = String(formData.get("email")); - const supabase = await createClient(); - const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; - - const { error } = await supabase.auth.signInWithOtp({ - email, - options: { emailRedirectTo: `${siteUrl}/auth/callback` }, - }); - - if (error) throw error; - redirect("/login?sent=1"); -} - -export default async function Login({ - searchParams, -}: { - searchParams: Promise<{ sent?: string }>; -}) { - const { sent } = await searchParams; - - return ( -
-

Sign in

-

- Enter your email and we'll send you a magic link. -

-
- - -
- {sent === "1" && ( -

- Check your inbox for the sign-in link. -

- )} -
- ); -} diff --git a/examples/stripe-projects-url-shortener/app/page.tsx b/examples/stripe-projects-url-shortener/app/page.tsx index a1af937..0756775 100644 --- a/examples/stripe-projects-url-shortener/app/page.tsx +++ b/examples/stripe-projects-url-shortener/app/page.tsx @@ -1,11 +1,113 @@ -import { redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; +import { redis } from "@/lib/redis"; +import { headers } from "next/headers"; +import { CreateLinkForm } from "./create-link-form"; +import { deleteLink } from "./actions"; + +export const dynamic = "force-dynamic"; + +type LinkRow = { + slug: string; + target_url: string; + created_at: number; +}; export default async function Home() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); + // Most recent 50 slugs. + const slugs = (await redis.zrange("links:recent", 0, 49, { + rev: true, + })) ?? []; + + const rows: LinkRow[] = slugs.length + ? ( + await Promise.all( + slugs.map(async (slug) => { + const data = await redis.hgetall<{ + target_url: string; + created_at: string; + }>(`link:${slug}`); + return data + ? { + slug, + target_url: data.target_url, + created_at: Number(data.created_at), + } + : null; + }), + ) + ).filter((r): r is LinkRow => r !== null) + : []; + + const counts = rows.length + ? ((await redis.mget<(number | null)[]>( + ...rows.map((r) => `clicks:${r.slug}`), + )) ?? []) + : []; + + const host = + process.env.NEXT_PUBLIC_SITE_URL ?? + `http://${(await headers()).get("host") ?? "localhost:3000"}`; + + return ( +
+
+

Shorty

+

+ A public URL shortener — built on Upstash Redis, deployed via Stripe Projects. +

+
+ + + + {rows.length === 0 ? ( +

No links yet. Shorten one above.

+ ) : ( +
    + {rows.map((link, i) => { + const shortUrl = `${host}/${link.slug}`; + return ( +
  • +
    + + {shortUrl} + +

    + → {link.target_url} +

    +
    + + {counts[i] ?? 0} clicks + + +
  • + ); + })} +
+ )} +
+ ); +} - redirect(user ? "/dashboard" : "/login"); +function DeleteButton({ slug }: { slug: string }) { + async function action() { + "use server"; + await deleteLink(slug); + } + return ( +
+ +
+ ); } diff --git a/examples/stripe-projects-url-shortener/lib/redis.ts b/examples/stripe-projects-url-shortener/lib/redis.ts index 2c7f79a..ae49f72 100644 --- a/examples/stripe-projects-url-shortener/lib/redis.ts +++ b/examples/stripe-projects-url-shortener/lib/redis.ts @@ -1,3 +1,9 @@ import { Redis } from "@upstash/redis"; -export const redis = Redis.fromEnv(); +// Stripe Projects writes UPSTASH_REST_URL / UPSTASH_REST_TOKEN to .env +// (the @upstash/redis SDK's `Redis.fromEnv()` expects UPSTASH_REDIS_*, so +// we wire it up explicitly here). +export const redis = new Redis({ + url: process.env.UPSTASH_REST_URL!, + token: process.env.UPSTASH_REST_TOKEN!, +}); diff --git a/examples/stripe-projects-url-shortener/lib/supabase/public.ts b/examples/stripe-projects-url-shortener/lib/supabase/public.ts deleted file mode 100644 index f0e101d..0000000 --- a/examples/stripe-projects-url-shortener/lib/supabase/public.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createClient } from "@supabase/supabase-js"; - -// Anon, cookie-free client for the public redirect path. -// Reads are allowed by the public-read RLS policy on `links`. -export const publicSupabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { auth: { persistSession: false } }, -); diff --git a/examples/stripe-projects-url-shortener/lib/supabase/server.ts b/examples/stripe-projects-url-shortener/lib/supabase/server.ts deleted file mode 100644 index f9b0b43..0000000 --- a/examples/stripe-projects-url-shortener/lib/supabase/server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createServerClient, type CookieOptions } from "@supabase/ssr"; -import { cookies } from "next/headers"; - -export async function createClient() { - const cookieStore = await cookies(); - - return createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - getAll() { - return cookieStore.getAll(); - }, - setAll( - cookiesToSet: { name: string; value: string; options: CookieOptions }[], - ) { - try { - cookiesToSet.forEach(({ name, value, options }) => - cookieStore.set(name, value, options), - ); - } catch { - // setAll called from a Server Component — ignore. - } - }, - }, - }, - ); -} diff --git a/examples/stripe-projects-url-shortener/package.json b/examples/stripe-projects-url-shortener/package.json index 61b7561..64f04b0 100644 --- a/examples/stripe-projects-url-shortener/package.json +++ b/examples/stripe-projects-url-shortener/package.json @@ -10,8 +10,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@supabase/ssr": "^0.5.2", - "@supabase/supabase-js": "^2.45.4", "@upstash/ratelimit": "^2.0.3", "@upstash/redis": "^1.34.3", "nanoid": "^5.0.7", diff --git a/examples/stripe-projects-url-shortener/supabase/migrations/20260430000000_links.sql b/examples/stripe-projects-url-shortener/supabase/migrations/20260430000000_links.sql deleted file mode 100644 index efa16de..0000000 --- a/examples/stripe-projects-url-shortener/supabase/migrations/20260430000000_links.sql +++ /dev/null @@ -1,21 +0,0 @@ -create table links ( - id uuid primary key default gen_random_uuid(), - user_id uuid references auth.users(id) on delete cascade not null, - slug text unique not null, - target_url text not null, - created_at timestamptz default now() -); - -create index links_user_id_idx on links(user_id); - -alter table links enable row level security; - --- Short URLs are public by design: anyone with the slug can resolve it. -create policy "public read links" - on links for select using (true); - -create policy "users insert their own links" - on links for insert with check (auth.uid() = user_id); - -create policy "users delete their own links" - on links for delete using (auth.uid() = user_id); From 6216004f4969d23ef87eb4bd7c36628a27f4a440 Mon Sep 17 00:00:00 2001 From: burak-upstash Date: Thu, 30 Apr 2026 01:32:02 +0300 Subject: [PATCH 3/4] shorty: live click counts (1s polling) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../.projects/state.json | 8 ++ .../.projects/state.local.json | 24 +++++ .../app/api/counts/route.ts | 25 ++++++ .../app/links-list.tsx | 90 +++++++++++++++++++ .../app/page.tsx | 79 +++------------- 5 files changed, 157 insertions(+), 69 deletions(-) create mode 100644 examples/stripe-projects-url-shortener/app/api/counts/route.ts create mode 100644 examples/stripe-projects-url-shortener/app/links-list.tsx diff --git a/examples/stripe-projects-url-shortener/.projects/state.json b/examples/stripe-projects-url-shortener/.projects/state.json index b5e44a5..7b8078c 100644 --- a/examples/stripe-projects-url-shortener/.projects/state.json +++ b/examples/stripe-projects-url-shortener/.projects/state.json @@ -6,6 +6,9 @@ }, "upstash": { "name": "Upstash" + }, + "vercel": { + "name": "Vercel" } }, "resources": { @@ -18,6 +21,11 @@ "name": "upstash-redis", "providerName": "Upstash", "serviceId": "redis" + }, + "vercel-project": { + "name": "vercel-project", + "providerName": "Vercel", + "serviceId": "project" } } } diff --git a/examples/stripe-projects-url-shortener/.projects/state.local.json b/examples/stripe-projects-url-shortener/.projects/state.local.json index e604847..94c6652 100644 --- a/examples/stripe-projects-url-shortener/.projects/state.local.json +++ b/examples/stripe-projects-url-shortener/.projects/state.local.json @@ -27,6 +27,16 @@ "redirectUrl": null, "requestedAt": "2026-04-29T21:57:51.777Z", "status": "complete" + }, + "vercel": { + "accountRequestId": "facctrq_61Ub4QAjTmnFIjap616P2SfsJCSQC1fRzkGTL2XIO7iK", + "configuration": {}, + "linkedAt": "2026-04-29T22:20:18.258Z", + "providerId": "prvdr_61ULUAr4zlQgZkvXY5LsG", + "providerName": "Vercel", + "redirectUrl": null, + "requestedAt": "2026-04-29T22:20:18.258Z", + "status": "complete" } }, "resources": { @@ -57,6 +67,20 @@ "providerName": "Upstash", "resourceId": "fres_61Ub44WezWFH4L6Pq16P2SfsJCSQC1fRzkGTL2XIOUhs", "serviceId": "redis" + }, + "vercel-project": { + "configuration": { + "name": "shorty", + "price": "free" + }, + "createdAt": "2026-04-29T22:20:21.907Z", + "lastRevealedAt": "2026-04-29T22:20:22.460Z", + "lastStatus": "complete", + "name": "vercel-project", + "providerId": "prvdr_61ULUAr4zlQgZkvXY5LsG", + "providerName": "Vercel", + "resourceId": "fres_61Ub4QFH5y0pDzkM916P2SfsJCSQC1fRzkGTL2XIOV3o", + "serviceId": "project" } } } diff --git a/examples/stripe-projects-url-shortener/app/api/counts/route.ts b/examples/stripe-projects-url-shortener/app/api/counts/route.ts new file mode 100644 index 0000000..c0e03f2 --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/api/counts/route.ts @@ -0,0 +1,25 @@ +import { redis } from "@/lib/redis"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const slugs = url.searchParams + .get("slugs") + ?.split(",") + .map((s) => s.trim()) + .filter(Boolean) ?? []; + + if (slugs.length === 0) { + return Response.json({}, { headers: { "cache-control": "no-store" } }); + } + + const counts = await redis.mget<(number | null)[]>( + ...slugs.map((s) => `clicks:${s}`), + ); + + const result: Record = {}; + slugs.forEach((slug, i) => { + result[slug] = counts[i] ?? 0; + }); + + return Response.json(result, { headers: { "cache-control": "no-store" } }); +} diff --git a/examples/stripe-projects-url-shortener/app/links-list.tsx b/examples/stripe-projects-url-shortener/app/links-list.tsx new file mode 100644 index 0000000..2a625ef --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/links-list.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { deleteLink } from "./actions"; + +type Row = { slug: string; target_url: string }; + +export function LinksList({ + rows, + host, + initialCounts, +}: { + rows: Row[]; + host: string; + initialCounts: number[]; +}) { + const [counts, setCounts] = useState>(() => + Object.fromEntries(rows.map((r, i) => [r.slug, initialCounts[i] ?? 0])), + ); + + useEffect(() => { + if (rows.length === 0) return; + + const slugs = rows.map((r) => r.slug).join(","); + let cancelled = false; + + async function poll() { + if (document.hidden) return; + try { + const res = await fetch(`/api/counts?slugs=${slugs}`, { + cache: "no-store", + }); + if (!res.ok) return; + const data = (await res.json()) as Record; + if (!cancelled) setCounts(data); + } catch { + // swallow transient network errors + } + } + + const id = setInterval(poll, 1000); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [rows]); + + if (rows.length === 0) { + return

No links yet. Shorten one above.

; + } + + return ( +
    + {rows.map((link) => { + const shortUrl = `${host}/${link.slug}`; + return ( +
  • +
    + + {shortUrl} + +

    + → {link.target_url} +

    +
    + + {counts[link.slug] ?? 0} clicks + +
    deleteLink(link.slug)}> + +
    +
  • + ); + })} +
+ ); +} diff --git a/examples/stripe-projects-url-shortener/app/page.tsx b/examples/stripe-projects-url-shortener/app/page.tsx index 0756775..d63dae7 100644 --- a/examples/stripe-projects-url-shortener/app/page.tsx +++ b/examples/stripe-projects-url-shortener/app/page.tsx @@ -1,46 +1,36 @@ import { redis } from "@/lib/redis"; import { headers } from "next/headers"; import { CreateLinkForm } from "./create-link-form"; -import { deleteLink } from "./actions"; +import { LinksList } from "./links-list"; export const dynamic = "force-dynamic"; type LinkRow = { slug: string; target_url: string; - created_at: number; }; export default async function Home() { - // Most recent 50 slugs. - const slugs = (await redis.zrange("links:recent", 0, 49, { - rev: true, - })) ?? []; + const slugs = + (await redis.zrange("links:recent", 0, 49, { rev: true })) ?? []; const rows: LinkRow[] = slugs.length ? ( await Promise.all( slugs.map(async (slug) => { - const data = await redis.hgetall<{ - target_url: string; - created_at: string; - }>(`link:${slug}`); - return data - ? { - slug, - target_url: data.target_url, - created_at: Number(data.created_at), - } - : null; + const data = await redis.hgetall<{ target_url: string }>( + `link:${slug}`, + ); + return data ? { slug, target_url: data.target_url } : null; }), ) ).filter((r): r is LinkRow => r !== null) : []; - const counts = rows.length + const initialCounts = rows.length ? ((await redis.mget<(number | null)[]>( ...rows.map((r) => `clicks:${r.slug}`), - )) ?? []) + )) ?? []).map((c) => c ?? 0) : []; const host = @@ -58,56 +48,7 @@ export default async function Home() { - {rows.length === 0 ? ( -

No links yet. Shorten one above.

- ) : ( -
    - {rows.map((link, i) => { - const shortUrl = `${host}/${link.slug}`; - return ( -
  • -
    - - {shortUrl} - -

    - → {link.target_url} -

    -
    - - {counts[i] ?? 0} clicks - - -
  • - ); - })} -
- )} + ); } - -function DeleteButton({ slug }: { slug: string }) { - async function action() { - "use server"; - await deleteLink(slug); - } - return ( -
- -
- ); -} From 29be59e82df76f030094440aafd031c152e10e73 Mon Sep 17 00:00:00 2001 From: burak-upstash Date: Thu, 30 Apr 2026 02:27:40 +0300 Subject: [PATCH 4/4] stripe-app --- .../.projects/state.json | 31 ------- .../.projects/state.local.json | 86 ------------------- 2 files changed, 117 deletions(-) delete mode 100644 examples/stripe-projects-url-shortener/.projects/state.json delete mode 100644 examples/stripe-projects-url-shortener/.projects/state.local.json diff --git a/examples/stripe-projects-url-shortener/.projects/state.json b/examples/stripe-projects-url-shortener/.projects/state.json deleted file mode 100644 index 7b8078c..0000000 --- a/examples/stripe-projects-url-shortener/.projects/state.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "version": 1, - "providers": { - "supabase": { - "name": "Supabase" - }, - "upstash": { - "name": "Upstash" - }, - "vercel": { - "name": "Vercel" - } - }, - "resources": { - "supabase-project": { - "name": "supabase-project", - "providerName": "Supabase", - "serviceId": "project" - }, - "upstash-redis": { - "name": "upstash-redis", - "providerName": "Upstash", - "serviceId": "redis" - }, - "vercel-project": { - "name": "vercel-project", - "providerName": "Vercel", - "serviceId": "project" - } - } -} diff --git a/examples/stripe-projects-url-shortener/.projects/state.local.json b/examples/stripe-projects-url-shortener/.projects/state.local.json deleted file mode 100644 index 94c6652..0000000 --- a/examples/stripe-projects-url-shortener/.projects/state.local.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "version": 1, - "project": { - "initializedAt": "2026-04-29T21:56:46.683Z", - "merchantDisplayName": null, - "merchantId": "acct_1HoeMzF0KWKLiikN", - "projectId": "project_61Ub43SdnilEX7Q1t16P2SfsJCSQC1fRzkGTL2XIOJH6", - "projectName": "shorty" - }, - "providers": { - "supabase": { - "accountRequestId": "facctrq_61Ub4FP2wxznsCGHA16P2SfsJCSQC1fRzkGTL2XIO3ei", - "configuration": {}, - "linkedAt": "2026-04-29T22:09:17.484Z", - "providerId": "prvdr_61UIXYHdsyF69pZbG52n2", - "providerName": "Supabase", - "redirectUrl": null, - "requestedAt": "2026-04-29T22:09:17.484Z", - "status": "complete" - }, - "upstash": { - "accountRequestId": "facctrq_61Ub44VstjSfh6yXQ16P2SfsJCSQC1fRzkGTL2XIO4qG", - "configuration": {}, - "linkedAt": "2026-04-29T21:57:51.777Z", - "providerId": "prvdr_61UVa3kQyvSOEnWgd5Naa", - "providerName": "Upstash", - "redirectUrl": null, - "requestedAt": "2026-04-29T21:57:51.777Z", - "status": "complete" - }, - "vercel": { - "accountRequestId": "facctrq_61Ub4QAjTmnFIjap616P2SfsJCSQC1fRzkGTL2XIO7iK", - "configuration": {}, - "linkedAt": "2026-04-29T22:20:18.258Z", - "providerId": "prvdr_61ULUAr4zlQgZkvXY5LsG", - "providerName": "Vercel", - "redirectUrl": null, - "requestedAt": "2026-04-29T22:20:18.258Z", - "status": "complete" - } - }, - "resources": { - "supabase-project": { - "configuration": { - "name": "shorty", - "price": "free" - }, - "createdAt": "2026-04-29T22:09:43.857Z", - "lastRevealedAt": "2026-04-29T22:09:44.373Z", - "lastStatus": "complete", - "name": "supabase-project", - "providerId": "prvdr_61UIXYHdsyF69pZbG52n2", - "providerName": "Supabase", - "resourceId": "fres_61Ub4FahNk615F2Kg16P2SfsJCSQC1fRzkGTL2XIOB4i", - "serviceId": "project" - }, - "upstash-redis": { - "configuration": { - "name": "shorty", - "price": "free" - }, - "createdAt": "2026-04-29T21:57:53.606Z", - "lastRevealedAt": "2026-04-29T21:57:54.068Z", - "lastStatus": "complete", - "name": "upstash-redis", - "providerId": "prvdr_61UVa3kQyvSOEnWgd5Naa", - "providerName": "Upstash", - "resourceId": "fres_61Ub44WezWFH4L6Pq16P2SfsJCSQC1fRzkGTL2XIOUhs", - "serviceId": "redis" - }, - "vercel-project": { - "configuration": { - "name": "shorty", - "price": "free" - }, - "createdAt": "2026-04-29T22:20:21.907Z", - "lastRevealedAt": "2026-04-29T22:20:22.460Z", - "lastStatus": "complete", - "name": "vercel-project", - "providerId": "prvdr_61ULUAr4zlQgZkvXY5LsG", - "providerName": "Vercel", - "resourceId": "fres_61Ub4QFH5y0pDzkM916P2SfsJCSQC1fRzkGTL2XIOV3o", - "serviceId": "project" - } - } -}