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
useImageGeneratorfor single image generation with loading and error statesuseImageBatchfor generating multiple images with progress trackingImagePreviewfor placeholder-to-image transitionsImageGalleryfor 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.