Small e-commerce businesses on BigCommerce spend hours writing product descriptions, often copying competitors or using thin content, resulting in poor search rankings and low conversions.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
Small e-commerce merchants on BigCommerce spend hours writing product descriptions, often resorting to copy-pasting from competitors or publishing thin content that tanks search rankings and conversions. This tutorial builds a RAG-powered product description generator that uses your existing BigCommerce catalog, Voyage AI embeddings stored in pgvector (Neon Postgres), and Anthropic’s Claude to generate SEO-optimized descriptions at scale. You’ll wire up an LLM cache, a context window planner that fits within Claude’s 128K token limit, structured output repair to fix malformed JSON, and a session store for multi-step editing.
Prerequisites
Node.js >= 22 and pnpm 10 installed
An Anthropic API key — for Claude (claude-sonnet-4-6)
A Voyage AI API key — for embedding generation (voyage-3-large)
A Neon Postgres database with the pgvector extension enabled
A BigCommerce store with a store hash and V3 API access token
A Langfuse account (optional — for observability tracing)
Basic familiarity with TypeScript and Next.js App Router patterns
Step 1: Scaffold the project and install dependencies
Start with a fresh Next.js project using the App Router. The recipe already includes package.json, next.config.ts, and vitest.config.ts, so you just need to install the dependencies.
terminal
pnpm install
Expected output:pnpm install completes without errors. You’ll see all dependencies from @anthropic-ai/sdk to installed under .
zod
node_modules/
Step 2: Set up environment variables
Copy .env.example to .env and fill in your real API keys and credentials. The project reads configuration entirely from environment variables — nothing hardcoded.
env
# Env vars used by anthropic-rag-product-desc-generator-for-bigcommerce-smb-sellers.# Keep placeholders only — never commit real values.NODE_ENV=developmentANTHROPIC_API_KEY=<your-anthropic-key>VOYAGE_API_KEY=<your-voyage-api-key>DATABASE_URL=postgres://user:***@host.neon.tech/dbBIGCOMMERCE_STORE_HASH=<your-store-hash>BIGCOMMERCE_ACCESS_TOKEN=<your-access-token>LANGFUSE_PUBLIC_KEY=<pk-...>LANGFUSE_SECRET_KEY=<sk-...>LANGFUSE_BASE_URL=https://cloud.langfuse.comEMBEDDING_API_KEY=<your-voyage-key>
Expected output: The .env file is listed in .gitignore so your keys stay out of version control. Each service (Anthropic, Voyage, Neon, BigCommerce, Langfuse) reads from its respective key at runtime.
Step 3: Define the shared types
Create src/types.ts with all the core data structures that flow through the pipeline. Every subsequent module imports from here.
Expected output:pnpm typecheck exits clean with no errors.
Step 4: Create the database schema and instrumentation
The pgvector table stores product embeddings for fast cosine-similarity search. The schema migration is called once at app startup via Next.js instrumentation.ts.
ts
// src/lib/schema.tsimport { Pool } from "@neondatabase/serverless";export async function runMigrations(pool: Pool): Promise<void> { await pool.query("CREATE EXTENSION IF NOT EXISTS vector"); await pool.query(` CREATE TABLE IF NOT EXISTS products ( id SERIAL PRIMARY KEY, product_id TEXT UNIQUE NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL, embedding vector(1024) NOT NULL, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW() ) `); await pool.query(` CREATE INDEX IF NOT EXISTS idx_products_embedding ON products USING hnsw (embedding vector_cosine_ops) `);}
Next, create the instrumentation file that runs this migration when the Node.js server starts:
ts
// src/instrumentation.tsimport { Pool } from "@neondatabase/serverless";import { runMigrations } from "./lib/schema";export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { const pool = new Pool({ connectionString: process.env.DATABASE_URL }); try { await runMigrations(pool); } finally { await pool.end(); } }}
The Next.js config starts as a minimal placeholder:
ts
// next.config.tsimport type { NextConfig } from "next";const nextConfig: NextConfig = { /* config options here */};export default nextConfig;
Expected output: On pnpm dev startup, register() runs the migration if NEXT_RUNTIME is "nodejs", creating the pgvector extension, the products table, and an HNSW index on the embedding column.
Step 5: Build the BigCommerce API client
The BigCommerceClient wraps the BigCommerce V3 REST API to fetch, batch-fetch, and update product descriptions. Each method authenticates with the X-Auth-Token header.
Expected output: You can instantiate new BigCommerceClient({ storeHash, accessToken }) and call getProduct, getProducts, and updateProductDescription. Errors are typed as BigCommerceApiError with the original HTTP status code.
Step 6: Build the embedding service and vector store
The EmbeddingService wraps the Voyage AI client to produce 1024-dimension embeddings using the voyage-3-large model.
ts
// src/lib/embeddings.tsimport { VoyageAIClient, VoyageAIError } from "voyageai";export class EmbeddingError extends Error { statusCode: number; constructor(message: string, statusCode: number) { super(message); this.name = "EmbeddingError"; this.statusCode = statusCode; }}export class EmbeddingService { private client: VoyageAIClient; constructor() { const apiKey = process.env.VOYAGE_API_KEY; if (!apiKey) { throw new Error("VOYAGE_API_KEY environment variable is not set"); } this.client = new VoyageAIClient({ apiKey }); } async embed(text: string): Promise<number[]> { try { const response = await this.client.embed({ input: text, model: "voyage-3-large", }); if (!response.data || !response.data[0]) { throw new EmbeddingError("No embedding data returned", 500); } const embedding = response.data[0].embedding; if (!embedding) { throw new EmbeddingError("Embedding data missing embedding array", 500); } return embedding; } catch (error) { if (error instanceof EmbeddingError) { throw error; } if (error instanceof VoyageAIError) { throw new EmbeddingError(error.message, error.statusCode ?? 500); } throw error; } } async embedBatch(texts: string[]): Promise<number[][]> { try { const response = await this.client.embed({ input: texts, model: "voyage-3-large", }); if (!response.data) { throw new EmbeddingError("No embedding data returned", 500); } return response.data.map((item) => { if (!item.embedding) { throw new EmbeddingError("Embedding data item missing embedding array", 500); } return item.embedding; }); } catch (error) { if (error instanceof EmbeddingError) { throw error; } if (error instanceof VoyageAIError) { throw new EmbeddingError(error.message, error.statusCode ?? 500); } throw error; } }}
The ProductVectorStore wraps a Neon Postgres connection pool with pgvector. It registers the vector type on every new connection so the <=> cosine-distance operator works in queries.
ts
// src/lib/pgvector-store.tsimport { Pool } from "@neondatabase/serverless";import type { PoolClient } from "@neondatabase/serverless";import pgvector from "pgvector/pg";import { SimilarProduct } from "../types";export class ProductVectorStore { private pool: Pool; constructor() { const connectionString = process.env.DATABASE_URL; if (!connectionString) { throw new Error("DATABASE_URL environment variable is not set"); } this.pool = new Pool({ connectionString }); this.pool.on("connect", async (client: PoolClient) => { await pgvector.registerTypes(client); }); } async upsertProductEmbedding( productId: string, name: string, description: string, embedding: number[], ): Promise<void> { const vector = pgvector.toSql(embedding); await this.pool.query( `INSERT INTO products (product_id, name, description, embedding, metadata) VALUES ($1, $2, $3, $4, '{}') ON CONFLICT (product_id) DO UPDATE SET name = $2, description = $3, embedding = $4`, [productId, name, description, vector], ); } async findSimilarProducts( embedding: number[], limit: number = 5, ): Promise<SimilarProduct[]> { const vector = pgvector.toSql(embedding); const result = await this.pool.query( `SELECT product_id, name, description, 1 - (embedding <=> $1) AS relevance_score FROM products ORDER BY embedding <=> $1 LIMIT $2`, [vector, limit], ); return result.rows.map((row: { product_id: string; name: string; description: string; relevance_score: number }) => ({ productId: row.product_id, name: row.name, description: row.description, relevanceScore: row.relevance_score, })); } async close(): Promise<void> { await this.pool.end(); }}
Expected output:embed("wireless keyboard") returns a 1024-element number[]. findSimilarProducts(embedding) returns products ordered by cosine similarity descending, each with a relevanceScore between 0 and 1.
Step 7: Implement the Claude service
The ClaudeService wraps the Anthropic SDK. Note the default import (import Anthropic from ...) — the named { Anthropic } export fails at runtime with SDK v0.50+.
ts
// src/lib/anthropic.tsimport Anthropic from "@anthropic-ai/sdk";import { GenerationConfig } from "../types";export class ClaudeService { private client: Anthropic; constructor() { const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey) { throw new Error("ANTHROPIC_API_KEY environment variable is not set"); } this.client = new Anthropic({ apiKey }); } async generateDescription(prompt: string, config: GenerationConfig): Promise<string> { const message = await this.client.messages.create({ model: config.model || "claude-sonnet-4-6", max_tokens: config.maxTokens, temperature: config.temperature, system: "You are an expert e-commerce copywriter specializing in product descriptions for SMB sellers. Generate compelling, accurate product descriptions that drive conversions and improve SEO.", messages: [{ role: "user", content: prompt }], }); if (message.content.length > 0 && message.content[0].type === "text") { return message.content[0].text; } return ""; }}
Expected output: Calling generateDescription("Write a description for...", config) returns the generated text. The max_tokens parameter is always sent — the Anthropic API rejects requests that omit it. Content is always accessed as typed blocks (message.content[0].type === "text"), never as a plain string.
Step 8: Build the context window planner
The @reaatech/context-window-planner package packs system prompts, product info, RAG chunks, and a generation buffer into a 128K token budget using a priority-greedy strategy. Items with higher priority (Critical > High > Medium) are included first; the planner drops low-priority items when the budget is exceeded.
ts
// src/lib/context-planner.tsimport { ContextPlannerBuilder, createTokenizer, createPriorityGreedyStrategy, createSystemPrompt, createConversationTurn, createRAGChunk, createGenerationBuffer, Priority, type PackingResult,} from "@reaatech/context-window-planner";import type { BigCommerceProduct, SimilarProduct, GenerationConfig } from "../types";const SYSTEM_PROMPT_CONTENT = "You are an expert e-commerce copywriter for BigCommerce SMB sellers. Generate SEO-optimized product descriptions that drive conversions, highlight key features and benefits, and follow e-commerce best practices. Use clear, persuasive language appropriate to the target audience.";export class ContextPlanningService { private tokenizer; constructor() { this.tokenizer =
Expected output:planContext(product, similarProducts, config) returns a PackingResult where included contains only items that fit within the available budget, plus an assembled prompt string built from those included items. Warnings are logged for any dropped items.
Step 9: Create the structured output repair
Claude sometimes wraps JSON in markdown fences, hallucinates extra fields, or uses slightly wrong key names. @reaatech/structured-repair-core runs a graduated strategy pipeline (strip-fences → extract-json → fix-syntax → coerce-types → fuzzy-match-keys → remove-extra-fields) to salvage a valid result.
Expected output:repairDescription('```json\n{"description": "Great product..."}\n```') strips the fences and returns { description: "Great product..." }. Hallucinated fields like "hallucinated": true are automatically removed. If the output is truly unrepairable, undefined is returned — the pipeline falls back to the raw Claude output.
Step 10: Implement the session store and LLM cache
The GenerationSessionStore wraps @reaatech/session-continuity with an in-memory storage adapter and an approximate token counter (~3.5 chars/token, matching the Anthropic heuristic). The sliding-window compression keeps session history under 28K tokens.
ts
// src/lib/session-store.tsimport { SessionManager, type Session, type Message, type IStorageAdapter, type TokenCounter,} from "@reaatech/session-continuity";import type { GenerationConfig } from "../types";class InMemorySessionAdapter implements IStorageAdapter { private sessions: Map<string, Session> = new Map(); private messages: Map<string, Message[]> = new Map(); createSession
The GenerationCache wraps @reaatech/llm-cache for dual-mode caching — exact SHA-256 match and semantic cosine-similarity match. Both the storage and vector storage use the in-memory adapter; the embedder is a Voyage AI wrapper.
Expected output:GenerationSessionStore.createSession("user1", config) returns a session with status: "active". GenerationCache.get("prompt", "claude-sonnet-4-6") returns { hit: false, reason: "..." } before any set, and { hit: true, type: "exact", entry: {...} } after a matching set.
Step 11: Build the orchestrator
The ProductDescriptionGenerator ties every service together. For each product ID, it fetches the product from BigCommerce, embeds its description, finds similar products from pgvector, plans the context window, checks the cache, calls Claude (if cache miss), repairs the output, records the session turn, updates BigCommerce, and upserts the new embedding.
ts
// src/lib/generator.tsimport type { GenerateDescriptionRequest, GenerateDescriptionResponse, GenerationConfig } from "../types";import { BigCommerceClient } from "./bigcommerce";import { EmbeddingService } from "./embeddings";import { ClaudeService } from "./anthropic";import { ProductVectorStore } from "./pgvector-store";import { ContextPlanningService } from "./context-planner";import { RepairService } from "./description-repair";import { GenerationSessionStore } from "./session-store";import { GenerationCache } from "./cache";const DEFAULT_CONFIG: GenerationConfig =
Expected output: Calling generator.generate({ productIds: ["123"] }) runs the full pipeline and returns an array with one GenerateDescriptionResponse. A cache hit skips the Claude call entirely (saves tokens and latency). A repair failure falls back to the raw Claude output without crashing.
Step 12: Create the API route
The Next.js App Router route handler exposes three endpoints: POST to generate, GET to retrieve session history, and PUT to record user edits. All responses use NextResponse.json().
ts
// app/api/generate/route.tsimport { NextRequest, NextResponse } from "next/server";import { z } from "zod";import { BigCommerceClient } from "../../../src/lib/bigcommerce";import { EmbeddingService } from "../../../src/lib/embeddings";import { ClaudeService } from "../../../src/lib/anthropic";import { ProductVectorStore } from "../../../src/lib/pgvector-store";import { ContextPlanningService } from "../../../src/lib/context-planner";import { GenerationSessionStore } from "../../../src/lib/session-store";import { GenerationCache } from "../../../src/lib/cache";import { ProductDescriptionGenerator } from "../../../src/lib/generator";
Expected output: Start the dev server with pnpm dev, then:
Your terminal should show a JSON response array with one item per product, each tagged with status: "success" or status: "error". A GET /api/generate?sessionId=<id> returns the message history.
Step 13: Export the public API and run the tests
Replace the placeholder src/index.ts with named exports so consumers can import everything from a single entry point:
ts
// src/index.tsexport { BigCommerceClient, BigCommerceApiError } from "./lib/bigcommerce";export { EmbeddingService, EmbeddingError } from "./lib/embeddings";export { ClaudeService } from "./lib/anthropic";export { ProductVectorStore } from "./lib/pgvector-store";export { ContextPlanningService } from "./lib/context-planner";export { RepairService, descriptionSchema } from "./lib/description-repair";export { GenerationSessionStore } from "./lib/session-store";export { GenerationCache } from "./lib/cache";export { ProductDescriptionGenerator } from "./lib/generator";export type { BigCommerceProduct, ProductEmbedding, GenerateDescriptionRequest, GenerateDescriptionResponse, SimilarProduct, GenerationConfig,} from "./types";
Now run the full quality gate:
terminal
pnpm typecheckpnpm lintpnpm test
The test script runs vitest with coverage: vitest run --coverage --reporter=json --outputFile=vitest-report.json.
Expected output:pnpm typecheck exits 0. pnpm lint exits 0. The vitest report shows numFailedTests: 0 and all four coverage metrics (lines, branches, functions, statements) at 90% or above for runtime code (src/**/*.ts + app/**/route.ts).
Next steps
Add persistent storage to the session store — swap the in-memory IStorageAdapter for a Postgres-backed adapter (e.g., using @neondatabase/serverless) so sessions survive server restarts and you can inspect them in a dashboard
Wire up Langfuse tracing in the orchestrator — the observability module in src/lib/observability.ts provides createTrace, traceGeneration, and flushTrace helpers. Pass a trace to ProductDescriptionGenerator so every pipeline step (embedding, cache lookup, Claude call, repair) is visible in the Langfuse UI for cost analysis and debugging
Add BigCommerce webhook support — listen for product create/update webhooks from BigCommerce, automatically generate descriptions for new products and re-index embeddings for updated ones
Promise
<
T
> {
const text = await response.text();
const parsed: unknown = JSON.parse(text);
return parsed as T;
}
async function parseBodyOnError(response: Response): Promise<unknown> {
" tone. The description must be SEO-optimized, incorporate relevant keywords naturally, highlight key features and benefits, and be compelling to SMB buyers on BigCommerce.";