Skip to content

RESOURCES / BLOG

High-Performance Video Backgrounds: Automating Smart Previews With TanStack Start and PowerFlows

Hero videos are visually engaging, but can be a silent performance killer on a slow network. And if you swap them for static images to reduce page weight, you lose the beautiful animations that made your hero section so compelling.

We can change that with Automated Smart Previews. In this guide, we’ll combine TanStack Start with Cloudinary PowerFlows to create a system that will trigger a low-code workflow to generate, optimize, and deliver a lightweight preview clip the moment an upload happens. By the end, you’ll have a functional pipeline where uploading a video in the navbar instantly transforms the hero section into a high-performance experience.

The goal is to stop loading full videos for first impressions. Instead, you’ll generate a high-impact highlight that’s:

  • Trimmed so the video highlights the best few seconds.
  • Resized and scaled specifically for hero backgrounds.
  • Optimized using automatic format and quality switching.
  • Synced to the app via webhooks for an instant UI update.

The full video still exists, but the preview carries the weight of the initial page load.

We’ve cut the manual glue code by connecting the frontend directly to a media pipeline.

  1. Upload. Use the Cloudinary Upload Widget in the TanStack Start frontend for direct-to-cloud delivery.
  2. Trigger. A PowerFlow catches the upload event automatically.
  3. Process. The flow generates a preview URL (e.g., using so_0, eo_5 for a 5-second loop).
  4. Notify. PowerFlows sends the structured media data to a TanStack Start API route via a webhook.
  5. Update. The app refreshes the hero component with the new optimized asset.

The architecture is lean. We aren’t building a custom media server, just orchestrating specialized tools to do one job well.

TanStack Start acts as your command center. It handles the app shell, route loading, and the server-side logic required to catch webhooks. It doesn’t process the video itself. Instead, it waits for structured media data and updates the UI state. This separation ensures the frontend remains fast and focused on rendering.

Use the Cloudinary Upload Widget as the entry point. It removes the need for custom multi-part upload logic by handling direct browser-to-cloud delivery, chunked uploads for large files, and success callbacks to trigger the next phase.

This is the engine. A PowerFlow is a low-code workflow that triggers the moment a file hits Cloudinary. It passes data through a sequence of connected blocks:

  • Cloudinary Upload receives the asset.
  • Create Asset URL generates a transformed preview (e.g., a 5-second loop).
  • Send HTTP Request POSTs the preview metadata back to your app.

The hero section is powered by two distinct URLs. The first is the preview, which is a lightweight, trimmed, and optimized version for the initial background load. Second is the full asset, or the original high-quality video, loaded only when necessary.

The PowerFlow sends a payload (PublicID, Preview URL, and metadata) back to a dedicated TanStack Start API route. The app receives this, updates the global state, and the hero component refreshes instantly.

So why this architecture? By offloading the heavy lifting to PowerFlows, the project stays easy to manage. The code you write is mostly “glue” rather than infrastructure. You’ll get:

  • Zero backend bloat. No ffmpeg or local processing required.
  • Predictable performance. Previews are generated once and cached at the edge.
  • High scalability. Cloudinary handles the traffic while your app stays lean.

The project remains lightweight because you’ve divided the logic by responsibility. The application essentially runs on four “engines” that handle the data flow from upload to UI.

  1. The Webhook route. A server-side handler that receives the POST payload from PowerFlows. It acts as the “ear” of the application, waiting for processed media data.
  2. The asset store. A simple in-memory store for this demo. It holds the latest processed video metadata so the UI can update without a full database setup.
  3. The payload builder. A normalization layer. It serves the latest MediaFlows data if available, or falls back to a high-quality demo video to keep the rendering path stable.
  4. The hero renderer. A pure UI component. It doesn’t “know” about the pipeline; it simply renders the previewUrl for the background and prepares the fullVideoUrl for user interaction.

By offloading video processing to Cloudinary and orchestration to PowerFlows, the TanStack Start code stays focused on state. The logic is a straight line: The loader checks the store, and the UI renders the result. You aren’t fighting with ffmpeg or complex backend tasks; you’re simply managing structured data.

Before the Upload Widget can work, it needs a rulebook. In Cloudinary, that’s the Upload Preset. It centralizes your logic so you don’t have to scatter upload rules throughout your frontend code.

Use an Unsigned preset for this build. This is the low-friction route for browser-based uploads because it doesn’t require a custom backend signing endpoint.

Keep the setup focused on consistency:

  • Name: mediaflows_demo_video_unsigned -*Mode: Unsigned
  • Folder* mediaflows-demo (ensures the Media Library stays organized and triggers stay predictable).
  • Allowed formats: mp4, mov, webm, m4v (strictly enforced to ensure the pipeline receives video).

Leave Transformations and Analysis settings empty in the preset. You’ll want the “intelligence” of the preview generation to live inside MediaFlows. Keeping the preset as a simple gatekeeper makes the workflow modular and much easier to debug.

Once the preset is live in your dashboard, the integration is a one-liner in your .env file:

VITE_CLOUDINARY_CLOUD_NAME=your_cloud_name
VITE_CLOUDINARY_UPLOAD_PRESET=mediaflows_demo_video_unsigned

This keeps the environment clean. The widget is now authorized to upload directly to your folder, which automatically kicks off the automation.

The upload engine is where the app transforms from a static demo into an interactive system. By clicking a single button in the navbar, a user triggers the entire pipeline.

Instead of building custom upload infrastructure, use the Cloudinary Upload Widget. It handles the heavy lifting: file selection, transport, and browser-side restrictions directly from the client.

Keep the configuration focused on video integrity. The goal is to ensure only compatible assets enter the pipeline.

widgetRef.current = window.cloudinary.createUploadWidget(
  {
    cloudName,
    uploadPreset,
    sources: ["local", "camera", "url"],
    multiple: false,
    maxFiles: 1,
    resourceType: "video",
    clientAllowedFormats: ["mp4", "mov", "webm", "m4v"],
    showAdvancedOptions: false,
    cropping: false,
  },
  async (error, result) => {
    if (error) {
      console.error("[upload-widget] callback error:", error);
      return;
    }

    if (result?.event === "success") {
      const uploadedPublicId = result?.info?.public_id;
      // The handoff: wait for the MediaFlows webhook to finish processing
    }
  }
);
Code language: JavaScript (javascript)

Let’s break down each of the video settings:

  • resourceType: “video” aligns the upload flow with our video-processing pipeline.
  • clientAllowedFormats forces the browser to reject incompatible files before the upload even begins.
  • Unsigned uploads use the preset you configured earlier to keep the frontend code simple and secure without custom backend signing.

An upload “success” event is only half the story. When a video finishes uploading, MediaFlows still needs a moment to trigger, generate the preview, and call your webhook.

Because of this, the success callback acts as a handoff point. In this project, the UI doesn’t refresh immediately. Instead, it waits until the app detects that the latest saved asset in the store matches the newly uploaded public_id. This polling step ensures the hero updates only when the optimized preview is actually ready to be served.

To handle high-resolution videos, utilize chunking settings like maxChunkSize and maxVideoFileSize. These options allow the widget to manage larger uploads gracefully. However, it’s important to remember that account-level limits still apply. If a file exceeds your product environment’s maximum size, the upload will fail regardless of your widget settings.

By placing the upload button in the navbar, you’ll make the entire workflow testable directly from the live demo. There’s no need for a separate admin panel or the Cloudinary dashboard. A user uploads a file, the pipeline processes it, and the hero updates in real time.

Explore the code on GitHub.

This is the automated bridge of the entire project. While the Upload Widget handles the entry point, MediaFlows PowerFlows transforms that raw file into a high-performance hero preview without any manual intervention.

Articles Images MediaFlows Demo

You’ve built this workflow using a minimal three-block chain. Because PowerFlows allows blocks to pass response values to subsequent steps, the logic stays lean and easy to debug.

  • The trigger (Cloudinary Upload). You configured this to trigger “On Asset Upload” and filtered it for “Asset Type: Video”. This ensures the preview logic only runs when it should, avoiding unnecessary processing on images.

  • The transformation (Create Asset URL). This is where the preview clip is built. You used a specific transformation string to shape the output:

    so_1,du_8,c_fill,ar_16:9,w_1600,q_auto,f_auto,vc_auto

    • so_1, du_8 starts 1 second in and keeps only 8 seconds of footage.
    • c_fill, ar_16:9, w_1600 crops and resizes the video for a high-resolution desktop hero banner.
    • q_auto, f_auto, vc_auto optimizes quality, format, and codec automatically for the best performance.
  • The handoff (“Send HTTP Request”). Once the preview URL is generated, and the flow POSTs the data to your app’s webhook.

The “Send HTTP Request” block uses dynamic variables from the previous steps to build a structured payload for the app.

{
  "source": "mediaflows",
  "event": "video_preview_created",
  "asset": {
    "publicId": "{{$.Cloudinary_Upload.result.public_id}}",
    "title": "{{$.Cloudinary_Upload.result.public_id}}",
    "posterPublicId": "{{$.Cloudinary_Upload.result.public_id}}",
    "preview": {
      "startOffset": 1,
      "duration": 8,
      "aspectRatio": "16:9"
    }
  },
  "previewUrl": "{{$.Create_Asset_URL.result.url}}",
  "fullVideoUrl": "{{$.Cloudinary_Upload.result.secure_url}}"
}
Code language: JSON / JSON with Comments (json)

On the TanStack Start side, the webhook route receives this payload and maps it into your internal store. This closes the loop between cloud processing and the product UI.

Once this loop is active, the workflow is seamless. A user uploads a video, Cloudinary stores it, MediaFlows generates the optimized preview, and the app instantly updates the hero. You get a high-motion background that loads nearly as fast as a static image.

Instead of forcing the browser to struggle loading a 50MB background video, your system will generate a lightweight, “smart” preview clip. Cloudinary’s transformation engine handles the trimming, resizing, and optimization in a single request, ensuring the hero stays fast without losing its cinematic feel.

This is the exact string used in the Create Asset URL block inside MediaFlows. It defines the preview’s DNA:

so_1,du_8,c_fill,ar_16:9,w_1600,q_auto,f_auto,vc_auto

  • so_1 / du_8 starts at the 1-second mark and captures an 8-second highlight. This avoids opening on a blank frame or a slow fade-in.
  • c_fill / ar_16:9 / w_1600 forces the video into a 16:9 aspect ratio and crops it to fill the hero container at a crisp 1600px width.
  • q_auto / f_auto / vc_auto are the performance trifecta. Cloudinary automatically picks the best quality, format (like WebM or MP4), and codec for the user’s specific browser.

The app doesn’t guess how to find the preview. MediaFlows explicitly defines it in the “Send HTTP Request” block and hands it over via the webhook.

{
  "previewUrl": "{{$.Create_Asset_URL.result.url}}",
  "fullVideoUrl": "{{$.Cloudinary_Upload.result.secure_url}}"
}
Code language: JSON / JSON with Comments (json)

This split is crucial: previewUrl is the fast track for the background, while fullVideoUrl is kept in reserve for the high-quality source.

To make the data usable, you’ll map the incoming webhook payload into a consistent frontend-friendly object. This logic ensures the Hero component doesn’t need to know if the data came from a real-time upload or a fallback.

View the file here.

export function mapMediaFlowsAssetToHeroVideoAsset(
  asset: MediaFlowsPreviewAsset
): HeroVideoAsset {
  return {
    publicId: asset.publicId,
    title: asset.title,
    posterPublicId: asset.posterPublicId,
    startOffset: asset.preview.startOffset,
    duration: asset.preview.duration,
    aspectRatio: asset.preview.aspectRatio || "16:9",
    previewUrl: asset.previewUrl,
    fullVideoUrl: asset.secureUrl,
    posterImageUrl: asset.previewUrl,
  };
}
Code language: JavaScript (javascript)

The rendering layer follows a two-path strategy for resilience. If the webhook provides a pregenerated URL, you’ll use it directly. If not, fall back to generating responsive sources on the fly.

View the file here.

export function buildHeroVideoUrls(asset: HeroVideoAsset): HeroVideoUrls {
  // Path 1: Use the PowerFlow-generated preview directly
  if (asset.previewUrl && asset.fullVideoUrl) {
    return {
      previewSources: [{ src: asset.previewUrl, width: 1600 }],
      fullVideoUrl: asset.fullVideoUrl,
      posterImageUrl: asset.posterImageUrl || asset.previewUrl,
    };
  }

  // Path 2: Local fallback generation for demo assets
  const startOffset = asset.startOffset ?? 0;
  const duration = asset.duration ?? 5;
  const aspectRatio = asset.aspectRatio ?? "16:9";

  const previewSources: ResponsiveVideoSource[] = [
    buildPreviewSource(
      asset.publicId,
      640,
      startOffset,
      duration,
      aspectRatio,
      "(max-width: 640px)"
    ),
    buildPreviewSource(
      asset.publicId,
      960,
      startOffset,
      duration,
      aspectRatio,
      "(max-width: 1024px)"
    ),
    buildPreviewSource(
      asset.publicId,
      1600,
      startOffset,
      duration,
      aspectRatio
    ),
  ];

  return {
    previewSources,
    fullVideoUrl: buildVideoUrl(asset.publicId, fullTransform),
    posterImageUrl: buildVideoPosterUrl(posterPublicId, posterTransform),
  };
}
Code language: JavaScript (javascript)

This works because most implementations either hardcode URLs or rebuild them entirely in the frontend. This hybrid approach is better:

  1. Real uploads use the PowerFlow-generated URL exactly as it was processed.
  2. The app still functions with fallback demo assets without waiting for a webhook.
  3. The complexity of video manipulation stays in MediaFlows, where it belongs.

Once MediaFlows generates the preview URL, the app needs a way to ingest it. This is the webhook’s job: the Send HTTP Request block in PowerFlows posts the result back to our application, turning a cloud-processed asset into live UI state.

To keep the integration predictable, use a structured JSON payload in the PowerFlow. This ensures the app receives the PublicID, the optimized preview URL, and the full original source in one pass.

{
  "source": "mediaflows",
  "event": "video_preview_created",
  "asset": {
    "publicId": "{{$.Cloudinary_Upload.result.public_id}}",
    "title": "{{$.Cloudinary_Upload.result.public_id}}",
    "posterPublicId": "{{$.Cloudinary_Upload.result.public_id}}",
    "preview": {
      "startOffset": 1,
      "duration": 8,
      "aspectRatio": "16:9"
    }
  },
  "previewUrl": "{{$.Create_Asset_URL.result.url}}",
  "fullVideoUrl": "{{$.Cloudinary_Upload.result.secure_url}}"
}
Code language: JSON / JSON with Comments (json)

The app receives this data at src/routes/api/mediaflows/webhook.ts. This route is intentionally lightweight; it doesn’t process media or rebuild URLs. It simply validates the incoming JSON and maps it to our internal store.

View the file here.

export const Route = createFileRoute("/api/mediaflows/webhook")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const body = (await request.json()) as WebhookBody

        const asset: MediaFlowsPreviewAsset = {
          publicId: body.asset.publicId,
          title: body.asset.title,
          posterPublicId: body.asset.posterPublicId,
          resourceType: "video",
          secureUrl: body.fullVideoUrl,
          previewUrl: body.previewUrl,
          preview: {
            startOffset: body.asset.preview.startOffset,
            duration: body.asset.preview.duration,
            aspectRatio: body.asset.preview.aspectRatio || "16:9",
          },
        }

        setLatestMediaFlowsAsset(asset)

        return Response.json({ ok: true, saved: true })
      },
    },
  },
})
Code language: JavaScript (javascript)

Once the webhook is called, the asset is saved in a simple store at src/lib/mediaflows/store.ts (GitHub).

For this demo, you’ll use an in-memory variable. While a production app would use Redis or Postgres, this is enough to prove the loop: The webhook writes, and the homepage loader reads.

The final bridge to the UI happens in src/lib/video/preview-payload.ts. This logic ensures the page stays stable by defaulting to a fallback asset if no recent upload is found.

View the file here.

export function getHeroPreviewPayload(): HeroPreviewPayload {
  const latestAsset = getLatestMediaFlowsAsset();
  const mediaFlowsAsset = latestAsset || getMockMediaFlowsAsset();
  const asset = mapMediaFlowsAssetToHeroVideoAsset(mediaFlowsAsset);

  return {
    asset,
    eyebrow: "Smart video previews",
    heading: "High-performance video hero previews",
    subheading:
      "Use a short Cloudinary clip for the first impression, then load the full video later.",
  };
}
Code language: JavaScript (javascript)

This webhook loop is what makes the UI dynamic. Without it, your upload and app are disconnected silos. With it, the flow is complete: The user uploads, MediaFlows automates the heavy lifting, and the app instantly reflects the optimized result.

At this stage, the app has exactly what it needs: a preview URL, a full video URL, and the layout metadata. The final step is turning that data into a responsive, lightweight hero background that can update the moment a new upload is processed.

The hero uses a dual-path strategy to ensure the homepage remains stable.

  • Webhook path. If a fresh upload exists, the hero uses the previewUrl provided by MediaFlows.
  • Fallback path. If no recent upload is found, the hero defaults to a mock asset and generates the delivery URLs locally.

To keep the component logic “dumb” and focused only on display, map the raw data into a normalized shape. This ensures the Hero component never has to parse a PowerFlows payload directly.

View the file here.

export function mapMediaFlowsAssetToHeroVideoAsset(
  asset: MediaFlowsPreviewAsset
): HeroVideoAsset {
  return {
    publicId: asset.publicId,
    title: asset.title,
    posterPublicId: asset.posterPublicId,
    startOffset: asset.preview.startOffset,
    duration: asset.preview.duration,
    aspectRatio: asset.preview.aspectRatio || "16:9",
    previewUrl: asset.previewUrl,
    fullVideoUrl: asset.secureUrl,
    posterImageUrl: asset.previewUrl,
  };
}
Code language: JavaScript (javascript)

The URL builder handles the heavy lifting for different screen sizes. If MediaFlows has already done the work, you’ll use that URL. Otherwise, generate a responsive array of sources to ensure mobile users aren’t downloading desktop-sized clips.

View the file here.

export function buildHeroVideoUrls(asset: HeroVideoAsset): HeroVideoUrls {
  if (asset.previewUrl && asset.fullVideoUrl) {
    return {
      previewSources: [{ src: asset.previewUrl, width: 1600 }],
      fullVideoUrl: asset.fullVideoUrl,
      posterImageUrl: asset.posterImageUrl || asset.previewUrl,
    };
  }

  const startOffset = asset.startOffset ?? 0;
  const duration = asset.duration ?? 5;
  const aspectRatio = asset.aspectRatio ?? "16:9";

  const previewSources: ResponsiveVideoSource[] = [
    buildPreviewSource(
      asset.publicId,
      640,
      startOffset,
      duration,
      aspectRatio,
      "(max-width: 640px)"
    ),
    buildPreviewSource(
      asset.publicId,
      960,
      startOffset,
      duration,
      aspectRatio,
      "(max-width: 1024px)"
    ),
    buildPreviewSource(
      asset.publicId,
      1600,
      startOffset,
      duration,
      aspectRatio
    ),
  ];

  return {
    previewSources,
    fullVideoUrl: buildVideoUrl(asset.publicId, fullTransform),
    posterImageUrl: buildVideoPosterUrl(posterPublicId, posterTransform),
  };
}
Code language: JavaScript (javascript)

For the actual video tag, use standard HTML5 attributes to ensure a smooth, mobile-friendly background. muted is non-negotiable for autoplay, while playsInline prevents mobile browsers from forcing the video into fullscreen.

View the file here.

{
  asset.previewUrl ? (
    <video
      className="absolute inset-0 h-full w-full object-cover"
      autoPlay
      muted
      loop
      playsInline
      preload="metadata"
      poster={urls.posterImageUrl}
      aria-label={asset.title}
    >
      {urls.previewSources.map((source) => (
        <source
          key={`${source.width}-${source.media || "default"}`}
          src={source.src}
          media={source.media}
          type="video/mp4"
        />
      ))}
    </video>
  ) : (
    <video
      id={playerId}
      className="cld-video-player absolute inset-0 h-full w-full object-cover"
      muted
      playsInline
      preload="metadata"
      poster={urls.posterImageUrl}
    />
  );
}
Code language: HTML, XML (xml)

The “wow” factor of this demo is the real-time update. Instead of a hard refresh, the Upload Widget polls the app until it sees the new asset in the store. It then dispatches a custom event that the homepage route listens for to trigger an invalidation.

View the file here.

if (result?.event === "success") {
  const uploadedPublicId = result?.info?.public_id;
  if (!uploadedPublicId) return;

  const found = await waitForLatestAsset(uploadedPublicId);
  if (found) {
    notifyLatestAssetReady(uploadedPublicId);
  }
}
Code language: JavaScript (javascript)

View the file here.

useEffect(() => {
  const handler = () => {
    router.invalidate();
  };
  window.addEventListener("mediaflows:asset-ready", handler);
  return () => {
    window.removeEventListener("mediaflows:asset-ready", handler);
  };
}, [router]);
Code language: JavaScript (javascript)

The UI stays performant because it isn’t burdened by media logic. Cloudinary handles the delivery, PowerFlows manages the preview generation, and TanStack Start coordinates the state. The result is a hero background that feels instantaneous and cinematic.

Most upload demos end at the storage bucket. You upload a file, the UI shows it’s a “Success”, and then… nothing. You’re left manually refreshing to see the results. This breaks the user experience.

This demo treats the upload as the trigger for a larger workflow. The goal is to bridge the gap between the file reaching Cloudinary and the processed preview appearing in the hero section.

There’s a distinct window of time between the widget finishing the upload and the PowerFlow completing its processing. If you reload too early, you still see the old asset. Solve this by waiting for proof of the update rather than using a blind timeout.

The logic begins in the Upload Widget callback. Instead of finishing when the file is sent, the component enters a “waiting” state while it polls the application for the processed result.

View the file here.

if (result?.event === "success") {
  const uploadedPublicId = result?.info?.public_id;
  if (!uploadedPublicId) return;

  if (mounted) setStatus("waiting");

  // The wait: Poll the app until the backend store matches our new upload
  const found = await waitForLatestAsset(uploadedPublicId);

  if (found) {
    notifyLatestAssetReady(uploadedPublicId);
  }

  if (mounted) setStatus("ready");
}
Code language: JavaScript (javascript)

Use a small helper to check our internal API. This ensures the UI only updates once the webhook has successfully saved the MediaFlows payload to the store.

async function waitForLatestAsset(publicId: string, tries = 15, delay = 2000) {
  for (let i = 0; i < tries; i++) {
    try {
      const res = await fetch("/api/debug/latest-asset", { cache: "no-store" });
      const data = await res.json();

      if (data?.asset?.publicId === publicId) {
        return true;
      }
    } catch (error) {
      console.error("[upload-widget] polling error:", error);
    }
    await new Promise((resolve) => setTimeout(resolve, delay));
  }
  return false;
}
Code language: JavaScript (javascript)

Once the asset is confirmed, dispatch a custom browser event: mediaflows:asset-ready. The homepage listens for this event and tells the TanStack Start router to invalidate the current route.

View the file here.

useEffect(() => {
  const handler = () => {
    router.invalidate(); // Triggers a re-fetch of the route data
  };

  window.addEventListener("mediaflows:asset-ready", handler);

  return () => {
    window.removeEventListener("mediaflows:asset-ready", handler);
  };
}, [router]);
Code language: JavaScript (javascript)

When the route invalidates, the loader fetches the newest asset from the store, and the Hero component rerenders with the fresh preview URL. No full browser refresh is required.

This is a functional demo, but moving toward production would mean hardening a few areas:

  • Swapping the in-memory store for Redis, Postgres, or Supabase for more durable storage.
  • Saving cleaner meta titles and richer meta descriptions beyond the public_id.
  • Adding granular upload progress bars and persistent state across deploys for cleaner UX design.

The real value here isn’t the hero section itself. It’s the fact that a raw file becomes a product-ready asset automatically. PowerFlows turns Cloudinary from a storage bucket into a live media workflow engine.

Ready to start building? Sign up for a Cloudinary account today.

Resources:

Start Using Cloudinary

Sign up for our free plan and start creating stunning visual experiences in minutes.

Sign Up for Free