<?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: songxiaoman</title>
    <description>The latest articles on DEV Community by songxiaoman (@songxiaomanai).</description>
    <link>https://dev.to/songxiaomanai</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%2F4216312%2F7f95b897-2c58-4726-b87a-6dbb73907633.png</url>
      <title>DEV Community: songxiaoman</title>
      <link>https://dev.to/songxiaomanai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/songxiaomanai"/>
    <language>en</language>
    <item>
      <title>Building an AI Video App on Cloudflare Workers with TanStack Start</title>
      <dc:creator>songxiaoman</dc:creator>
      <pubDate>Fri, 30 Oct 2026 03:12:46 +0000</pubDate>
      <link>https://dev.to/songxiaomanai/building-an-ai-video-app-on-cloudflare-workers-with-tanstack-start-lij</link>
      <guid>https://dev.to/songxiaomanai/building-an-ai-video-app-on-cloudflare-workers-with-tanstack-start-lij</guid>
      <description>&lt;p&gt;I run a small AI video web app: users upload a character photo and a short motion clip, and a hosted video model renders the character performing those moves. The whole thing (UI, API, auth, payments, storage and the job pipeline) runs as &lt;strong&gt;one Cloudflare Worker&lt;/strong&gt;. No servers, no containers, no separate queue service.&lt;/p&gt;

&lt;p&gt;This post walks through the architecture and the handful of problems that turned out to be interesting. If you're considering Workers for anything that handles large files or long-running third-party jobs, most of this transfers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TanStack Start + React 19&lt;/strong&gt; for SSR and file-based routing, built with Vite and the Cloudflare Vite plugin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single Worker entry&lt;/strong&gt; (&lt;code&gt;src/server.ts&lt;/code&gt;) that hands requests to TanStack Start's handler&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;D1&lt;/strong&gt; (SQLite) through &lt;strong&gt;Drizzle ORM&lt;/strong&gt; for users, credits and jobs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;R2&lt;/strong&gt; for uploads and finished videos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KV&lt;/strong&gt; as a cache&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workers AI&lt;/strong&gt; for a vision-model safety check on uploaded photos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better Auth&lt;/strong&gt; for email/password and Google sign-in&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Email&lt;/strong&gt; for transactional mail, behind a small provider interface&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions&lt;/strong&gt; deploying with &lt;code&gt;wrangler deploy&lt;/code&gt; on every push to &lt;code&gt;main&lt;/code&gt;, plus per-PR preview URLs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;TanStack Start's server functions (&lt;code&gt;createServerFn&lt;/code&gt; with a Zod validator and middleware chain) replace a separate API layer. The client calls them like ordinary async functions, and they run inside the same Worker with direct access to the bindings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: large uploads without blowing up memory
&lt;/h2&gt;

&lt;p&gt;Motion clips can be tens of megabytes. Buffering that in a Worker is a bad idea. Instead, the upload route takes the raw file as the request body and streams it straight into R2:&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;// simplified&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`uploads/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;.mp4`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&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;BUCKET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;httpMetadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&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;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client then passes that &lt;code&gt;key&lt;/code&gt; to the "start job" server function. Uploads are deleted as soon as a job has used them, and an &lt;strong&gt;R2 lifecycle rule&lt;/strong&gt; removes anything older than a day as a safety net. That keeps storage costs flat and means user-provided material doesn't sit around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: pricing a video by length without downloading it
&lt;/h2&gt;

&lt;p&gt;Pricing is per second of motion video, so the server needs the clip's duration before charging. Reading the entire file just to get one number defeats the point of streaming.&lt;/p&gt;

&lt;p&gt;MP4 and MOV files store their duration in the &lt;code&gt;mvhd&lt;/code&gt; box inside &lt;code&gt;moov&lt;/code&gt;. So the server uses &lt;strong&gt;R2 range reads&lt;/strong&gt; to walk the top-level box headers (8â€“16 bytes each), skips over &lt;code&gt;mdat&lt;/code&gt; without reading it, fetches only the &lt;code&gt;moov&lt;/code&gt; box, and parses &lt;code&gt;timescale&lt;/code&gt; and &lt;code&gt;duration&lt;/code&gt; from &lt;code&gt;mvhd&lt;/code&gt;. Typically that's a few kilobytes read instead of the whole file. When the job is submitted, the video body goes to the model provider as a stream, again never held whole in memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: jobs that outlive the request
&lt;/h2&gt;

&lt;p&gt;Video rendering takes a few minutes, far longer than a request should stay open. Queues or Durable Objects would work, but a simpler pattern is enough:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start&lt;/strong&gt;: one request validates input, runs the photo check, charges credits, submits to the provider and writes a row to an &lt;code&gt;effect_job&lt;/code&gt; table with the provider's request ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advance on poll&lt;/strong&gt;: the client polls a status server function. Each poll asks the provider for status and moves the job forward. When it's done, the result is copied into R2 under the user's files. When it fails, the credits are refunded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notify&lt;/strong&gt;: on completion, an email goes to the user ("your video is ready") so they can close the tab.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the status call is idempotent (it only moves a job forward from its stored state), duplicate polls from multiple tabs are harmless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: refunds that happen minutes later
&lt;/h2&gt;

&lt;p&gt;The codebase has a &lt;code&gt;withCredits&lt;/code&gt; helper that charges, runs a callback and refunds if the callback throws. That's perfect for synchronous AI calls but useless here: the job can fail five minutes after the request that charged for it has finished.&lt;/p&gt;

&lt;p&gt;The fix was to &lt;strong&gt;store the spend on the job row&lt;/strong&gt;. When a poll discovers a failure, it refunds exactly what that job cost and marks the row as refunded, so the refund can't happen twice. Treat the job row as the source of truth for money, not the call stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: safety checks before charging
&lt;/h2&gt;

&lt;p&gt;Every photo passes through a &lt;strong&gt;Workers AI vision model&lt;/strong&gt; via the &lt;code&gt;AI&lt;/code&gt; binding before any credits move. It flags sexual content and recognizable public figures (both always refused) and applies extra rules for photos that appear to include minors. If the check itself errors, the photo is refused rather than let through. Running it first means users are never charged for a request that would be rejected anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 6: private files, shareable thumbnails
&lt;/h2&gt;

&lt;p&gt;Finished videos are private. They're served from a route that checks the owner's session. But generating a first-frame poster image uses Cloudflare Media Transformations, which needs to fetch the video by URL. The answer was &lt;strong&gt;short-lived signed links&lt;/strong&gt;: the video route accepts either the owner's session or a valid signature, and the poster pipeline uses the latter, then stores the WebP through the Images binding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local development against remote resources
&lt;/h2&gt;

&lt;p&gt;One opinionated choice: local dev and end-to-end tests connect to the &lt;strong&gt;remote&lt;/strong&gt; D1, R2 and KV (&lt;code&gt;"remote": true&lt;/code&gt; on each binding in &lt;code&gt;wrangler.jsonc&lt;/code&gt;) rather than local emulators. That removes a whole class of "works locally" bugs around SQLite versions and R2 behaviour, at the cost of needing a dev database. For a solo project that trade has been worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I do it again?
&lt;/h2&gt;

&lt;p&gt;Yes. The single-Worker model keeps operations close to zero: one deploy, one log stream, costs that scale with usage, and no idle servers. Its constraints (no Node-only APIs, careful memory use, request time limits) pushed the design toward streaming and stateless polling, which ended up simpler than a queue-based design.&lt;/p&gt;

&lt;p&gt;If you want to see the result, the app is live at &lt;a href="https://motioncontrolai.info/" rel="noopener noreferrer"&gt;MotionControlAI&lt;/a&gt;. It's an independent project, not affiliated with Kling AI or Kuaishou, even though it runs their motion control models. There's a plain-English &lt;a href="https://motioncontrolai.info/what-is-kling-motion-control" rel="noopener noreferrer"&gt;guide to how Kling Motion Control works&lt;/a&gt; if you're curious about the model side.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>react</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
