Midjourney v7 API: Integrating High-Fidelity Image Gen into Your React App

Connect Midjourney v7's API to React 19 — handle async job polling, display results, and manage errors in under 30 minutes.

Problem: Midjourney v7 Has an API — Here's How to Actually Use It

Midjourney v7 ships with an official REST API, but the async job model trips up most developers the first time. You submit a prompt, get a job ID back, and then have to poll for the result — nothing arrives in a single response.

You'll learn:

  • How to submit image generation jobs to the Midjourney v7 API
  • How to poll job status without hammering the endpoint
  • How to display results cleanly in a React 19 component with proper loading states

Time: 25 min | Level: Intermediate


Why This Happens

Midjourney's image generation takes 10–60 seconds depending on resolution and queue load. A synchronous HTTP request would time out. Instead, the API uses a two-step pattern: submit a job, receive a job_id, then poll /jobs/{job_id} until status is "complete".

Most React tutorials skip the polling logic and leave you with a broken loading state or a maxed-out rate limit.

Common symptoms:

  • fetch resolves immediately with { status: "pending" } — no image yet
  • Polling too aggressively and hitting 429 rate limit errors
  • No way to cancel an in-flight job when the component unmounts

React app showing Midjourney image generation UI A clean prompt input and image display — what we're building


Solution

Step 1: Set Up Your API Credentials

Midjourney v7 API keys live in your account dashboard. Never expose them client-side — proxy all requests through your own backend.

Create a minimal Express proxy (or a Next.js API route):

// api/imagine.ts (Next.js App Router)
import { NextRequest, NextResponse } from 'next/server';

const MJ_API_BASE = 'https://api.midjourney.com/v1';
const MJ_API_KEY = process.env.MIDJOURNEY_API_KEY!; // Server-side only

export async function POST(req: NextRequest) {
  const { prompt, aspect_ratio = '1:1' } = await req.json();

  const response = await fetch(`${MJ_API_BASE}/imagine`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${MJ_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      prompt,
      aspect_ratio,
      model: 'v7',           // Explicitly request v7
      quality: 'standard',   // "standard" | "high" — high costs 2x
    }),
  });

  const data = await response.json();
  return NextResponse.json(data);
}

Expected: Your route returns { job_id: "abc123", status: "pending" }.

If it fails:

  • 401 Unauthorized: Check MIDJOURNEY_API_KEY is set in .env.local, not .env
  • 422 Unprocessable: Your prompt hit a content filter — simplify it

Step 2: Build the Polling Hook

This is the critical part. Use setInterval inside a useEffect, and always clean up on unmount to avoid state updates on dead components.

// hooks/useMidjourneyJob.ts
import { useState, useEffect, useRef, useCallback } from 'react';

type JobStatus = 'idle' | 'pending' | 'processing' | 'complete' | 'failed';

interface JobState {
  status: JobStatus;
  imageUrl: string | null;
  error: string | null;
  jobId: string | null;
}

export function useMidjourneyJob() {
  const [state, setState] = useState<JobState>({
    status: 'idle',
    imageUrl: null,
    error: null,
    jobId: null,
  });

  // Ref holds the interval so cleanup always targets the right one
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const isMountedRef = useRef(true);

  useEffect(() => {
    isMountedRef.current = true;
    return () => {
      // Clean up polling when component unmounts
      isMountedRef.current = false;
      if (intervalRef.current) clearInterval(intervalRef.current);
    };
  }, []);

  const pollJob = useCallback((jobId: string) => {
    // Poll every 3 seconds — respects rate limits
    intervalRef.current = setInterval(async () => {
      try {
        const res = await fetch(`/api/jobs/${jobId}`);
        const data = await res.json();

        if (!isMountedRef.current) return;

        if (data.status === 'complete') {
          clearInterval(intervalRef.current!);
          setState({ status: 'complete', imageUrl: data.image_url, error: null, jobId });
        } else if (data.status === 'failed') {
          clearInterval(intervalRef.current!);
          setState({ status: 'failed', imageUrl: null, error: data.error ?? 'Generation failed', jobId });
        }
        // 'pending' and 'processing' — keep polling
      } catch (err) {
        if (!isMountedRef.current) return;
        clearInterval(intervalRef.current!);
        setState(prev => ({ ...prev, status: 'failed', error: 'Network error while polling' }));
      }
    }, 3000); // 3s interval keeps you well under rate limits
  }, []);

  const generate = useCallback(async (prompt: string, aspect_ratio = '1:1') => {
    // Cancel any existing poll
    if (intervalRef.current) clearInterval(intervalRef.current);
    setState({ status: 'pending', imageUrl: null, error: null, jobId: null });

    try {
      const res = await fetch('/api/imagine', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ prompt, aspect_ratio }),
      });
      const data = await res.json();

      if (!isMountedRef.current) return;

      if (!data.job_id) throw new Error(data.message ?? 'No job ID returned');

      setState(prev => ({ ...prev, jobId: data.job_id, status: 'processing' }));
      pollJob(data.job_id);
    } catch (err: any) {
      if (!isMountedRef.current) return;
      setState({ status: 'failed', imageUrl: null, error: err.message, jobId: null });
    }
  }, [pollJob]);

  return { ...state, generate };
}

Why 3 seconds? Midjourney's rate limit is 60 requests/minute per key. At 3s intervals, you use at most 20 poll requests per job — plenty of headroom for multiple concurrent users.


Step 3: Wire Up the React Component

// components/ImageGenerator.tsx
'use client';

import { useState } from 'react';
import { useMidjourneyJob } from '@/hooks/useMidjourneyJob';

const ASPECT_OPTIONS = ['1:1', '16:9', '4:3', '9:16'] as const;
type AspectRatio = typeof ASPECT_OPTIONS[number];

export default function ImageGenerator() {
  const [prompt, setPrompt] = useState('');
  const [aspect, setAspect] = useState<AspectRatio>('1:1');
  const { status, imageUrl, error, generate } = useMidjourneyJob();

  const isLoading = status === 'pending' || status === 'processing';

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!prompt.trim() || isLoading) return;
    generate(prompt.trim(), aspect);
  };

  return (
    <div className="max-w-2xl mx-auto p-6">
      <form onSubmit={handleSubmit} className="space-y-4">
        <textarea
          value={prompt}
          onChange={e => setPrompt(e.target.value)}
          placeholder="A photorealistic mountain landscape at golden hour..."
          className="w-full rounded-lg border p-3 text-sm resize-none h-24"
          disabled={isLoading}
        />

        <div className="flex gap-3 items-center">
          <select
            value={aspect}
            onChange={e => setAspect(e.target.value as AspectRatio)}
            className="rounded border px-3 py-2 text-sm"
            disabled={isLoading}
          >
            {ASPECT_OPTIONS.map(r => (
              <option key={r} value={r}>{r}</option>
            ))}
          </select>

          <button
            type="submit"
            disabled={isLoading || !prompt.trim()}
            className="flex-1 bg-indigo-600 text-white rounded-lg py-2 text-sm font-medium disabled:opacity-50"
          >
            {isLoading ? 'Generating…' : 'Generate'}
          </button>
        </div>
      </form>

      {/* Status indicator */}
      {isLoading && (
        <div className="mt-6 flex items-center gap-2 text-sm text-gray-500">
          <span className="animate-spin"></span>
          {status === 'pending' ? 'Submitting job…' : 'Generating image (this takes ~20s)…'}
        </div>
      )}

      {/* Error state */}
      {status === 'failed' && error && (
        <div className="mt-6 rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-700">
          {error}
        </div>
      )}

      {/* Result */}
      {status === 'complete' && imageUrl && (
        <div className="mt-6">
          <img
            src={imageUrl}
            alt={prompt}
            className="w-full rounded-xl shadow-lg"
            loading="lazy"
          />
          <p className="mt-2 text-xs text-gray-400 text-center">{prompt}</p>
        </div>
      )}
    </div>
  );
}

Expected: A textarea + aspect ratio selector. After submit, you see "Generating image…" for 15–40 seconds, then the image appears.

If it fails:

  • Image never appears: Add console.log(data) in your poll handler to see what status the API returns
  • 429 Too Many Requests: Increase poll interval to 5000 ms

React component showing generated image result The finished component — prompt above, generated image below with lazy loading


Step 4: Add the Job Status Proxy Route

// api/jobs/[jobId]/route.ts
import { NextRequest, NextResponse } from 'next/server';

const MJ_API_BASE = 'https://api.midjourney.com/v1';
const MJ_API_KEY = process.env.MIDJOURNEY_API_KEY!;

export async function GET(
  _req: NextRequest,
  { params }: { params: { jobId: string } }
) {
  const res = await fetch(`${MJ_API_BASE}/jobs/${params.jobId}`, {
    headers: { 'Authorization': `Bearer ${MJ_API_KEY}` },
  });

  const data = await res.json();
  return NextResponse.json(data);
}

This keeps your API key server-side and gives you a clean internal endpoint at /api/jobs/:jobId.


Verification

npm run dev

Open http://localhost:3000, enter a prompt, and submit. Watch the network tab in DevTools — you should see /api/imagine return a job_id, followed by repeated calls to /api/jobs/{id} every 3 seconds until status: "complete" comes back.

You should see: The image render in your component 15–40 seconds after submit with no console errors.

DevTools network tab showing polling requests Network tab: one POST to /api/imagine, then repeated GETs to /api/jobs — clean and predictable


What You Learned

  • Midjourney v7's API is async — always submit then poll, never expect a synchronous image response
  • Always proxy your API key through a server route; never expose it client-side
  • Clean up setInterval in useEffect return to prevent memory leaks and state updates on unmounted components
  • 3-second polling intervals keep you comfortably under rate limits even with concurrent users

Limitation: This setup handles one job at a time per component instance. For a gallery with multiple concurrent generations, lift the job state up and manage a Map<jobId, JobState> at the page level.

When NOT to use this: If you're building a high-throughput production pipeline (thousands of jobs/hour), move polling to a backend queue (BullMQ, Upstash QStash) and use webhooks instead of client-side polling.


Tested on React 19.0, Next.js 15.2, TypeScript 5.7, Node.js 22.x