diff --git a/examples/stripe-projects-url-shortener/.env.example b/examples/stripe-projects-url-shortener/.env.example new file mode 100644 index 0000000..80e2d84 --- /dev/null +++ b/examples/stripe-projects-url-shortener/.env.example @@ -0,0 +1,7 @@ +# Populated automatically by `stripe projects add upstash/redis` +UPSTASH_REST_URL= +UPSTASH_REST_TOKEN= + +# 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 new file mode 100644 index 0000000..2027100 --- /dev/null +++ b/examples/stripe-projects-url-shortener/.gitignore @@ -0,0 +1,11 @@ +node_modules +.next +.vercel +*.log +.DS_Store +tsconfig.tsbuildinfo + +.projects/cache +.projects/vault +.env +.env.local diff --git a/examples/stripe-projects-url-shortener/README.md b/examples/stripe-projects-url-shortener/README.md new file mode 100644 index 0000000..cdb5480 --- /dev/null +++ b/examples/stripe-projects-url-shortener/README.md @@ -0,0 +1,47 @@ +# Shorty — URL shortener on 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 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 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 and deploy + +```bash +stripe projects init shorty +stripe projects add upstash/redis +stripe projects add vercel/project +``` + +Each command writes credentials to `.env` (locally) and attaches them to the Vercel project (in production). After the third command, your app is deployed. + +## Develop locally + +```bash +npm install +npm run dev +``` + +Open http://localhost:3000 and shorten something. + +## Test + +```bash +SHORT=https://your-app.vercel.app/abc123 +for i in $(seq 1 100); do curl -s -o /dev/null $SHORT; done +``` + +Refresh the home page. The counter says 100. + +## Files + +- `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 new file mode 100644 index 0000000..49758af --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/[slug]/route.ts @@ -0,0 +1,15 @@ +import { redis } from "@/lib/redis"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + + const target = await redis.hget(`link:${slug}`, "target_url"); + if (!target) return new Response("Not found", { status: 404 }); + + 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..beef528 --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/actions.ts @@ -0,0 +1,63 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; +import { customAlphabet } from "nanoid"; +import { Ratelimit } from "@upstash/ratelimit"; +import { redis } from "@/lib/redis"; + +const generateSlug = customAlphabet( + "abcdefghijklmnopqrstuvwxyz0123456789", + 6, +); + +const ratelimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(10, "1m"), + prefix: "shorty:create", +}); + +async function clientId() { + const h = await headers(); + return ( + h.get("x-forwarded-for")?.split(",")[0]?.trim() || + h.get("x-real-ip") || + "anonymous" + ); +} + +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 { + new URL(targetUrl); + } catch { + throw new Error("Invalid URL."); + } + + const slug = generateSlug(); + const createdAt = Date.now(); + + await Promise.all([ + redis.hset(`link:${slug}`, { + target_url: targetUrl, + created_at: String(createdAt), + }), + redis.zadd("links:recent", { score: createdAt, member: slug }), + ]); + + revalidatePath("/"); + return slug; +} + +export async function deleteLink(slug: string) { + await Promise.all([ + redis.del(`link:${slug}`), + redis.del(`clicks:${slug}`), + redis.zrem("links:recent", slug), + ]); + revalidatePath("/"); +} 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/create-link-form.tsx b/examples/stripe-projects-url-shortener/app/create-link-form.tsx new file mode 100644 index 0000000..6e17d68 --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/create-link-form.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { createLink } from "./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/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..9e8bc60 --- /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 + Upstash Redis.", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +
{children}
+ + + ); +} 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 new file mode 100644 index 0000000..d63dae7 --- /dev/null +++ b/examples/stripe-projects-url-shortener/app/page.tsx @@ -0,0 +1,54 @@ +import { redis } from "@/lib/redis"; +import { headers } from "next/headers"; +import { CreateLinkForm } from "./create-link-form"; +import { LinksList } from "./links-list"; + +export const dynamic = "force-dynamic"; + +type LinkRow = { + slug: string; + target_url: string; +}; + +export default async function Home() { + 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 }>( + `link:${slug}`, + ); + return data ? { slug, target_url: data.target_url } : null; + }), + ) + ).filter((r): r is LinkRow => r !== null) + : []; + + const initialCounts = rows.length + ? ((await redis.mget<(number | null)[]>( + ...rows.map((r) => `clicks:${r.slug}`), + )) ?? []).map((c) => c ?? 0) + : []; + + 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. +

+
+ + + + +
+ ); +} 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..ae49f72 --- /dev/null +++ b/examples/stripe-projects-url-shortener/lib/redis.ts @@ -0,0 +1,9 @@ +import { Redis } from "@upstash/redis"; + +// 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/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..64f04b0 --- /dev/null +++ b/examples/stripe-projects-url-shortener/package.json @@ -0,0 +1,29 @@ +{ + "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": { + "@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/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" + ] +}