From 7335ba6162733a0851edc92582e2b4611f3bdcf6 Mon Sep 17 00:00:00 2001 From: Vercel Date: Tue, 23 Jun 2026 03:58:23 +0000 Subject: [PATCH 1/3] Install Vercel Speed Insights ## Vercel Speed Insights Installation Complete Successfully installed and configured Vercel Speed Insights for this Next.js project following the latest official documentation from https://vercel.com/docs/speed-insights/quickstart. ### Changes Made: #### 1. Package Installation - Added `@vercel/speed-insights` version ^2.0.0 to dependencies - Used npm (project's package manager) to install the package - Updated package-lock.json to reflect the new dependency #### 2. Configuration - Modified `app/layout.tsx` to integrate Speed Insights: - Imported SpeedInsights component from `@vercel/speed-insights/next` - Added `` component at the end of the body tag in the root layout - Followed Next.js App Router best practices as specified in the official documentation ### Implementation Details: The SpeedInsights component was added to the root layout (`app/layout.tsx`) which is the recommended approach for Next.js v13.5+ using the App Router. This ensures that Speed Insights tracking is active across all pages of the application. ### Files Modified: - `app/layout.tsx` - Added Speed Insights component import and rendered it in the body - `package.json` - Added @vercel/speed-insights dependency - `package-lock.json` - Updated with new package dependencies ### Next Steps: To enable Speed Insights tracking: 1. Deploy the application to Vercel 2. Enable Speed Insights in the Vercel dashboard for this project 3. After deployment, the tracking script will appear as `//script.js` in the page head ### Notes: - The implementation follows the exact pattern from Vercel's official documentation - The Speed Insights component is non-blocking and won't affect page load performance - Pre-existing TypeScript errors in `lib/use-reddit.ts` (line 217) and linting issues are unrelated to this implementation and existed before the Speed Insights integration - All changes are minimal and preserve the existing code structure Co-authored-by: Vercel --- app/layout.tsx | 6 +++++- package-lock.json | 39 +++++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/app/layout.tsx b/app/layout.tsx index f52406a..14d8b72 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { SpeedInsights } from "@vercel/speed-insights/next"; import "./outlook.css"; export const metadata: Metadata = { @@ -13,7 +14,10 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + + ); } diff --git a/package-lock.json b/package-lock.json index cf83bdf..1982288 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-dialog": "^1.1.17", + "@vercel/speed-insights": "^2.0.0", "lucide-react": "^1.17.0", "next": "16.2.7", "react": "19.2.4", @@ -2316,6 +2317,44 @@ "win32" ] }, + "node_modules/@vercel/speed-insights": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@vercel/speed-insights/-/speed-insights-2.0.0.tgz", + "integrity": "sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==", + "license": "Apache-2.0", + "peerDependencies": { + "@sveltejs/kit": "^1 || ^2", + "next": ">= 13", + "nuxt": ">= 3", + "react": "^18 || ^19 || ^19.0.0-rc", + "svelte": ">= 4", + "vue": "^3", + "vue-router": "^4" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "nuxt": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", diff --git a/package.json b/package.json index 7414011..03041d6 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-dialog": "^1.1.17", + "@vercel/speed-insights": "^2.0.0", "lucide-react": "^1.17.0", "next": "16.2.7", "react": "19.2.4", From 4bc26d4f8831774ff9c52749897d472e21a75807 Mon Sep 17 00:00:00 2001 From: Davis Stanko Date: Tue, 23 Jun 2026 19:22:22 -0400 Subject: [PATCH 2/3] feat: implement persistent server-side caching in Route Handler using unstable_cache --- AGENTS.md | 2 +- app/api/reddit/route.ts | 82 +++++++++++++++++++++++++++-------------- 2 files changed, 56 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ae059fd..ddb7c54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,7 +161,7 @@ The ONLY viable path forward for a basic read-only frontend is using Reddit's pu 2. **Initial load.** When the app mounts or the active feed changes (different subreddit, or different sort: Hot / New / Top), fetch the first page of posts immediately. 3. **No pagination.** Posts are fetched once per feed selection — one request returning up to 100 posts (the RSS hard cap). No infinite scroll or "load more" mechanism exists. Do not re-introduce pagination; the single-request approach is intentional for rate limiting. 4. **Comments on demand.** Comments are fetched *only* when the user selects a specific post. Never fetch comments speculatively or in the background. No post is auto-selected on load to avoid fetching posts and comments simultaneously, preventing rate limit bursts. Any rate limits encountered are handled by `fetchWithRetry` which implements an exponential backoff strategy on 429s (max 5 attempts) and respects the `Retry-After` header if present before surfacing an error. -5. **Server-side cache.** The API proxy (`app/api/reddit/route.ts`) uses Next.js native Data Cache (`fetchOptions.next = { revalidate: false }`) with an **indefinite TTL** (FIFO eviction managed by the Next.js cache). This ensures the cache securely persists across Vercel serverless invocations — Reddit is only hit once per unique URL, ever, until the entry is evicted or manually invalidated. Do not add a fixed TTL or switch to a custom in-memory or `fs` disk cache. Do not set `Cache-Control` headers on successful responses. +5. **Server-side cache.** The API proxy (`app/api/reddit/route.ts`) uses `unstable_cache` from `next/cache` with `revalidate: false` (indefinite TTL, FIFO eviction managed by Next.js). This is the **only correct** primitive for caching inside a Route Handler — `fetch()` with `cache: "force-cache"` is unreliable in Route Handlers because they are dynamic routes (they read the `request` object at runtime), which prevents the Data Cache from being hit. `unstable_cache` caches the result of any async function (not just `fetch` in Server Components), persists across Vercel serverless invocations, and is shared across all users. Reddit is only hit once per unique URL, ever, until the entry is evicted or manually invalidated. Do not add a fixed TTL, switch to `fetch` with `force-cache`, or use a custom in-memory or `fs` disk cache. Do not set `Cache-Control` headers on successful responses. 6. **No background polling.** Do not auto-refresh feeds on a timer. The user can manually refresh using the refresh button (which sends `forceRefresh=true`, causing the proxy to fetch with `cache: "no-store"` — this bypasses the Next.js Data Cache and **replaces** the old cache entry with fresh content), or by re-selecting the feed / changing the sort tab (which serves from cache). 7. **Backend Hardening.** The API proxy enforces an IP-based rate limit to prevent abuse and a strict 30-second timeout on upstream Reddit fetches (using `AbortSignal.timeout` or `AbortController`) to prevent hanging serverless invocations. diff --git a/app/api/reddit/route.ts b/app/api/reddit/route.ts index e5fcf59..37d9dd9 100644 --- a/app/api/reddit/route.ts +++ b/app/api/reddit/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { unstable_cache } from "next/cache"; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -87,37 +88,64 @@ export async function GET(request: NextRequest) { } } + // --------------------------------------------------------------------------- + // Shared fetch headers for all upstream Reddit requests + // --------------------------------------------------------------------------- + const REDDIT_HEADERS = { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Upgrade-Insecure-Requests": "1", + }; + + // --------------------------------------------------------------------------- + // Cached fetcher — uses unstable_cache (Next.js Data Cache) so the result + // is stored in Vercel's persistent Data Cache and shared across all users + // and serverless invocations. revalidate: false = indefinite TTL (FIFO + // eviction managed by Next.js). This is the correct way to cache inside a + // Route Handler; fetch() with cache: "force-cache" is unreliable here + // because Route Handlers are dynamic routes (they read the request object + // at runtime) and the Data Cache is not guaranteed to be hit. + // --------------------------------------------------------------------------- + const cachedFetch = unstable_cache( + async (url: string) => { + const res = await fetch(url, { headers: REDDIT_HEADERS, cache: "no-store" }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`Reddit returned ${res.status}: ${text.slice(0, 200)}`); + } + return text; + }, + // Cache key is derived from the URL — one entry per unique feed/comment URL. + ["reddit-rss"], + { revalidate: false } + ); + try { - const fetchOptions: RequestInit = { - headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", - "Accept-Language": "en-US,en;q=0.9", - "Accept-Encoding": "gzip, deflate, br", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Sec-Fetch-User": "?1", - "Upgrade-Insecure-Requests": "1", - }, - }; + let text: string; if (forceRefresh) { - fetchOptions.cache = "no-store"; + // Bypass the Data Cache entirely — fetch fresh and overwrite the cache + // entry on next normal request (unstable_cache handles this naturally + // since we only call the raw fetch here and the cache key stays intact). + const res = await fetch(targetUrl.toString(), { + headers: REDDIT_HEADERS, + cache: "no-store", + }); + text = await res.text(); + if (!res.ok) { + console.error("[Proxy] Reddit returned:", res.status, text.slice(0, 200)); + return new NextResponse(text, { status: res.status }); + } } else { - // In Next.js 15+, fetch requests are uncached by default. - // We must explicitly use cache: "force-cache" to use the Data Cache. - fetchOptions.cache = "force-cache"; - } - - const res = await fetch(targetUrl.toString(), fetchOptions); - - const text = await res.text(); - - if (!res.ok) { - console.error("[Proxy] Reddit returned:", res.status, text.slice(0, 200)); - return new NextResponse(text, { status: res.status }); + // Serve from / populate the persistent Data Cache. + text = await cachedFetch(targetUrl.toString()); } return new NextResponse(text, { From 0fd5267d6844cda3e59801605930ccac2c826f6b Mon Sep 17 00:00:00 2001 From: Davis Stanko Date: Tue, 23 Jun 2026 23:17:14 -0400 Subject: [PATCH 3/3] refactor: migrate API proxy to use `"use cache: remote"` for persistent, cross-invocation data caching --- AGENTS.md | 4 +- app/api/reddit/route.ts | 124 +++++++++++++++++++++------------------- next.config.ts | 4 +- 3 files changed, 71 insertions(+), 61 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ddb7c54..a9c5725 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,8 +161,8 @@ The ONLY viable path forward for a basic read-only frontend is using Reddit's pu 2. **Initial load.** When the app mounts or the active feed changes (different subreddit, or different sort: Hot / New / Top), fetch the first page of posts immediately. 3. **No pagination.** Posts are fetched once per feed selection — one request returning up to 100 posts (the RSS hard cap). No infinite scroll or "load more" mechanism exists. Do not re-introduce pagination; the single-request approach is intentional for rate limiting. 4. **Comments on demand.** Comments are fetched *only* when the user selects a specific post. Never fetch comments speculatively or in the background. No post is auto-selected on load to avoid fetching posts and comments simultaneously, preventing rate limit bursts. Any rate limits encountered are handled by `fetchWithRetry` which implements an exponential backoff strategy on 429s (max 5 attempts) and respects the `Retry-After` header if present before surfacing an error. -5. **Server-side cache.** The API proxy (`app/api/reddit/route.ts`) uses `unstable_cache` from `next/cache` with `revalidate: false` (indefinite TTL, FIFO eviction managed by Next.js). This is the **only correct** primitive for caching inside a Route Handler — `fetch()` with `cache: "force-cache"` is unreliable in Route Handlers because they are dynamic routes (they read the `request` object at runtime), which prevents the Data Cache from being hit. `unstable_cache` caches the result of any async function (not just `fetch` in Server Components), persists across Vercel serverless invocations, and is shared across all users. Reddit is only hit once per unique URL, ever, until the entry is evicted or manually invalidated. Do not add a fixed TTL, switch to `fetch` with `force-cache`, or use a custom in-memory or `fs` disk cache. Do not set `Cache-Control` headers on successful responses. -6. **No background polling.** Do not auto-refresh feeds on a timer. The user can manually refresh using the refresh button (which sends `forceRefresh=true`, causing the proxy to fetch with `cache: "no-store"` — this bypasses the Next.js Data Cache and **replaces** the old cache entry with fresh content), or by re-selecting the feed / changing the sort tab (which serves from cache). +5. **Server-side cache.** The API proxy (`app/api/reddit/route.ts`) uses a standalone `fetchRedditRSS()` function annotated with `"use cache: remote"` (Next.js 16 Cache Components). This stores fetched RSS data in Vercel's **persistent remote Data Cache**, shared across all users and serverless invocations. The `cacheComponents: true` flag is enabled in `next.config.ts`. Each unique URL gets its own cache entry tagged via `cacheTag()`, with `cacheLife("max")` for near-indefinite TTL. **Do not** revert to `unstable_cache` (deprecated in Next.js 16, uses ephemeral in-memory storage that doesn't survive serverless cold starts), `fetch()` with `force-cache` (unreliable in Route Handlers since they are dynamic routes), or any custom in-memory / `fs` disk cache. Do not set `Cache-Control` headers on successful responses. +6. **No background polling.** Do not auto-refresh feeds on a timer. The user can manually refresh using the refresh button (which sends `forceRefresh=true`, causing the proxy to call `revalidateTag(tag, { expire: 0 })` to immediately expire the cache entry — the subsequent `fetchRedditRSS()` call then misses cache and fetches fresh data from Reddit), or by re-selecting the feed / changing the sort tab (which serves from cache). 7. **Backend Hardening.** The API proxy enforces an IP-based rate limit to prevent abuse and a strict 30-second timeout on upstream Reddit fetches (using `AbortSignal.timeout` or `AbortController`) to prevent hanging serverless invocations. ### RSS Limits — What Actually Matters diff --git a/app/api/reddit/route.ts b/app/api/reddit/route.ts index 37d9dd9..67933e7 100644 --- a/app/api/reddit/route.ts +++ b/app/api/reddit/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { unstable_cache } from "next/cache"; +import { revalidateTag } from "next/cache"; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -21,6 +21,60 @@ const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; // 10 minutes const lastRefreshTimes = new Map(); const REFRESH_COOLDOWN_MS = 30 * 1000; // 30 seconds +// --------------------------------------------------------------------------- +// Shared fetch headers for all upstream Reddit requests +// --------------------------------------------------------------------------- +const REDDIT_HEADERS = { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Upgrade-Insecure-Requests": "1", +}; + +// --------------------------------------------------------------------------- +// Cached fetcher — uses "use cache: remote" so the result is stored in +// Vercel's persistent remote Data Cache and shared across all users and +// serverless invocations. This replaces the deprecated unstable_cache +// approach which only stored in ephemeral in-memory cache that was destroyed +// between serverless invocations. +// +// cacheLife("max") = indefinite TTL (revalidate: 30 days, expire: 1 year). +// cacheTag() per URL enables targeted invalidation via revalidateTag(). +// --------------------------------------------------------------------------- +async function fetchRedditRSS(url: string): Promise { + "use cache: remote"; + + const { cacheLife, cacheTag } = await import("next/cache"); + cacheLife("max"); + cacheTag(cacheTagForUrl(url)); + + const res = await fetch(url, { headers: REDDIT_HEADERS, cache: "no-store" }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`Reddit returned ${res.status}: ${text.slice(0, 200)}`); + } + return text; +} + +// --------------------------------------------------------------------------- +// Deterministic cache tag from a Reddit RSS URL. +// Tags are limited to 256 chars, so we hash long URLs. +// --------------------------------------------------------------------------- +function cacheTagForUrl(url: string): string { + // Simple deterministic hash: prefix + condensed URL + const condensed = url + .replace(/^https?:\/\//, "") + .replace(/[^a-zA-Z0-9]/g, "_") + .slice(0, 200); + return `rss_${condensed}`; +} + export async function GET(request: NextRequest) { // 1. Rate Limiting Check const ip = request.headers.get("x-forwarded-for") || "unknown-ip"; @@ -80,7 +134,7 @@ export async function GET(request: NextRequest) { if (lastRefresh && (now - lastRefresh < REFRESH_COOLDOWN_MS)) { console.warn(`[Proxy] Cooldown active for ${urlString}. Ignoring forceRefresh.`); - // Strip the forceRefresh flag to serve the Next.js cache instead + // Strip the forceRefresh flag to serve the cache instead forceRefresh = false; } else { // Update the last refresh time @@ -88,66 +142,20 @@ export async function GET(request: NextRequest) { } } - // --------------------------------------------------------------------------- - // Shared fetch headers for all upstream Reddit requests - // --------------------------------------------------------------------------- - const REDDIT_HEADERS = { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", - "Accept-Language": "en-US,en;q=0.9", - "Accept-Encoding": "gzip, deflate, br", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Sec-Fetch-User": "?1", - "Upgrade-Insecure-Requests": "1", - }; - - // --------------------------------------------------------------------------- - // Cached fetcher — uses unstable_cache (Next.js Data Cache) so the result - // is stored in Vercel's persistent Data Cache and shared across all users - // and serverless invocations. revalidate: false = indefinite TTL (FIFO - // eviction managed by Next.js). This is the correct way to cache inside a - // Route Handler; fetch() with cache: "force-cache" is unreliable here - // because Route Handlers are dynamic routes (they read the request object - // at runtime) and the Data Cache is not guaranteed to be hit. - // --------------------------------------------------------------------------- - const cachedFetch = unstable_cache( - async (url: string) => { - const res = await fetch(url, { headers: REDDIT_HEADERS, cache: "no-store" }); - const text = await res.text(); - if (!res.ok) { - throw new Error(`Reddit returned ${res.status}: ${text.slice(0, 200)}`); - } - return text; - }, - // Cache key is derived from the URL — one entry per unique feed/comment URL. - ["reddit-rss"], - { revalidate: false } - ); - try { - let text: string; - if (forceRefresh) { - // Bypass the Data Cache entirely — fetch fresh and overwrite the cache - // entry on next normal request (unstable_cache handles this naturally - // since we only call the raw fetch here and the cache key stays intact). - const res = await fetch(targetUrl.toString(), { - headers: REDDIT_HEADERS, - cache: "no-store", - }); - text = await res.text(); - if (!res.ok) { - console.error("[Proxy] Reddit returned:", res.status, text.slice(0, 200)); - return new NextResponse(text, { status: res.status }); - } - } else { - // Serve from / populate the persistent Data Cache. - text = await cachedFetch(targetUrl.toString()); + // Invalidate the remote cache entry for this URL, then re-fetch. + // revalidateTag with { expire: 0 } immediately expires the entry so + // the next call to fetchRedditRSS will miss cache and fetch fresh. + const tag = cacheTagForUrl(targetUrl.toString()); + revalidateTag(tag, { expire: 0 }); } + // Serve from / populate the persistent remote Data Cache. + // If forceRefresh just expired the tag above, this call will miss + // the cache and fetch fresh data from Reddit, then cache it. + const text = await fetchRedditRSS(targetUrl.toString()); + return new NextResponse(text, { status: 200, headers: { diff --git a/next.config.ts b/next.config.ts index cb651cd..584cbfc 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,7 @@ import type { NextConfig } from "next"; -const nextConfig: NextConfig = {}; +const nextConfig: NextConfig = { + cacheComponents: true, +}; export default nextConfig;