Got it—“Timed out or missing SVG URL” means the job didn’t finish within our short poll window or the output shape wasn’t the simple “array of .svg URLs” we assumed.

I’m going to fix both:

Give the job more time (queues can be slow).

Parse outputs robustly (array, object, nested fields; pick the .svg if present).

Return clearer errors so we see what Replicate actually sent.

Drop-in replacement below.

✅ Replace your API route with this (more robust + longer wait)

src/app/api/vector/recraft/route.ts

export const runtime = "nodejs";

import { NextRequest, NextResponse } from "next/server";

const REPLICATE_TOKEN = process.env.REPLICATE_API_TOKEN!;
let VERSION = process.env.REPLICATE_RECRAFT_V3_VERSION || "";

async function getLatestVersion(): Promise<string> {
  const resp = await fetch("https://api.replicate.com/v1/models/recraft-ai/recraft-v3", {
    headers: { Authorization: `Token ${REPLICATE_TOKEN}` },
    cache: "no-store",
  });
  if (!resp.ok) {
    const t = await resp.text();
    throw new Error(`Failed to read model info (${resp.status}): ${t}`);
  }
  const json = await resp.json();
  const id = json?.latest_version?.id;
  if (!id) throw new Error("No latest_version.id on model payload");
  return id;
}

export async function POST(req: NextRequest) {
  try {
    if (!REPLICATE_TOKEN) {
      return NextResponse.json({ error: "Missing REPLICATE_API_TOKEN" }, { status: 500 });
    }

    const { prompt } = await req.json();
    if (!prompt || typeof prompt !== "string" || !prompt.trim()) {
      return NextResponse.json({ error: "Missing prompt" }, { status: 400 });
    }

    if (!VERSION || VERSION.length < 16) {
      VERSION = await getLatestVersion();
      console.log("[recraft] resolved latest version:", VERSION);
    }

    // 1) Kick off prediction
    const createRes = await fetch("https://api.replicate.com/v1/predictions", {
      method: "POST",
      headers: {
        Authorization: `Token ${REPLICATE_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        version: VERSION,
        input: {
          prompt,
          output_format: "svg", // ask explicitly for SVG
        },
      }),
    });

    const rawCreate = await createRes.text();
    if (!createRes.ok) {
      console.error("[recraft] create failed:", createRes.status, rawCreate);
      return NextResponse.json({ error: `Create failed: ${rawCreate}` }, { status: createRes.status || 502 });
    }

    const start = JSON.parse(rawCreate);
    const id: string | undefined = start?.id;
    if (!id) {
      console.error("[recraft] no prediction id. payload:", start);
      return NextResponse.json({ error: "No prediction id" }, { status: 502 });
    }

    // 2) Poll until done (extend to 3 minutes; some queues are slow)
    const timeoutAt = Date.now() + 180_000; // 3 minutes
    let svgUrl: string | null = null;
    let lastStatusPayload: any = null;

    while (Date.now() < timeoutAt) {
      await sleep(2000);

      const stRes = await fetch(`https://api.replicate.com/v1/predictions/${id}`, {
        headers: { Authorization: `Token ${REPLICATE_TOKEN}` },
        cache: "no-store",
      });

      const rawStatus = await stRes.text();
      if (!stRes.ok) {
        console.error("[recraft] status failed:", stRes.status, rawStatus);
        return NextResponse.json({ error: `Status failed: ${rawStatus}` }, { status: stRes.status || 502 });
      }

      const data = JSON.parse(rawStatus);
      lastStatusPayload = data;

      if (data.status === "succeeded") {
        svgUrl = pickSvgUrl(data.output);
        break;
      }
      if (data.status === "failed") {
        console.error("[recraft] generation failed:", rawStatus);
        return NextResponse.json({ error: "Generation failed" }, { status: 502 });
      }
      // else queued/starting/processing → keep polling
    }

    if (!svgUrl) {
      console.error("[recraft] no svgUrl resolved. last status payload:", JSON.stringify(lastStatusPayload));
      return NextResponse.json({ error: "Timed out or missing SVG URL" }, { status: 504 });
    }

    return NextResponse.json({ svgUrl, predictionId: id, version: VERSION });
  } catch (err: any) {
    console.error("[recraft] route error:", err);
    return NextResponse.json({ error: err?.message ?? "Server error" }, { status: 500 });
  }
}

function sleep(ms: number) {
  return new Promise((r) => setTimeout(r, ms));
}

/** Try multiple output shapes and pick the best SVG URL */
function pickSvgUrl(output: unknown): string | null {
  // Case 1: a plain string URL
  if (typeof output === "string") {
    if (isSvgUrl(output)) return output;
    return null;
  }

  // Case 2: array of URLs
  if (Array.isArray(output)) {
    // Prefer explicit .svg
    const svg = output.find((u: any) => typeof u === "string" && isSvgUrl(u));
    if (typeof svg === "string") return svg;

    // Some models return objects with url/uri fields
    for (const item of output) {
      if (item && typeof item === "object") {
        const maybe = (item as any).url || (item as any).uri || (item as any).href;
        if (typeof maybe === "string" && isSvgUrl(maybe)) return maybe;
      }
    }
    return null;
  }

  // Case 3: object with a field containing the URL
  if (output && typeof output === "object") {
    const obj = output as any;
    const candidates = [obj.svg, obj.svg_url, obj.url, obj.uri, obj.href];
    for (const c of candidates) {
      if (typeof c === "string" && isSvgUrl(c)) return c;
    }
  }

  return null;
}

function isSvgUrl(u: string): boolean {
  const lower = u.toLowerCase();
  return lower.endsWith(".svg") || lower.includes(".svg?");
}

Why this helps

3-minute timeout covers slow queues.

pickSvgUrl understands multiple output shapes and finds the .svg even if it’s nested.

On failures, we log the raw response so you see exactly what Replicate returned.

Optional (best practice): client-side polling

If your host has strict serverless time limits, split the flow:

Route A: create → returns predictionId

Route B: status/:id → check progress

The UI polls Route B every 2s.
If you want, I’ll hand you that two-route setup too.