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_dump cron)

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.

Related guides