React Image Generation: Components, Hooks & Examples

React Image Generation: Components, Hooks & Examples

React apps need dynamic images more often than you'd think. OG previews for shared links. Personalized user avatars. Dashboard screenshots for export. Social cards for marketing pages.

Building this from scratch every time is tedious. What you really want is a set of reusable components and hooks that handle the generation, loading states, error handling, and display, so you can drop them into any project.

That's what we're building here. By the end of this tutorial, you'll have a complete toolkit: a custom hook for generation, components for preview and display, batch generation support, and a full working example that ties everything together. If you're new to image generation APIs, start with our guide on how to generate images with an API first.

Architecture keep the api key server sideArchitecture: Keep the API Key Server-Side

Before writing any React code, let's get the architecture right. Here's the flow:

React Component → Your API Route → Imejis API → Image Binary → Back to React

Your React components never talk to the Imejis API directly. They send requests to your own backend proxy, which holds the API key and forwards the request. This keeps your key out of the browser's network tab where anyone could grab it.

The proxy receives your template data, calls POST https://render.imejis.io/v1/{designId} with the dma-api-key header, and returns the image as a blob or base64 string back to your React app.

Simple. Secure. Let's build it.

The backend proxyThe Backend Proxy

You need a thin backend layer between React and the API. Here's a Next.js API route that does the job in about 15 lines. (For a deeper dive into Next.js patterns, check our Next.js dynamic image generation guide.)

// app/api/generate-image/route.ts
import { NextRequest, NextResponse } from "next/server"
 
export async function POST(req: NextRequest) {
  const { designId, modifications } = await req.json()
 
  const response = await fetch(`https://render.imejis.io/v1/${designId}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "dma-api-key": process.env.IMEJIS_API_KEY!,
    },
    body: JSON.stringify({ modifications }),
  })
 
  if (!response.ok) {
    return NextResponse.json(
      { error: "Image generation failed" },
      { status: response.status }
    )
  }
 
  const imageBlob = await response.blob()
  return new NextResponse(imageBlob, {
    headers: { "Content-Type": "image/png" },
  })
}

If you're using Express instead of Next.js, the equivalent is just as short:

// server.ts
app.post("/api/generate-image", async (req, res) => {
  const { designId, modifications } = req.body
 
  const response = await fetch(`https://render.imejis.io/v1/${designId}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "dma-api-key": process.env.IMEJIS_API_KEY!,
    },
    body: JSON.stringify({ modifications }),
  })
 
  if (!response.ok)
    return res.status(response.status).json({ error: "Generation failed" })
 
  const buffer = Buffer.from(await response.arrayBuffer())
  res.set("Content-Type", "image/png").send(buffer)
})

Either way, your React app hits /api/generate-image, never the external API.

Custom hook useimagegeneratorCustom Hook: useImageGenerator

Here's the core of the system. This hook handles the request lifecycle: loading, success, error, and retry.

// hooks/useImageGenerator.ts
import { useCallback, useState } from "react"
 
interface GenerateParams {
  designId: string
  modifications: Record<string, string>
}
 
interface UseImageGeneratorReturn {
  imageUrl: string | null
  isLoading: boolean
  error: string | null
  generate: (params: GenerateParams) => Promise<void>
  reset: () => void
}
 
export function useImageGenerator(): UseImageGeneratorReturn {
  const [imageUrl, setImageUrl] = useState<string | null>(null)
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)
 
  const generate = useCallback(
    async (params: GenerateParams) => {
      setIsLoading(true)
      setError(null)
 
      try {
        const res = await fetch("/api/generate-image", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(params),
        })
 
        if (!res.ok) throw new Error(`Generation failed: ${res.status}`)
 
        const blob = await res.blob()
        const url = URL.createObjectURL(blob)
 
        // Clean up previous blob URL
        if (imageUrl) URL.revokeObjectURL(imageUrl)
        setImageUrl(url)
      } catch (err) {
        setError(err instanceof Error ? err.message : "Unknown error")
      } finally {
        setIsLoading(false)
      }
    },
    [imageUrl]
  )
 
  const reset = useCallback(() => {
    if (imageUrl) URL.revokeObjectURL(imageUrl)
    setImageUrl(null)
    setError(null)
  }, [imageUrl])
 
  return { imageUrl, isLoading, error, generate, reset }
}

Notice the blob URL cleanup. Every URL.createObjectURL allocates memory, so we revoke the previous URL before creating a new one. Small detail, but it prevents memory leaks in long-running sessions.

Component imagegeneratorComponent: ImageGenerator

This component gives users a form to fill in template fields, hit generate, and see the result.

// components/ImageGenerator.tsx
import { useState } from "react"
 
import { useImageGenerator } from "../hooks/useImageGenerator"
 
interface ImageGeneratorProps {
  designId: string
  fields: { name: string; label: string; defaultValue?: string }[]
}
 
export function ImageGenerator({ designId, fields }: ImageGeneratorProps) {
  const { imageUrl, isLoading, error, generate } = useImageGenerator()
  const [values, setValues] = useState<Record<string, string>>(
    Object.fromEntries(fields.map((f) => [f.name, f.defaultValue ?? ""]))
  )
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    generate({ designId, modifications: values })
  }
 
  return (
    <div>
      <form onSubmit={handleSubmit}>
        {fields.map((field) => (
          <label key={field.name}>
            {field.label}
            <input
              type="text"
              value={values[field.name]}
              onChange={(e) =>
                setValues((prev) => ({ ...prev, [field.name]: e.target.value }))
              }
            />
          </label>
        ))}
        <button type="submit" disabled={isLoading}>
          {isLoading ? "Generating..." : "Generate Image"}
        </button>
      </form>
 
      {error && <p className="text-red-500">{error}</p>}
      {imageUrl && <img src={imageUrl} alt="Generated image" />}
    </div>
  )
}

Drop it into any page like this:

<ImageGenerator
  designId="your-design-id"
  fields={[
    { name: "title", label: "Title", defaultValue: "Hello World" },
    { name: "subtitle", label: "Subtitle" },
    { name: "author", label: "Author Name" },
  ]}
/>

The component doesn't care what the template looks like. It just maps field names to inputs. Swap the designId and fields props, and it works with any template.

Component imagepreviewComponent: ImagePreview

Nobody likes staring at a blank space while an image generates. This component shows a skeleton placeholder, then crossfades to the real image once it's ready.

// components/ImagePreview.tsx
interface ImagePreviewProps {
  imageUrl: string | null
  isLoading: boolean
  width?: number
  height?: number
  alt?: string
}
 
export function ImagePreview({
  imageUrl,
  isLoading,
  width = 1200,
  height = 630,
  alt = "Generated image",
}: ImagePreviewProps) {
  const aspectRatio = `${width} / ${height}`
 
  if (isLoading) {
    return (
      <div
        className="animate-pulse bg-gray-200 rounded-lg"
        style={{ aspectRatio, maxWidth: width }}
      />
    )
  }
 
  if (!imageUrl) {
    return (
      <div
        className="border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center text-gray-400"
        style={{ aspectRatio, maxWidth: width }}
      >
        Your image will appear here
      </div>
    )
  }
 
  return (
    <img
      src={imageUrl}
      alt={alt}
      className="rounded-lg shadow-md transition-opacity duration-300"
      style={{ maxWidth: width }}
    />
  )
}

Three states, three renders: empty placeholder, loading skeleton, final image. Pair it with the hook:

const { imageUrl, isLoading, generate } = useImageGenerator();
 
return <ImagePreview imageUrl={imageUrl} isLoading={isLoading} />;

Component imagegalleryComponent: ImageGallery

When you're generating multiple images (say, a batch of social cards) you need a gallery to display them. This component handles a grid layout with lazy loading.

// components/ImageGallery.tsx
interface GalleryImage {
  id: string
  url: string
  alt: string
}
 
interface ImageGalleryProps {
  images: GalleryImage[]
  columns?: 2 | 3 | 4
  onImageClick?: (image: GalleryImage) => void
}
 
export function ImageGallery({
  images,
  columns = 3,
  onImageClick,
}: ImageGalleryProps) {
  const gridClass = {
    2: "grid-cols-2",
    3: "grid-cols-3",
    4: "grid-cols-4",
  }[columns]
 
  return (
    <div className={`grid ${gridClass} gap-4`}>
      {images.map((image) => (
        <button
          key={image.id}
          onClick={() => onImageClick?.(image)}
          className="overflow-hidden rounded-lg hover:ring-2 hover:ring-blue-500 transition-all"
        >
          <img
            src={image.url}
            alt={image.alt}
            loading="lazy"
            className="w-full h-auto"
          />
        </button>
      ))}
    </div>
  )
}

The loading="lazy" attribute tells the browser to only load images as they scroll into view. If you're rendering 50 social cards, this makes a real difference.

Batch generation useimagebatch hookBatch Generation: useImageBatch Hook

Sometimes you need to generate many images at once: OG cards for every blog post, product images from a catalog, team member cards from a spreadsheet. This hook handles that.

// hooks/useImageBatch.ts
import { useCallback, useState } from "react"
 
interface BatchItem {
  id: string
  designId: string
  modifications: Record<string, string>
}
 
interface BatchResult {
  id: string
  url: string
  error?: string
}
 
export function useImageBatch() {
  const [results, setResults] = useState<BatchResult[]>([])
  const [isLoading, setIsLoading] = useState(false)
  const [progress, setProgress] = useState(0)
 
  const generateBatch = useCallback(
    async (items: BatchItem[], concurrency = 3) => {
      setIsLoading(true)
      setProgress(0)
      setResults([])
 
      const completed: BatchResult[] = []
 
      // Process in chunks to avoid overwhelming the API
      for (let i = 0; i < items.length; i += concurrency) {
        const chunk = items.slice(i, i + concurrency)
 
        const chunkResults = await Promise.allSettled(
          chunk.map(async (item) => {
            const res = await fetch("/api/generate-image", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({
                designId: item.designId,
                modifications: item.modifications,
              }),
            })
 
            if (!res.ok) throw new Error(`Failed: ${res.status}`)
            const blob = await res.blob()
            return { id: item.id, url: URL.createObjectURL(blob) }
          })
        )
 
        for (const result of chunkResults) {
          if (result.status === "fulfilled") {
            completed.push(result.value)
          } else {
            completed.push({
              id: "unknown",
              url: "",
              error: result.reason.message,
            })
          }
        }
 
        setResults([...completed])
        setProgress(Math.min(i + concurrency, items.length))
      }
 
      setIsLoading(false)
    },
    []
  )
 
  return { results, isLoading, progress, generateBatch }
}

The concurrency parameter controls how many requests run in parallel. Default is 3, enough to be fast without hammering the API. The hook reports progress so you can show a progress bar.

If you've got a CSV of data you want to turn into images, check out our guide on batch image generation from CSV for the data pipeline side of things.

Error handling and retry logicError Handling and Retry Logic

The basic hook works, but production apps need retry logic. Here's a utility you can wrap around the fetch call:

// utils/fetchWithRetry.ts
export async function fetchWithRetry(
  url: string,
  options: RequestInit,
  maxRetries = 3,
  baseDelay = 1000
): Promise<Response> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const res = await fetch(url, options)
 
      // Don't retry client errors (4xx) — only server errors (5xx)
      if (res.ok || (res.status >= 400 && res.status < 500)) return res
 
      if (attempt === maxRetries) return res
    } catch (err) {
      if (attempt === maxRetries) throw err
    }
 
    // Exponential backoff: 1s, 2s, 4s
    await new Promise((r) => setTimeout(r, baseDelay * Math.pow(2, attempt)))
  }
 
  throw new Error("Max retries exceeded")
}

Swap fetch for fetchWithRetry in the useImageGenerator hook, and your app automatically retries on network blips or server errors. The exponential backoff prevents flooding a recovering server.

One important detail: we don't retry 4xx errors. If the API says your request is bad (wrong design ID, invalid modifications), retrying won't help. Only 5xx errors and network failures get retried.

Performance tipsPerformance Tips

Image generation is fast (typically 1-2 seconds) but there are ways to make your React app feel even snappier.

Debounce inputs. If your form triggers generation on every keystroke, you'll flood the API. Use a debounce:

import { useDebouncedCallback } from "use-debounce"
 
const debouncedGenerate = useDebouncedCallback(
  (params) => generate(params),
  500
)

Cache results with React Query or SWR. Instead of raw fetch, use a data-fetching library that caches by key. Same inputs? Same image. No redundant API calls.

import useSWR from "swr"
 
const { data, isLoading } = useSWR(
  shouldGenerate ? ["generate-image", designId, modifications] : null,
  () =>
    fetch("/api/generate-image", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ designId, modifications }),
    }).then((r) => r.blob().then(URL.createObjectURL))
)

Prefetch likely images. If a user is filling out a form and you know the next likely generation, kick it off early. This is especially useful for templates with limited options, like selecting a color theme from a dropdown.

Clean up blob URLs. We covered this in the hook, but it bears repeating. Every URL.createObjectURL holds a reference to the blob data in memory. If you're generating lots of images in a session, revoke old URLs when they're no longer displayed.

Full example social card generatorFull Example: Social Card Generator

Let's bring everything together. Here's a complete social card generator that uses all the components and hooks from above.

// app/social-cards/page.tsx
"use client"
 
import { useState } from "react"
 
import { ImageGallery } from "../../components/ImageGallery"
import { ImagePreview } from "../../components/ImagePreview"
import { useImageGenerator } from "../../hooks/useImageGenerator"
 
const DESIGN_ID = "your-social-card-design-id"
 
interface CardData {
  title: string
  author: string
  tag: string
}
 
export default function SocialCardGenerator() {
  const { imageUrl, isLoading, error, generate } = useImageGenerator()
  const [saved, setSaved] = useState<
    { id: string; url: string; alt: string }[]
  >([])
  const [form, setForm] = useState<CardData>({
    title: "",
    author: "",
    tag: "",
  })
 
  const handleGenerate = async () => {
    await generate({
      designId: DESIGN_ID,
      modifications: form,
    })
  }
 
  const handleSave = () => {
    if (imageUrl) {
      setSaved((prev) => [
        ...prev,
        { id: crypto.randomUUID(), url: imageUrl, alt: form.title },
      ])
    }
  }
 
  const handleDownload = (url: string, filename: string) => {
    const a = document.createElement("a")
    a.href = url
    a.download = `${filename}.png`
    a.click()
  }
 
  return (
    <div className="max-w-4xl mx-auto p-8 space-y-8">
      <h1 className="text-3xl font-bold">Social Card Generator</h1>
 
      <div className="grid grid-cols-2 gap-8">
        <div className="space-y-4">
          <input
            placeholder="Card title"
            value={form.title}
            onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
            className="w-full border rounded p-2"
          />
          <input
            placeholder="Author name"
            value={form.author}
            onChange={(e) => setForm((f) => ({ ...f, author: e.target.value }))}
            className="w-full border rounded p-2"
          />
          <input
            placeholder="Tag"
            value={form.tag}
            onChange={(e) => setForm((f) => ({ ...f, tag: e.target.value }))}
            className="w-full border rounded p-2"
          />
 
          <div className="flex gap-2">
            <button
              onClick={handleGenerate}
              disabled={isLoading}
              className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
            >
              {isLoading ? "Generating..." : "Generate Card"}
            </button>
            {imageUrl && (
              <button onClick={handleSave} className="border px-4 py-2 rounded">
                Save to Gallery
              </button>
            )}
          </div>
 
          {error && <p className="text-red-500 text-sm">{error}</p>}
        </div>
 
        <ImagePreview imageUrl={imageUrl} isLoading={isLoading} />
      </div>
 
      {saved.length > 0 && (
        <div>
          <h2 className="text-xl font-semibold mb-4">Saved Cards</h2>
          <ImageGallery
            images={saved}
            columns={3}
            onImageClick={(img) => handleDownload(img.url, img.alt)}
          />
        </div>
      )}
    </div>
  )
}

This gives you a two-column layout: form on the left, preview on the right. Generate a card, save it to the gallery below, and click any saved card to download it. You could ship this as-is or extend it with template selection, color pickers, or image uploads.

For dynamically generating OG images on every page of your site, see our guide on generating OG images dynamically. And if you're working across multiple languages, we've also covered Python image API integration.

Whats nextWhat's Next

You've now got a full React toolkit for image generation:

  • A backend proxy that keeps your API key safe
  • useImageGenerator for single image generation with loading and error states
  • useImageBatch for generating multiple images with progress tracking
  • ImagePreview for placeholder-to-image transitions
  • ImageGallery for displaying collections with lazy loading
  • Retry logic that handles transient failures gracefully

All of it is typed with TypeScript, and all of it is composable. Mix and match the pieces for your specific use case.

To start building, grab an API key from imejis.io, set up a template in the dashboard, and drop these components into your app. You can go from zero to a working image generator in under an hour.

FaqFAQ

Can i use this with nextjsCan I use this with Next.js?

Yes. The hooks and components work in any React environment. For Next.js specifically, use the API route pattern to keep your API key server-side instead of exposing it in the browser.

Does the api work client sideDoes the API work client-side?

Technically yes, but don't expose your API key in browser code. Use a backend proxy (Next.js API route, Express endpoint) that forwards requests. The React components call your proxy, not the API directly.

How do i handle loading statesHow do I handle loading states?

The useImageGenerator hook returns an isLoading boolean. Use it to show a skeleton, spinner, or placeholder while the image generates. Typical generation takes 1-2 seconds.

Can i preview the template before generatingCan I preview the template before generating?

Yes. Build a preview component that renders a placeholder with your template's layout. Swap it with the real image once generation completes. The ImagePreview component in this guide shows this pattern.

What about caching generated imagesWhat about caching generated images?

Once generated, store the image URL or blob in React state or a cache (React Query, SWR). Don't regenerate the same image on every render. The API returns a permanent URL you can cache indefinitely.