Producing one visual for one page means leaving Strapi: write a brief, open another tool, export, come back, upload, hunt for the file. Image Gen closes that loop where your editors already are.
released September 10, 2026
npm install strapi-plugin-image-genGenerate and retouch Media Library images from inside Strapi, with Google's Nano Banana models.
A generated file is an ordinary asset — thumbnail, responsive formats, storage provider, folder. Nothing downstream has to know it was drawn by a model: every front end that already reads a media field keeps working unchanged.
Describe an image and get one. Or pick images you already have, describe the change, and get a new asset — the original is never overwritten.
Retouching is the part that earns its place on a library that is already full: recolour a background, extend a photo to 21:9 for a hero, drop a subject onto a plain ground for a card.
Settings → Image studio — the full screen: generate, retouch, and a grid of everything generated so far. A visual and its declinations share one card; clicking any image opens it in full with the description that made it, its model, size, ratio, cost and date, and links to the file and to its Media Library folder. The history is searchable by description, name, model or ratio, accents folded.
Generated images can also be deleted from the studio, with a confirmation: the asset goes for good, and content still pointing at it loses its image. The journal entry stays, flagged as deleted — the image is gone, but the money was spent, and dropping the row would quietly lower the running total.
Inside a content entry — every media field gains a Generate an image button (or Retouch with AI, when the field already holds one). It fills the field directly, which is fewer steps than the picker beside it: no generate, close, reopen, hunt for the file.
Why not a button in the Media Library itself? Because Strapi does not allow it.
@strapi/uploadregisters neitherinjectionZonesnorapis, soapp.getPlugin('upload').injectComponent(...)is a silent no-op, andMediaLibrary/AssetDialogexpose no slot. The one supported hook is the field registry — which replaces the component, so this plugin captures Strapi's own media field and renders it inside its own wrapper. If Strapi's internals ever move, the wrapper is not registered at all: a media field that lost its picker would be far worse than a missing button.
Tick the extra ratios and the same image comes back as a 16:9 hero, a 4:3 card and a 9:16 mobile.
Each one is a retouch of the first image, not a second run of the prompt: the API draws one ratio per call, and running the same description again at another ratio returns a different photograph — fine as a variation, useless as a declination. Here the model extends the scene it already drew, so the set belongs together.
Every ratio is a charged render, so the button says the total before you commit to it. The calls go one at a time from the browser: four 55-second renders in a single request would pass the proxy timeout, and this way a failure on the third ratio does not cost you the first two — you can re-run just the missing ones against the image you already have.
A run can be stopped, and stopping is honest: no further render starts. The one already in flight finishes and is saved, because it has been paid for whatever you do with it. The dialog cannot be closed while it is working, which was previously a way to keep being charged by a batch you thought you had cancelled.
The file name ends up in the asset's public URL, so naming a generated file after its prompt publishes a description of the people in it. Each image gets a short title written instead — the scene, the trade, the place, never someone's age, gender, body, ethnicity or clothing. Write your own in the dialog if you prefer. The prompt is kept in the journal, which is not public.
Naming never costs you an image: if the title call fails for any reason, the image still ships under a trimmed prompt.
Image generation is billed per image, and the spread between the cheapest and the most expensive combination here is a factor of seven. The button says what the render will cost. The studio says what has been spent.
| Model | Per image |
|---|---|
gemini-3-pro-image — Nano Banana Pro | $0.134 at 1K/2K, $0.24 at 4K |
gemini-3.1-flash-image — Nano Banana 2 | $0.045 (512px) → $0.151 (4K) |
gemini-3.1-flash-lite-image — Nano Banana 2 Lite | $0.0336 at 1K |
There is no free tier. The catalogue and its prices are served by the plugin, so the figure in the UI cannot drift from the one being charged.
Every entry in the studio history has a Reuse button: it reopens the dialog with that description, model, size and ratio. A declination reuses the description that started its family rather than its own recorded prompt, which is the reframe instruction the server composed and means nothing on its own.
Every generation is recorded: prompt, model, size, ratio, reference images,
estimated cost, author, and the file it produced. Kept plugin-side rather than
in the asset's caption — that field is editorial and a front end may render
it, which would publish your prompts.
The log is capped at the 500 most recent entries: it lives in one core-store row that is read whole, and an unbounded array there is a slow leak.
npm install strapi-plugin-image-genNo configuration is required. The key is read from the admin first, then
config/plugins.ts, then IMAGE_GEN_API_KEY, GEMINI_API_KEY or
GOOGLE_API_KEY — so a project that already talks to Gemini works immediately.
Everything is optional:
// config/plugins.ts
export default ({ env }) => ({
"image-gen": {
enabled: true,
config: {
apiKey: env("GEMINI_API_KEY"),
model: "gemini-3-pro-image", // default
imageSize: "2K", // 512px | 1K | 2K | 4K, per model
aspectRatio: "16:9",
folderName: "Generated images",
titleModel: "gemini-2.0-flash", // writes the asset title
maxPromptLength: 2000,
models: {
// Patch a price without waiting for a release of this plugin…
"gemini-3-pro-image": { price: { "1K": 0.15, "2K": 0.15, "4K": 0.26 } },
// …drop a model Google retired…
"gemini-3.1-flash-lite-image": null,
// …or add one it just shipped (a complete spec is required).
"gemini-4-image": {
label: "Nano Banana 3",
sizes: ["1K", "4K"],
price: { "1K": 0.1, "4K": 0.3 },
maxReferences: 20,
outputMimeTypes: ["image/jpeg"],
note: "",
},
},
},
},
});Get a key from Google AI Studio. Permissions
live under Settings → Roles: Generate and retouch images and
Manage the API key and defaults.
Output format is the model's, not yours. The models disagree on what they
can emit and Google publishes no matrix: gemini-3-pro-image answers
"The value 'image/png' is not supported" and takes JPEG only, so images
from it have no transparency — for a cut-out subject on a clear background,
use Nano Banana 2, which accepts PNG. The plugin sends each model the format it
declares and rejects a mismatch before spending anything.
Write the house style as a description, not an instruction. The API has no system-instruction field, so the style travels inside the prompt — and a style that reads like an order ("write prompts for…", "you are an art director") is obeyed as one: the model answers with a written brief instead of an image. Describe the look — photographic, direct flash, hard shadows, muted palette — and it behaves. The plugin names this as the likely cause when it happens.
The prices go stale, and the plugin says so. They come from Google's
published pricing and they move — the original Nano Banana was deprecated
during this plugin's own construction. The dialog states when the list was last
checked, and turns that line into a warning once it is over six months old.
config.models patches a price, removes a retired model or adds a new one; a
malformed entry stops the boot with an explanation rather than reaching an
editor as an unpriced render.
Every image carries a SynthID watermark. It is invisible, it identifies the image as AI-generated, and Google provides no way to disable it. The dialog says so, because an editor publishing a client-facing visual should know.
Generated images land in one folder (Generated images by default), created
on first use. That is what makes "which of these did a model draw?" answerable
months later — and makes the set auditable, replaceable or purgeable at once.
A retouch reads the asset back. Above 4 MB the largest responsive format is sent instead of the original: base64 of a 4K original is several megabytes per reference, and a retouch may carry more than one.
Reference images are capped at what the model accepts (14). The server enforces the model, size and ratio against its own catalogue — the browser is not an authority, so a hand-rolled request cannot bill a 4K Pro render through a form that offered Lite.
All routes are admin-only, behind admin::isAuthenticatedAdmin plus a
permission.
| Route | Permission | |
|---|---|---|
GET /image-gen/catalogue | generate | Models, sizes, prices, ratios |
POST /image-gen/generate | generate | { prompt, title?, model?, imageSize?, aspectRatio?, referenceFileIds?, previousInteractionId? }or { reframeOf, aspectRatio } to decline an existing image into another ratio — no prompt, no house style |
GET /image-gen/journal | generate | The provenance log and the running total |
GET /image-gen/health | generate | Running Strapi vs the version the hooks were verified against |
DELETE /image-gen/journal/:fileId | generate | Deletes the asset. 404 for a file this plugin did not generate |
GET /image-gen/settings | generate | Redacted — never returns the key |
PUT /image-gen/settings | settings | An absent apiKey keeps the stored one; "" clears it |
POST /image-gen/settings/test | settings | One 1K Lite render, ~$0.034, to prove the credentials |
This calls Google's Interactions API
(POST /v1beta/interactions, key in the x-goog-api-key header), not
:generateContent. If you are copying from a text-generation integration, note
that the endpoint, the auth and the body all differ — and that
gemini-2.5-flash-image, the original Nano Banana, is deprecated.
Two traps in the response, both learned the hard way. interaction.output_image
appears throughout the documentation but is a convenience accessor on the
client SDK objects — it is not a field of the JSON that arrives over HTTP,
where the bytes are content blocks inside steps[].content[]. And the docs
publish no example of a raw response body at all, so this parser reads both
shapes and tolerates the interaction wrapper being absent. When no image comes
back, it repeats what the model said rather than guessing: a refusal normally
explains itself.
Assets are created through strapi.plugin('upload').service('upload'), which is
what earns the thumbnails and responsive formats. That service requires a file
on disk — enhanceAndValidateFile wraps fs.createReadStream(file.filepath)
and never looks at a buffer — so bytes are written to a temp file first, exactly
as Strapi's own "add from URL" does, and cleaned up in a finally.
npm install && npm test && npm run build && npm run verifyTests run on plain Node for the server and jsdom for the admin — admin files
declare @vitest-environment jsdom in their own docblock, so there is no config
split. The admin suite is the regression net for the integration points above.
Strapi v5, verified against 5.51. Node ≥ 18.
The integration points are Strapi internals — the media field registry
(app.library.fields.media), useField, and the media-library component —
none of which any version promises. When one of them moves, the plugin
registers nothing rather than break your media field, and the studio says so:
it reports the in-entry button, the reference picker, and your Strapi version
against the one these hooks were verified with. A silent degradation is the
failure mode this plugin has already lived through once.
MIT
Share your work with the community and get it listed in the Strapi ecosystem for everyone to discover and use.
Submit
