<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nick Fasulo</title>
    <description>The latest articles on DEV Community by Nick Fasulo (@nickfasulo).</description>
    <link>https://dev.to/nickfasulo</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F367528%2Fb21b45bc-bdc7-4bc3-85a3-0aa660d469b9.png</url>
      <title>DEV Community: Nick Fasulo</title>
      <link>https://dev.to/nickfasulo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nickfasulo"/>
    <language>en</language>
    <item>
      <title>Vercel Was Too Expensive, So I Migrated My Next.js App to Cloudflare Workers</title>
      <dc:creator>Nick Fasulo</dc:creator>
      <pubDate>Wed, 14 Oct 2026 23:45:44 +0000</pubDate>
      <link>https://dev.to/nickfasulo/vercel-was-too-expensive-so-i-migrated-my-nextjs-app-to-cloudflare-workers-36jl</link>
      <guid>https://dev.to/nickfasulo/vercel-was-too-expensive-so-i-migrated-my-nextjs-app-to-cloudflare-workers-36jl</guid>
      <description>&lt;p&gt;I recently moved production traffic for Galaxy Movies, my Next.js movie/TV discovery site, off Vercel and onto Cloudflare Workers. This isn't a Vercel hit piece: Vercel is a great platform, and the app ran happily there for a long time. But I was paying for a Pro plan for what is ultimately a side project, my DNS was already on Cloudflare, and the entire backend surface is fetch-based (Upstash REST, OpenAI, Resend, TMDB); no databases, no TCP. That's about as close to a best-case scenario for running Next.js on workers as you can get.&lt;/p&gt;

&lt;p&gt;The site is Next.js 16 Pages Router, React 19, Chakra UI, with ISR catalog pages, a pile of API routes, four Vercel cron jobs, and dynamic OG images via next/og. Here's what the migration actually looked like, including the parts that fought back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The adapter: @opennextjs/cloudflare&lt;/strong&gt;&lt;br&gt;
The OpenNext Cloudflare adapter takes a standard next build output and rebundles it into a worker. Setup is two files. First, open-next.config.ts. The interesting bit is the incremental cache override, which points ISR's cache at an R2 bucket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineCloudflareConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@opennextjs/cloudflare&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;r2IncrementalCache&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineCloudflareConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;

  &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;experimentalBundledNextServer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="na"&gt;incrementalCache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r2IncrementalCache&lt;/span&gt;

&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then wrangler.jsonc:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".open-next/entry.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"galaxy-movies"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"compatibility_flags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"nodejs_compat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"global_fetch_strictly_public"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"assets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"directory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".open-next/assets"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ASSETS"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"services"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WORKER_SELF_REFERENCE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"galaxy-movies"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"r2_buckets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NEXT_INC_CACHE_R2_BUCKET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"bucket_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"galaxy-movies-inc-cache"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"triggers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"crons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0 6 * * *"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"30 6 * * *"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0 7 * * *"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0 16 * * 5"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"find_additional_modules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"globs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"public/fonts/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public/backdrop_fallback.jpg"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fallthrough"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy is two commands: opennextjs-cloudflare build then wrangler deploy. Everything below is the gap between "two commands" and "actually works in production."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha 1: Next's dev-only require()s break the bundle&lt;/strong&gt;&lt;br&gt;
The adapter bundles the Next server with esbuild, and esbuild wants every require() to resolve, including dev-only code paths inside Next's own dist files (next-dev-server, setup-dev-bundler, an upgrade nudge) and an RSC-only path (react-server-dom-webpack) that a Pages Router app never executes. They're dead code in production, but dead code still has to resolve.&lt;/p&gt;

&lt;p&gt;I fixed it with patch-package to mark them external in the adapter's bundle step, plus a postbuild script that drops tiny stub modules where the runtime resolution lands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/opennext-postbuild.js: runs after opennextjs-cloudflare build&lt;/span&gt;

&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.../dev/next-dev-server.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;module.exports = {}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.../router-utils/setup-dev-bundler.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;module.exports = {}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// ...etc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The patch also adds preview-props.json to the manifest glob the adapter uses, which the Pages Router build emits, and the adapter wasn't collecting. Not elegant, but it's contained in patches/ and reapplied on every npm install by patch-package. If the upstream adapter fixes these, the patch just stops applying cleanly, and I delete it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha 2: next/og picks the wrong runtime&lt;/strong&gt;&lt;br&gt;
This one took the longest. I generate OG images with next/og, which wraps @vercel/og. The library has two entries: a Node build that does readFileSync on a bundled font and calls WebAssembly.instantiate at runtime (both disallowed on workerd), and an edge build that doesn't.&lt;/p&gt;

&lt;p&gt;The problem: OpenNext forces NEXT_RUNTIME="nodejs" at bundle time, so next/og always resolves the Node entry, even inside a worker. The workaround is sniffing the runtime myself and importing the edge build directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ImageResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;userAgent&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cloudflare-Workers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* webpackIgnore: true */&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/dist/compiled/@vercel/og/index.edge.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/og&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ImageResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;ImageResponseType&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Navigator.userAgent === 'Cloudflare-Workers' is genuinely how you detect workerd. The webpackIgnore comment keeps webpack from trying to bundle the path at build time, and I pin the whole @vercel/og compiled directory into the output via outputFileTracingIncludes in next.config.ts so the edge build and its resvg wasm actually ship.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha 3: fs reads land in the wrong directory&lt;/strong&gt;&lt;br&gt;
My OG route also reads custom fonts and a fallback backdrop off disk with readFile(path.join(process.cwd(), 'public', ...)). On Vercel, process.cwd() is the project root. On workerd, the vfs cwd is /bundle (the .open-next output root), so public/fonts doesn't exist where the code looks for it.&lt;/p&gt;

&lt;p&gt;The fix is a three-part move: the postbuild script copies public/fonts and backdrop_fallback.jpg into .open-next/public/, and wrangler's find_additional_modules + the Data rules in wrangler.jsonc ship those files in the worker's virtual filesystem. Same code path, files just exist where the worker looks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha 4: packages hiding behind the workerd export condition&lt;/strong&gt;&lt;br&gt;
Some packages, in my case @emotion/* (Chakra's styling engine) and uncrypto, publish their ESM builds behind a workerd export condition. Next's file tracer only follows the CJS variants, so the worker bundle ends up with nothing it can resolve for those imports.&lt;/p&gt;

&lt;p&gt;The fix is listing them in serverExternalPackages in next.config.ts, which tells the adapter to copy the packages whole instead of relying on tracing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;serverExternalPackages:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="err"&gt;'@emotion/react'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'@emotion/cache'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'@emotion/serialize'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="err"&gt;'@emotion/sheet'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'@emotion/weak-memoize'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'@emotion/hash'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="err"&gt;'@emotion/memoize'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'@emotion/unitless'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'@emotion/utils'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="err"&gt;'@emotion/use-insertion-effect-with-fallbacks'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="err"&gt;'hoist-non-react-statics'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'uncrypto'&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you hit "cannot resolve module" errors on workerd that don't reproduce locally, this is the first thing to check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha 5: Vercel crons don't exist on Workers&lt;/strong&gt;&lt;br&gt;
vercel.json crons are a Vercel platform feature; nothing in a Next.js build output describes them. On Workers the equivalent is triggers.crons + a scheduled() handler. But the adapter generates worker.js, so I wrap it in a custom entry that adds the handler and maps each cron expression back to the same endpoint Vercel used to hit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// .open-next/entry.js, written by the postbuild script&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./worker.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CRON_ROUTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0 6 * * *&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/cron/generate-content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;30 6 * * *&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/cron/streaming-changes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0 7 * * *&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/cron/watchlist-alerts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0 16 * * 5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/cron/weekly-digest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;scheduled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;CRON_ROUTES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;

      &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WORKER_SELF_REFERENCE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;

        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://worker.internal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

          &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CRON_SECRET&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="p"&gt;})&lt;/span&gt;

      &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things make this nice. The WORKER_SELF_REFERENCE service binding lets the worker call itself as an internal fetch, no public round trip. And the endpoints needed zero changes: they already checked Authorization: Bearer $CRON_SECRET because that's how Vercel's cron caller authenticates too. Testing is wrangler dev --test-scheduled, then hit /__scheduled?cron=0 6 * * *.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Secrets&lt;/strong&gt;&lt;br&gt;
wrangler secret bulk takes a JSON file, so a 20-line script converts .env.local into .secrets.json, skipping NEXT_PUBLIC_* vars (those get inlined at build time anyway) and my local Cloudflare API token. npm run cf:secrets and done: no dashboard clicking through a dozen vars.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cutover and rollback&lt;/strong&gt;&lt;br&gt;
The safest part of the whole migration was the cutover itself, because I deliberately ran both platforms in parallel:&lt;/p&gt;

&lt;p&gt;npm run cf:deploy, then verify everything on the *.workers.dev URL (all routes, OG images, cron triggers registered).&lt;br&gt;
Attach the galaxymovies.app + www custom domains to the worker. Since DNS was already on Cloudflare, this was instant, no propagation wait.&lt;br&gt;
Keep Vercel as cold standby: git.deploymentEnabled.main: false in vercel.json stops main pushes from auto-deploying, and I disabled the Vercel crons in the dashboard so the two platforms don't double-fire jobs.&lt;/p&gt;

&lt;p&gt;Rollback is re-adding two DNS records (A to Vercel's anycast IP, CNAME www to the Vercel DNS name). Traffic flips back immediately because the worker custom domains only take precedence while they're the only records on those names. That escape hatch made the whole thing low-stress: worst case is a DNS edit, not a redeploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's actually running&lt;/strong&gt;&lt;br&gt;
Same codebase, same next build, no platform fork in the source. SSR, ISR (backed by R2), all API routes, the OG image route, and all four crons work identically to how they did on Vercel. The only code that knows which platform it's on is that one navigator.userAgent check in the OG route.&lt;/p&gt;

&lt;p&gt;The honest summary: the adapter gets you ~80% there out of the box, and the remaining 20% is the kind of platform-shim work above. It's tractable, but expect an afternoon of debugging, not a lunch break. If your app leans on Node APIs that aren't fetch-shaped, the logic is different. But if you're already edge-friendly and your DNS is on Cloudflare anyway, the economics are hard to argue with, and Vercel sitting dormant as a free rollback target is a pretty nice safety net.&lt;/p&gt;

&lt;p&gt;Repo notes worth taking if you're doing this: patch-package for the adapter externals, a postbuild script for stubs + vfs assets + the cron entry, serverExternalPackages for workerd-conditioned packages, and wrangler dev --test-scheduled for cron testing. &lt;/p&gt;

&lt;p&gt;Happy to answer questions in the comments!&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>vercel</category>
      <category>cf</category>
    </item>
    <item>
      <title>My side project's free tiers were dying, so I counted what each service actually bills</title>
      <dc:creator>Nick Fasulo</dc:creator>
      <pubDate>Thu, 08 Oct 2026 22:41:27 +0000</pubDate>
      <link>https://dev.to/nickfasulo/my-side-projects-free-tiers-were-dying-so-i-counted-what-each-service-actually-bills-27m1</link>
      <guid>https://dev.to/nickfasulo/my-side-projects-free-tiers-were-dying-so-i-counted-what-each-service-actually-bills-27m1</guid>
      <description>&lt;p&gt;Galaxy Movies is a movie discovery side project ~ Next.js Pages Router on Vercel, TMDB for data, Upstash Redis for caching &amp;amp; rate limiting, and OpenAI for a chat assistant &amp;amp; generated synopses. Everything runs on free tiers, which is the whole point of a side project.&lt;/p&gt;

&lt;p&gt;A few weeks ago all three of those free tiers started groaning at once. Vercel function usage was climbing, Upstash was burning through its daily command quota well before midnight, and OpenAI spend had a suspicious correlation with traffic spikes that produced no actual readers.&lt;/p&gt;

&lt;p&gt;The fixes all came down to the same realization: &lt;strong&gt;every "free" tier bills a different unit, and I was counting the wrong things.&lt;/strong&gt; Here's what was actually billing me, and what I changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 1: The bots were running up my function bill
&lt;/h2&gt;

&lt;p&gt;Vercel bills function &lt;em&gt;duration&lt;/em&gt;, and my movie and TV detail pages were expensive: five parallel TMDB fetches (title, watch providers, credits, ratings, recommendations), plus Redis reads for the AI content slots, all in &lt;code&gt;getServerSideProps&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The expensive traffic wasn't humans. SEO crawlers and AI scrapers sweep the entire TMDB ID space in bulk, and every one of those detail pages ran the full fetch chain. They don't index anything and send zero readers. Pure cost.&lt;/p&gt;

&lt;p&gt;The fix was a thin render path just for them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bots that don't index get a minimal render. They sweep the TMDB id&lt;/span&gt;
&lt;span class="c1"&gt;// space in bulk and don't need providers, credits, videos, or AI slots.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isBot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isSearchEngine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;titleRes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://api.themoviedb.org/3/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;media&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?api_key=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// on failure return notFound or errorProps; otherwise:&lt;/span&gt;
  &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;private, no-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;thinProps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;media&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;topCast&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;isSearchEngine&lt;/code&gt; split.&lt;/strong&gt; &lt;code&gt;isBot&lt;/code&gt; matches a broad allowlist, but Googlebot, Bingbot, and DuckDuckGo still get the full render since they index. Social preview bots get the thin version. Whether a bot indexes is the only question that matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;private, no-store&lt;/code&gt; is critical.&lt;/strong&gt; If the stripped page entered the shared CDN cache, a cache collision could serve the bot version to a human, or worse, to Googlebot. Cheap responses must never contaminate the real cache.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also found &lt;code&gt;robots.txt&lt;/code&gt; was being rendered by a Next.js &lt;em&gt;page&lt;/em&gt;, which meant every crawler's very first request was a function invocation. It moved to a static &lt;code&gt;public/robots.txt&lt;/code&gt;, which doubles as an enforcement layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User-agent: AhrefsBot
User-agent: SemrushBot
User-agent: MJ12bot
User-agent: Bytespider
User-agent: GPTBot
User-agent: ClaudeBot
User-agent: CCBot
# ...several more
Disallow: /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, &lt;code&gt;Disallow&lt;/code&gt; is an honor system, but the big commercial crawlers honor it, and they were most of the volume.&lt;/p&gt;

&lt;p&gt;Finally, I lengthened the SSR CDN cache windows: &lt;code&gt;s-maxage&lt;/code&gt; from 1 hour to 1 day, &lt;code&gt;stale-while-revalidate&lt;/code&gt; from 1 day to 1 week. Repeat crawler hits on popular titles now terminate at the edge instead of running SSR again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 2: Upstash bills every single Redis command
&lt;/h2&gt;

&lt;p&gt;This one caught me off guard. Upstash's free tier bills &lt;em&gt;per command&lt;/em&gt;, and a pipeline doesn't save you, because &lt;strong&gt;each member of a pipeline is its own billed command&lt;/strong&gt;. My request paths fanned out into Redis calls that felt free because "Redis is fast."&lt;/p&gt;

&lt;p&gt;The audit turned up some embarrassing finds. Cache reads on detail pages were ~97% misses, so I was paying a billed command for a null almost every time. Rate limiting cost up to three commands per check. A dataset updated once a day was being fetched again on every pageview.&lt;/p&gt;

&lt;p&gt;The fixes, roughly in order of satisfaction:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting became one Lua script.&lt;/strong&gt; &lt;code&gt;INCRBY&lt;/code&gt; plus a conditional &lt;code&gt;EXPIRE&lt;/code&gt; plus a TTL recovery check run inside a single &lt;code&gt;EVAL&lt;/code&gt;, billed once no matter how many &lt;code&gt;redis.call()&lt;/code&gt;s it makes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"INCRBY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"EXPIRE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"TTL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"EXPIRE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The TTL check handles a real edge case: if a key somehow loses its expiry, a blocked client fixes it on the next attempt instead of being rate limited forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check caches before paying the rate limit write.&lt;/strong&gt; &lt;code&gt;/api/prices&lt;/code&gt; was rate limiting requests that would have been cache hits, paying a write before doing a read. Reorder, and every cache hit is one command instead of two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memoize what changes daily.&lt;/strong&gt; The &lt;code&gt;streaming-changes&lt;/code&gt; diffs are rewritten once a day by cron but were being fetched per key on every pageview. A per instance LRU holding results for ten minutes turns repeat page loads into zero Redis commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;changesMemo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;LRUCache&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ProviderChanges&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getProviderChanges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;providerKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;memoKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;providerKeys&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;memoized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;changesMemo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;memoKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;memoized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;memoized&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...Redis reads, then changesMemo.set(memoKey, changes)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;fresh&lt;/code&gt; flag exists because the cron itself must never match subscribers against a diff it just replaced. Memoization that's correct &lt;em&gt;most&lt;/em&gt; of the time is a bug with extra steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batch the counters.&lt;/strong&gt; Every &lt;code&gt;HINCRBY&lt;/code&gt; is billed, so AI usage and analytics deltas accumulate in a &lt;code&gt;Map&lt;/code&gt; and flush every 30 seconds (or 50 fields). Stats lag by up to half a minute and a dying instance loses its unflushed tail, a fine trade for telemetry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restructure data to match access patterns.&lt;/strong&gt; Detail pages fetch both AI content slots in one &lt;code&gt;MGET&lt;/code&gt; instead of two &lt;code&gt;GET&lt;/code&gt;s. Generated lists moved from one key per slug into a single hash, where &lt;code&gt;HGETALL&lt;/code&gt; is one command replacing an unbounded key fanout.&lt;/p&gt;

&lt;p&gt;Net result: roughly half the daily command volume, with quota headroom for the first time in weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 3: OpenAI spend, or "why is a scraper generating my content?"
&lt;/h2&gt;

&lt;p&gt;The AI features on detail pages (generated synopses, similar title blurbs) trigger an OpenAI call on a cache miss. Guess who was causing cache misses at scale: the same scrapers from Act 1. Bots were literally generating my content.&lt;/p&gt;

&lt;p&gt;So bots got read only access: they can consume AI content that's already cached but can never trigger generation (&lt;code&gt;generate: false&lt;/code&gt; on the cache lookup). Human traffic is unaffected; scraper sweeps now cost zero tokens.&lt;/p&gt;

&lt;p&gt;The other fixes were less dramatic but added up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Failed cron seeds stopped retrying forever.&lt;/strong&gt; A broken seed now records &lt;code&gt;failed&lt;/code&gt; with an attempt counter and gives up after 3 tries, instead of burning a &lt;code&gt;discover&lt;/code&gt; call on every daily run until the heat death of the universe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Longer cache TTLs.&lt;/strong&gt; Reviews and similar titles are static per title, so 365 day TTLs mean one generation per title, ever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt dieting.&lt;/strong&gt; Similar title prompts dropped to 6 candidates with 200 character overviews; editorial prompts got explicit word budgets; structured JSON output removed the "please don't ramble" tax.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared chat cache.&lt;/strong&gt; Identical chat requests (quick actions, common first questions; the bulk of traffic) now hit a Redis cache shared across instances, plus tighter limits: 10/hr per IP, 1000/day global.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per task model control.&lt;/strong&gt; All OpenAI calls route through one module with per task &lt;code&gt;reasoning_effort&lt;/code&gt;: &lt;code&gt;none&lt;/code&gt; for latency sensitive tasks, &lt;code&gt;low&lt;/code&gt; only for the discover generator. Real token usage gets logged so I can see where spend actually goes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The kill switch
&lt;/h3&gt;

&lt;p&gt;The scariest scenario isn't gradual spend. It's a quota or billing problem silently converting every request into an error, or worse, into retry storms. Billing and auth failures (&lt;code&gt;401&lt;/code&gt;/&lt;code&gt;402&lt;/code&gt;, &lt;code&gt;insufficient_quota&lt;/code&gt;) now trip a shared kill switch with a one hour cooldown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isQuotaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APIError&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;402&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;insufficient_quota&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;billing_hard_limit_reached&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;billing_not_active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While it's tripped: &lt;code&gt;/api/chat&lt;/code&gt; returns &lt;code&gt;503 { code: 'ai_unavailable' }&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; spending budget, the chat widget checks &lt;code&gt;/api/aiStatus&lt;/code&gt; on mount and hides itself entirely instead of showing an error bubble, the generators skip their calls, and the content cron skips the run &lt;em&gt;without&lt;/em&gt; burning seed attempts, since an outage isn't a seed's fault. Crucially, ordinary &lt;code&gt;429 rate_limit_exceeded&lt;/code&gt; errors don't trip it; those are transient.&lt;/p&gt;

&lt;p&gt;One deliberate choice throughout: &lt;strong&gt;degrade in the direction that saves money.&lt;/strong&gt; When Redis errors on a check that protects spend, the code falls back to a per instance limiter and never fails open. An in memory limiter might be less accurate, but a Redis outage can't be used to run up my OpenAI bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where I broke things
&lt;/h2&gt;

&lt;p&gt;Honest bookkeeping: the cost cuts shipped two bugs worth their own paragraph.&lt;/p&gt;

&lt;p&gt;After moving the chat cache to Redis, identical requests started hitting on every quick action prompt, and the cache hit path returned plain JSON that the SSE client never parsed. Cache hits went from "the fast path" to "the broken path" overnight. Fix: return cache hits through the SSE stream like everything else.&lt;/p&gt;

&lt;p&gt;And when bots stopped triggering AI generation, Lighthouse CI got caught in the crossfire. It audits the real detail pages, and its UA matched the bot list, so it was suddenly measuring the thin render. &lt;code&gt;Chrome-Lighthouse&lt;/code&gt; went on the search engine side of the check.&lt;/p&gt;

&lt;p&gt;Both bugs were the same shape: &lt;strong&gt;a filter added at the edge changed behavior for a consumer I forgot about.&lt;/strong&gt; If your bot detection doesn't have a list of &lt;em&gt;legitimate&lt;/em&gt; automated consumers, it will find one for you. In production. On a Sunday.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Count the billed unit, not the abstraction.&lt;/strong&gt; "Redis call," "function invocation," and "API request" are all wrong. It's commands, gigabyte seconds, and tokens, and the gap between what I thought I was spending and what I was billed lived entirely inside that distinction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segment bots by whether they index&lt;/strong&gt;, not whether they're bots. That one boolean decided which requests deserved the expensive render, the Redis reads, and the cacheable output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache at the cheapest layer that works.&lt;/strong&gt; CDN edge &amp;gt; shared Redis &amp;gt; per instance LRU &amp;gt; regenerate. I had requests traversing the whole hierarchy when a static file would do (looking at you, &lt;code&gt;robots.txt&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free tier quotas are architecture constraints&lt;/strong&gt;, not billing details. "A few Redis commands per request is fine" is a statement that only survives until you do the multiplication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instrument spend itself.&lt;/strong&gt; The usage counters I added to survive the Upstash quota are now how I watch OpenAI costs. The fix for a quota crisis becomes your observability for the next one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The site is &lt;a href="https://www.galaxymovies.app" rel="noopener noreferrer"&gt;galaxymovies.app&lt;/a&gt; if you want to see the thin renders in the wild. View source as a human, please; the bots are expensive enough. Happy to compare notes in the comments if you've fought the same free tier math!&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>serverless</category>
      <category>redis</category>
    </item>
    <item>
      <title>I Added a Chatbot to My Movie Discovery App</title>
      <dc:creator>Nick Fasulo</dc:creator>
      <pubDate>Sat, 03 Oct 2026 23:17:00 +0000</pubDate>
      <link>https://dev.to/nickfasulo/i-added-a-chatbot-to-my-movie-discovery-app-3p12</link>
      <guid>https://dev.to/nickfasulo/i-added-a-chatbot-to-my-movie-discovery-app-3p12</guid>
      <description>&lt;p&gt;I recently added a chatbot feature to Galaxy Movies, my movie discovery app.&lt;/p&gt;

&lt;p&gt;The idea came from a problem I run into all the time: there are too many movies to choose from, and browsing doesn’t always help.&lt;/p&gt;

&lt;p&gt;You can scroll through trending lists, genres, ratings, and streaming options, but sometimes what you really want is to ask a simple question: “What should I watch tonight?”&lt;/p&gt;

&lt;p&gt;So I built Galaxy Bot.&lt;/p&gt;

&lt;p&gt;Galaxy Bot is a conversational movie assistant that helps users find movies based on what they actually want in the moment. Instead of only relying on filters, users can describe their mood, who they’re watching with, how much time they have, or what kind of movie they’re trying to avoid.&lt;/p&gt;

&lt;p&gt;Some example prompts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“Recommend a feel-good movie under two hours.”&lt;/li&gt;
&lt;li&gt;“I want a sci-fi movie that isn’t too confusing.”&lt;/li&gt;
&lt;li&gt;“What should I watch with my family?”&lt;/li&gt;
&lt;li&gt;“Give me something tense but not horror.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The main thing I focused on was making the chatbot feel useful without getting in the way. It should make discovery faster, not make the app feel heavier.&lt;/p&gt;

&lt;p&gt;This first version is about helping users get unstuck when they don’t know what to watch. Over time, I’d like to make it better at understanding taste, saved movies, ratings, and streaming availability.&lt;/p&gt;

&lt;p&gt;You can check it out here: &lt;a href="https://galaxymovies.app" rel="noopener noreferrer"&gt;Galaxy Movies&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How I Built an Ultra-Fast Movie Discovery App with Next.js, Edge Runtime, and OpenAI</title>
      <dc:creator>Nick Fasulo</dc:creator>
      <pubDate>Thu, 24 Sep 2026 16:46:50 +0000</pubDate>
      <link>https://dev.to/nickfasulo/how-i-built-an-ultra-fast-movie-discovery-app-with-nextjs-edge-runtime-and-openai-33dj</link>
      <guid>https://dev.to/nickfasulo/how-i-built-an-ultra-fast-movie-discovery-app-with-nextjs-edge-runtime-and-openai-33dj</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Finding something to watch shouldn't feel like a chore. Legacy movie sites are often weighed down by heavy ads, sluggish page transitions, and buried streaming links. I wanted a tool that got straight to the point: instant search, immediate streaming availability, and quick, objective AI synopses.&lt;/p&gt;

&lt;p&gt;So, I solo-developed Galaxy Movies.&lt;/p&gt;

&lt;p&gt;Live Demo: &lt;a href="https://www.galaxymovies.app/" rel="noopener noreferrer"&gt;https://www.galaxymovies.app/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/NickFasulo/galaxy-movies" rel="noopener noreferrer"&gt;https://github.com/NickFasulo/galaxy-movies&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Features
&lt;/h2&gt;

&lt;p&gt;Lightning-Fast Search: Browse thousands of titles and explore cast networks in a clean, cosmic dark-mode UI.&lt;/p&gt;

&lt;p&gt;One-Click Streaming Check: Instantly view exact platform availability (where to stream, rent, or buy) without jumping through menus.&lt;/p&gt;

&lt;p&gt;Instant AI Synopses: Concise, objective film overviews powered by OpenAI to give quick context before pressing play.&lt;/p&gt;

&lt;p&gt;Dynamic OG Cards: Social share preview cards rendered on the fly with custom branding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack &amp;amp; Architecture Highlights
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; Next.js (Pages Router) + Chakra UI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State &amp;amp; Data:&lt;/strong&gt; React Query + TMDB API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge &amp;amp; AI Services:&lt;/strong&gt; Vercel Edge Runtime (&lt;code&gt;@vercel/og&lt;/code&gt;) + OpenAI API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality Assurance:&lt;/strong&gt; Lighthouse CI&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic Social Cards on Vercel Edge Runtime:&lt;/strong&gt; Built dynamic Open Graph image generation using &lt;code&gt;@vercel/og&lt;/code&gt;. Requests parse title and poster data on Edge functions to serve custom social cards with sub-100ms response times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TMDB Image Optimization Bypass:&lt;/strong&gt; Direct-to-CDN poster loading to bypass server image transformation limits while maintaining high performance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated Quality Checks:&lt;/strong&gt; Integrated Lighthouse CI to enforce performance, accessibility, and SEO thresholds on builds.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>ai</category>
      <category>nextjs</category>
      <category>openai</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
