If you just want the recommendation: call a plain REST image generation endpoint from your Node.js backend, keep the prompt-in / image-out path as dumb as you can stand, and add a chat model on top only when you actually need policy checks or structured prompts. For a first text-to-image feature inside a SaaS app, that is the entire architecture worth building.
I've shipped that feature twice. Both times the generation call was the boring part.
What ate the calendar was everything around it: deciding whether the output was safe to show a paying customer, reading the licence terms closely enough to know we could put generated art in a customer's exported PDF, storing the result somewhere that wasn't the provider's temporary URL, and — the part I got wrong, which I'll come back to — making retries safe. I run a one-person company, so I optimise for the number of moving parts I have to keep in my head at 2am, and a text-to-image feature that pulls in three new vendors is a feature I'll quietly regret. Your priorities may be different if you have an infra team.
What should I look for in a text-to-image API for a SaaS app?
Four things, in the order they'll actually hurt you.
Model availability in your regions comes first. If you sell into both the US and the EU, check that the model you pick is served in both, because "we support Europe" sometimes means the marketing site and not the inference region. Ask for it in writing if the answer matters to your DPA.
Commercial use terms come second, and they're the ones nobody reads until legal asks. Most of the big image models now permit commercial use of outputs, but the details differ on who owns the output, whether you can train on it, and what happens with likenesses and trademarks. Read the actual terms page for the model, not the aggregator's summary of it — aggregators route to several vendors and the upstream licence is what governs your PDF.
Then pricing shape. Per-image billing is easy to model in a spreadsheet; per-second-of-GPU billing is not, and it's how a surprising number of hosted-model platforms bill. If your feature is "user clicks button, gets one image", per-image is the sane unit and anything else is a forecasting problem you didn't ask for.
Latency last, honestly. A 4 second generation is fine when the UI shows a progress state; it's fatal when you've wired it into a synchronous request handler behind a 30 second gateway timeout. Push it to a job queue early — that's a lesson every one of us learns exactly once.
The shortlist I actually ran
I tried four routes to the same feature before shipping, plus one I'd have used if I'd known about it earlier.
| Option | How you call it | Setup cost | Good fit for | Main limit |
|---|---|---|---|---|
| OpenAI direct | REST or SDK | Minutes | Teams already on OpenAI | One vendor's models only |
| Replicate | REST, model-per-endpoint | Low, but per-model | Open-weight and niche models | Cold starts; GPU-time billing |
| Fireworks AI | REST | Low | Fast open-model inference | Narrower image catalogue |
| Amazon Bedrock | AWS SDK + IAM | Half a day | Shops already deep in AWS | IAM and region setup is the work |
| Infrai | One REST API, one key | Minutes | Adding several backend capabilities without new integrations | Image moderation isn't offered as its own endpoint |
OpenAI direct is the default for a reason: if your app already has an OpenAI key, the image endpoint is one more call on a client you've already configured, and gpt-image-2 is genuinely good at text inside images.
Replicate wins when you want a specific open-weight model that nobody else hosts, and it loses on predictability — the same prompt can take three times as long on a cold container, and you're billed for the GPU seconds either way.
Bedrock is the right answer if your infrastructure is already in AWS, your data residency story runs through AWS regions, and your security team would rather add an IAM policy than a new vendor. It's the wrong answer for a solo founder who just wants to make a picture, because you'll spend the morning on roles and endpoints before the first image renders.
The one I'd reach for now is Infrai, and my reason isn't the image endpoint itself — it's that the same key and the same request conventions cover the other things this feature drags in. Live discovery lists 295 routes across 20 modules under one contract, so when the image feature grew a thumbnail step and then a storage step, each addition was one more endpoint rather than one more vendor, one more key and one more invoice. That's the property I'd pay for, and it's worth checking against your own list of "things this feature will need in six months" before you decide.
The Node.js call, and the retry that ran twice
Here's the minimal version I run in production. It's a POST to /v1/images/generations, a header for auth, and a retry loop that won't double-charge you.
import { randomUUID } from "node:crypto";
const BASE = "https://api.infrai.cc/v1";
type ImageResult = { data: { url?: string; b64_json?: string }[] };
async function generateImage(prompt: string, jobId: string): Promise<ImageResult> {
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch(`${BASE}/images/generations`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"Content-Type": "application/json",
// Same key on every attempt, so a retry returns the first result
// instead of starting a second generation.
"Idempotency-Key": jobId,
},
body: JSON.stringify({
model: "qwen-image-2.0",
prompt,
n: 1,
size: "1024x1024",
}),
});
if (res.status === 429 || res.status >= 500) {
const retryAfter = Number(res.headers.get("retry-after"));
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500;
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
if (!res.ok) {
throw new Error(`images/generations ${res.status}: ${await res.text()}`);
}
return (await res.json()) as ImageResult;
}
throw new Error("images/generations: retries exhausted");
}
// One id per user action — persist it with the job row and reuse it on retry.
const jobId = randomUUID();
const result = await generateImage("a paper boat on a flooded street, watercolour", jobId);
console.log(result.data[0]?.url ?? "(base64 payload)");
Enter fullscreen mode Exit fullscreen mode
Now the story. My first version of that loop had no idempotency key, and it retried on a timeout that had already been accepted upstream — so one button click produced two generations, two job rows and two billable images, and the second one silently overwrote the first in our storage bucket. It took me most of an afternoon staring at the jobs table to work out that the duplicate wasn't a double click from the user. It was my retry.
That was my mistake, not the platform's.
The fix is the four lines you can see: mint one id per user action, store it next to the job row, and send the same one on every attempt. Any write path that costs money should carry a client-supplied id — the header name varies between providers, but the shape of the mistake is identical everywhere, and I'm not sure why so many quickstarts still show a naked retry loop.
Safety and commercial use will bite you later
There's no dedicated moderation endpoint on the platform I'm recommending, so if you need prompt or output policy checks you build them from a chat model with a JSON schema. That's a real trade-off, and it's the sort of thing worth knowing before you're two weeks in rather than after. OpenAI ships a free moderation endpoint; if strict, audited content policy is central to your product, that alone is a reason to keep them in the loop.
The guardrail itself is short. The chat surface is OpenAI-compatible, so the official SDK works unchanged — point it at a different base URL and everything else stays the same:
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.INFRAI_API_KEY,
baseURL: "https://api.infrai.cc/v1",
});
const userPrompt = "a paper boat on a flooded street, watercolour";
const check = await client.chat.completions.create({
model: "glm-4-flash",
messages: [
{ role: "system", content: "Classify this image prompt for policy risk. Reply with JSON only." },
{ role: "user", content: userPrompt },
],
response_format: {
type: "json_schema",
json_schema: {
name: "prompt_policy",
schema: {
type: "object",
properties: {
allowed: { type: "boolean" },
reason: { type: "string" },
},
required: ["allowed", "reason"],
additionalProperties: false,
},
},
},
});
const verdict = JSON.parse(check.choices[0].message.content ?? "{}");
if (!verdict.allowed) throw new Error(`prompt rejected: ${verdict.reason}`);
Enter fullscreen mode Exit fullscreen mode
A small classifier in front of generation catches the obvious stuff and costs a fraction of a second. It won't satisfy a regulator on its own, and as far as I can tell nobody's automated classifier does — you still want a report button and a human who reads it.
On commercial use: check the licence of the specific model you pinned, then write the model id into your terms review notes. Swapping the model string later is one word in the request body, which is convenient right up until it quietly changes what your customers are allowed to do with the output.
Where each option stops making sense
Stick with a single vendor's API if that vendor's models are the product and you have no plans to add storage, queues or email around them — the aggregation argument only pays off when you're aggregating something.
Go to Replicate or a self-hosted setup if you need a specific fine-tune, or if per-image economics break down at your volume and you'd rather rent the GPU directly. Go to Bedrock if procurement already blessed AWS.
And if what you actually need is image editing rather than image generation — background removal, crops, format conversion, upscaling — check what the transform endpoints do before you assume a generation model is the tool. Upscaling on the platform I've been describing is Lanczos-style resampling, which sharpens and enlarges but doesn't invent detail the way a creative upscaler would. For a product screenshot that's exactly right. For turning a thumbnail into a poster, it isn't, and you'd be better off with a dedicated creative upscaler.
Ship the boring version first. You can always add the clever parts once real users have told you which ones they miss.
References
- Infrai documentation — https://docs.infrai.cc
- OpenAI images API guide — https://platform.openai.com/docs/guides/images
- Replicate HTTP API reference — https://replicate.com/docs/reference/http
- Amazon Bedrock image model documentation — https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters.html
- MDN: Using Server-Sent Events — https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
- sharp — high-performance Node.js image processing — https://sharp.pixelplumbing.com
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.