Anything shipped to a browser is public, so a key in page JavaScript would soon be spent by strangers. The browser calls your server and only the server calls OpenAI 86 , choosing the model, quality and format, screening prompts with the free moderation endpoint (Content Policy) and applying Pricing and Budget's guard and ledger:
// server.mjs: the only place the key lives (npm install express openai)
import express from 'express';
import OpenAI from 'openai';
import { estimate, guard, log } from '../budget.mjs';
const openai = new OpenAI(); // OPENAI_API_KEY from the environment
const app = express().use(express.json({ limit: '4kb' }), express.static('public'));
const SIZES = ['1024x1024', '1024x1536', '1536x1024'];
app.post('/api/image', async (req, res) => {
const { prompt, size = '1024x1024' } = req.body ?? {};
if (typeof prompt !== 'string' || !/^.{3,400}$/s.test(prompt) || !SIZES.includes(size)) {
return res.status(400).json({ error: 'Send a 3-400 character prompt and a size' });
}
try {
const check = await openai.moderations.create({ model: 'omni-moderation-latest',
input: prompt }); // free; screens the user's text
if (check.results[0].flagged) return res.status(422).json({ error: 'Prompt not allowed' });
const params = { model: 'gpt-image-2.5-flare', prompt, size, quality: 'low',
output_format: 'jpeg', output_compression: 80 }; // decided here, not by the browser
guard(estimate(params)); // throws when the cap is reached
const result = await openai.images.generate(params);
const { cost } = log({ ...params, what: `8.6.3 server: ${prompt.slice(0, 40)}` },
result.usage);
res.json({ image: `data:image/jpeg;base64,${result.data[0].b64_json}`, cost });
} catch (err) {
console.error(err.message);
res.status(err.status ?? 503).json({ error: 'Image service unavailable' });
}
});
app.listen(3108, () => console.log('BookNest images on http://localhost:3108'));Start it with node --env-file=.env server.mjs from the server folder (Express 5.2.1 24,430 ). A malformed request never reaches OpenAI ({"size": "999x999"} got a 400). In production, add authentication and a rate limiter.
<!doctype html>
<style>
body { margin: 0; padding: 8px; background: #fafaf7; font: 12px system-ui, sans-serif; color: #263238; }
svg { width: 100%; max-width: 600px; display: block; }
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<p>Send: <button data-r="ok">valid prompt</button> <button data-r="size">size 999x999</button>
<button data-r="flag">flagged prompt</button> <button data-r="cap">over budget</button></p>
<svg viewBox="0 0 600 250" font-size="11"></svg>
<p id="out"></p>
<script>
const svg = d3.select('svg');
const nodes = [
{ id: 'browser', x: 70, title: 'browser', lines: ['public page', 'no key here'], color: '#1f5f8b' },
{ id: 'server', x: 300, title: 'server.mjs', lines: ['1 validate prompt, size', '2 moderation (free)', '3 guard(estimate)', '4 images.generate', '5 log(usage)', 'OPENAI_API_KEY 🔑'], color: '#3f7d3a' },
{ id: 'openai', x: 530, title: 'OpenAI', lines: ['gpt-image-2.5-flare', 'low, jpeg 80'], color: '#5b3f99' },
];
const g = svg.selectAll('g.n').data(nodes).join('g').attr('class', 'n').attr('transform', d => `translate(${d.x - 65},20)`);
g.append('rect').attr('width', 130).attr('height', d => d.id === 'server' ? 130 : 60).attr('rx', 8)
.attr('fill', '#fff').attr('stroke', d => d.color).attr('stroke-width', 2);
g.append('text').attr('x', 8).attr('y', 17).attr('font-weight', 'bold').attr('fill', d => d.color).text(d => d.title);
g.selectAll('text.l').data(d => d.lines).join('text').attr('class', 'l').attr('x', 8).attr('y', (t, i) => 34 + i * 15).text(t => t);
svg.append('line').attr('x1', 135).attr('x2', 235).attr('y1', 50).attr('y2', 50).attr('stroke', '#b0bec5');
svg.append('line').attr('x1', 365).attr('x2', 465).attr('y1', 50).attr('y2', 50).attr('stroke', '#b0bec5');
svg.append('text').attr('x', 140).attr('y', 44).text('POST /api/image');
svg.append('text').attr('x', 370).attr('y', 44).text('HTTPS + key');
const outcomes = {
ok: { stop: 'openai', status: '200 { image: data:image/jpeg;base64,…, cost: 0.0051 }', color: '#3f7d3a' },
size: { stop: 'server', status: '400 Send a 3-400 character prompt and a size (never reached OpenAI)', color: '#b5452f' },
flag: { stop: 'server', status: '422 Prompt not allowed (moderation flagged it)', color: '#b5452f' },
cap: { stop: 'server', status: '503 Image service unavailable (guard refused: cap reached)', color: '#b5452f' },
};
const dot = svg.append('circle').attr('r', 8).attr('cy', 50).attr('cx', 70).attr('opacity', 0);
function send(kind) {
const o = outcomes[kind], far = o.stop === 'openai' ? 530 : 300;
d3.select('#out').text('');
dot.interrupt().attr('cx', 70).attr('opacity', 1).attr('fill', '#e09a10')
.transition().duration(700).attr('cx', far)
.transition().duration(far === 530 ? 900 : 200).attr('fill', o.color)
.transition().duration(far === 530 ? 900 : 500).attr('cx', 70)
.on('end', () => { d3.select('#out').style('color', o.color).text(o.status); dot.attr('opacity', 0); });
}
d3.selectAll('[data-r]').on('click', e => send(e.target.dataset.r));
svg.append('text').attr('x', 10).attr('y', 200).attr('fill', '#546e7a')
.text('The browser only ever sees the server. In production, add authentication and a rate limiter.');
send('size');
</script>