Why SafeShare Exists
Sharing a Reddit post should be simple. Paste a link, the other person reads it. In practice, clicking a Reddit link on mobile triggers a full-screen app install prompt before you can see a single word of the post you were trying to read. On desktop, it's login walls, cookie banners, and a layout that seems designed to distract you from the thing you came to see.
SafeShare is a workaround: paste a Reddit URL, get back a clean shareable link that shows just the post and top comments — nothing else. No account required on either end. The earlier post covered the user-facing workflow and the one-click bookmarklet. This post is about how the data pipeline works under the hood, and why the architecture looks the way it does.
The Core Problem: Reddit Blocks Server-Side Requests
The obvious approach to building something like this is: user submits a Reddit URL, your server fetches the post data, you render it. Simple server-side fetch, render, done.
That doesn't work. Reddit blocks server-side requests from datacenter IP ranges — the same IP ranges Vercel, AWS, and every other cloud platform sits on. A fetch() call from a Next.js API route to reddit.com returns a 403 or a redirect to a login page, not the post JSON.
You can work around this with the official Reddit API (OAuth tokens, rate limits, API key registration), but that adds significant complexity and creates a dependency on Reddit's API terms staying favorable. For a personal tool used occasionally, that overhead isn't worth it.
The Solution: Flip Who Does the Fetching
The architecture in SafeShare inverts the usual pattern. Instead of the server fetching Reddit data, the browser does it — and then ships the result to the server for storage.
Here's why this works: Reddit doesn't block browser requests from real user IP addresses. When you're logged into Reddit and click something, requests come from a residential or mobile IP, not a datacenter. SafeShare takes advantage of this by keeping the Reddit fetch entirely on the client side, using the user's own network connection and browser context.
The flow looks like this:
- User pastes a Reddit URL into SafeShare's input field
- The browser appends
.jsonto the Reddit URL and callsfetch()directly - Reddit returns the post JSON because the request looks like a normal browser request
- The browser POSTs that JSON payload to a Next.js API route at
/api/snapshot - The API route stores it in Vercel KV (Redis) under a generated slug with a 90-day TTL
- The API route returns the slug, and the browser redirects to
/s/<slug> - Anyone with that link gets the clean reader view — served from Redis, no Reddit request needed
No Reddit API credentials at any point. No server-to-Reddit network calls. The only thing the server ever receives is already-fetched JSON from a browser that Reddit was happy to serve.
The Next.js App Router Structure
SafeShare uses Next.js 16 with the App Router — the app/ directory convention rather than pages/. The directory structure maps cleanly to the data flow:
app/
page.tsx ← landing page with URL input form
api/
snapshot/
route.ts ← POST handler: validates, stores to KV, returns slug
s/
[slug]/
page.tsx ← dynamic reader view route
lib/
reddit.ts ← Reddit JSON parsing and normalization
kv.ts ← Vercel KV client wrappers
types.ts ← shared TypeScript interfaces
The separation between lib/reddit.ts and the route handlers keeps the parsing logic testable and isolated. Reddit's JSON response is notoriously nested — a top-level array with two items, where [0] is post metadata and [1] is the comment forest. The reddit.ts module normalizes that into a flat RedditPost type that the rest of the app works with.
The Client-Side Fetch in Detail
The landing page's form submission triggers a client-side async function, not a server action. That distinction matters — it needs to run in the browser to use the user's network context for the Reddit request.
async function handleSubmit(url: string) {
// Reddit's .json endpoint returns the full post + comments tree
const redditUrl = url.replace(/\/?$/, '') + '.json?limit=50';
const response = await fetch(redditUrl, {
headers: {
'Accept': 'application/json',
// Reddit requires a User-Agent that isn't a generic fetch string
'User-Agent': 'SafeShare/1.0 (personal sharing tool)',
},
});
if (!response.ok) {
throw new Error(`Reddit returned ${response.status}`);
}
const data = await response.json();
// Ship the raw Reddit JSON to the API route for storage
const snapshot = await fetch('/api/snapshot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ redditData: data, sourceUrl: url }),
});
const { slug } = await snapshot.json();
router.push(`/s/${slug}`);
}
The User-Agent header matters. Reddit rejects requests with generic strings like node-fetch/1.0 or no User-Agent at all. A descriptive, honest User-Agent is the right approach and consistent with Reddit's API guidelines for personal tools.
The API Route: Validation, Storage, Slug Generation
The /api/snapshot/route.ts handler receives the Reddit JSON and needs to do three things: validate the payload, generate a stable slug, and persist to Vercel KV.
import { kv } from '@vercel/kv';
import { nanoid } from 'nanoid';
import { parseRedditPost } from '@/lib/reddit';
import type { NextRequest } from 'next/server';
export async function POST(req: NextRequest) {
const body = await req.json();
// Basic shape validation — reject clearly malformed payloads
if (!body?.redditData || !Array.isArray(body.redditData)) {
return Response.json({ error: 'Invalid payload' }, { status: 400 });
}
const post = parseRedditPost(body.redditData);
if (!post) {
return Response.json({ error: 'Could not parse Reddit post' }, { status: 422 });
}
const slug = nanoid(10);
// Store with 90-day TTL (in seconds)
await kv.set(`snapshot:${slug}`, post, { ex: 60 * 60 * 24 * 90 });
return Response.json({ slug });
}
nanoid(10) generates a 10-character URL-safe random ID — short enough for easy sharing, collision-resistant enough that you'd need millions of snapshots before duplicates become probable. The key prefix snapshot: keeps these entries namespaced in Redis in case the KV instance is shared with other data later.
The Redis Storage Model
Vercel KV is managed Redis. The integration is minimal: install @vercel/kv, pull the environment variables with vercel env pull, and you get a kv client that works both locally and in production with no configuration differences.
Each snapshot stores a RedditPost object — the normalized form of the Reddit JSON — not the raw response. That has two benefits: the reader view page never has to re-parse the Reddit format, and the storage size is predictable (text posts are a few KB; image posts with gallery metadata might reach 50–100KB). Storing the raw Reddit JSON would add noise and increase key sizes unnecessarily.
The 90-day TTL is a pragmatic call. Reddit posts are typically most relevant in the week or two after they're posted. 90 days covers essentially all real sharing use cases while preventing indefinite storage growth. Vercel KV's free tier has storage limits; automatic expiry keeps the instance healthy without any cleanup job.
The Reader View Page
The reader view at /s/[slug]/page.tsx is a standard Next.js dynamic route. It reads the slug from params, fetches from KV, and renders the post. Because the route is server-rendered, there's no loading state — the page either returns the full post or a 404.
import { kv } from '@vercel/kv';
import { notFound } from 'next/navigation';
import type { RedditPost } from '@/lib/types';
export default async function SnapshotPage({
params,
}: {
params: { slug: string };
}) {
const post = await kv.get<RedditPost>(`snapshot:${params.slug}`);
if (!post) notFound();
return (
<article className="max-w-2xl mx-auto px-4 py-8">
<PostHeader post={post} />
<PostBody post={post} />
<CommentList comments={post.comments} />
</article>
);
}
The Tailwind classes keep the reader layout deliberately minimal: constrained width, comfortable line height, no sidebars. The goal is that the person you share the link with reads the post, not navigates a UI.
Supported Content Types
Reddit posts come in several flavors, and the parseRedditPost() function in lib/reddit.ts normalizes them into a consistent RedditPost shape with a type discriminant:
- Text posts — body rendered as Markdown via a lightweight parser
- Image posts — direct image URL extracted from the
urlfield - Gallery posts —
media_metadataparsed into an ordered image array - Link posts — preview image shown with the external URL clearly labeled
- Video posts — thumbnail shown with a note; Reddit's video format uses DASH streaming that's non-trivial to re-serve from a different domain
Videos are the one rough edge. Reddit serves video and audio as separate DASH streams that the official player combines client-side. Reproducing that in a reader view would require proxying the streams, which crosses into territory that's both technically complex and legally murky. The current behavior — show the thumbnail, link back to Reddit for playback — is the honest tradeoff.
The Bookmarklet Bridge
The bookmarklet covered in the previous post works by extracting the current page URL and opening SafeShare's home page with it pre-filled in the input. It's a one-liner that lives in your browser's bookmarks bar:
javascript:(function(){
window.open(
'https://safe-share-gamma.vercel.app/?url=' +
encodeURIComponent(location.href),
'_blank'
);
})()
The home page checks for a url query parameter on mount and, if present, auto-submits the form. The result is that on any Reddit post, one bookmark click triggers the full pipeline: client-side fetch → API route → KV storage → redirect to reader view.
TypeScript Across the Stack
TypeScript accounts for 78% of the codebase, with the rest being the HTML shell and a small amount of config JavaScript. The type coverage pays off most in lib/reddit.ts, where Reddit's JSON structure is chaotic enough that a typed parser catches mismatches at build time rather than at runtime in production.
The RedditPost interface is the central contract between the parser, the API route, and the reader view:
interface RedditPost {
id: string;
title: string;
author: string;
subreddit: string;
score: number;
created: number;
type: 'text' | 'image' | 'gallery' | 'link' | 'video';
body?: string; // text posts
imageUrl?: string; // image posts
gallery?: string[]; // gallery posts (ordered image URLs)
linkUrl?: string; // link posts
previewUrl?: string; // thumbnail for video/link posts
comments: RedditComment[];
sourceUrl: string;
}
Having this defined once in lib/types.ts means the Vercel KV generic kv.get<RedditPost>() call in the reader route gives full autocompletion and type checking through to the JSX render. No casting, no any.
Deploying on Vercel
The deployment is straightforward. Connect the GitHub repo to a Vercel project, provision a KV store in the Vercel dashboard, and the environment variables (KV_REST_API_URL, KV_REST_API_TOKEN) are automatically injected into the build. vercel env pull .env.local brings them into the local dev environment.
There's no build script beyond the standard Next.js next build. Unlike the Watch List project which uses a custom build.js to inject credentials into a single-file HTML app, SafeShare uses Vercel's native environment variable system — which is the right tool when you're already in the Vercel ecosystem.
What's Next
A few things I'd add given more time:
Deduplication by source URL. Currently, sharing the same Reddit post twice creates two separate slugs. A lookup index from source URL to slug would avoid redundant KV entries and let you surface "this post was already shared" to the user.
Better video handling. The DASH stream limitation is the most visible gap. A server-side approach that fetches and remuxes the video stream — either on demand or at snapshot time — would make video posts first-class. The complexity is real, but so is the use case.
Comment threading. The current reader view flattens top-level comments. Reddit's comment tree is deeply nested; rendering it fully (collapsed by default past a certain depth) would make the reader view genuinely useful for discussion-heavy posts, not just link and text posts.
The repo is at github.com/josefresco/safe-share. The client-side fetch trick — using the browser's network context to reach APIs that block server-side requests — is a pattern worth keeping in your toolkit. It shows up more than you'd expect once you start running into API blocks that OAuth overhead isn't worth solving.