null

Test

? Phase 1 — Build the First Version 1:57

This guide documents the real development journey of a BigCommerce SEO Optimiser — built entirely inside Claude.ai without writing a single line of code manually. We started with a simple idea: "Can Claude generate page titles and meta descriptions for 1,557 products?" What followed was six rounds of enhancement, four bug fixes, and a complete architectural rewrite.

Each section maps to a real conversation exchange, so you can follow exactly what prompts were used, what broke, why it broke, and how it was fixed. By the end you'll be able to build your own version — or adapt the pattern for any bulk AI processing task.

What the finished tool does

Bulk AI Generation

Processes 1,500+ products using the Claude API in batches.

?

Crash Recovery

Progress saved to localStorage — survives logout and tab close.

?

Auto-Resume

Detects credit limits and resumes automatically when they refresh.

?️

Safe CSV Output

PapaParse preserves all original data — only 4 SEO columns touched.

Partial Export

Download a BigCommerce-ready CSV at any point mid-run.

Pause & Resume

Manually pause at any time and pick up exactly where you left off.


1
Phase 1 · Foundation

Build the First Version

? Phase 2 — Fix the JSON Truncation Error 1:45
1

Upload your product CSV to Claude

Export your product catalogue from BigCommerce (Products → Export → CSV). Upload it directly into the Claude.ai chat. Claude will read the file and identify the column structure — specifically Item, ID, Name, Description, Page Title, Meta Description, Search Keywords and Meta Keywords.

Prompt used "Analyse data and improve SEO page title, meta description, search keywords, meta keywords for each product and provide CSV output file"
2

Ask Claude to build a React artifact

Claude reads the CSV, identifies all product rows and builds a React artifact — an interactive app running directly in the chat window. It reads the uploaded file client-side, calls the Claude API in batches, and renders a live preview of generated SEO copy.

?
Why an artifact, not a script? A React artifact runs in the browser with no server required. The user uploads their CSV, the AI processes it, and they download the result — all in one tab. No infrastructure needed.
3

Define a strong system prompt for SEO quality

The quality of generated SEO copy depends entirely on the instructions given to the Claude API inside the artifact. The system prompt specifies exact character limits, format rules and tone for each field.

System prompt — inside the artifact
const SEO_SYSTEM_PROMPT = `
PAGE TITLE: 50-60 chars max. Keyword-rich, natural, readable.
META DESCRIPTION: 150-160 chars. Value prop + CTA.
  End with "Shop now" or "Free UK delivery".
SEARCH KEYWORDS: 8-12 comma-separated phrases.
META KEYWORDS: Same, semicolon-separated.

Respond ONLY with a valid JSON array:
[{"id":"112","pageTitle":"...","metaDescription":"..."}]
`;
⚠️
Always end the prompt with strict JSON instructions. Without "Respond ONLY with a valid JSON array", the model may include markdown fences or preamble text that breaks your JSON.parse() call.
4

Test with a small batch first

Before running all products, upload the CSV and click Start. Watch the first 3–4 batches complete and inspect the live preview panel. Check that page titles are under 60 characters and meta descriptions are 150–160 characters. Adjust the system prompt if quality isn't right before committing to a full run.


2
Phase 2 · First Bug

Fix the JSON Truncation Error

? Phase 3 — Persist Progress Across Sessions 1:37

The first run immediately produced this error for every single batch:

Error seen in the app
Batch IDs 112, 113, 114, 115, 116:
Unterminated string in JSON at position 3666
(line 33 column 101)
❌ Problem — 0 of 1,557 products optimised
The API was returning a valid response, but it was being cut off mid-JSON because max_tokens: 1000 was too low. A batch of 5 products with full SEO fields easily exceeds 1,000 tokens. The JSON was incomplete, so JSON.parse() threw a syntax error and the entire batch was skipped.
✅ Fix — Three changes made together
1. Increase max_tokens from 1,000 → 4,000 — gives the model enough room to complete all fields without truncating.

2. Reduce batch size from 5 → 2 products — smaller batches mean less output per call, reducing the chance of hitting the limit.

3. Add individual retry fallback — if a 2-product batch still fails to parse, retry each product individually before skipping.
The fix
// Before
const PRODUCTS_PER_BATCH = 5;
max_tokens: 1000

// After
const PRODUCTS_PER_BATCH = 2;
max_tokens: 4000

// Add retry logic on JSON parse failure
try {
  parsed = JSON.parse(clean);
} catch {
  for (const p of batch) {
    const r = await callAPI([p]);
    parsed.push(...r);
  }
}
max_tokens JSON truncation batch size retry logic

3
Phase 3 · Resilience

Persist Progress Across Sessions

? Phase 4 — Fix the CSV Output Bug 1:43

Processing 1,557 products takes time. If the tab closes or the user logs out mid-run, all progress is lost. The solution is to save results to localStorage after every batch.

1

Save after every batch

After each successful batch, serialise the results object and write it to localStorage under a fixed key. This happens automatically with no user action required.

Save after each batch
const STORAGE_KEY = "bc_seo_progress";

localStorage.setItem(STORAGE_KEY, JSON.stringify({
  savedResults:      current,
  savedFileName:     fileName,
  savedProductCount: products.length,
  savedAt:           new Date().toISOString(),
}));
2

Restore on page load

When the app loads, check localStorage for a previous session. If found, pre-populate the results and show a "Progress Restored" banner telling the user how many products were already done.

Restore on mount
useEffect(() => {
  const raw = localStorage.getItem(STORAGE_KEY);
  if (!raw) return;
  const { savedResults } = JSON.parse(raw);
  if (savedResults) {
    E.results = savedResults;
    setUiStatus("restored");
  }
}, []);
3

Resume from the right position

When processing starts, filter the product list to only those without a saved result. Since results are keyed by product ID, this automatically skips everything already done.

Skip already-processed products
const current = { ...E.results };
const todo = products.filter(p => !current[p.id]);
// Only processes products not yet in results

4
Phase 4 · Data Safety

Fix the CSV Output Bug

? Phase 5 — Auto-Resume After Credit Refresh 2:09

After the first successful download, re-importing the CSV into BigCommerce added extra rows. Product descriptions containing HTML bullet lists with embedded newlines were being split across multiple rows.

Example description that caused the problem
// This is ONE CSV cell — correctly quoted in the original file:
"<ul>
<li>Round necklace & stud earring set</li>
<li>Purple mother of pearl shell</li>
<li>Pendant 15mm high</li>
</ul>"

// Custom JS parser incorrectly split this into 5 separate rows
❌ Root cause — custom CSV parser
The app used a hand-written CSV parser that split on newline characters. It didn't handle the case where a newline appears inside a quoted field — which is perfectly valid in CSV but requires proper state-machine parsing to handle correctly.
✅ Fix — Switch to PapaParse for both reading and writing
PapaParse is a battle-tested CSV library available in React artifacts (import Papa from "papaparse"). It correctly handles quoted fields containing newlines, commas, and special characters.

Use Papa.unparse() with quotes: true when writing output. This re-quotes every field, so newlines inside descriptions are preserved safely.
Before and after
// ❌ Before — custom parser, breaks on multiline fields
function parseCSV(text) {
  const lines = text.split('\n'); // WRONG
}

// ✅ After — PapaParse handles all edge cases
Papa.parse(file, {
  header: true,
  skipEmptyLines: false, // keep ALL rows
  complete: (parsed) => { ... }
});

// ✅ Write back with proper quoting
const csv = Papa.unparse({ fields, data: updated }, {
  quotes: true,
  newline: "\n",
});
?️
Only update the 4 SEO columns. When rebuilding output rows, spread the original row first and only overwrite the 4 target fields. Every other column — descriptions, images, variants, pricing — passes through untouched: { ...row, "Page Title": res.pageTitle }

5
Phase 5 · Smart Recovery

Add Auto-Resume After Credit Refresh

? Phase 6 — Fix the Stale Closure Bug 0:58

Running 1,557 products in batches of 2 means roughly 780 API calls. On a standard plan this will eventually hit a usage limit. Instead of requiring the user to manually resume, the app should detect the error, wait, and resume automatically.

1

Detect credit errors vs other errors

When the API returns an error, inspect the message and HTTP status code to classify it. Credit/billing errors need polling; rate limit errors just need a short wait; other errors should skip the batch and continue.

Error classification
function isCreditError(msg, httpStatus) {
  return /credit|billing|payment|quota|insufficient/i
    .test(msg || '') || httpStatus === 402;
}
function isRateLimit(msg, httpStatus) {
  return /rate.limit|overloaded/i.test(msg || '')
    || httpStatus === 429 || httpStatus === 529;
}
2

Build a lightweight probe function

The probe sends a tiny request to the API — just "ping" with max_tokens of 10. If it gets any valid response back, billing is fine and we can resume. Only credit errors should keep the polling going.

probeCredits() — critical detail
async function probeCredits() {
  try {
    const res = await fetch(API_URL, { ...body });
    const data = await res.json();
    if (!data.error) return { ok: true }; // ✅ credits restored
    if (isCreditError(data.error.message, res.status))
      return { ok: false, reason: "credit" };
    return { ok: true }; // other API error = billing fine, resume
  } catch {
    return { ok: false, reason: "network" };
  }
}
?
Common mistake: returning false for ALL errors in probeCredits. If you catch network errors and return false, the probe never returns true and auto-resume never fires. Only credit errors should return { ok: false }.
3

Run a countdown polling loop

When a credit error is detected, set a 60-second timer and show a countdown in the UI. When the timer fires, call probeCredits(). If ok, resume immediately. If not, reschedule for another 60 seconds.

schedulePoll() — the polling engine
E.schedulePoll = function(attempt) {
  let secs = 60;
  E.cdTimer = setInterval(() => {
    setUiCountdown(--secs);
  }, 1000);

  E.pollTimer = setTimeout(async () => {
    clearInterval(E.cdTimer);
    const probe = await probeCredits();
    if (probe.ok) {
      E.runLoop(); // ✅ resume immediately
    } else {
      E.schedulePoll(attempt + 1);
    }
  }, 60000);
};

6
Phase 6 · Architecture Fix

Fix the Stale Closure Bug

Video · Phase 6 — The stale closure problem and the engine pattern · Coming soon

Auto-resume still didn't work. The probe succeeded and called runLoop() — but nothing happened. This was the most subtle bug of the entire build.

❌ Root cause — React stale closures
The processing and polling functions were defined using useCallback hooks. In React, hooks capture the values of variables at the time they're created. When schedulePoll was defined, it captured the initial version of runProcessingLoop. By the time the probe succeeded and called it, it was calling a frozen copy with no connection to live state — so nothing happened.
✅ Fix — The Engine Pattern
Move all processing logic out of React hooks entirely and into a plain JavaScript object (an "engine") created once using useRef. Plain object methods always read from the same shared mutable object — there are no closures, no stale captures, and no React rendering cycle involved.
The engine pattern — created once, lives forever
// Create the engine once and store in a ref
const engineRef = useRef(null);
if (!engineRef.current) {
  engineRef.current = createEngine(setters);
}

// Inside createEngine() — plain functions, no hooks
function createEngine(setters) {
  const E = { products: null, results: {}, isRunning: false };

  E.schedulePoll = function(attempt) {
    E.pollTimer = setTimeout(async () => {
      // Always calls E.runLoop — the SAME live function
      if ((await probeCredits()).ok) E.runLoop(); // ✅
      else E.schedulePoll(attempt + 1);
    }, 60000);
  };

  E.runLoop = async function() { /* ... */ };
  return E;
}
?
Rule of thumb: If you have async loops that call each other (processing → polling → processing), keep them in a plain object, not React hooks. Use React state only for rendering — pass setter functions into the engine so it can update the UI without being part of React's render cycle.

Add console logging to verify

During development, add console.log statements at key points. Open browser DevTools → Console and watch for [SEO] probe result: {ok: true} followed by [SEO] runLoop started. This confirms auto-resume is firing correctly.


+
Summary

Full Enhancement Timeline

Phase 1 · Initial build

First working prototype

React artifact with CSV upload, Claude API batching, live preview, and download. Batch size 5, max_tokens 1000.

Bug discovered

JSON truncation — 0 products processed

max_tokens too low caused API responses to be cut off mid-JSON, breaking JSON.parse() on every batch.

Phase 2 · Fix

max_tokens 4,000 · batch size 2 · individual retry

All 1,557 products now processable. JSON parse errors fall back to per-product retry before skipping.

Phase 3 · Feature

localStorage persistence + pause/resume

Progress saved after every batch. Restore banner on page load. Resume skips already-processed products automatically.

Bug discovered

Extra rows added to CSV on download

Custom JS CSV parser split multiline quoted fields (HTML bullet lists) into multiple rows on re-import.

Phase 4 · Fix

PapaParse for read and write

Replaced custom parser with PapaParse. Used Papa.unparse() with quotes: true. Only 4 SEO columns updated.

Phase 5 · Feature

Auto-pause + auto-resume on credit limit

Detects credit errors, shows countdown timer, probes every 60s, resumes automatically when limits refresh.

Bug discovered

Auto-resume fires but nothing happens

useCallback stale closures meant schedulePoll was calling a frozen initial version of runLoop.

Phase 6 · Fix

Engine pattern — plain object in useRef

All processing logic moved to a plain createEngine() object. No hooks, no stale closures. Auto-resume now works reliably.


Reference

Build Checklist

Use this when building your own version. Each item is a lesson learned from this real build.

  • System prompt ends with "Respond ONLY with valid JSON array" — prevents markdown fences breaking JSON.parse()
  • max_tokens ≥ 4,000 — never use 1,000 for multi-product batches
  • Batch size ≤ 2 for reliability; add individual retry fallback on JSON parse failure
  • Use PapaParse for both reading and writing CSV files — never a custom parser
  • Papa.unparse with quotes: true — safely handles descriptions with newlines, HTML, commas
  • Only overwrite the target columns — spread original row first: { ...row, "Page Title": ... }
  • Save to localStorage after every batch — not just at the end
  • Restore on mount — check localStorage in useEffect, pre-populate results before user does anything
  • Classify errors correctly — credit errors need polling; rate limits need 30s wait; other errors skip and continue
  • probeCredits() must return true for non-credit API errors — only billing errors should keep polling
  • Use the engine pattern for any async loop that calls itself — plain object in useRef, not useCallback
  • Use base64 data URI for CSV downloadURL.createObjectURL is blocked in sandboxed iframes
  • isRunning flag — prevents runLoop() from being called twice concurrently
  • Add console.log at probe and runLoop entry — essential for debugging auto-resume

Ready to build your own?

Open Claude.ai, upload your product CSV, and use the starter prompt below. The entire tool was built through conversation — no code editor required.

Open Claude.ai →
Starter prompt "Analyse my product CSV and build a React artifact that uses the Claude API to generate optimised SEO page titles (50-60 chars), meta descriptions (150-160 chars), search keywords and meta keywords for each product. Process in batches of 2 with max_tokens 4000, save progress to localStorage after every batch, use PapaParse for CSV handling, and output a BigCommerce-compatible CSV that preserves all original data."