{"slug": "building-a-text-to-image-pipeline-with-next-js-architecture-decisions", "title": "Building a Text-to-Image Pipeline with Next.js — Architecture Decisions", "summary": "A developer building Pixova's free AI image generator implemented an async job queue pattern to connect a Next.js frontend to a GPU inference endpoint, avoiding timeout issues from long-running HTTP connections. The architecture returns a job ID immediately and polls for status every two seconds, trading real-time updates for simpler implementation suitable for an early-stage product.", "body_md": "When I built the backend for [free AI image generator no credit card](https://pixova.io), the core challenge was connecting a Next.js frontend to a GPU inference endpoint efficiently. Here's the architecture thinking — not the specific implementation, but the decisions that shaped it.\n\nText-to-image generation has an unusual request profile:\n\nThe solution: **async job queue pattern.**\n\n```\nUser submits prompt → \nJob created in queue → \nJob ID returned immediately → \nFrontend polls for status → \nResult delivered when ready\n// app/api/generate/route.js\nexport async function POST(request) {\n  const { prompt } = await request.json();\n\n  // BAD: holds connection open for 15+ seconds\n  const image = await runInference(prompt);\n  return NextResponse.json({ image });\n}\n```\n\n**Problem:** HTTP connections time out. Vercel serverless functions have execution limits. One slow generation blocks the connection.\n\n```\n// app/api/generate/route.js — Submit job\nexport async function POST(request) {\n  const { prompt } = await request.json();\n\n  // Submit to inference queue, get job ID immediately\n  const jobId = await submitToQueue({ prompt });\n\n  // Return immediately — don't wait for completion\n  return NextResponse.json({ jobId, status: 'processing' });\n}\n\n// app/api/status/[jobId]/route.js — Check status\nexport async function GET(request, { params }) {\n  const { jobId } = params;\n  const result = await checkJobStatus(jobId);\n\n  return NextResponse.json({\n    status: result.status, // 'processing' | 'completed' | 'failed'\n    image: result.image ?? null,\n  });\n}\n```\n\n**Frontend polls /api/status/[jobId] every 2 seconds until complete.**\n\n```\n// Real-time updates via WebSocket\n// Backend notifies frontend when job completes\n// No polling overhead\n// More infrastructure to maintain\n```\n\nFor an early-stage product, Option B (polling) is the right tradeoff — simpler to implement and debug, good enough UX.\n\n``` python\n'use client';\nimport { useState, useCallback } from 'react';\nimport Image from 'next/image';\n\nexport default function ImageGenerator() {\n  const [prompt, setPrompt] = useState('');\n  const [status, setStatus] = useState('idle');\n  // idle | submitting | processing | complete | error\n  const [imageUrl, setImageUrl] = useState(null);\n\n  const generate = useCallback(async () => {\n    if (!prompt.trim() || status === 'processing') return;\n\n    setStatus('submitting');\n\n    try {\n      // Submit job\n      const submitRes = await fetch('/api/generate', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ prompt }),\n      });\n\n      const { jobId } = await submitRes.json();\n      setStatus('processing');\n\n      // Poll for result\n      await pollForResult(jobId);\n\n    } catch {\n      setStatus('error');\n    }\n  }, [prompt, status]);\n\n  const pollForResult = async (jobId) => {\n    const maxAttempts = 60; // 2 min timeout\n\n    for (let i = 0; i < maxAttempts; i++) {\n      await new Promise(r => setTimeout(r, 2000));\n\n      const statusRes = await fetch(`/api/status/${jobId}`);\n      const data = await statusRes.json();\n\n      if (data.status === 'completed') {\n        setImageUrl(data.image);\n        setStatus('complete');\n        return;\n      }\n\n      if (data.status === 'failed') {\n        setStatus('error');\n        return;\n      }\n    }\n\n    setStatus('error'); // Timeout\n  };\n\n  return (\n    <div className=\"flex flex-col gap-4 max-w-2xl mx-auto p-6\">\n      <textarea\n        value={prompt}\n        onChange={(e) => setPrompt(e.target.value)}\n        placeholder=\"Describe the image you want...\"\n        className=\"w-full p-3 border border-border rounded-xl \n          resize-none h-24 bg-background text-foreground\"\n        disabled={status === 'processing'}\n      />\n\n      <button\n        onClick={generate}\n        disabled={status === 'processing' || !prompt.trim()}\n        className=\"bg-orange-500 text-white px-6 py-3 \n          rounded-full font-semibold transition-colors\n          hover:bg-orange-600 disabled:opacity-50\"\n      >\n        {status === 'processing' ? 'Generating...' : 'Generate'}\n      </button>\n\n      {/* Loading skeleton — matches output dimensions */}\n      {status === 'processing' && (\n        <div className=\"w-full aspect-square rounded-2xl \n          bg-neutral-100 dark:bg-neutral-800 animate-pulse\" />\n      )}\n\n      {/* Generated result */}\n      {status === 'complete' && imageUrl && (\n        <div className=\"relative w-full aspect-square \n          rounded-2xl overflow-hidden\">\n          <Image\n            src={imageUrl}\n            alt={prompt}\n            fill\n            className=\"object-cover\"\n            priority\n          />\n          <a\n            href={imageUrl}\n            download=\"generated.png\"\n            className=\"absolute bottom-4 right-4 \n              bg-white/90 backdrop-blur text-black \n              px-4 py-2 rounded-full text-sm font-semibold\n              hover:bg-white transition-colors\"\n          >\n            Download\n          </a>\n        </div>\n      )}\n\n      {status === 'error' && (\n        <p className=\"text-red-500 text-sm text-center\">\n          Something went wrong. Try again.\n        </p>\n      )}\n    </div>\n  );\n}\n```\n\nThe skeleton placeholder has the exact dimensions of the output image. When the result arrives, no layout shift — the image drops into the same space.\n\n```\n{/* Dimensions match expected output */}\n{status === 'processing' && (\n  <div className=\"w-full aspect-square rounded-2xl \n    bg-neutral-100 animate-pulse\" />\n)}\n```\n\nUsers shouldn't be able to submit a second job while one is processing. Disable both the input and the button.\n\nThe button should reflect `'submitting'`\n\nstate before the API call returns — not after. Users notice the lag.\n\nWithout user accounts, rate limiting falls on IP address:\n\n``` js\n// middleware.js\nconst requestCounts = new Map();\n\nexport function middleware(request) {\n  const ip = request.headers.get('x-forwarded-for') \n    ?? 'unknown';\n  const now = Date.now();\n  const window = 60_000; // 1 minute\n  const limit = 8; // requests per window\n\n  const history = (requestCounts.get(ip) ?? [])\n    .filter(t => now - t < window);\n\n  if (history.length >= limit) {\n    return new Response('Rate limit exceeded', { \n      status: 429 \n    });\n  }\n\n  requestCounts.set(ip, [...history, now]);\n  return NextResponse.next();\n}\n\nexport const config = {\n  matcher: '/api/generate',\n};\n```\n\n**Important:** In-memory rate limiting resets on server restart and doesn't work across multiple instances. For production, use a distributed cache for rate limit state.\n\n| Problem | Solution |\n|---|---|\n| Cold starts on inference | Keep minimum workers warm |\n| Large image payloads | Use CDN URL instead of base64 |\n| Layout shift on load | Pre-sized skeleton placeholder |\n| Polling overhead | Stop polling after completion |\n| Timeout handling | Max attempts with user feedback |\n\n**Webhooks over polling.** The inference backend notifying the frontend when complete is cleaner than the frontend repeatedly asking. Polling works, but it's chatty.\n\n**Better error categorization.** A single `'error'`\n\nstate isn't enough. Timeout is different from inference failure is different from invalid prompt. Each deserves different user messaging.\n\nFor a full breakdown of how the no-account architecture affects product decisions, I wrote a [detailed post here](https://pixova.io/blog/how-can-i-generate-ai-images-for-free).\n\nQuestions about the architecture? Comments open.", "url": "https://wpnews.pro/news/building-a-text-to-image-pipeline-with-next-js-architecture-decisions", "canonical_source": "https://dev.to/aon_infotech_3a1b6ff525fc/building-a-text-to-image-pipeline-with-nextjs-architecture-decisions-2gbo", "published_at": "2026-06-16 06:50:42+00:00", "updated_at": "2026-06-16 07:17:27.201873+00:00", "lang": "en", "topics": ["artificial-intelligence", "generative-ai", "developer-tools", "ai-infrastructure"], "entities": ["Pixova", "Next.js", "Vercel", "GPU"], "alternates": {"html": "https://wpnews.pro/news/building-a-text-to-image-pipeline-with-next-js-architecture-decisions", "markdown": "https://wpnews.pro/news/building-a-text-to-image-pipeline-with-next-js-architecture-decisions.md", "text": "https://wpnews.pro/news/building-a-text-to-image-pipeline-with-next-js-architecture-decisions.txt", "jsonld": "https://wpnews.pro/news/building-a-text-to-image-pipeline-with-next-js-architecture-decisions.jsonld"}}