Reconciler Multi-Storefront / Channels

New BigCommerce storefront channel starts with an incomplete category tree

You spin up a second storefront channel, expecting the same navigation the primary storefront already has. Instead half the categories are missing, or the tree is entirely empty. Nothing broke. A category tree in BigCommerce's Multi-Storefront setup can only ever belong to one channel, and creating a channel never clones it. Here is why that gap is permanent by default and a small script that finds exactly which category nodes are missing and backfills them safely.

Python and Node.js BigCommerce V3 Catalog Trees API Safe by default (dry run)
A white board with writing on it
Photo by sarah b on Unsplash
The short answer

A category tree (a /v3/catalog/trees object) may only be assigned to a maximum of one channel at a time, and creating a new storefront channel does not clone the primary storefront's tree. The new channel starts unassigned or pointed at a fresh, empty tree, so none of the primary tree's category nodes exist under the new tree_id unless someone copies them over. Run a small Python or Node.js script that pulls the full node set for both the primary and the secondary channel's tree with GET /v3/catalog/trees/{tree_id}/categories, diffs them by a stable name-and-parent-path key, and backfills only the missing nodes into the secondary tree with POST /v3/catalog/trees/categories, parent-first. Full code, tests, and a dry run guard are below.

The problem in plain words

In BigCommerce's Multi-Storefront architecture, the catalog is shared across channels, but the category tree that organizes it into navigation is not automatically shared. A tree is its own resource under /v3/catalog/trees, and it can be assigned to at most one channel at a time. When you create a second storefront channel, BigCommerce does not walk the primary channel's tree and reproduce it under a new tree_id. The new channel either starts with no tree assigned or with a brand new, empty one.

Category-to-tree membership is explicit. A category belongs to a specific tree_id, not to every channel that happens to share the same product catalog. So any category node that existed before the second channel was provisioned, and was never manually copied, simply does not exist in the new tree. Any node added later, to either tree, only ever lands in the one tree it was created against. The two storefronts drift apart permanently unless something keeps them in sync.

Primary channel tree_id 1, full nodes Shoes / Boots / Shirts New channel created no tree cloned Never cloned Category tree (tree) /v3/catalog/trees max one channel per tree tree_id 2, empty or partial no Shoes, no Boots Second storefront navigation gap, permanent
Both channels can share the same catalog of products, but each channel's tree is a standalone object assigned to at most one channel, and BigCommerce never clones one into the other.

Why it happens

This is not a bug so much as a consequence of how Multi-Storefront separates the catalog from its navigation structure. A few concrete ways the gap shows up:

The result is a storefront that looks half-built to a shopper: a navbar with some categories missing, or entirely bare on a freshly created channel, even though the products themselves are all there in the shared catalog.

The key insight

A channel's tree_id tells you nothing about what should be in that tree, only what currently is. The two channels are independent trees that happen to want the same shape. So the safe pattern is not "assign the primary tree_id to the new channel" (unsupported, and the docs are explicit that a tree serves at most one channel), it is "diff the two trees by a stable path and backfill only the nodes that are missing in the secondary tree." We compute the path from each node's parent_id chain rather than trusting raw ids, because ids differ per tree even when the category is conceptually the same one.

The fix, as a flow

We do not touch the primary tree, and we do not try to merge the two trees into one. We list channels to find the primary and secondary tree_id, pull every category node from both trees, diff them by name-and-parent-path, and bulk create the missing nodes into the secondary tree in parent-first order so every parent_id reference resolves.

List channels find both tree_id Fetch both trees categories, paginated diffCategoryTrees pure, path-based diff, depth sorted Missing node list parent-first order Bulk create into secondary tree DRY_RUN first
The primary tree is never modified. Every missing node is created in the secondary tree only, parent-first, so parent_id references always resolve, and a dry run logs the plan before anything writes.

Build it step by step

1

Get a store hash and an API access token

Create an API account in your BigCommerce control panel under Settings, API, or reuse the store's existing app credentials. Grant it Storefront Channel Settings and Products (modify) scope so it can read channels, trees, and categories, and create new category nodes. 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.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export PRIMARY_CHANNEL_ID="1"
export SECONDARY_CHANNEL_ID="2"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export PRIMARY_CHANNEL_ID="1"
export SECONDARY_CHANNEL_ID="2"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the V3 Catalog Trees and Channels REST API

Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. V3 responses wrap the payload in {data, meta.pagination}. A small helper handles GET and POST, follows meta.pagination.links.next, and raises on a non-2xx response.

step2.py
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_all(path, params=None):
    """Follow meta.pagination.links.next and return every item in data."""
    items = []
    query = dict(params or {})
    query.setdefault("limit", 250)
    page = 1
    while True:
        query["page"] = page
        r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=query, timeout=30)
        r.raise_for_status()
        body = r.json()
        items.extend(body.get("data", []))
        next_link = (body.get("meta", {}).get("pagination", {}).get("links", {}) or {}).get("next")
        if not next_link:
            return items
        page += 1

def bc_post(path, body):
    r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()
step2.js
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 bcGetAll(path, params = {}) {
  const items = [];
  let page = 1;
  while (true) {
    const url = new URL(`${API_BASE}${path}`);
    for (const [key, value] of Object.entries({ limit: 250, ...params, page })) {
      url.searchParams.set(key, value);
    }
    const res = await fetch(url, { headers: HEADERS });
    if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
    const body = await res.json();
    items.push(...(body.data || []));
    const next = body.meta?.pagination?.links?.next;
    if (!next) return items;
    page += 1;
  }
}

async function bcPost(path, body) {
  const res = await fetch(`${API_BASE}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
3

Resolve each channel's tree_id and pull its category nodes

Call GET /v3/channels filtered to type=storefront and active, then GET /v3/catalog/trees?channel_id:in={id} to get the tree assigned to each channel. For each tree, call GET /v3/catalog/trees/{tree_id}/categories, paginated, to collect every node's id, parent_id, and name.

step3.py
def tree_id_for_channel(channel_id):
    trees = bc_get_all("/catalog/trees", {"channel_id:in": channel_id})
    if not trees:
        return None
    return trees[0]["id"]

def tree_categories(tree_id):
    return bc_get_all(f"/catalog/trees/{tree_id}/categories")
step3.js
async function treeIdForChannel(channelId) {
  const trees = await bcGetAll("/catalog/trees", { "channel_id:in": channelId });
  if (!trees.length) return null;
  return trees[0].id;
}

async function treeCategories(treeId) {
  return bcGetAll(`/catalog/trees/${treeId}/categories`);
}
4

Diff the two trees with one pure function

Keep the comparison in its own function that takes only the two plain node arrays. It builds a path (the join of ancestor names, resolved through each node's parent_id chain) for every node on both sides, so it compares by structure rather than by id, since ids are never shared between two different trees. It returns every primary node whose path does not exist in the secondary tree, sorted by depth ascending so parents always appear before their children.

diff.py
def _build_paths(nodes):
    by_id = {n["id"]: n for n in nodes}

    def path_for(node):
        chain = []
        current = node
        seen = set()
        while current is not None:
            if current["id"] in seen:
                break
            seen.add(current["id"])
            chain.append(current["name"])
            parent_id = current.get("parent_id")
            current = by_id.get(parent_id) if parent_id else None
        return list(reversed(chain))

    return {n["id"]: path_for(n) for n in nodes}

def diff_category_trees(primary_nodes, secondary_nodes):
    primary_paths = _build_paths(primary_nodes)
    secondary_paths = _build_paths(secondary_nodes)
    secondary_set = {tuple(p) for p in secondary_paths.values()}

    missing = []
    for node in primary_nodes:
        path = primary_paths[node["id"]]
        if tuple(path) in secondary_set:
            continue
        missing.append({
            "path": path,
            "name": node["name"],
            "parent_path": path[:-1],
        })

    missing.sort(key=lambda m: len(m["path"]))
    return {"missing": missing}
diff.js
function buildPaths(nodes) {
  const byId = new Map(nodes.map((n) => [n.id, n]));

  function pathFor(node) {
    const chain = [];
    let current = node;
    const seen = new Set();
    while (current) {
      if (seen.has(current.id)) break;
      seen.add(current.id);
      chain.push(current.name);
      current = current.parent_id ? byId.get(current.parent_id) : undefined;
    }
    return chain.reverse();
  }

  const paths = new Map();
  for (const n of nodes) paths.set(n.id, pathFor(n));
  return paths;
}

export function diffCategoryTrees(primaryNodes, secondaryNodes) {
  const primaryPaths = buildPaths(primaryNodes);
  const secondaryPaths = buildPaths(secondaryNodes);
  const secondarySet = new Set([...secondaryPaths.values()].map((p) => p.join("/")));

  const missing = [];
  for (const node of primaryNodes) {
    const path = primaryPaths.get(node.id);
    if (secondarySet.has(path.join("/"))) continue;
    missing.push({ path, name: node.name, parent_path: path.slice(0, -1) });
  }

  missing.sort((a, b) => a.path.length - b.path.length);
  return { missing };
}
5

Backfill the missing nodes, parent-first, into the secondary tree

Walk the sorted missing list in order. For each node, resolve its new parent_id by looking up the parent path in a name-to-id map you build as you go (starting empty, filled in as each level is created), then call POST /v3/catalog/trees/categories with the secondary tree's tree_id, in batches of at most 200. Because the list is depth-sorted, every parent is created, and its new id recorded, before any of its children are attempted.

apply.py
MAX_BATCH = 200

def backfill_batch(tree_id, categories):
    payload = [{**c, "tree_id": tree_id} for c in categories]
    return bc_post("/catalog/trees/categories", payload[:MAX_BATCH])
apply.js
const MAX_BATCH = 200;

async function backfillBatch(treeId, categories) {
  const payload = categories.map((c) => ({ ...c, tree_id: treeId }));
  return bcPost("/catalog/trees/categories", payload.slice(0, MAX_BATCH));
}
6

Wire it together with a dry run guard

The run loop resolves both tree ids, fetches both node sets, runs the pure diff, then either logs the full backfill plan (source tree_id, target tree_id, every missing node's path, and the parent_id each one will resolve to) when DRY_RUN is on, or executes the parent-first batches when it is off. Read the dry run output and confirm the parent_id mapping looks right before switching it off. This is a one-time or occasional job, not something to run on a schedule, since it should only fire after you notice or provision a new channel.

Run it safe

Always start with DRY_RUN=true and read the logged parent_id mapping before writing anything. Never call PUT to change an existing tree's channels field, since that field is unsupported on tree updates, and never assume a node exists in the secondary tree just because its name looks the same, since a renamed parent can silently break the path match and duplicate a branch.

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 only ever writes into the secondary channel's tree, never touching the primary tree it reads from.

View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.

backfill_category_tree.py
"""Backfill a new BigCommerce storefront channel's incomplete category tree.

In BigCommerce's Multi-Storefront architecture, a category tree (a
/v3/catalog/trees object) is a standalone resource assigned to at most one
channel at a time. Creating a new storefront channel does not clone the
primary storefront's tree, so the new channel starts unassigned or pointed at
a fresh, empty tree. Because category-to-tree membership is explicit
(categories belong to a specific tree_id, not automatically to all channels),
any node created after the second channel was provisioned, or never manually
copied, produces a permanent structural gap between the two storefronts'
navigation.

This job resolves the primary and secondary channel's tree_id, pulls the
full category node set for both trees, diffs them by a stable name-and-
parent-path key with a pure function, and backfills only the missing nodes
into the secondary tree, parent-first, so every parent_id reference
resolves. Never modifies the primary tree. Safe to run again and again,
since already-backfilled nodes will match on path and be skipped.

Guide: https://www.allanninal.dev/bigcommerce/new-channel-incomplete-category-tree/
"""
import os
import logging

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("backfill_category_tree")

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"
PRIMARY_CHANNEL_ID = os.environ.get("PRIMARY_CHANNEL_ID", "1")
SECONDARY_CHANNEL_ID = os.environ.get("SECONDARY_CHANNEL_ID", "2")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

MAX_BATCH = 200

HEADERS = {
    "X-Auth-Token": ACCESS_TOKEN,
    "Content-Type": "application/json",
    "Accept": "application/json",
}


def bc_get_all(path, params=None):
    """Follow meta.pagination.links.next and return every item in data."""
    items = []
    query = dict(params or {})
    query.setdefault("limit", 250)
    page = 1
    while True:
        query["page"] = page
        r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=query, timeout=30)
        r.raise_for_status()
        body = r.json()
        items.extend(body.get("data", []))
        next_link = (body.get("meta", {}).get("pagination", {}).get("links", {}) or {}).get("next")
        if not next_link:
            return items
        page += 1


def bc_post(path, body):
    r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()


def _build_paths(nodes):
    """Map each node id to its ancestor-name path, oldest ancestor first."""
    by_id = {n["id"]: n for n in nodes}

    def path_for(node):
        chain = []
        current = node
        seen = set()
        while current is not None:
            if current["id"] in seen:
                break
            seen.add(current["id"])
            chain.append(current["name"])
            parent_id = current.get("parent_id")
            current = by_id.get(parent_id) if parent_id else None
        return list(reversed(chain))

    return {n["id"]: path_for(n) for n in nodes}


def diff_category_trees(primary_nodes, secondary_nodes):
    """Pure. No network, no side effects.

    Builds a path string (join of ancestor names) for every node in both
    trees using parent_id chains, builds a set of secondary paths, then
    returns every primary node whose path is not in the secondary set,
    sorted by depth ascending so parent nodes are listed before their
    children.
    """
    primary_paths = _build_paths(primary_nodes)
    secondary_paths = _build_paths(secondary_nodes)
    secondary_set = {tuple(p) for p in secondary_paths.values()}

    missing = []
    for node in primary_nodes:
        path = primary_paths[node["id"]]
        if tuple(path) in secondary_set:
            continue
        missing.append({
            "path": path,
            "name": node["name"],
            "parent_path": path[:-1],
        })

    missing.sort(key=lambda m: len(m["path"]))
    return {"missing": missing}


def tree_id_for_channel(channel_id):
    trees = bc_get_all("/catalog/trees", {"channel_id:in": channel_id})
    if not trees:
        return None
    return trees[0]["id"]


def tree_categories(tree_id):
    return bc_get_all(f"/catalog/trees/{tree_id}/categories")


def backfill_batch(tree_id, categories):
    payload = [{**c, "tree_id": tree_id} for c in categories]
    return bc_post("/catalog/trees/categories", payload[:MAX_BATCH])


def run():
    primary_tree_id = tree_id_for_channel(PRIMARY_CHANNEL_ID)
    secondary_tree_id = tree_id_for_channel(SECONDARY_CHANNEL_ID)

    if primary_tree_id is None or secondary_tree_id is None:
        log.warning(
            "Could not resolve tree ids. primary_channel=%s -> %s, secondary_channel=%s -> %s",
            PRIMARY_CHANNEL_ID, primary_tree_id, SECONDARY_CHANNEL_ID, secondary_tree_id,
        )
        return

    primary_nodes = tree_categories(primary_tree_id)
    secondary_nodes = tree_categories(secondary_tree_id)

    result = diff_category_trees(primary_nodes, secondary_nodes)
    missing = result["missing"]

    if not missing:
        log.info("No gap. Secondary tree %s already matches primary tree %s.", secondary_tree_id, primary_tree_id)
        return

    name_to_new_id = {}
    for m in missing:
        parent_path = tuple(m["parent_path"])
        parent_id = name_to_new_id.get(parent_path) if parent_path else None
        log.info(
            "%s source_tree=%s target_tree=%s path=%s resolved_parent_id=%s",
            "PLAN" if DRY_RUN else "CREATE",
            primary_tree_id, secondary_tree_id, "/".join(m["path"]), parent_id,
        )
        if not DRY_RUN:
            created = backfill_batch(secondary_tree_id, [{
                "name": m["name"],
                "parent_id": parent_id or 0,
            }])
            new_id = (created.get("data") or [{}])[0].get("id")
            name_to_new_id[tuple(m["path"])] = new_id
        else:
            name_to_new_id[tuple(m["path"])] = f""

    log.info(
        "Done. %d node(s) %s in secondary tree %s.",
        len(missing), "planned" if DRY_RUN else "created", secondary_tree_id,
    )


if __name__ == "__main__":
    run()
backfill-category-tree.js
/**
 * Backfill a new BigCommerce storefront channel's incomplete category tree.
 *
 * In BigCommerce's Multi-Storefront architecture, a category tree (a
 * /v3/catalog/trees object) is a standalone resource assigned to at most one
 * channel at a time. Creating a new storefront channel does not clone the
 * primary storefront's tree, so the new channel starts unassigned or
 * pointed at a fresh, empty tree. Because category-to-tree membership is
 * explicit (categories belong to a specific tree_id, not automatically to
 * all channels), any node created after the second channel was provisioned,
 * or never manually copied, produces a permanent structural gap between the
 * two storefronts' navigation.
 *
 * This job resolves the primary and secondary channel's tree_id, pulls the
 * full category node set for both trees, diffs them by a stable name-and-
 * parent-path key with a pure function, and backfills only the missing
 * nodes into the secondary tree, parent-first. Never modifies the primary
 * tree. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/new-channel-incomplete-category-tree/
 */
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 PRIMARY_CHANNEL_ID = process.env.PRIMARY_CHANNEL_ID || "1";
const SECONDARY_CHANNEL_ID = process.env.SECONDARY_CHANNEL_ID || "2";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const MAX_BATCH = 200;

const HEADERS = {
  "X-Auth-Token": ACCESS_TOKEN,
  "Content-Type": "application/json",
  Accept: "application/json",
};

/**
 * Pure. No network, no side effects.
 *
 * Builds a path (join of ancestor names) for every node in both trees using
 * parent_id chains, builds a set of secondary paths, then returns every
 * primary node whose path is not in the secondary set, sorted by depth
 * ascending so parent nodes are listed before their children.
 */
function buildPaths(nodes) {
  const byId = new Map(nodes.map((n) => [n.id, n]));

  function pathFor(node) {
    const chain = [];
    let current = node;
    const seen = new Set();
    while (current) {
      if (seen.has(current.id)) break;
      seen.add(current.id);
      chain.push(current.name);
      current = current.parent_id ? byId.get(current.parent_id) : undefined;
    }
    return chain.reverse();
  }

  const paths = new Map();
  for (const n of nodes) paths.set(n.id, pathFor(n));
  return paths;
}

export function diffCategoryTrees(primaryNodes, secondaryNodes) {
  const primaryPaths = buildPaths(primaryNodes);
  const secondaryPaths = buildPaths(secondaryNodes);
  const secondarySet = new Set([...secondaryPaths.values()].map((p) => p.join("/")));

  const missing = [];
  for (const node of primaryNodes) {
    const path = primaryPaths.get(node.id);
    if (secondarySet.has(path.join("/"))) continue;
    missing.push({ path, name: node.name, parent_path: path.slice(0, -1) });
  }

  missing.sort((a, b) => a.path.length - b.path.length);
  return { missing };
}

async function bcGetAll(path, params = {}) {
  const items = [];
  let page = 1;
  while (true) {
    const url = new URL(`${API_BASE}${path}`);
    for (const [key, value] of Object.entries({ limit: 250, ...params, page })) {
      url.searchParams.set(key, value);
    }
    const res = await fetch(url, { headers: HEADERS });
    if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
    const body = await res.json();
    items.push(...(body.data || []));
    const next = body.meta?.pagination?.links?.next;
    if (!next) return items;
    page += 1;
  }
}

async function bcPost(path, body) {
  const res = await fetch(`${API_BASE}${path}`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function treeIdForChannel(channelId) {
  const trees = await bcGetAll("/catalog/trees", { "channel_id:in": channelId });
  if (!trees.length) return null;
  return trees[0].id;
}

async function treeCategories(treeId) {
  return bcGetAll(`/catalog/trees/${treeId}/categories`);
}

async function backfillBatch(treeId, categories) {
  const payload = categories.map((c) => ({ ...c, tree_id: treeId }));
  return bcPost("/catalog/trees/categories", payload.slice(0, MAX_BATCH));
}

export async function run() {
  const primaryTreeId = await treeIdForChannel(PRIMARY_CHANNEL_ID);
  const secondaryTreeId = await treeIdForChannel(SECONDARY_CHANNEL_ID);

  if (primaryTreeId == null || secondaryTreeId == null) {
    console.warn(
      `Could not resolve tree ids. primary_channel=${PRIMARY_CHANNEL_ID} -> ${primaryTreeId}, ` +
      `secondary_channel=${SECONDARY_CHANNEL_ID} -> ${secondaryTreeId}`
    );
    return;
  }

  const primaryNodes = await treeCategories(primaryTreeId);
  const secondaryNodes = await treeCategories(secondaryTreeId);

  const { missing } = diffCategoryTrees(primaryNodes, secondaryNodes);

  if (!missing.length) {
    console.log(`No gap. Secondary tree ${secondaryTreeId} already matches primary tree ${primaryTreeId}.`);
    return;
  }

  const nameToNewId = new Map();
  for (const m of missing) {
    const parentPath = m.parent_path.join("/");
    const parentId = parentPath ? nameToNewId.get(parentPath) : null;

    console.log(
      `${DRY_RUN ? "PLAN" : "CREATE"} source_tree=${primaryTreeId} target_tree=${secondaryTreeId} ` +
      `path=${m.path.join("/")} resolved_parent_id=${parentId ?? null}`
    );

    if (!DRY_RUN) {
      const created = await backfillBatch(secondaryTreeId, [{ name: m.name, parent_id: parentId || 0 }]);
      const newId = created.data?.[0]?.id;
      nameToNewId.set(m.path.join("/"), newId);
    } else {
      nameToNewId.set(m.path.join("/"), `<new:${m.path.join("/")}>`);
    }
  }

  console.log(`Done. ${missing.length} node(s) ${DRY_RUN ? "planned" : "created"} in secondary tree ${secondaryTreeId}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The diff is the part most worth testing, because it decides exactly what gets created in a live storefront's navigation. Because diff_category_trees takes only plain node arrays and returns a plain object, the test needs no network and no BigCommerce store. It just feeds in in-memory trees and checks the answer, including renamed parents, reordered siblings, and multi-level gaps.

test_new_channel_tree_diff.py
from backfill_category_tree import diff_category_trees


def node(id_, name, parent_id=None):
    return {"id": id_, "name": name, "parent_id": parent_id}


def test_identical_trees_have_no_missing_nodes():
    primary = [node(1, "Shoes"), node(2, "Boots", parent_id=1)]
    secondary = [node(11, "Shoes"), node(12, "Boots", parent_id=11)]
    assert diff_category_trees(primary, secondary)["missing"] == []


def test_empty_secondary_tree_reports_every_primary_node():
    primary = [node(1, "Shoes"), node(2, "Boots", parent_id=1)]
    missing = diff_category_trees(primary, [])["missing"]
    assert [m["path"] for m in missing] == [["Shoes"], ["Shoes", "Boots"]]


def test_parents_are_listed_before_their_children():
    primary = [
        node(1, "Shoes"),
        node(2, "Boots", parent_id=1),
        node(3, "Winter Boots", parent_id=2),
    ]
    missing = diff_category_trees(primary, [])["missing"]
    depths = [len(m["path"]) for m in missing]
    assert depths == sorted(depths)


def test_reordered_siblings_still_match_by_path():
    primary = [node(1, "Shoes"), node(2, "Boots", parent_id=1), node(3, "Sandals", parent_id=1)]
    secondary = [node(21, "Shoes"), node(22, "Sandals", parent_id=21), node(23, "Boots", parent_id=21)]
    assert diff_category_trees(primary, secondary)["missing"] == []


def test_renamed_parent_causes_children_to_appear_missing():
    primary = [node(1, "Shoes"), node(2, "Boots", parent_id=1)]
    secondary = [node(11, "Footwear"), node(12, "Boots", parent_id=11)]
    missing = diff_category_trees(primary, secondary)["missing"]
    paths = [m["path"] for m in missing]
    assert ["Shoes"] in paths
    assert ["Shoes", "Boots"] in paths


def test_multi_level_gap_reports_only_the_missing_branch():
    primary = [
        node(1, "Shoes"),
        node(2, "Boots", parent_id=1),
        node(3, "Winter Boots", parent_id=2),
    ]
    secondary = [node(11, "Shoes"), node(12, "Boots", parent_id=11)]
    missing = diff_category_trees(primary, secondary)["missing"]
    assert [m["path"] for m in missing] == [["Shoes", "Boots", "Winter Boots"]]
backfill-category-tree.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffCategoryTrees } from "./backfill-category-tree.js";

const node = (id, name, parentId = null) => ({ id, name, parent_id: parentId });

test("identical trees have no missing nodes", () => {
  const primary = [node(1, "Shoes"), node(2, "Boots", 1)];
  const secondary = [node(11, "Shoes"), node(12, "Boots", 11)];
  assert.deepEqual(diffCategoryTrees(primary, secondary).missing, []);
});

test("empty secondary tree reports every primary node", () => {
  const primary = [node(1, "Shoes"), node(2, "Boots", 1)];
  const missing = diffCategoryTrees(primary, []).missing;
  assert.deepEqual(missing.map((m) => m.path), [["Shoes"], ["Shoes", "Boots"]]);
});

test("parents are listed before their children", () => {
  const primary = [node(1, "Shoes"), node(2, "Boots", 1), node(3, "Winter Boots", 2)];
  const missing = diffCategoryTrees(primary, []).missing;
  const depths = missing.map((m) => m.path.length);
  assert.deepEqual(depths, [...depths].sort((a, b) => a - b));
});

test("reordered siblings still match by path", () => {
  const primary = [node(1, "Shoes"), node(2, "Boots", 1), node(3, "Sandals", 1)];
  const secondary = [node(21, "Shoes"), node(22, "Sandals", 21), node(23, "Boots", 21)];
  assert.deepEqual(diffCategoryTrees(primary, secondary).missing, []);
});

test("renamed parent causes children to appear missing", () => {
  const primary = [node(1, "Shoes"), node(2, "Boots", 1)];
  const secondary = [node(11, "Footwear"), node(12, "Boots", 11)];
  const paths = diffCategoryTrees(primary, secondary).missing.map((m) => m.path);
  assert.ok(paths.some((p) => JSON.stringify(p) === JSON.stringify(["Shoes"])));
  assert.ok(paths.some((p) => JSON.stringify(p) === JSON.stringify(["Shoes", "Boots"])));
});

test("multi-level gap reports only the missing branch", () => {
  const primary = [node(1, "Shoes"), node(2, "Boots", 1), node(3, "Winter Boots", 2)];
  const secondary = [node(11, "Shoes"), node(12, "Boots", 11)];
  const missing = diffCategoryTrees(primary, secondary).missing;
  assert.deepEqual(missing.map((m) => m.path), [["Shoes", "Boots", "Winter Boots"]]);
});

Case studies

Second brand storefront

The retailer who launched a second brand on a shared catalog

A retailer used Multi-Storefront to run a second, differently branded storefront off the same product catalog as their main store. Marketing was ready to launch, but the new site's navigation only showed a fraction of the department pages the main site had. The tree for the new channel had been created fresh and empty, and nobody had populated it beyond the handful of categories someone clicked together by hand.

Running the diff against the primary tree surfaced eighty-plus missing nodes across four levels of nesting. The dry run log made the parent-first order obvious, and after one confirmed pass the new storefront's navigation matched the main site's structure exactly, without anyone touching a single product.

Regional storefront drift

The team that kept adding categories to only one channel

A team running a US and an EU storefront kept adding new categories over several months, but always through the US channel's admin view, since that was the default context most staff worked in. The EU storefront quietly fell behind, missing every category added since the two channels diverged, without any error or warning anywhere in the admin.

Once they scripted the tree diff as a recurring manual check (not a schedule, since it should only run when someone notices drift), they caught the gap immediately after the next batch of new categories went in, and backfilled it in minutes instead of discovering it from a customer complaint.

What good looks like

After this runs and its dry run output is confirmed, the secondary channel's category tree matches the primary tree's structure node for node, created parent-first so every reference resolves cleanly. The primary tree is never touched, nothing is written without a human confirming the parent_id mapping first, and renamed parents or reordered siblings are handled correctly because the comparison is based on path, not on raw ids that were never shared between the two trees to begin with.

FAQ

Why does a new BigCommerce storefront channel start with a broken or empty category tree?

A category tree is a standalone resource that can be assigned to at most one channel at a time. Creating a new storefront channel does not clone the primary storefront's tree, so the new channel starts unassigned or pointed at a fresh, empty tree. Because categories belong to a specific tree_id rather than automatically to every channel, none of the primary tree's nodes exist under the new channel's tree until someone copies them over.

Can I just assign the primary channel's tree_id to the new channel instead of copying nodes?

No. BigCommerce's own documentation states a tree may only be assigned to a maximum of one channel, and the channels field is not a supported field on a tree update, so you cannot PUT a change to make one tree serve two channels. The supported path is to give the new channel its own tree and backfill that tree's categories to match the primary tree's structure.

Is it safe to auto-create every missing category node in the secondary tree?

Not without a dry run first. The nodes have to be created parent-first so parent_id references resolve, and a stale parent_id or a mismatched url path segment can produce duplicate or orphaned categories. Run the backfill under a DRY_RUN flag that only logs the planned bulk-create payload, the source and target tree_id, the node paths, and the computed parent_id mapping, and get a human to confirm that mapping before writing anything.

Related field notes

Citations

On the problem:

  1. BigCommerce Developer Center: Multi-Storefront API guide. developer.bigcommerce.com Multi-Storefront API guide
  2. BigCommerce Support: configuring your catalog for Multi-Storefront. support.bigcommerce.com configuring your catalog for Multi-Storefront
  3. BigCommerce Developer Center: category trees reference. developer.bigcommerce.com category trees

On the solution:

  1. BigCommerce Developer Center: category trees reference, tree-to-channel assignment. developer.bigcommerce.com category trees
  2. BigCommerce Developer Center: categories endpoint, bulk create and tree_id. developer.bigcommerce.com categories
  3. BigCommerce Developer Center: channels API, listing storefront channels. developer.bigcommerce.com channels

Stuck on a tricky one?

If you have a problem in BigCommerce Multi-Storefront, channels, catalog, or category structure that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this fill in your missing navigation?

If this saved you from hand-clicking a category tree back together, 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

Back to all BigCommerce field notes