Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions examples/stripe-projects-url-shortener/.env.example
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions examples/stripe-projects-url-shortener/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
node_modules
.next
.vercel
*.log
.DS_Store
tsconfig.tsbuildinfo

.projects/cache
.projects/vault
.env
.env.local
47 changes: 47 additions & 0 deletions examples/stripe-projects-url-shortener/README.md
Original file line number Diff line number Diff line change
@@ -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)
15 changes: 15 additions & 0 deletions examples/stripe-projects-url-shortener/app/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -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<string>(`link:${slug}`, "target_url");
if (!target) return new Response("Not found", { status: 404 });

redis.incr(`clicks:${slug}`).catch(() => {});

return Response.redirect(target, 302);
}
63 changes: 63 additions & 0 deletions examples/stripe-projects-url-shortener/app/actions.ts
Original file line number Diff line number Diff line change
@@ -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("/");
}
25 changes: 25 additions & 0 deletions examples/stripe-projects-url-shortener/app/api/counts/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {};
slugs.forEach((slug, i) => {
result[slug] = counts[i] ?? 0;
});

return Response.json(result, { headers: { "cache-control": "no-store" } });
}
43 changes: 43 additions & 0 deletions examples/stripe-projects-url-shortener/app/create-link-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import { useState, useTransition } from "react";
import { createLink } from "./actions";

export function CreateLinkForm() {
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();

return (
<form
action={(formData) => {
setError(null);
startTransition(async () => {
try {
await createLink(formData);
} catch (e) {
setError(e instanceof Error ? e.message : "Something went wrong.");
}
});
}}
className="space-y-2"
>
<div className="flex gap-2">
<input
name="url"
type="url"
required
placeholder="https://example.com/very/long/path"
className="flex-1 rounded-md border border-zinc-300 bg-white px-3 py-2 dark:border-zinc-700 dark:bg-zinc-900"
/>
<button
type="submit"
disabled={pending}
className="rounded-md bg-zinc-900 px-4 py-2 text-white disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
>
{pending ? "..." : "Shorten"}
</button>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
</form>
);
}
17 changes: 17 additions & 0 deletions examples/stripe-projects-url-shortener/app/globals.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
21 changes: 21 additions & 0 deletions examples/stripe-projects-url-shortener/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<body className="min-h-screen">
<main className="mx-auto max-w-2xl px-6 py-12">{children}</main>
</body>
</html>
);
}
90 changes: 90 additions & 0 deletions examples/stripe-projects-url-shortener/app/links-list.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, number>>(() =>
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<string, number>;
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 <p className="text-zinc-500">No links yet. Shorten one above.</p>;
}

return (
<ul className="divide-y divide-zinc-200 rounded-md border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{rows.map((link) => {
const shortUrl = `${host}/${link.slug}`;
return (
<li
key={link.slug}
className="flex items-center justify-between gap-4 p-4"
>
<div className="min-w-0 flex-1">
<a
href={shortUrl}
target="_blank"
rel="noreferrer"
className="font-mono text-sm font-medium hover:underline"
>
{shortUrl}
</a>
<p className="truncate text-sm text-zinc-500">
→ {link.target_url}
</p>
</div>
<span className="tabular-nums text-sm text-zinc-600 dark:text-zinc-400">
{counts[link.slug] ?? 0} clicks
</span>
<form action={async () => deleteLink(link.slug)}>
<button
type="submit"
className="text-sm text-zinc-500 hover:text-red-600"
>
Delete
</button>
</form>
</li>
);
})}
</ul>
);
}
Loading