Repair SEO
Deleting a product or category leaves a dangling URL with no redirect
Change a product's custom_url and BigCommerce quietly writes a 301 so the old link keeps working. Delete that same product outright, and nothing gets written at all. The old path just starts 404ing, forever, silently discarding whatever link equity, backlinks, and bookmarks were pointing at it. Here is why deletions skip the redirect step entirely and a small script that finds every dangling path and fixes only the ones nothing already covers.
BigCommerce only auto-generates a 301 redirect when a product's or category's custom_url changes while the entity still exists, the storefront URL-rewrite history feature, which can even be switched off with "Force 301 Redirect on old URL". Delete the record outright through the admin UI, DELETE /v3/catalog/products/{id}, or DELETE /v3/catalog/categories/{id}, and there is no "old path to new path" for BigCommerce to reconcile, so no row is written to /v3/storefront/redirects and the storefront serves its generic 404 for that URL indefinitely. Run a small Python or Node.js script that snapshots live product and category URLs, diffs a later snapshot against it to find the ids that vanished, checks each dangling path against existing redirects, and bulk-creates a 301 with PUT /v3/storefront/redirects only for the paths that truly have none. Full code, tests, and a dry run guard are below.
The problem in plain words
BigCommerce's redirect system is reactive to a change, not to a disappearance. When you edit a product's URL in the admin, or the API changes custom_url, BigCommerce can see both the old value and the new value on the same record at the same moment, and it writes a 301 from one to the other. That is the entire mechanism the storefront URL-rewrite history relies on, and it is exactly why the "Force 301 Redirect on old URL" checkbox exists, to let you opt in or out of that specific behavior on a rename.
A deletion never gives BigCommerce that pair. Whether it happens by clicking Delete in the catalog admin or by calling DELETE /v3/catalog/products/{id} or DELETE /v3/catalog/categories/{id}, the record is simply gone after the call succeeds. There is no new path to associate the old one with, so the redirect table is never touched. The next request for that URL falls through to the storefront's generic 404 template, and it stays that way forever unless someone notices and fixes it by hand.
Why it happens
The gap is a direct consequence of how the redirect feature is built, not a bug in any single call. A few concrete ways it shows up:
- A merchant deletes a discontinued product from the catalog admin. The old product page URL, which may have years of backlinks and search rankings, starts 404ing the moment the delete confirms.
- A catalog cleanup script calls
DELETE /v3/catalog/products/{id}orDELETE /v3/catalog/categories/{id}for a batch of ids. Every one of those custom_url values goes dangling in the same run, with no warning and no automatic fallback. - The "Force 301 Redirect on old URL" checkbox only ever applies to a rename. It has no effect on a delete, because there is no "old URL" event fired for a record that no longer exists to compare against.
- A category is deleted while products still reference it in navigation or in old emails and ad campaigns, so the old category URL keeps getting hit by real traffic long after the category itself is gone.
This is a recurring question in BigCommerce's own support community, deleting a product without a redirect is confirmed to have real SEO downside, since the old URL simply stops resolving to anything. See the citations at the end for the exact threads and docs.
You cannot ask BigCommerce "what did I just delete." You have to know it yourself, from a snapshot taken before the deletion. The safe pattern is to keep a periodic export of every live product and category custom_url, then after any deletion event, diff the previous snapshot's ids against the ids that are still live. Any id that vanished and whose URL is not already covered by an existing row in /v3/storefront/redirects is a true gap, and only those get a new redirect.
The fix, as a flow
We do not touch the catalog or the delete flow. We add a job that keeps a URL snapshot, detects which ids disappeared since the last snapshot, checks each candidate against the existing redirects, and bulk-creates a 301 only for the paths that truly have none.
Build it step by step
Get a store hash and an API access token
Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it Content (modify) scope for the storefront redirects endpoint and Products/Categories (read) so it can snapshot custom_url values. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export BIGCOMMERCE_SITE_ID="1"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export BIGCOMMERCE_SITE_ID="1"
export DRY_RUN="true" // start safe, change to false to write
Talk to the V3 Catalog and Redirects REST API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. A small helper handles GET and PUT and raises on a non-2xx response. We reuse it to list product and category URLs and to read and upsert redirects.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def bc_put(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGet(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPut(path, body) {
const res = await fetch(`${API_BASE}${path}`, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
Snapshot live URLs and pull the existing redirects
Call GET /v3/catalog/products?include_fields=custom_url,id&limit=250 and GET /v3/catalog/categories?include_fields=custom_url,id&limit=250, paginating with meta.pagination, and record each id to custom_url.url mapping. Save this to disk or a small table as "the previous snapshot" for next time. Then call GET /v3/storefront/redirects?path:in={candidates} for the paths you suspect went dangling, to see which ones are already covered.
def snapshot_urls(resource):
urls = {}
page = 1
while True:
res = bc_get(f"/catalog/{resource}", {
"include_fields": "custom_url,id",
"page": page,
"limit": 250,
})
for item in res["data"]:
url = (item.get("custom_url") or {}).get("url")
if url:
urls[item["id"]] = url
if page >= res["meta"]["pagination"]["total_pages"]:
return urls
page += 1
def existing_redirect_paths(candidate_paths):
if not candidate_paths:
return set()
res = bc_get("/storefront/redirects", {"path:in": ",".join(candidate_paths)})
return {row["from_path"] for row in res["data"]}
async function snapshotUrls(resource) {
const urls = {};
let page = 1;
while (true) {
const res = await bcGet(`/catalog/${resource}`, {
include_fields: "custom_url,id",
page,
limit: 250,
});
for (const item of res.data) {
const url = item.custom_url && item.custom_url.url;
if (url) urls[item.id] = url;
}
if (page >= res.meta.pagination.total_pages) return urls;
page += 1;
}
}
async function existingRedirectPaths(candidatePaths) {
if (!candidatePaths.length) return new Set();
const res = await bcGet("/storefront/redirects", { "path:in": candidatePaths.join(",") });
return new Set(res.data.map((row) => row.from_path));
}
Decide, with one pure function
Keep the decision in its own function that takes the previous snapshot, the set of ids that are still live, the set of paths already covered by a redirect, and a fallback target. It never touches the network. It just walks the previous snapshot and emits an upsert record for every id that vanished and whose URL is not already redirected, skipping everything still live and everything already covered.
def plan_redirects(previous_urls, current_ids, existing_redirect_paths, fallback_target):
plan = []
for entity_id, url in previous_urls.items():
if entity_id in current_ids:
continue # still live, nothing deleted here
if url in existing_redirect_paths:
continue # already covered, avoid a duplicate or conflicting rule
plan.append({"from_path": url, "to": fallback_target})
return plan
export function planRedirects(previousUrls, currentIds, existingRedirectPaths, fallbackTarget) {
const plan = [];
for (const [entityId, url] of Object.entries(previousUrls)) {
if (currentIds.has(Number(entityId))) continue; // still live, nothing deleted here
if (existingRedirectPaths.has(url)) continue; // already covered, avoid a duplicate or conflicting rule
plan.push({ from_path: url, to: fallbackTarget });
}
return plan;
}
Upsert the redirects the same way the admin action would
For every record in the plan, call PUT /v3/storefront/redirects with a body of [{"from_path": url, "site_id": site_id, "to": fallback_target}]. The fallback target can point at the home page ({"type": "url", "url": "/"}) or, when a suitable replacement or parent category still exists, at that entity directly ({"type": "category", "entity_id": id}). After writing, call GET /v3/storefront/redirects again and confirm each from_path now resolves before reporting success.
def upsert_redirects(site_id, plan):
body = [{"from_path": item["from_path"], "site_id": site_id, "to": item["to"]} for item in plan]
return bc_put("/storefront/redirects", body)
async function upsertRedirects(siteId, plan) {
const body = plan.map((item) => ({ from_path: item.from_path, site_id: siteId, to: item.to }));
return bcPut("/storefront/redirects", body);
}
Wire it together with a dry run guard
The loop ties every piece together. Load the previous snapshot, fetch the current live ids, build the candidate list, check it against existing redirects, run the pure planner, and either log the plan or write it. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs the {from_path, to} pairs it would upsert. Read the output, agree with it, then switch it off, save the new snapshot for next time, and run it on a schedule, for example nightly.
Always start with DRY_RUN=true, and always exclude any path that already has a redirect before writing. Upserting a redirect for a path that is already redirected elsewhere can create a conflicting or duplicate rule that is harder to untangle than the original 404 was.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only writes redirects for paths that are both confirmed deleted and confirmed uncovered.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Find and repair dangling URLs left behind by deleted BigCommerce products and categories.
BigCommerce only auto-generates a 301 redirect when a product's or category's
custom_url is changed while the record still exists, the storefront URL-rewrite
history feature. Deleting the record outright, through the admin UI or
DELETE /v3/catalog/products/{id} or /v3/catalog/categories/{id}, never gives
BigCommerce an old path and a new path to reconcile, so no redirect row is ever
written and the old URL 404s indefinitely. This job keeps a snapshot of live
product and category custom_url values, diffs the previous snapshot against the
ids that are still live to find what was deleted, checks each candidate path
against the existing redirects, and upserts a 301 only for the paths that are
both confirmed deleted and confirmed uncovered. Run on a schedule. Safe to run
again and again.
Guide: https://www.allanninal.dev/bigcommerce/deleted-product-no-redirect/
"""
import json
import logging
import os
from pathlib import Path
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("repair_deleted_url_redirects")
STORE_HASH = os.environ.get("BIGCOMMERCE_STORE_HASH", "example_hash")
ACCESS_TOKEN = os.environ.get("BIGCOMMERCE_ACCESS_TOKEN", "bc_dummy")
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
SITE_ID = int(os.environ.get("BIGCOMMERCE_SITE_ID", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
SNAPSHOT_PATH = Path(os.environ.get("SNAPSHOT_PATH", "url_snapshot.json"))
FALLBACK_TARGET = {"type": "url", "url": "/"}
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json()
def bc_put(path, body):
r = requests.put(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
def plan_redirects(previous_urls, current_ids, existing_redirect_paths, fallback_target):
"""Pure decision. No network, no side effects.
previous_urls: dict[int, str] mapping entity id to its custom_url.url from
the last snapshot. current_ids: set[int] of ids that are still live right
now. existing_redirect_paths: set[str] of from_path values that already
have a redirect. fallback_target: the "to" object to use for any new
redirect.
For each (id, url) in previous_urls where id is missing from current_ids
(deleted) and url is not already in existing_redirect_paths (no redirect
yet), emit {"from_path": url, "to": fallback_target}. Ids still present
are skipped (not deleted). Urls already redirected are skipped (no-op,
avoids duplicate or conflicting redirects).
"""
plan = []
for entity_id, url in previous_urls.items():
if entity_id in current_ids:
continue
if url in existing_redirect_paths:
continue
plan.append({"from_path": url, "to": fallback_target})
return plan
def snapshot_urls(resource):
"""resource is "products" or "categories". Returns dict[int, str]."""
urls = {}
page = 1
while True:
res = bc_get(
f"/catalog/{resource}",
{"include_fields": "custom_url,id", "page": page, "limit": 250},
)
for item in res.get("data", []):
url = (item.get("custom_url") or {}).get("url")
if url:
urls[item["id"]] = url
pagination = res.get("meta", {}).get("pagination", {})
if page >= pagination.get("total_pages", page):
return urls
page += 1
def existing_redirect_paths(candidate_paths):
if not candidate_paths:
return set()
res = bc_get("/storefront/redirects", {"path:in": ",".join(candidate_paths)})
return {row["from_path"] for row in res.get("data", [])}
def upsert_redirects(plan):
body = [{"from_path": item["from_path"], "site_id": SITE_ID, "to": item["to"]} for item in plan]
return bc_put("/storefront/redirects", body)
def load_previous_snapshot():
if not SNAPSHOT_PATH.exists():
return {}
with SNAPSHOT_PATH.open() as f:
raw = json.load(f)
return {int(k): v for k, v in raw.items()}
def save_snapshot(urls):
with SNAPSHOT_PATH.open("w") as f:
json.dump(urls, f)
def run():
previous_urls = load_previous_snapshot()
current_products = snapshot_urls("products")
current_categories = snapshot_urls("categories")
current_urls = {**current_products, **current_categories}
current_ids = set(current_urls.keys())
candidate_paths = [url for entity_id, url in previous_urls.items() if entity_id not in current_ids]
covered = existing_redirect_paths(candidate_paths)
plan = plan_redirects(previous_urls, current_ids, covered, FALLBACK_TARGET)
for item in plan:
log.info(
"from_path=%s to=%s (%s)",
item["from_path"], item["to"], "dry run" if DRY_RUN else "upserting",
)
if plan and not DRY_RUN:
upsert_redirects(plan)
confirmed = existing_redirect_paths([item["from_path"] for item in plan])
for item in plan:
if item["from_path"] not in confirmed:
log.warning("Redirect for %s did not confirm after upsert.", item["from_path"])
save_snapshot(current_urls)
log.info(
"Done. %d dangling path(s) %s.",
len(plan), "found (dry run)" if DRY_RUN else "repaired",
)
if __name__ == "__main__":
run()
/**
* Find and repair dangling URLs left behind by deleted BigCommerce products and categories.
*
* BigCommerce only auto-generates a 301 redirect when a product's or category's
* custom_url is changed while the record still exists, the storefront URL-rewrite
* history feature. Deleting the record outright, through the admin UI or
* DELETE /v3/catalog/products/{id} or /v3/catalog/categories/{id}, never gives
* BigCommerce an old path and a new path to reconcile, so no redirect row is ever
* written and the old URL 404s indefinitely. This job keeps a snapshot of live
* product and category custom_url values, diffs the previous snapshot against the
* ids that are still live to find what was deleted, checks each candidate path
* against the existing redirects, and upserts a 301 only for the paths that are
* both confirmed deleted and confirmed uncovered. Run on a schedule.
*
* Guide: https://www.allanninal.dev/bigcommerce/deleted-product-no-redirect/
*/
import { readFile, writeFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const SITE_ID = Number(process.env.BIGCOMMERCE_SITE_ID || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SNAPSHOT_PATH = process.env.SNAPSHOT_PATH || "url_snapshot.json";
const FALLBACK_TARGET = { type: "url", url: "/" };
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No network, no side effects.
*
* previousUrls: object mapping entity id (number or numeric string) to its
* custom_url.url from the last snapshot. currentIds: Set of ids that
* are still live right now. existingRedirectPaths: Set of from_path
* values that already have a redirect. fallbackTarget: the "to" object to use
* for any new redirect.
*
* For each (id, url) in previousUrls where id is missing from currentIds
* (deleted) and url is not already in existingRedirectPaths (no redirect
* yet), emit {from_path: url, to: fallbackTarget}. Ids still present are
* skipped (not deleted). Urls already redirected are skipped (no-op, avoids
* duplicate or conflicting redirects).
*/
export function planRedirects(previousUrls, currentIds, existingRedirectPaths, fallbackTarget) {
const plan = [];
for (const [entityId, url] of Object.entries(previousUrls)) {
if (currentIds.has(Number(entityId))) continue;
if (existingRedirectPaths.has(url)) continue;
plan.push({ from_path: url, to: fallbackTarget });
}
return plan;
}
async function bcGet(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
return res.json();
}
async function bcPut(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: "PUT",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
async function snapshotUrls(resource) {
const urls = {};
let page = 1;
while (true) {
const res = await bcGet(`/catalog/${resource}`, {
include_fields: "custom_url,id",
page,
limit: 250,
});
for (const item of res.data || []) {
const url = item.custom_url && item.custom_url.url;
if (url) urls[item.id] = url;
}
const pagination = (res.meta && res.meta.pagination) || {};
if (page >= (pagination.total_pages || page)) return urls;
page += 1;
}
}
async function existingRedirectPaths(candidatePaths) {
if (!candidatePaths.length) return new Set();
const res = await bcGet("/storefront/redirects", { "path:in": candidatePaths.join(",") });
return new Set((res.data || []).map((row) => row.from_path));
}
async function upsertRedirects(plan) {
const body = plan.map((item) => ({ from_path: item.from_path, site_id: SITE_ID, to: item.to }));
return bcPut("/storefront/redirects", body);
}
async function loadPreviousSnapshot() {
try {
const raw = await readFile(SNAPSHOT_PATH, "utf8");
return JSON.parse(raw);
} catch {
return {};
}
}
async function saveSnapshot(urls) {
await writeFile(SNAPSHOT_PATH, JSON.stringify(urls));
}
export async function run() {
const previousUrls = await loadPreviousSnapshot();
const currentProducts = await snapshotUrls("products");
const currentCategories = await snapshotUrls("categories");
const currentUrls = { ...currentProducts, ...currentCategories };
const currentIds = new Set(Object.keys(currentUrls).map(Number));
const candidatePaths = Object.entries(previousUrls)
.filter(([entityId]) => !currentIds.has(Number(entityId)))
.map(([, url]) => url);
const covered = await existingRedirectPaths(candidatePaths);
const plan = planRedirects(previousUrls, currentIds, covered, FALLBACK_TARGET);
for (const item of plan) {
console.log(`from_path=${item.from_path} to=${JSON.stringify(item.to)} (${DRY_RUN ? "dry run" : "upserting"})`);
}
if (plan.length && !DRY_RUN) {
await upsertRedirects(plan);
const confirmed = await existingRedirectPaths(plan.map((item) => item.from_path));
for (const item of plan) {
if (!confirmed.has(item.from_path)) {
console.warn(`Redirect for ${item.from_path} did not confirm after upsert.`);
}
}
}
await saveSnapshot(currentUrls);
console.log(`Done. ${plan.length} dangling path(s) ${DRY_RUN ? "found (dry run)" : "repaired"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which URLs get a new redirect written on top of them. Because plan_redirects takes only plain dicts and sets and returns a plain list, the test needs no network and no BigCommerce store. It just feeds in plain data and checks the plan.
from repair_deleted_url_redirects import plan_redirects
FALLBACK = {"type": "url", "url": "/"}
def test_no_plan_when_nothing_deleted():
previous = {1: "/old-widget/"}
assert plan_redirects(previous, {1}, set(), FALLBACK) == []
def test_plans_a_redirect_for_a_deleted_uncovered_url():
previous = {1: "/old-widget/", 2: "/old-gadget/"}
plan = plan_redirects(previous, {2}, set(), FALLBACK)
assert plan == [{"from_path": "/old-widget/", "to": FALLBACK}]
def test_skips_a_deleted_url_already_covered_by_a_redirect():
previous = {1: "/old-widget/"}
plan = plan_redirects(previous, set(), {"/old-widget/"}, FALLBACK)
assert plan == []
def test_handles_multiple_deletions_independently():
previous = {1: "/old-widget/", 2: "/old-gadget/", 3: "/old-gizmo/"}
plan = plan_redirects(previous, set(), {"/old-gadget/"}, FALLBACK)
from_paths = {item["from_path"] for item in plan}
assert from_paths == {"/old-widget/", "/old-gizmo/"}
def test_empty_previous_snapshot_yields_empty_plan():
assert plan_redirects({}, {1, 2, 3}, set(), FALLBACK) == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { planRedirects } from "./repair-deleted-url-redirects.js";
const FALLBACK = { type: "url", url: "/" };
test("no plan when nothing deleted", () => {
const previous = { 1: "/old-widget/" };
assert.deepEqual(planRedirects(previous, new Set([1]), new Set(), FALLBACK), []);
});
test("plans a redirect for a deleted uncovered url", () => {
const previous = { 1: "/old-widget/", 2: "/old-gadget/" };
const plan = planRedirects(previous, new Set([2]), new Set(), FALLBACK);
assert.deepEqual(plan, [{ from_path: "/old-widget/", to: FALLBACK }]);
});
test("skips a deleted url already covered by a redirect", () => {
const previous = { 1: "/old-widget/" };
const plan = planRedirects(previous, new Set(), new Set(["/old-widget/"]), FALLBACK);
assert.deepEqual(plan, []);
});
test("handles multiple deletions independently", () => {
const previous = { 1: "/old-widget/", 2: "/old-gadget/", 3: "/old-gizmo/" };
const plan = planRedirects(previous, new Set(), new Set(["/old-gadget/"]), FALLBACK);
const fromPaths = new Set(plan.map((item) => item.from_path));
assert.deepEqual(fromPaths, new Set(["/old-widget/", "/old-gizmo/"]));
});
test("empty previous snapshot yields empty plan", () => {
assert.deepEqual(planRedirects({}, new Set([1, 2, 3]), new Set(), FALLBACK), []);
});
Case studies
The store that deleted 200 discontinued products in one afternoon
A merchant ran an end-of-season cleanup, deleting a couple hundred discontinued products straight from the admin catalog view. Nobody thought about redirects, because nothing in the delete flow mentions them. Within a week, the 404 count in Search Console had climbed by exactly the same number, all of them old product URLs with real backlinks and years of accumulated rankings.
Running the snapshot-diff job against a backup taken the day before the cleanup found every one of those two hundred paths in a single pass. None of them had an existing redirect, so all two hundred got a 301 to the closest matching category, recovering most of the SEO value that would otherwise have just evaporated.
The rebrand that deleted an old category the ad campaigns still linked to
During a site rebrand, an old top-level category was deleted as part of restructuring the navigation. Nobody remembered that a paid ad campaign from the previous year still pointed straight at that category's URL, and traffic from that stale campaign kept arriving at a 404 for months.
Because the nightly snapshot job had already been running, the very first diff after the deletion caught the category's custom_url as gone with no covering redirect, and the fix went in as a 301 to the new parent category the same night, before anyone even noticed the ad traffic was landing wrong.
After this runs on a schedule, a deleted product or category is never more than one run away from a working 301, whether the deletion happened through the admin UI, a bulk API cleanup, or a script nobody remembered to check for redirects afterward. Paths that already have a redirect are never touched twice, so there is no risk of the job stacking conflicting rules on top of a fix someone already made by hand.
FAQ
Why does BigCommerce not create a redirect when I delete a product?
BigCommerce only auto-generates a 301 when a product's or category's custom_url changes while the record still exists, because the URL-rewrite history feature needs an old path and a new path on the same entity to reconcile. Deleting the entity outright removes it before there is ever a new path to point to, so no redirect row gets written and the old path 404s indefinitely.
Does a 404 from a deleted product actually hurt SEO?
Yes. Any inbound backlinks, bookmarks, or search engine results pointing at the old URL now land on a generic 404 page instead of being credited toward a live page, which silently discards link equity that took time to build. A 301 redirect to a relevant replacement or a fallback page preserves most of that value instead of losing it.
Is it safe to bulk-create redirects for every deleted product URL?
Only after checking each candidate path against the existing redirects list. If a path already has a redirect, creating another one risks a duplicate or conflicting rule, so the repair should only write redirects for paths confirmed to have none, and it should default to a dry run that logs the planned upserts before writing anything.
Related field notes
Citations
On the problem:
- BigCommerce Support: deleted products, redirect or 404 error. support.bigcommerce.com deleted products redirect or 404 error
- BigCommerce Support: does deleting a product without a 301 redirect have negative SEO effects. support.bigcommerce.com deleting a product without a 301 redirect
- BigCommerce Help Center: 301 Redirects. support.bigcommerce.com 301 redirects
On the solution:
- BigCommerce Developer Center: Redirects (REST Management). developer.bigcommerce.com redirects
- BigCommerce API Reference: Upsert Redirects. docs.bigcommerce.com upsert redirects
- BigCommerce Developer Center: Products, custom_url, and deleting by id:in. developer.bigcommerce.com products
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, webhooks, inventory, or SEO that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this save a pile of dead links?
If this saved you from silently losing link equity on a deleted product or category, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi