Most "Build an AI image generator" tutorials bottom out on one of two walls. Either they tell you to rent a GPU (you are now paying 70 cents an hour to learn), or they push you at a commercial API with a 30-day trial and a credit card requirement. I wanted a version that a student on a ten year old laptop could finish on a Saturday afternoon with nothing but Python installed.
So here it is. A real text-to-image web app. Under 200 lines total. No GPU. No signup. No API key. I'll walk you through the whole thing, and at the end you will have a working demo you can drop into a portfolio.
I'm the solo founder of ZSky AI, a self-hosted AI creativity platform. We let people generate images without an account precisely so tutorials like this are possible — you can point any code at a public endpoint and just see pixels come back. We run the whole thing on seven RTX 5090s that we own outright, so "free" actually means free for you as the reader.
Let's build it.
What we're making
A single-page web app with:
- A prompt input
- A "Generate" button
- A live preview of the returned image
- A simple Flask backend that forwards the prompt to a free public generation endpoint and streams the result back
- Zero state, zero database, zero accounts
Total files: app.py (backend, about 80 lines) and index.html (frontend, about 90 lines). Plus a requirements.txt with two lines. That is the whole project.
The backend
Create app.py:
import os
import requests
from flask import Flask, request, jsonify, send_from_directory
app = Flask(__name__, static_folder=".")
# Public no-auth generation endpoint.
# In production, you'd point this at your own service or a provider's API.
GEN_URL = "https://zsky.ai/api/public/generate"
@app.route("/")
def home():
return send_from_directory(".", "index.html")
@app.route("/generate", methods=["POST"])
def generate():
data = request.get_json(silent=True) or {}
prompt = (data.get("prompt") or "").strip()
if not prompt:
return jsonify({"error": "Prompt is required"}), 400
if len(prompt) > 500:
return jsonify({"error": "Prompt too long (max 500 chars)"}), 400
try:
resp = requests.post(
GEN_URL,
json={
"prompt": prompt,
"aspect_ratio": "1:1",
"style": "photographic",
},
timeout=60,
)
resp.raise_for_status()
except requests.RequestException as e:
return jsonify({"error": f"Upstream error: {e}"}), 502
payload = resp.json()
image_b64 = payload.get("image_base64")
if not image_b64:
return jsonify({"error": "No image in response"}), 500
return jsonify({
"image": f"data:image/png;base64,{image_b64}",
"prompt": prompt,
})
if __name__ == "__main__":
port = int(os.environ.get("PORT", "5050"))
app.run(host="0.0.0.0", port=port, debug=True)
That is the entire backend. Eighty lines including blank ones. Notice what is not there: no API keys, no database, no authentication middleware, no background worker. A real production app would add rate limiting and a queue, but for a portfolio demo this is enough.
A few things worth pointing out:
- We validate the prompt length on the server. Never trust the client. I have seen demo code that lets anyone paste a 50,000 character prompt and wonders why it crashes.
-
timeout=60is non-negotiable. Image generation can take a few seconds. If the upstream hangs, you do not want your Flask worker hanging with it. - We return base64 data URLs. This avoids storing images on your server, which avoids an entire class of security and cleanup problems. You cannot leak what you never wrote to disk.
The frontend
Create index.html next to app.py:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tiny AI Image Generator</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
font-family: -apple-system, system-ui, sans-serif;
background: #0b0d12;
color: #e8e8ea;
display: grid;
place-items: center;
min-height: 100vh;
padding: 24px;
}
.card {
width: min(560px, 100%);
background: #14171f;
border: 1px solid #242a35;
border-radius: 16px;
padding: 24px;
}
h1 { margin: 0 0 8px; font-size: 20px; }
p.muted { margin: 0 0 16px; color: #8a93a6; font-size: 14px; }
textarea {
width: 100%;
min-height: 80px;
background: #0b0d12;
color: #e8e8ea;
border: 1px solid #2a3140;
border-radius: 8px;
padding: 12px;
font: inherit;
resize: vertical;
}
button {
margin-top: 12px;
width: 100%;
background: #7a5cff;
border: 0;
color: white;
padding: 12px 16px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
}
button[disabled] { opacity: 0.6; cursor: wait; }
.preview {
margin-top: 16px;
aspect-ratio: 1/1;
background: #0b0d12;
border: 1px dashed #2a3140;
border-radius: 8px;
display: grid;
place-items: center;
overflow: hidden;
}
.preview img { width: 100%; height: 100%; object-fit: cover; }
.status { color: #8a93a6; font-size: 14px; }
.err { color: #ff7a7a; margin-top: 12px; font-size: 13px; }
</style>
</head>
<body>
<div class="card">
<h1>Tiny AI Image Generator</h1>
<p class="muted">Type a prompt, hit generate. No signup, no GPU.</p>
<textarea id="prompt" placeholder="a lone lighthouse at dusk, long exposure, photographic"></textarea>
<button id="go">Generate</button>
<div id="err" class="err"></div>
<div class="preview" id="preview">
<span class="status" id="status">Your image will appear here.</span>
</div>
</div>
<script>
const btn = document.getElementById("go");
const input = document.getElementById("prompt");
const preview = document.getElementById("preview");
const status = document.getElementById("status");
const err = document.getElementById("err");
// Safe DOM helpers. Never use innerHTML with anything derived from user input.
function clearPreview() {
while (preview.firstChild) preview.removeChild(preview.firstChild);
}
function setStatus(msg) {
clearPreview();
const span = document.createElement("span");
span.className = "status";
span.textContent = msg;
preview.appendChild(span);
}
function setImage(src, alt) {
clearPreview();
const img = document.createElement("img");
img.src = src;
img.alt = alt; // Alt is set via property, never as HTML.
preview.appendChild(img);
}
btn.addEventListener("click", async () => {
err.textContent = "";
const prompt = input.value.trim();
if (!prompt) { err.textContent = "Write a prompt first."; return; }
btn.disabled = true;
btn.textContent = "Generating...";
setStatus("Working...");
try {
const res = await fetch("/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Unknown error");
setImage(data.image, prompt);
} catch (e) {
err.textContent = e.message;
setStatus("Generation failed.");
} finally {
btn.disabled = false;
btn.textContent = "Generate";
}
});
</script>
</body>
</html>
Run it:
pip install flask requests
python app.py
Open http://localhost:5050, type a prompt, and watch an image appear. That is the entire app.
Security note: why we avoid innerHTML
The frontend above uses createElement and textContent exclusively. This is not style preference — it is the reason the demo is safe. If you ever set element.innerHTML = someUserInputOrApiResponse, you have just created a cross-site scripting vulnerability. The prompt the user types might be their own, but six months from now a teammate will copy this code and paste it into a feature that loads strings from a URL parameter. Keep the habit tight from day one.
If you absolutely need to render HTML-shaped content (say, you want to allow bold and italic in prompts), use an HTML sanitizer like DOMPurify. Never "just trust the backend."
Why this works without a GPU on your side
The first question I get is "but where is the actual model running?" The answer is that it runs on a machine owned by the endpoint provider. In this tutorial I pointed at ZSky AI's public generation endpoint because we do not require a signup or a credit card to try it, which makes for a clean tutorial path. The same structure works against any HTTP-based generation endpoint — swap the GEN_URL line and adjust the request body to match whichever service you pick.
The lesson here is structural, not commercial: for almost every frontend developer learning this category, you do not need a GPU. You need a well-shaped HTTP call and a patient event loop. Hardware ownership becomes a question later, when you care about cost per generation or latency floors. For a weekend build, it is a distraction.
If you want to see what a production version of this looks like, the free AI image generator on ZSky is the same pattern scaled up. One prompt box, one generate button, and a queue on the backend. That is most of what image generation UX has ever been.
Common mistakes I see in student builds
I have reviewed dozens of attempts at this exact pattern. Four mistakes come up over and over:
Mistake 1: Calling the generation endpoint from the browser directly. Tempting because it is less code, but you will leak whatever key or rate-limit state you have to every visitor who opens DevTools. Always go through your own backend, even if your backend is just a proxy.
Mistake 2: Storing the generated image on disk. Every tutorial I see starts with img.save("output.png") and then never addresses cleanup. Within a week your demo is shipping a hard disk of stranger's prompts. Return data URLs until you have a reason not to.
Mistake 3: No loading state. A generation takes a few seconds. If your button looks the same during those seconds, users will click it again. Now you have two pending requests and a mystery bug. Always disable the button and show a working state.
Mistake 4: Not handling the upstream timeout. Networks fail. Endpoints time out. Show a friendly error, clear the loading state, and let the user try again without reloading.
What to add next
Once the hello-world version works, here are the most valuable additions in order:
-
Prompt history. Keep the last 10 prompts in
localStorage. Costs nothing, massively improves the feel of the app. - Aspect ratio selector. Let the user pick square, portrait, landscape. Pass it through to the backend.
-
Download button. A two-line JavaScript
a[download]trick that saves the image locally. - Style presets. Five buttons — "photographic", "illustration", "anime", "cinematic", "minimalist" — that prepend a style string to the prompt. This is the single biggest quality jump for new users.
- Rate limiting. Once your app is public, add simple per-IP rate limiting. Flask has several drop-in options.
If you want to see what a tuned prompt input actually looks like in production, compare this with the prompt flow on ZSky's create page. Same bones, just with guard rails and a prompt enhancer bolted on.
The meta lesson
The hardest part of shipping an "AI image generator" is not the AI. It is the glue. Networking, error handling, UX state, loading spinners, friendly error messages — these are the things that make a demo feel like a product. The model is a shared utility now. Your value is in how you present it.
If you build something off this tutorial, please share it. Tag me on X at @zskyai or drop a link in the comments. Bonus points if you push your version to GitHub — the next dev after you will read it.
Further reading
- Free AI image generator (no signup)
- Why we bought 7 RTX 5090s instead of renting from AWS
- The honest developer's guide to free AI image APIs
Happy shipping.
Top comments (0)