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.
If you want to save time you can download the file we have developed and enhance it using Claude to meet your own requirements. Download Now
What the finished tool does
Bulk AI Generation
Processes 1,500+ products in batches using the Claude API.
Crash Recovery
Progress saved to localStorage — survives logout and tab close.
Auto-Resume
Detects credit limit errors and resumes automatically when limits refresh.
Safe CSV Output
PapaParse preserves all original data — only 4 SEO columns are touched.
Partial Export
Download a BigCommerce-ready CSV at any point during processing.
Pause & Resume
Manually pause at any time and pick up exactly where you left off.
Build the First Version
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.
Ask Claude to build a React artifact
Claude reads the CSV, identifies 1,557 product rows and builds a React artifact — an interactive app running directly in the chat window. It reads the uploaded file client-side using PapaParse, calls the Claude API in batches of 5, and renders a live preview of generated SEO copy.
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.
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":"..."}] `;
Test with a small batch first
Before running all 1,557 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 the quality isn't right before committing to a full run.
Fix the JSON Truncation Error
The first run immediately produced this error for every single batch:
Batch IDs 112, 113, 114, 115, 116: Unterminated string in JSON at position 3666 (line 33 column 101)
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.2. Reduce batch size from 5 → 2 products — smaller batches mean less output per call, reducing the chance of hitting the limit even at 4,000 tokens.
3. Add individual retry fallback — if a 2-product batch still fails to parse, retry each product individually before skipping.
// Before const PRODUCTS_PER_BATCH = 5; max_tokens: 1000 // After const PRODUCTS_PER_BATCH = 2; max_tokens: 4000 // Add retry logic try { parsed = JSON.parse(clean); } catch { // Retry each product individually for (const p of batch) { const r = await callAPI([p]); parsed.push(...r); } }
Persist Progress Across Sessions
Processing 1,557 products takes time. If the tab is closed, the browser crashes, or the user logs out of Claude mid-run, all progress is lost. The solution is to save results to localStorage after every batch.
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.
const STORAGE_KEY = "bc_seo_progress"; // After processing each batch: localStorage.setItem(STORAGE_KEY, JSON.stringify({ savedResults: current, savedFileName: fileName, savedProductCount: products.length, savedAt: new Date().toISOString(), }));
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.
useEffect(() => { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; const { savedResults, savedFileName } = JSON.parse(raw); if (savedResults) { E.results = savedResults; setUiResults(savedResults); setUiStatus("restored"); } }, []);
Resume from the right position
When processing starts (or resumes), filter the product list to only those without a saved result. Since results are keyed by product ID, this automatically skips everything already done.
const current = { ...E.results }; const todo = products.filter(p => !current[p.id]); // Only processes products not yet in results
Fix the CSV Output Bug
After the first successful download, the user noticed that re-importing the CSV into BigCommerce added extra rows. The original product descriptions — which contain HTML bullet lists with embedded newlines — were being split across multiple rows.
// Original Description field (inside a quoted CSV cell): "" // Custom JS parser incorrectly split this into 5 rows!
- Round necklace & stud earring set
- Purple mother of pearl shell
- Pendant 15mm high
import Papa from "papaparse"). It correctly handles quoted fields containing newlines, commas, and special characters — exactly what BigCommerce exports.Crucially, use
Papa.unparse() with quotes: true when writing the output CSV. This re-quotes every field, so newlines inside descriptions are preserved safely.
// ❌ 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, // always quote — safest for re-import newline: "\n", });
...row) and only overwrite Page Title, Meta Description, Search Keywords, and Meta Keywords. Every other column — description, images, variants, pricing — passes through untouched.
Add Auto-Resume After Credit Refresh
Running 1,557 products through the Claude API in batches of 2 makes roughly 780 API calls. On a standard plan this will eventually hit a rate or credit limit. Instead of requiring the user to manually resume, the app should detect the error, wait, and resume automatically.
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.
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; }
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 (even a different kind of error), billing is fine and we can resume. Only credit/billing errors should keep the polling going.
async function probeCredits() { try { const res = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", body: JSON.stringify({ model: "claude-sonnet-4-6", max_tokens: 10, system: "Reply: ok", messages: [{ role: "user", content: "ping" }] }) }); 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 } catch { return { ok: false, reason: "network" }; } }
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 }.
Run a countdown polling loop
When a credit error is detected, set a 60-second timer. Decrement a countdown counter every second for the UI. When the timer fires, call probeCredits(). If ok, call runLoop() directly. If not, reschedule.
E.schedulePoll = function(attempt) { let secs = 60; E.cdTimer = setInterval(() => { secs -= 1; 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); // try again } }, 60000); };
Fix the Stale Closure Bug
Auto-resume still didn't work. The polling probe succeeded, runLoop() was called — but nothing happened. This was the most subtle bug of the entire build.
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 runProcessingLoop(), it was calling a frozen copy that had no connection to the live app state — so nothing happened.
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.
// Create the engine object once and store it in a ref const engineRef = useRef(null); if (!engineRef.current) { engineRef.current = createEngine(setters); } const E = engineRef.current; // Inside createEngine() — plain functions, no hooks function createEngine(setters) { const E = { products: null, results: {}, isRunning: false, pollTimer: null, }; E.schedulePoll = function(attempt) { // Always calls E.runLoop — the SAME function, not a stale copy E.pollTimer = setTimeout(async () => { if ((await probeCredits()).ok) E.runLoop(); // ✅ always live else E.schedulePoll(attempt + 1); }, 60000); }; E.runLoop = async function() { ... }; return E; }
setUiStatus, setUiResults) 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 in the engine. Open browser DevTools → Console and watch for [SEO] probe result: {ok: true} followed by [SEO] runLoop started. This confirms auto-resume is firing correctly.
// In schedulePoll, after probe: console.log("[SEO] probe result:", probe); // At the start of runLoop: console.log("[SEO] runLoop started, todo:", todo.length);
Full Enhancement Timeline
First working prototype
React artifact with CSV upload, Claude API batching, live preview, and download. Batch size 5, max_tokens 1000.
JSON truncation — 0 products processed
max_tokens too low caused API responses to be cut off mid-JSON, breaking JSON.parse() on every batch.
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.
localStorage persistence + pause/resume
Progress saved after every batch. Restore banner on page load. Resume skips already-processed products automatically.
Extra rows added to CSV on download
Custom JS CSV parser split multiline quoted fields (HTML bullet lists) into multiple rows.
PapaParse for read and write
Replaced custom parser with PapaParse. Used Papa.unparse() with quotes: true. Only 4 SEO columns updated.
Auto-pause + auto-resume on credit limit
Detects credit errors, shows countdown, probes every 60s, resumes automatically when credits refresh.
Auto-resume fires but nothing happens
useCallback stale closures meant schedulePoll was calling a frozen initial version of runLoop.
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.
Build Checklist
Use this as a reference when building your own version. Each item represents a lesson learned from this build.
- System prompt ends with "Respond ONLY with valid JSON array" — no markdown fences
- 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 — 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 download —
URL.createObjectURLis 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 start with the prompt below. The entire tool was built through conversation — no code editor required.
Open Claude.ai →If you want to save time you can download the file we have developed and enhance it using Claude to meet your own requirements. Download Now