Generating Images with AI in React using the fal.ai API

Generating Images with AI in React using the fal.ai API

AI image generation has gone from a novelty to a practical tool that developers are embedding directly into their applications. Whether you're building a design tool, a creative platform, or just want to add a "generate avatar" feature to your app, the infrastructure is now accessible enough to wire up in a single afternoon.

In this guide we'll integrate fal.ai's image generation API into a React app. We'll cover the theory — what fal.ai is, how async job queuing works, and why your API key must never touch the frontend — then build a complete prompt-to-image generator with a persistent gallery. We'll show both the official fal-client SDK and the raw REST API so you understand what's happening under the hood.

What is fal.ai — and How Does It Differ from OpenAI Image Generation?

fal.ai is a serverless AI inference platform that specialises in running open-source image generation models at scale. Where OpenAI's image generation (DALL-E) is a single proprietary model behind a single API endpoint, fal.ai gives you access to dozens of models — Flux, Stable Diffusion, SDXL, and more — each with different characteristics, speeds, and pricing.

The practical differences matter for developers. OpenAI's DALL-E is easy to get started with and produces consistently polished results, but you have limited control over the model's internals and the pricing is fixed. fal.ai gives you model choice — Flux.1 (the current state of the art for photorealistic images), SDXL for artistic styles, and specialised models for specific use cases. You also get more control over parameters like image dimensions, number of inference steps, and guidance scale.

fal.ai is also built differently at the infrastructure level. It runs models on GPU clusters and queues jobs asynchronously — meaning when you submit a generation request, you get a job ID back immediately and then poll or subscribe for the result when it's ready. This is fundamentally different from a synchronous API call where you wait for the response. Understanding this async model is key to building a good user experience around it.

Think of fal.ai like a print shop. You hand over your order (the prompt), they give you a ticket number (job ID), and you come back when it's ready. You're not standing at the counter waiting — the job runs in the background and you check on it.

How Async Job Queuing Works — Why Image Generation Isn't Instant

Image generation is computationally expensive. A single high-quality image can take anywhere from 2 to 20 seconds to generate depending on the model, the resolution, and the number of inference steps. Unlike a database query or a text generation request, you can't just wait synchronously for the result — the HTTP connection would time out before the image is ready.

fal.ai solves this with a queue-based system. When you submit a generation request, fal.ai immediately returns a request ID and a response URL. Your image is now in a queue on fal.ai's GPU infrastructure. You then have two options for getting the result: polling (repeatedly checking a status endpoint until the job is complete) or subscribing (using a webhook or long-polling connection that fal.ai pushes the result to when it's ready).

The fal-client SDK abstracts most of this complexity. When you call fal.subscribe(), the SDK submits the job, sets up polling automatically, and resolves a Promise when the result is ready — so from your code's perspective it looks like a normal async/await call. But under the hood, the SDK is polling fal.ai's queue endpoint every few seconds and waiting for a "completed" status before it resolves.

  • Submit request → fal.ai returns a request_id immediately
  • fal.ai processes the image on its GPU infrastructure (2–20 seconds)
  • Your code polls /queue/status/{request_id} until status is 'completed'
  • When complete, you fetch the result from /queue/result/{request_id}
  • The result contains an array of image URLs hosted on fal.ai's CDN

Why Your fal.ai API Key Must Live on the Backend

The same rule from the ChatGPT guide applies here: any API key that grants access to a paid service must live on a server, never in your React frontend. fal.ai keys are billed per image generated. If your key leaks into a public GitHub repo or is visible in browser DevTools, someone can generate thousands of images at your expense before you notice.

fal.ai actually provides a built-in proxy solution for exactly this use case — fal.ai/docs/reference/server-side — where your Express server acts as a relay between your React frontend and fal.ai's API. Your frontend calls your own backend, your backend forwards the request to fal.ai with the API key in the server-side environment, and the response comes back through the same channel. The key never touches the browser.

We'll show the direct SDK call first (for local development and understanding the API), then build the proper backend proxy for production.

How to Handle Loading States for Slow AI Jobs

Standard loading spinners work fine for operations that take under a second. For image generation that takes 5–15 seconds, a spinner alone creates a poor user experience — the user has no idea if something is happening or if the app has frozen.

The best practice for slow async operations is a multi-stage loading state. Instead of a binary loading/not-loading boolean, you track the specific phase of the operation: "submitting", "queued", "generating", and "complete". Each phase shows a different message to the user so they know exactly where their job is in the pipeline. This dramatically reduces perceived wait time because the user has feedback at every stage.

A progress bar — even a fake one that advances at a fixed rate — also helps significantly. Since you can't know exactly when fal.ai will finish, a simulated progress bar that moves from 0% to 90% over an estimated duration, then jumps to 100% when the result arrives, gives the user a much more satisfying experience than watching a spinner for 10 seconds.

Getting Started — API Key and Project Setup

Sign up at fal.ai and navigate to your dashboard to generate an API key. fal.ai offers free credits on signup — enough to experiment with for this guide without spending anything.

In your React project, install the fal client SDK:

bash
npm install @fal-ai/client

Add your key to your local .env for development:

javascript
# .env (never commit this)
VITE_FAL_KEY=your-fal-api-key-here

Part 1: Quick Version — fal-client SDK Direct (Development Only)

This version uses the fal-client SDK directly from React. It's safe only for local development — the API key is exposed in the browser bundle in production. We're showing it first because it gives you the clearest picture of how the fal.ai API works before we add a backend proxy layer.

Create src/components/ImageGenerator.jsx:

jsx
// src/components/ImageGenerator.jsx  (DEV ONLY — insecure)
import { useState } from "react";
import { fal } from "@fal-ai/client";

// Configure the client with your key — dev only
fal.config({
  credentials: import.meta.env.VITE_FAL_KEY,
});

const STAGES = {
  IDLE: "idle",
  SUBMITTING: "submitting",
  GENERATING: "generating",
  DONE: "done",
  ERROR: "error",
};

const STAGE_MESSAGES = {
  submitting: "Submitting your request...",
  generating: "Generating your image — this takes around 10 seconds...",
  done: "Done!",
  error: "Something went wrong. Please try again.",
};

function ImageGenerator() {
  const [prompt, setPrompt] = useState("");
  const [stage, setStage] = useState(STAGES.IDLE);
  const [imageUrl, setImageUrl] = useState(null);
  const [progress, setProgress] = useState(0);

  const generateImage = async () => {
    if (!prompt.trim() || stage === STAGES.GENERATING) return;

    setStage(STAGES.SUBMITTING);
    setImageUrl(null);
    setProgress(0);

    // Simulate progress bar advancing while we wait
    const progressInterval = setInterval(() => {
      setProgress((prev) => (prev < 85 ? prev + 5 : prev));
    }, 800);

    try {
      setStage(STAGES.GENERATING);

      // fal.subscribe submits the job and polls until complete
      const result = await fal.subscribe("fal-ai/flux/schnell", {
        input: {
          prompt: prompt.trim(),
          image_size: "landscape_4_3",
          num_inference_steps: 4,   // schnell is fast — 4 steps is enough
          num_images: 1,
        },
        onQueueUpdate: (update) => {
          // Called as the job moves through the queue
          console.log("Queue status:", update.status);
        },
      });

      clearInterval(progressInterval);
      setProgress(100);

      // result.images is an array — we take the first one
      const url = result.data.images[0].url;
      setImageUrl(url);
      setStage(STAGES.DONE);
    } catch (error) {
      clearInterval(progressInterval);
      console.error("fal.ai error:", error);
      setStage(STAGES.ERROR);
    }
  };

  const isLoading = stage === STAGES.SUBMITTING || stage === STAGES.GENERATING;

  return (
    <div className="image-generator">
      <h2>AI Image Generator</h2>
      <p className="generator-subtitle">Powered by Flux Schnell via fal.ai</p>

      <div className="prompt-row">
        <textarea
          value={prompt}
          onChange={(e) => setPrompt(e.target.value)}
          placeholder="Describe the image you want to generate..."
          rows={3}
          disabled={isLoading}
        />
        <button onClick={generateImage} disabled={isLoading || !prompt.trim()}>
          {isLoading ? "Generating..." : "Generate"}
        </button>
      </div>

      {/* Multi-stage loading feedback */}
      {isLoading && (
        <div className="loading-state">
          <p className="loading-message">{STAGE_MESSAGES[stage]}</p>
          <div className="progress-bar">
            <div
              className="progress-fill"
              style={{ width: `${progress}%`, transition: "width 0.8s ease" }}
            />
          </div>
        </div>
      )}

      {stage === STAGES.ERROR && (
        <p className="error-message">{STAGE_MESSAGES.error}</p>
      )}

      {imageUrl && (
        <div className="generated-image-wrapper">
          <img src={imageUrl} alt={prompt} className="generated-image" />
          <a href={imageUrl} download="generated-image.jpg" className="download-btn">
            Download Image
          </a>
        </div>
      )}
    </div>
  );
}

export default ImageGenerator;
We're using fal-ai/flux/schnell — the fastest Flux model, designed for quick generations in 4 steps. For higher quality at the cost of speed, swap it for fal-ai/flux/dev (more detail) or fal-ai/flux-pro (best quality, higher cost).

Part 2: Using the Raw REST API — Understanding What the SDK Does

The fal-client SDK is convenient, but it's worth understanding what it's doing under the hood. Here's the same generation flow using raw fetch calls — this is exactly what fal.subscribe() does internally:

javascript
// What fal.subscribe() does under the hood — using raw fetch
const FAL_KEY = import.meta.env.VITE_FAL_KEY;
const MODEL_ID = "fal-ai/flux/schnell";

async function generateWithRawAPI(prompt) {
  // Step 1: Submit the job to the queue
  const submitRes = await fetch(
    `https://queue.fal.run/${MODEL_ID}`,
    {
      method: "POST",
      headers: {
        "Authorization": `Key ${FAL_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        prompt,
        image_size: "landscape_4_3",
        num_inference_steps: 4,
        num_images: 1,
      }),
    }
  );

  const { request_id } = await submitRes.json();
  console.log("Job submitted, request_id:", request_id);

  // Step 2: Poll for status until complete
  while (true) {
    const statusRes = await fetch(
      `https://queue.fal.run/${MODEL_ID}/requests/${request_id}/status`,
      { headers: { "Authorization": `Key ${FAL_KEY}` } }
    );
    const status = await statusRes.json();
    console.log("Current status:", status.status);

    if (status.status === "COMPLETED") break;
    if (status.status === "FAILED") throw new Error("Generation failed");

    // Wait 2 seconds before polling again
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }

  // Step 3: Fetch the result
  const resultRes = await fetch(
    `https://queue.fal.run/${MODEL_ID}/requests/${request_id}`,
    { headers: { "Authorization": `Key ${FAL_KEY}` } }
  );
  const result = await resultRes.json();

  return result.images[0].url;
}

Seeing this raw implementation makes it clear why the SDK exists — the polling loop alone would be annoying to write correctly every time. But knowing what's underneath means you can debug issues, customise the polling interval, or use fal.ai in environments where the SDK isn't available.

Part 3: Secure Version — Express Backend Proxy

For production, your Express server acts as the relay between React and fal.ai. The API key lives in your server's environment — never in the browser. Install the SDK in your server folder:

bash
cd server
npm install @fal-ai/client
javascript
# server/.env
FAL_KEY=your-fal-api-key-here

Create the route — server/routes/imageGen.js:

javascript
// server/routes/imageGen.js
const express = require("express");
const { fal } = require("@fal-ai/client");
const router = express.Router();

fal.config({
  credentials: process.env.FAL_KEY, // Safe — server-side only
});

// POST /api/generate
// Body: { prompt: string }
router.post("/", async (req, res) => {
  const { prompt } = req.body;

  if (!prompt || typeof prompt !== "string" || prompt.trim().length === 0) {
    return res.status(400).json({ error: "A prompt is required." });
  }

  // Basic prompt length guard
  if (prompt.length > 1000) {
    return res.status(400).json({ error: "Prompt too long. Maximum 1000 characters." });
  }

  try {
    const result = await fal.subscribe("fal-ai/flux/schnell", {
      input: {
        prompt: prompt.trim(),
        image_size: "landscape_4_3",
        num_inference_steps: 4,
        num_images: 1,
      },
    });

    const imageUrl = result.data.images[0].url;
    res.status(200).json({ imageUrl });
  } catch (error) {
    console.error("fal.ai error:", error.message);
    res.status(500).json({ error: "Image generation failed. Please try again." });
  }
});

module.exports = router;

Register it in server.js:

javascript
// server/server.js
app.use("/api/generate", require("./routes/imageGen"));

Now update the React component to call your backend instead of fal.ai directly:

jsx
// src/components/ImageGenerator.jsx  (Production — secure)
import { useState } from "react";

const API_URL = import.meta.env.VITE_API_URL;

const STAGES = {
  IDLE: "idle",
  GENERATING: "generating",
  DONE: "done",
  ERROR: "error",
};

function ImageGenerator({ onImageGenerated }) {
  const [prompt, setPrompt] = useState("");
  const [stage, setStage] = useState(STAGES.IDLE);
  const [imageUrl, setImageUrl] = useState(null);
  const [progress, setProgress] = useState(0);

  const generateImage = async () => {
    if (!prompt.trim() || stage === STAGES.GENERATING) return;

    setStage(STAGES.GENERATING);
    setImageUrl(null);
    setProgress(0);

    const progressInterval = setInterval(() => {
      setProgress((prev) => (prev < 85 ? prev + 4 : prev));
    }, 800);

    try {
      const res = await fetch(`${API_URL}/generate`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ prompt: prompt.trim() }),
      });

      if (!res.ok) {
        const err = await res.json();
        throw new Error(err.error || "Generation failed");
      }

      const data = await res.json();
      clearInterval(progressInterval);
      setProgress(100);
      setImageUrl(data.imageUrl);
      setStage(STAGES.DONE);

      // Notify parent so the gallery can update
      if (onImageGenerated) {
        onImageGenerated({ prompt: prompt.trim(), url: data.imageUrl });
      }
    } catch (error) {
      clearInterval(progressInterval);
      console.error(error);
      setStage(STAGES.ERROR);
    }
  };

  const isLoading = stage === STAGES.GENERATING;

  return (
    <div className="image-generator">
      <h2>AI Image Generator</h2>
      <p className="generator-subtitle">Powered by Flux Schnell via fal.ai</p>

      <div className="prompt-row">
        <textarea
          value={prompt}
          onChange={(e) => setPrompt(e.target.value)}
          placeholder="Describe the image you want to generate..."
          rows={3}
          disabled={isLoading}
        />
        <button onClick={generateImage} disabled={isLoading || !prompt.trim()}>
          {isLoading ? "Generating..." : "Generate"}
        </button>
      </div>

      {isLoading && (
        <div className="loading-state">
          <p className="loading-message">
            Generating your image — this usually takes around 10 seconds...
          </p>
          <div className="progress-bar">
            <div
              className="progress-fill"
              style={{ width: `${progress}%`, transition: "width 0.8s ease" }}
            />
          </div>
        </div>
      )}

      {stage === STAGES.ERROR && (
        <p className="error-message">Something went wrong. Please try again.</p>
      )}

      {imageUrl && (
        <div className="generated-image-wrapper">
          <img src={imageUrl} alt={prompt} className="generated-image" />
          <a href={imageUrl} download="generated.jpg" className="download-btn">
            Download
          </a>
        </div>
      )}
    </div>
  );
}

export default ImageGenerator;

Part 4: Adding a Persistent Gallery

A single generator is useful, but a gallery that saves every image you've generated makes the feature genuinely sticky. We'll store the generated images in localStorage so the gallery persists across page reloads — no database required.

Create src/components/ImageGallery.jsx:

jsx
// src/components/ImageGallery.jsx
function ImageGallery({ images }) {
  if (images.length === 0) {
    return (
      <div className="gallery-empty">
        <p>Your generated images will appear here.</p>
      </div>
    );
  }

  return (
    <div className="image-gallery">
      <h2>Your Generations</h2>
      <div className="gallery-grid">
        {/* Show newest first */}
        {[...images].reverse().map((item, i) => (
          <div key={i} className="gallery-item">
            <img src={item.url} alt={item.prompt} />
            <div className="gallery-item-overlay">
              <p className="gallery-prompt">{item.prompt}</p>
              <a href={item.url} download={`image-${i}.jpg`} className="gallery-download">
                Download
              </a>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

export default ImageGallery;

Now wire everything together in a parent page component — src/pages/GeneratorPage.jsx. This is where we manage the shared images state and persist it to localStorage:

jsx
// src/pages/GeneratorPage.jsx
import { useState, useEffect } from "react";
import ImageGenerator from "../components/ImageGenerator";
import ImageGallery from "../components/ImageGallery";

const STORAGE_KEY = "fal_generated_images";

function GeneratorPage() {
  // Initialise from localStorage on first render
  const [images, setImages] = useState(() => {
    try {
      const stored = localStorage.getItem(STORAGE_KEY);
      return stored ? JSON.parse(stored) : [];
    } catch {
      return [];
    }
  });

  // Persist to localStorage whenever images updates
  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(images));
  }, [images]);

  const handleImageGenerated = (newImage) => {
    setImages((prev) => [...prev, newImage]);
  };

  const clearGallery = () => {
    setImages([]);
    localStorage.removeItem(STORAGE_KEY);
  };

  return (
    <div className="generator-page">
      <ImageGenerator onImageGenerated={handleImageGenerated} />

      {images.length > 0 && (
        <button className="clear-btn" onClick={clearGallery}>
          Clear Gallery
        </button>
      )}

      <ImageGallery images={images} />
    </div>
  );
}

export default GeneratorPage;

The pattern here is clean and reusable. The GeneratorPage owns the state. ImageGenerator calls onImageGenerated when a new image arrives. ImageGallery is a pure display component that just renders whatever it's given. Each component has one clear responsibility.

Choosing the Right fal.ai Model

fal.ai hosts many models. For most React apps, you'll choose between three Flux variants depending on your speed vs quality requirements:

  • fal-ai/flux/schnell — Fastest (2-4 seconds, 4 inference steps). Best for interactive tools where speed matters more than absolute quality. This is what we've used throughout this guide.
  • fal-ai/flux/dev — Balanced (8-12 seconds, up to 28 steps). Higher quality than schnell, good for tools where the user is willing to wait a few extra seconds for a better result.
  • fal-ai/flux-pro — Highest quality (10-20 seconds). Best photorealism and prompt adherence. Use when image quality is the primary goal and cost is secondary.
  • fal-ai/stable-diffusion-v3-medium — Good for artistic and stylised images. Different aesthetic to Flux — more painterly, less photorealistic.

To swap models, just change the model ID string in your fal.subscribe() call or your backend route. The input schema is similar across Flux models — you may need to check the fal.ai model page for any model-specific parameters.

Production Tips

  • Rate limit your /api/generate endpoint — image generation is expensive. Use express-rate-limit to prevent abuse, e.g. 5 requests per minute per IP.
  • Add content moderation — fal.ai has built-in safety filters, but you should also validate prompts on your backend before forwarding them. Reject prompts containing obviously harmful keywords.
  • Cache results where possible — if the same prompt is submitted multiple times, return the cached image URL instead of generating a new one. Store prompt → URL mappings in MongoDB or Redis.
  • Set a spending limit in your fal.ai dashboard — just like OpenAI, fal.ai lets you set a monthly budget cap. Always do this before going live.
  • Handle the case where fal.ai CDN URLs expire — fal.ai image URLs are temporary. For permanent storage, download the image to your own S3 bucket or Cloudinary immediately after generation.

Final Thoughts

fal.ai makes state-of-the-art image generation accessible to any developer with a few lines of code. The async job queuing model feels unfamiliar at first, but once you understand that the SDK's fal.subscribe() is just a polite wrapper around submit → poll → fetch, the whole thing demystifies quickly.

The same patterns in this guide — backend proxy for API keys, multi-stage loading states, localStorage persistence, parent-owned state with child callbacks — apply to every AI integration you'll ever build. Master these and you'll be able to wire up any AI API into any React app confidently.

Want to see how to save generated images permanently to Cloudinary, or how to add style presets so users can pick an artistic style before generating? Drop a comment in LiveChat and I'll cover it in a follow-up guide.