Build an AI-Powered Product Description Generator
Build an AI product description generator with Next.js and an LLM API: templates per store, brand voice and bulk mode.
Build a SaaS that turns five bullet points into store-ready product descriptions with tone control, template variations and bulk mode.
What we’re building
flowchart LR
User --> Next[Next.js]
Next --> LLM[LLM API]
Next --> PG[(PostgreSQL)]
User --> Bulk[Bulk generation]
What you’ll learn
- Prompt templates that output conforming copy (length, tone, structure)
- Brand voice saved per account
- Cost control with batch limits
- A route handler with a hard per-user rate limit enforced before the API call
Prerequisites
- Node.js 22+
- PostgreSQL 17 (local via Docker, or any managed Postgres)
- LLM API key
- 1 GB VPS (deploy step)
1. Scaffold
npx create-next-app@latest productdescriptions --ts --app --use-npm
cd productdescriptions
npm i openai pg
npm i -D @types/pg
2. Database schema and local Postgres
schema.sql:
CREATE TABLE brands (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
name TEXT NOT NULL,
tone TEXT NOT NULL DEFAULT 'professional',
style_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE generations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
input JSONB NOT NULL,
output TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
docker-compose.yml (dev — the db service; the app and Caddy are added in section 7):
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: dev
POSTGRES_DB: prodd
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
docker compose up -d db
psql postgres://postgres:dev@localhost:5432/prodd -f schema.sql
To reset during development: docker compose down -v && docker compose up -d db and re-apply the schema.
lib/db.ts:
import { Pool } from 'pg';
export const pool = new Pool({
connectionString:
process.env.DATABASE_URL ?? 'postgres://postgres:dev@localhost:5432/prodd',
});
3. The generator
lib/generate.ts — the OpenAI import, typed input and explicit failure handling make this copy/paste runnable:
import OpenAI from 'openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export interface Input {
points: string[];
tone: 'professional' | 'fun' | 'luxury';
store: string;
styleNotes?: string;
}
const TONE_ROLES: Record<Input['tone'], string> = {
professional: 'Warm, confident, benefit-first. No hype.',
fun: 'Playful, energetic, short sentences. Max two emoji.',
luxury: 'Restrained, sensory, understated. No exclamation marks.',
};
export async function generateDescription({ points, tone, store, styleNotes }: Input): Promise<string> {
if (!points.length || points.length > 20) {
throw new Error('Provide between 1 and 20 attribute points.');
}
try {
const res = await openai.chat.completions.create({
model: 'gpt-4o-mini',
temperature: tone === 'fun' ? 0.9 : 0.5,
messages: [
{
role: 'system',
content: [
`Write a product description for ${store}.`,
`Tone: ${TONE_ROLES[tone]}.`,
styleNotes ? `Brand voice notes: ${styleNotes}.` : '',
'Structure: 1 hook sentence, 3 attribute bullets, 1 CTA sentence.',
'Never invent specs not in the input — the input list is exhaustive.',
'Max 120 words.',
]
.filter(Boolean)
.join('\n'),
},
{
role: 'user',
content: points.map((p) => `- ${p}`).join('\n'),
},
],
});
const content = res.choices[0]?.message.content?.trim();
if (!content) throw new Error('Empty completion from the LLM API.');
return content;
} catch (err) {
throw new Error(`LLM call failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
Rules in the system prompt do the heavy lifting: length, structure and honesty constraints beat “be creative” every time — and they keep token spend predictable.
4. Brand voice (the retention feature)
Store voice per account, then feed style_notes into the system prompt via the styleNotes field of generateDescription. Customers feel the difference — it’s the reason they stay subscribed.
5. The API routes
app/api/generate/route.ts — validates input, enforces the rate limit before any API spend, persists the generation, and returns it:
import { NextRequest, NextResponse } from 'next/server';
import { generateDescription, type Input } from '@/lib/generate';
import { pool } from '@/lib/db';
const FREE_MONTHLY_LIMIT = 20;
const PAID_MONTHLY_LIMIT = 200;
const TONES = ['professional', 'fun', 'luxury'] as const;
export async function POST(req: NextRequest) {
let body: Partial<Input> & { userId?: string; brandId?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 });
}
const { userId, brandId, points, tone, store } = body;
if (typeof userId !== 'string' || !userId) {
return NextResponse.json({ error: 'userId required.' }, { status: 401 });
}
if (!Array.isArray(points) || points.length === 0 || points.some((p) => typeof p !== 'string')) {
return NextResponse.json({ error: 'points: non-empty array of strings required.' }, { status: 400 });
}
if (!tone || !TONES.includes(tone as (typeof TONES)[number])) {
return NextResponse.json({ error: `tone must be one of: ${TONES.join(', ')}.` }, { status: 400 });
}
if (typeof store !== 'string' || !store.trim()) {
return NextResponse.json({ error: 'store name required.' }, { status: 400 });
}
try {
const used = await pool.query<{ c: string }>(
`SELECT COUNT(*)::text AS c FROM generations
WHERE user_id = $1 AND created_at > date_trunc('month', now())`,
[userId],
);
const limit = Number(used.rows[0].c) >= FREE_MONTHLY_LIMIT ? PAID_MONTHLY_LIMIT : FREE_MONTHLY_LIMIT;
if (Number(used.rows[0].c) >= limit) {
return NextResponse.json({ error: 'Monthly generation limit reached.' }, { status: 429 });
}
const output = await generateDescription({ points, tone, store });
const saved = await pool.query(
`INSERT INTO generations (user_id, brand_id, input, output)
VALUES ($1, $2, $3, $4) RETURNING id`,
[userId, brandId ?? null, JSON.stringify({ points, tone, store }), output],
);
return NextResponse.json({ id: saved.rows[0].id, output }, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Generation failed.';
return NextResponse.json({ error: message }, { status: 502 });
}
}
app/api/history/route.ts — every generation is stored, so customers can pin favorites:
import { NextRequest, NextResponse } from 'next/server';
import { pool } from '@/lib/db';
export async function GET(req: NextRequest) {
const userId = req.nextUrl.searchParams.get('userId');
if (!userId) {
return NextResponse.json({ error: 'userId query param required.' }, { status: 400 });
}
try {
const result = await pool.query(
`SELECT id, input, output, created_at FROM generations
WHERE user_id = $1 ORDER BY created_at DESC LIMIT 100`,
[userId],
);
return NextResponse.json(result.rows);
} catch (err) {
return NextResponse.json(
{ error: `History query failed: ${err instanceof Error ? err.message : ''}` },
{ status: 500 },
);
}
}
6. Bulk mode (make money without API surf)
lib/bulk.ts — bounded concurrency, per-item error isolation, monthly cost shown to the user before the batch runs:
import { generateDescription, type Input } from './generate';
const CONCURRENCY = 5;
export async function generateBulk(products: Input[]) {
const results: Array<{ input: Input; description: string | null; error?: string }> = [];
let cursor = 0;
async function worker() {
while (cursor < products.length) {
const i = cursor++;
const p = products[i];
try {
results[i] = { input: p, description: await generateDescription(p) };
} catch (err) {
results[i] = { input: p, description: null, error: err instanceof Error ? err.message : 'failed' };
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
return results;
}
Rate limit: 20 descriptions/mo on free trial, 200 on paid — enforced in the route (section 5) before any API call, not after. In bulk mode, warn the user of the worst-case token cost (200 × ~600 tokens ≈ 120k tokens) before starting the batch.
7. UI
- Left: bullets input + tone select + brand selector
- Right: live preview + “Copy” + regenerate with variants (3 outputs queued)
- History (GET
/api/history) so customers can pin favorites
8. Run locally
docker compose up -d db
psql postgres://postgres:dev@localhost:5432/prodd -f schema.sql
export DATABASE_URL=postgres://postgres:dev@localhost:5432/prodd
export OPENAI_API_KEY=your_key_here
npm run dev
Test: POST /api/generate with a JSON body, then confirm the row lands in generations and the same user gets a 429 after the monthly limit.
curl -X POST http://localhost:3000/api/generate \
-H 'content-type: application/json' \
-d '{"userId":"u_1","points":["Ships in 48h","Carbon neutral"],"tone":"professional","store":"Nordic outdoor gear"}'
9. Deploy to a VPS
Dockerfile:
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
docker-compose.yml (full — replaces the dev version):
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: prodd
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
app:
build: .
environment:
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/prodd
OPENAI_API_KEY: ${OPENAI_API_KEY}
NEXT_PUBLIC_APP_URL: https://YOUR-DOMAIN.com
depends_on:
- db
restart: unless-stopped
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
restart: unless-stopped
volumes:
pgdata:
caddy_data:
Caddyfile:
YOUR-DOMAIN.com {
reverse_proxy app:3000
}
On the VPS:
git clone <your-repo> prodd && cd prodd
cp .env.example .env # fill every value
docker compose up -d --build
psql "$DATABASE_URL" -f schema.sql # once
Point the DNS A record of YOUR-DOMAIN.com at the VPS IP; Caddy issues HTTPS automatically.
Environment variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string. Locally postgres://postgres:dev@localhost:5432/prodd; on the VPS it points at the db service. |
OPENAI_API_KEY |
yes | LLM API key (platform.openai.com → API keys). |
NEXT_PUBLIC_APP_URL |
no | Public origin used for absolute URLs. |
Cost (assumptions: 10k descriptions/month on gpt-4o-mini)
| Service | Monthly cost |
|---|---|
| VPS 1 GB | $4.49 |
| LLM (gpt-4o-mini, 10k descriptions ≈ 4M tokens) | $8 |
| Total | ≈ $12.50/month |
At $15/mo: 1 user covers infra; 50 users ≈ $650/month on $13 of costs.
Production checklist
- Temperature varies by tone, honesty constraints always on
- Per-user usage counter enforced before API calls (429 on limit)
- Input validated (points array, tone enum, store name) before any spend
- Content moderation on input (no brand names/logos pasted for legal safety)
- History purge on cancel (GDPR)
- Database backups (
pg_dumpcron)
Common problems
- Hallucinated specs: the “never invent specs” rule + reminding the LLM the input is exhaustive (“only these points exist”).
- Bulk API cost surprises: compute worst case (200 × 600 tokens ≈ 120k tokens) and warn users before batch.
- Same-sounding copy: add random seed/variant flag for regenerate.
- Rate limit bypassed: always check the counter server-side; client-side gates are cosmetic.
Improvements
- Amazon/Etsy/Shopify format presets (different CTA structures)
- A/B testing: generate 3, user picks winner, we learn their preference
- API access for agencies
Conclusion
A product description generator is a strict prompt template, a brand-voice table and a rate-limited loop. ~$12.50/month to run, and the moment customers experience “it sounds like us” they stop churning.