# How to Automate Ecommerce Category Page SEO (Free n8n Template)

> Automate ecommerce category page SEO with n8n: AI intros and meta tags from live products, links in code, sold-out items sorted last, weekly link audit.

*Last tested on n8n 2.45.7, November 2026.*

Category pages are where your store catches the broad, high-volume searches, the queries Ahrefs calls ["The Fat Head" and "The Chunky Middle"](https://ahrefs.com/blog/seo-ecommerce-category-pages/), yet most of them sit half-finished as your catalogue grows. Writing intros in a spreadsheet or paying an SEO app a monthly fee leaves you with generic keyword-stuffed paragraphs, broken internal links, and out-of-stock products sitting on row one while new subcategories go completely unlinked. 

By the end of this guide, you'll have a five-lane n8n workflow that writes grounded category intros and meta tags from each collection's live products, injects verified internal links in code, pushes sold-out items to the end daily, and runs a weekly orphan link audit with Slack approvals.

<TemplateCallout
  repo="https://github.com/reganmcgregor/n8n-ecommerce-templates/tree/main/category-page-seo"
  title="Category page SEO template for n8n"
>

- Generates 55-to-75-word category intros and SEO meta tags from each page's live brands, specs, and product range, then wires internal links to parent, child, and sibling categories in code.
- Reorders products daily so relevant in-stock items sit at the top and sold-out, draft, or missing-image products always sort last.
- Audits your navigation menus and category descriptions weekly to catch orphan categories and re-queue parent pages that don't link to their children.

</TemplateCallout>

## What you'll build

When a category page lacks copy or needs a refresh after a new subcategory is added, the workflow inspects its live products and navigation tree, drafts the intro and meta tags, adds HTML links to related categories, and posts a review card to Slack. Approving the card publishes the HTML description and SEO metadata straight to your store and saves the approved copy as a few-shot example for similar categories.

![A review card in Slack for Security Cameras: the meta title and description with their lengths, the intro, the keyword, "written by the model", the links, one warning, and Approve, Redraft and Skip buttons](./_attachments/ecommerce-category-page-seo/review-card.png "A review card in Slack for Security Cameras: the meta title and description with their lengths, the intro, the keyword, 'written by the model', the links, one warning, and Approve, Redraft and Skip buttons")

Alongside copy and links, the daily sort lane keeps your product grids clean without manual merchandising, and the weekly audit lane catches collections that nothing links to. On a store I run, the first sort set 63 collections to Manual and made 4,486 moves in about four minutes.

Everything runs on one n8n canvas across five lanes: Setup, Write, Review, Sort, and Audit.

![The full canvas: the overview note, then the Setup, Write, Review, Sort and Audit lanes](./_attachments/ecommerce-category-page-seo/template-canvas.png "The full canvas: the overview note, then the Setup, Write, Review, Sort and Audit lanes")

## What you need

- n8n (self-hosted Community Edition or n8n Cloud) with Data Tables enabled. Tested on n8n 2.45.7.
- A Shopify app (client ID and secret) with `read_products`, `write_products`, and `read_online_store_navigation` scopes on Admin GraphQL API `2026-10` (or REST/GraphQL credentials if you adapt it for WooCommerce or BigCommerce).
- A Slack app with Interactivity enabled, `chat:write` permissions for your review channel, and its signing secret saved in an n8n Crypto credential.
- An AI model via n8n's Chat Model sub-node (defaults to `gemma4` on Ollama Cloud).
- Optional: search volumes for your collections, from any keyword tool.

## How it works

1. **Setup (Manual Trigger):** Creates the `collection_copy` and `link_audit` Data Tables, where you import your keyword priorities.
2. **Write (Schedule Trigger):** Picks high-volume categories missing copy, pulls their live products and menu neighbours, writes the intro and meta tags with AI (or a simple template for long-tail pages), injects `<a href>` links in code, and posts a Slack approval card.
3. **Review (Webhook):** Verifies the click came from Slack, re-reads the live description in case someone edited it, updates the category description and SEO fields via `collectionUpdate` when approved, and saves the row as an approved example.
4. **Sort (Schedule Trigger, daily):** Scores products by keyword relevance and stock, sets sort order to `MANUAL`, and on the next run calls `collectionReorderProducts` so sold-out items sort last. It starts on a dry run that only reports.
5. **Audit (Schedule Trigger, weekly):** Scans menus and descriptions for orphan categories and menu links to empty collections, immediately queues parent pages missing child links for a redraft, and reports to Slack.

## What a good category page has

Every category page needs the same core elements whether you run Shopify, WooCommerce, or BigCommerce. Separate the jobs you set once in your theme from the jobs that drift as stock and categories change:

| Element | Who does it | What it needs |
|---|---|---|
| Title tag and meta description | The template does this | Matched to the main and secondary keywords (`nvr`, `network video recorder`, `poe nvr`), checked in code so meta titles stay under 60 characters |
| H1 | Set once in your theme | The theme prints the collection title, so name collections the way people search |
| Short intro copy above the grid | The template does this | A 55-to-75-word paragraph under the H1 describing the range from live brands and specs without pushing products below the fold |
| Contextual internal links | The template does this | Standard `<a href>` links to parent, child, and sibling categories so [Google understands your site structure](https://developers.google.com/search/docs/specialty/ecommerce/help-google-understand-your-ecommerce-site-structure) |
| Product sort order | The template does this | Relevant, in-stock products first and sold-out or unbuyable items last |
| Weekly orphan and child-link audit | The template does this | Finds published categories unlinked from both menus and body copy |
| Breadcrumbs and `BreadcrumbList` schema | Set once in your theme | Visible breadcrumbs with [Google's breadcrumb structured data](https://developers.google.com/search/docs/appearance/structured-data/breadcrumb) |
| Crawlable pagination | Set once in your theme | `<a href="?page=2">` links with self-referencing canonicals per [Google's pagination docs](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading), never canonicalising page 2 to page 1 |
| Faceted navigation and indexing | Set once in your theme | Follow [Google's faceted navigation guidance](https://developers.google.com/search/docs/crawling-indexing/crawling-managing-faceted-navigation) by blocking low-value filter parameters in `robots.txt` or applying `noindex` |
| Structured data | Set once in your theme | Keep `Product` offer and review markup on product pages rather than category grids per [Google's product snippet docs](https://developers.google.com/search/docs/appearance/structured-data/product-snippet) |
| Image alt text and page speed | Set once in your theme | Lazy-load thumbnails below row one and default alt text to `{{ product.title }}` |
| Buyer guides below the grid | Optional extra | A short comparison guide below the products for technical categories (see "Make it your own") |

## Step 1: Create the tables and settings

In the Setup lane, add a **Manual Trigger** connected to two [**Data table**](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.datatable/) nodes set to **Table -> Create** (`Reuse Existing Tables` enabled):

```text
collection_copy
  handle (String)            title (String)             keyword (String)
  other_searches (String)    volume (Number)            gid (String)
  parent_handle (String)     status (String)            intro (String)
  body_html (String)         meta_title (String)        meta_description (String)
  flags (String)             reviewed_by (String)       live_html (String)

link_audit
  run_date (Date)   handle (String)   issue (String)   parent_handle (String)
```

Merging your keyword list, drafts, and approved copy into a single `collection_copy` table means the Review lane can look up pending drafts by `handle` and the Write lane can use rows where `status = 'approved'` as few-shot examples. Import your search volumes as rows with `handle`, `keyword`, `other_searches` (comma-separated) and `volume`. Collections made by the [shopify-collections-seo](https://github.com/reganmcgregor/n8n-ecommerce-templates/tree/main/shopify-collections-seo) template don't need a row, because it saves each one's keyword and volume on the collection as `seo.target_keyword` and `seo.keyword_volume` metafields.

In the Write lane, add a **Schedule Trigger** (every four hours) connected to an **Edit Fields (Set)** node named `Write settings` in JSON mode. The `examples` carry your voice until you've approved real intros, and `skip_handles` keeps out the `frontpage` (Home page) collection every Shopify store has:

```json
{
  "store_domain": "your-store.myshopify.com",
  "store_name": "Your Store",
  "tree_menu": "main-menu",
  "max_per_run": 5,
  "ai_min_volume": 100,
  "rewrite_existing": false,
  "skip_handles": [
    "frontpage"
  ],
  "intro_words": [
    55,
    75
  ],
  "max_links": 6,
  "model": "gemma4:31b",
  "slack_channel": "#review-copy",
  "keyword_namespace": "seo",
  "spec_keys": [
    "custom.channels",
    "custom.storage_bays"
  ],
  "spec_labels": {
    "custom.channels": "Channels",
    "custom.storage_bays": "Drive bays"
  },
  "voice": "Plain, practical and specific. Contractions are fine. Explain a technical term the first time it appears.",
  "store_facts": "Free shipping over $100. 30-day returns.",
  "examples": [
    {
      "title": "Bulk Cable",
      "intro": "Cut your own cable from the box and every camera gets exactly the length it needs, with no joins in the roof and no spare loops in the ceiling. Run it from the switch to each spot along the eaves, finish the ends with plugs or wall sockets, and test every run before the cornice goes back on."
    }
  ]
}
```

## Step 2: Pick which pages to write first

New Shopify apps don't show a permanent token, so a `Get Shopify token` HTTP Request node first swaps your app's client ID and secret for a 24-hour token ([client credentials grant](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/client-credentials-grant), set up in the repo's SETUP.md). Connect an **HTTP Request** node named `Read store` (POST to `https://{{ $json.store_domain }}/admin/api/2026-10/graphql.json`, with the token in an `X-Shopify-Access-Token` header, Execute Once on) that reads your published collections, their rules and keyword metafields, your navigation menus and your vendors from Shopify's Admin GraphQL API (`2026-10`). Reading the [`menus` query](https://shopify.dev/docs/api/admin-graphql/2026-10/queries/menus) gives you the parent, child, and sibling hierarchy without maintaining a separate category tree spreadsheet:

```text
={{ JSON.stringify({ query: `query Store($after: String, $ns: String!) {
  collections(first: 100, after: $after, query: "published_status:published") {
    pageInfo { hasNextPage endCursor }
    nodes { id handle title descriptionHtml sortOrder productsCount { count }
      ruleSet { appliedDisjunctively rules { column relation condition } }
      keyword: metafield(namespace: $ns, key: "target_keyword") { value }
      volume: metafield(namespace: $ns, key: "keyword_volume") { value } }
  }
  menus(first: 25) {
    nodes { handle items { title resourceId url
      items { title resourceId url items { title resourceId url } } } }
  }
  productVendors(first: 250) { nodes }
}`, variables: { after: null, ns: $('Write settings').first().json.keyword_namespace } }) }}
```

Turn on Pagination (Update a Parameter in Each Request, Body, Name `variables`, Value `{{ { after: $response.body.data.collections.pageInfo.endCursor, ns: $('Write settings').first().json.keyword_namespace } }}`, Complete When `{{ !$response.body.data.collections.pageInfo.hasNextPage }}`), so stores with more than 100 collections are read in full.

Add a **Data table** Get node named `Get copy rows` (Return All, Execute Once, Always Output Data on), then join the collections against `collection_copy` in a **Code** node named `Pick collections`, keeping collections with an empty `descriptionHtml` (or `status` of `refresh`, or `failed` from a draft that didn't parse), sorted by `volume` descending and capped at `max_per_run` (`5`). Collections under `ai_min_volume` (`100`) get a simple template, while head and mid-tail pages like `/collections/nvr` (`keyword: "nvr"`, `other_searches: "network video recorder, poe nvr"`) go to the model. Collections that aren't in the menu, such as an attribute collection whose rules are "type is NVRs and channels is 8", sit under the collection whose only rule is that type:

```javascript
const cols = $('Read store').all().flatMap(i => i.json.data.collections.nodes);
const menus = $('Read store').first().json.data.menus.nodes;
const handleById = Object.fromEntries(cols.map(c => [c.id, c.handle]));
const handleOf = it => handleById[it.resourceId] || (it.url || '').match(/\/collections\/([^/?#]+)/)?.[1];
const s = $('Write settings').first().json;
const rows = Object.fromEntries($('Get copy rows').all().filter(i => i.json.handle).map(i => [i.json.handle, i.json]));
const titleOf = Object.fromEntries(cols.map(c => [c.handle, c.title]));

// Parent, children and siblings come from how the main menu is nested...
const parent = {}, kids = {};
const adopt = (h, up) => { if (h && up && h !== up && !parent[h]) { parent[h] = up; (kids[up] = kids[up] || []).push(h); } };
const walk = (items, up) => {
  for (const it of items || []) {
    const h = handleOf(it);
    adopt(h, up);
    walk(it.items, h && h !== up ? h : up);
  }
};
walk(menus.find(m => m.handle === s.tree_menu)?.items, null);
// ...and, for collections not in the menu, from their rules: "type is X and ..." sits under the collection whose only rule is "type is X".
const typePage = Object.fromEntries(cols.filter(c => c.ruleSet?.rules.length === 1 && c.ruleSet.rules[0].column === 'TYPE')
  .map(c => [c.ruleSet.rules[0].condition.toLowerCase(), c.handle]));
for (const c of cols) if (!parent[c.handle] && c.ruleSet && !c.ruleSet.appliedDisjunctively && c.ruleSet.rules.length > 1) {
  const t = c.ruleSet.rules.find(r => r.column === 'TYPE' && r.relation === 'EQUALS');
  if (t) adopt(c.handle, typePage[t.condition.toLowerCase()]);
}

const ref = h => (h && titleOf[h]) ? { handle: h, title: titleOf[h] } : null;
const empty = html => !(html || '').replace(/<[^>]+>/g, '').trim();
const vol = (c, r) => Number(r.volume || c.volume?.value || 0);

return cols
  .map(c => ({ c, r: rows[c.handle] || {} }))
  .filter(({ c, r }) => !s.skip_handles.includes(c.handle))
  .filter(({ c, r }) => ['refresh', 'failed'].includes(r.status) || (!r.status && (s.rewrite_existing || empty(c.descriptionHtml))))
  .sort((a, b) => (b.r.status === 'refresh') - (a.r.status === 'refresh') || vol(b.c, b.r) - vol(a.c, a.r))   // refreshes first, then by volume
  .slice(0, s.max_per_run)
  .map(({ c, r }) => ({ json: {
    gid: c.id, handle: c.handle, title: c.title,
    keyword: r.keyword || c.keyword?.value || c.title.toLowerCase(), other_searches: r.other_searches || '', volume: vol(c, r),
    use_ai: vol(c, r) >= s.ai_min_volume, live_html: c.descriptionHtml,
    parent: ref(parent[c.handle]),
    children: (kids[c.handle] || []).map(ref).filter(Boolean),
    siblings: (kids[parent[c.handle]] || []).filter(h => h !== c.handle).map(ref).filter(Boolean),
    vendors_all: $('Read store').first().json.data.productVendors.nodes,
  } }));
```

## Step 3: Give the model the collection's own products

Prompting from the keyword `"nvr"` alone produces generic filler. Pull each collection's products with an **HTTP Request** node named `Read products`, which runs once per collection:

```text
={{ JSON.stringify({ query: `query($id: ID!, $keys: [String!]) { collection(id: $id) { products(first: 100) { nodes {
    title vendor status totalInventory tracksInventory
    options { name values }
    metafields(first: 20, keys: $keys) { nodes { namespace key value } } } } } }`, variables: { id: $json.gid, keys: $('Write settings').first().json.spec_keys } }) }}
```

Connect a **Code** node named `Build context` that extracts the live brands, the specs at least two in-stock products share, sample product titles, and the parent, child, and sibling collections from Step 2. When you ask for metafields by `keys`, Shopify returns each key as `namespace.key` (`custom.channels`), which is how `spec_labels` is keyed:

```javascript
const s = $('Write settings').first().json;
const picks = $('Pick collections').all().map(i => i.json);
const tally = xs => Object.entries(xs.reduce((m, x) => (m[x] = (m[x] || 0) + 1, m), {})).sort((a, b) => b[1] - a[1]);

return $input.all().map((it, i) => {
  const c = picks[i];
  const all = it.json.data?.collection?.products?.nodes || [];
  const live = all.filter(p => p.status === 'ACTIVE' && (!p.tracksInventory || p.totalInventory > 0));
  const specs = tally(live.flatMap(p => [
    ...p.metafields.nodes.map(m => `${s.spec_labels[m.key] || m.key}: ${m.value}`),   // with keys:, Shopify returns key as namespace.key
    ...p.options.filter(o => o.name !== 'Title').map(o => `${o.name}: ${o.values.join(' / ')}`),
  ])).filter(([, n]) => n >= 2).slice(0, 12).map(([spec, n]) => `${spec} (${n} products)`);
  return { json: { ...c, live: live.length, specs,
    brands: tally(live.map(p => p.vendor).filter(Boolean)).slice(0, 6).map(([v]) => v),
    products: live.slice(0, 8).map(p => p.title),
    family: [...c.children, ...(c.parent ? [c.parent] : []), ...c.siblings] } };
});
```

Passing the product titles (`TP-LINK VIGI NVR1004H-4P`, `Hikvision DS-7608NXI-K1/8P`, `Reolink RLN8-410` and so on) shows the model the range while the prompt forbids naming individual model codes or sizes. Finish the step with an **If** node named `Head term?` on `{{ $json.use_ai }}`.

## Step 4: Write it with approved examples

Better retrieved examples fix repetitive AI copy much faster than adding more banned phrases. On the true branch of `Head term?`, add a **Code** node named `Build prompt` that takes approved intros from the rows `Get copy rows` already read, prefers ones from the same parent, and falls back to your settings `examples`:

```javascript
const s = $('Write settings').first().json;
const approved = $('Get copy rows').all().map(i => i.json).filter(r => r.status === 'approved' && r.intro);
const list = xs => xs.length ? xs.join(', ') : '(none)';

return $input.all().map(({ json: c }) => {
  const others = approved.filter(r => r.handle !== c.handle);
  const ex = [...others.filter(r => r.parent_handle && r.parent_handle === c.parent?.handle),
              ...others.filter(r => !r.parent_handle || r.parent_handle !== c.parent?.handle)].slice(0, 3);
  const examples = (ex.length ? ex : s.examples).map(e => `${e.title || e.handle}: ${e.intro}`).join('\n\n');
  const system = `You write category page copy for ${s.store_name}, an online store.
${s.voice}
- Describe the range. Never name a product model, model number or size.
- Open with what the buyer gets from this range. Never open with a definition.
- Name only brands from BRANDS and specs from COMMON SPECS.
- Never write a URL or a link.
- No em dashes.
- Shipping, returns and payment claims come only from STORE FACTS: ${s.store_facts}

EXAMPLE INTROS, for voice only. Never copy their sentences, brands or specs.
${examples}`;
  const prompt = `COLLECTION: ${c.title}
Target keyword: ${c.keyword}
Other searches: ${c.other_searches || '(none)'}
Parent: ${c.parent?.title || '(top level)'}
Children: ${list(c.children.map(k => k.title))}
Siblings: ${list(c.siblings.map(k => k.title))}
Live products: ${c.live}
BRANDS: ${list(c.brands)}
COMMON SPECS:
${c.specs.join('\n') || '(none)'}
PRODUCTS:
${c.products.join('\n') || '(none yet)'}

Write:
- intro: one paragraph, ${s.intro_words[0]} to ${s.intro_words[1]} words. Target keyword in the first or second sentence. Name children and siblings by their exact titles where it reads naturally.
- meta_title: 20 to 48 characters. The keyword as a plural category in Title Case, then an optional modifier from this context. No store name.
- meta_description: 130 to 155 characters. Start with Shop, Buy or Compare. End with one store fact.

Reply with only this JSON object: {"intro": "...", "meta_title": "...", "meta_description": "..."}`;
  return { json: { ...c, system, prompt } };
});
```

Add a **Basic LLM Chain** node named `Write the copy` (Prompt: Define below, `{{ $json.prompt }}`; System message `{{ $json.system }}`; Retry On Fail on; On Error: Continue) with an **Ollama Chat Model** sub-node (`gemma4:31b`, temperature 0.5). The prompt ends by asking for a JSON object with `intro`, `meta_title` and `meta_description`, and there's deliberately no Structured Output Parser: in testing, gemma4 on Ollama Cloud wrapped its JSON in a code fence and left a trailing comma after the last field, even with the node's Output Format set to JSON, and a parser rejects that. `Check and link` (Step 5) reads it leniently instead. On Error: Continue means a call that still fails comes out as an error item, so that collection is marked `failed` and tried again next run while the rest carry on.

Connect a **Merge** node (Combine, By Position) to join each `Build prompt` item with its draft. For `/collections/nvr`, the test draft read like this:

```text
Centralise your surveillance footage and manage recordings with a reliable nvr. These network
video recorders, which are specialised computers that record video from IP cameras, ensure your
data is stored securely. Choose from TP-LINK, Ubiquiti, Hikvision, and Reolink with 8 Channels.
Pair these with 4K Security Cameras and PoE Switches for a full setup, or browse our 8 Channel
NVRs for specific capacity needs.
```

On the false branch of `Head term?`, a **Code** node named `Template copy` gives long-tail pages a one-line intro and meta. A brand page names no other brand, so `/collections/reolink` gets "Shop the Reolink range." rather than "Shop our Reolink from Reolink.":

```javascript
const s = $('Write settings').first().json;
const and = xs => xs.length > 1 ? xs.slice(0, -1).join(', ') + ' and ' + xs.at(-1) : xs.join('');
return $input.all().map(({ json: c }) => {
  const brands = c.brands.filter(b => b.toLowerCase() !== c.title.toLowerCase());   // a brand page names no other brand
  return { json: { ...c, output: {
    intro: brands.length ? `Shop our ${c.title} from ${and(brands)}.` : `Shop the ${c.title} range.`,
    meta_title: brands.length ? `${c.title} | ${and(brands.slice(0, 2))}` : c.title,
    meta_description: `Shop ${brands.length ? c.title + ' from ' + and(brands.slice(0, 3)) : 'the ' + c.title + ' range'} at ${s.store_name}. ${s.store_facts}`,
  } } };
});
```

## Step 5: Check the draft and add links in code

Never let an LLM write `<a href="...">` tags directly, because models routinely invent URLs (`/collections/ip-security-cameras` instead of `/collections/security-cameras`). Let the model write plain text, and let a **Code** node named `Check and link`, connected to both the Merge node and `Template copy`, read the model's JSON, verify the facts and wrap the first mention of any related collection title in its real URL, longest title first so "4K Security Cameras" links to its own page rather than to "Security Cameras". Numbers are allowed when they come from the titles, specs, brands or store facts (`8 Channels`, `8ware`), never from a product. If the collection has child subcategories that weren't mentioned in the prose, it appends a clean "Shop by type:" line underneath:

```javascript
const s = $('Write settings').first().json;
const fix = t => String(t || '').replace(/\s*[\u2014\u2013]\s*/g, ', ').replace(/\s+/g, ' ').trim();
const esc = t => String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const rx = t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const has = (text, word) => new RegExp(`(^|[^\\w])${rx(word)}(?![\\w])`, 'i').test(text);
// Models wrap JSON in code fences and leave trailing commas, so read it leniently.
const readJson = t => {
  try {
    const j = JSON.parse(String(t || '').replace(/```(json)?/gi, '').replace(/,\s*([}\]])/g, '$1').trim());
    return j.output || j;
  } catch (e) { return {}; }
};

return $input.all().map((it) => {
  const c = it.json, d = c.output || readJson(c.text);
  const intro = fix(d.intro), metaDesc = fix(d.meta_description);
  const metaTitle = fix(d.meta_title).replace(new RegExp(`\\s*[|-]\\s*${rx(s.store_name)}$`, 'i'), '');
  const base = { gid: c.gid, handle: c.handle, title: c.title, keyword: c.keyword, other_searches: c.other_searches, volume: c.volume,
    parent_handle: c.parent?.handle || '', live_html: c.live_html, written_by: c.use_ai ? 'the model' : 'the template' };
  if (!intro) return { json: { ...base, intro: '', body_html: '', meta_title: '', meta_description: '', links: [],
    flags: ['no draft: ' + (c.error || 'the model returned nothing')], status: 'failed' } };   // tried again next run
  const flags = [];
  const words = intro.split(' ').filter(Boolean).length;
  if ((words < s.intro_words[0] - 10 || words > s.intro_words[1] + 10)) flags.push(`intro is ${words} words`);

  const stray = c.vendors_all.filter(v => !c.brands.includes(v) && has(intro, v));
  if (stray.length) flags.push('brands not in this collection: ' + stray.join(', '));
  const codes = [...new Set(c.products.join(' ').match(/\b(?=[A-Z0-9-]*\d)(?=[A-Z0-9-]*[A-Z])[A-Z0-9-]{4,}\b/g) || [])];
  const models = codes.filter(m => has(intro, m));
  if (models.length) flags.push('names a model: ' + models.join(', '));
  // Numbers are fine when they come from the titles, specs, brands or store facts, never from a product.
  const known = JSON.stringify([c.title, c.keyword, c.other_searches, c.family.map(f => f.title), c.specs, c.brands, s.store_facts]);
  const nums = [...new Set(intro.match(/[\w.]*\d[\w.]*/g) || [])].map(n => n.replace(/\.$/, '')).filter(n => !has(known, n));
  if (nums.length) flags.push('names a size or number: ' + nums.join(', '));
  if (metaTitle.length > 48) flags.push(`meta title is ${metaTitle.length} characters`);
  if (/\b(guide|tips|how to|explained|options)\b/i.test(metaTitle)) flags.push('meta title reads like an article');
  if (metaDesc.length < 120 || metaDesc.length > 160) flags.push(`meta description is ${metaDesc.length} characters`);

  // First mention of a related collection becomes a link; children not mentioned get a line of their own.
  let html = esc(intro), budget = s.max_links;
  const linked = [];
  for (const f of [...c.family].sort((a, b) => b.title.length - a.title.length)) {   // longest first: 4K Security Cameras before Security Cameras
    const re = new RegExp(`(^|[^\\w>])(${rx(esc(f.title))})(?![\\w<])`, 'i');
    if (budget > 0 && re.test(html)) {
      html = html.replace(re, `$1<a href="/collections/${f.handle}">$2</a>`);
      linked.push(f.handle); budget--;
    }
  }
  const missing = c.children.filter(k => !linked.includes(k.handle));
  const body = `<p>${html}</p>` + (missing.length ? `<p>Shop by type: ${missing.map(k =>
    `<a href="/collections/${k.handle}">${esc(k.title)}</a>`).join(', ')}.</p>` : '');
  const seoTitle = (metaTitle + ' | ' + s.store_name).length <= 60 ? `${metaTitle} | ${s.store_name}` : metaTitle;

  return { json: { ...base, intro, body_html: body, meta_title: seoTitle, meta_description: metaDesc,
    links: [...linked, ...missing.map(k => k.handle)], flags, status: 'review' } };
});
```

For `/collections/nvr`, this produces a `<p>` linking `<a href="/collections/4k-security-cameras">4K Security Cameras</a>`, `<a href="/collections/poe-switches">PoE Switches</a>` and `<a href="/collections/8-channel-nvrs">8 Channel NVRs</a>`.

## Step 6: Approve and publish

Upsert the row into `collection_copy` with a **Data table** node matched on `handle` (status `review`, or `failed` when there's no draft), let a **Filter** pass only `review` rows, and post a review card with a **Slack** node (Message -> Send, Message Type: Blocks) showing the meta title and description with their lengths, the intro, the links and any `flags`. Build the blocks with `JSON.stringify`, because copy with a quote mark in it breaks hand-written JSON:

```text
={{ JSON.stringify({ blocks: [
  { type: 'header', text: { type: 'plain_text', text: 'Copy to review: ' + $json.title } },
  { type: 'section', text: { type: 'mrkdwn', text: '*Meta title* (' + $json.meta_title.length + ')\n' + $json.meta_title + '\n*Meta description* (' + $json.meta_description.length + ')\n' + $json.meta_description } },
  { type: 'section', text: { type: 'mrkdwn', text: '*Intro*\n' + $json.intro } },
  { type: 'context', elements: [{ type: 'mrkdwn', text: '`/collections/' + $json.handle + '` · keyword `' + $json.keyword + '` · written by ' + $('Check and link').item.json.written_by +
    ' · links: ' + ($('Check and link').item.json.links.join(', ') || 'none') + ($json.flags ? '\n:warning: ' + $json.flags : '') }] },
  { type: 'actions', elements: [
    { type: 'button', style: 'primary', text: { type: 'plain_text', text: 'Approve' }, action_id: 'copy_approve', value: $json.handle },
    { type: 'button', text: { type: 'plain_text', text: 'Redraft' }, action_id: 'copy_redraft', value: $json.handle },
    { type: 'button', text: { type: 'plain_text', text: 'Skip' }, action_id: 'copy_skip', value: $json.handle } ] }
] }) }}
```

In the Review lane, add a **Webhook** node (Respond: Using 'Respond to Webhook' Node, Raw Body on) and a **Respond to Webhook** node straight after it ([Slack requires a 200 response within 3 seconds](https://docs.slack.dev/interactivity/handling-user-interaction)). A **Code** node named `Read the click` reads the raw body with [`getBinaryDataBuffer`](https://docs.n8n.io/code/cookbook/code-node/get-binary-data-buffer/) and rejects requests older than five minutes (n8n's Code node has no `URLSearchParams`, so it splits the form body by hand):

```javascript
const raw = (await this.helpers.getBinaryDataBuffer(0, 'data')).toString('utf8');
const h = $input.first().json.headers;
if (Math.abs(Date.now() / 1000 - Number(h['x-slack-request-timestamp'])) > 300) throw new Error('Stale request');
const field = raw.split('&').find(kv => kv.startsWith('payload=')) || ''; // no URLSearchParams in the Code node
const p = JSON.parse(decodeURIComponent(field.slice(8).replace(/\+/g, ' ')));
const a = p.actions[0];
return [{ json: { base: `v0:${h['x-slack-request-timestamp']}:${raw}`, expected: h['x-slack-signature'],
  action: a.action_id, handle: a.value, user: p.user.username, channel: p.channel.id, ts: p.message.ts } }];
```

Because anyone holding the webhook URL could otherwise approve copy, add a **Crypto** node and an **If** node that check [Slack's request signature](https://docs.slack.dev/authentication/verifying-requests-from-slack/):

```text
Crypto node:  Action Hmac, Type SHA256, Value {{ $json.base }},
              Property Name sig, Encoding HEX, Crypto credential = your Slack signing secret
If node:      {{ 'v0=' + $json.sig === $json.expected }}
```

Connect a **Data table** Get node named `Get draft` (`handle` matches and `status` equals `review`, so a second click on an old card stops here), then a **Switch** node on the action. Redraft sets `status` to `refresh` and Skip to `skipped`. When you click **Approve**, a `Review settings` node and its own token node lead to a re-read of the collection's `descriptionHtml`; if it no longer matches `live_html` (ignoring whitespace), someone edited the page in Shopify, so the row goes back to `refresh` instead of overwriting their change:

```text
={{ String($json.data?.collection?.descriptionHtml || '').replace(/\s+/g, ' ').trim() === String($('Get draft').first().json.live_html || '').replace(/\s+/g, ' ').trim() }}
```

Otherwise call Shopify's [`collectionUpdate` mutation](https://shopify.dev/docs/api/admin-graphql/2026-10/mutations/collectionUpdate) (`2026-10`, using the `collection` input argument), check it came back without `userErrors`, and update the row's `status` to `'approved'` with `live_html` set to what you wrote. One **Slack** Message Update node rewrites the card with the outcome:

```text
={{ JSON.stringify({
  query: `mutation($c: CollectionUpdateInput!) { collectionUpdate(collection: $c) {
    collection { handle } userErrors { field message } } }`,
  variables: { c: { id: $('Get draft').first().json.gid, descriptionHtml: $('Get draft').first().json.body_html,
    seo: { title: $('Get draft').first().json.meta_title, description: $('Get draft').first().json.meta_description } } }
}) }}
```

![The NVRs collection in Shopify admin after approval: the intro that names TP-LINK, Ubiquiti, Hikvision and Reolink and points to 4K Security Cameras, PoE Switches and 8 Channel NVRs](./_attachments/ecommerce-category-page-seo/nvr-collection-admin.png "The NVRs collection in Shopify admin after approval: the intro that names TP-LINK, Ubiquiti, Hikvision and Reolink and points to 4K Security Cameras, PoE Switches and 8 Channel NVRs")

## Step 7: Put the right products first

In the Sort lane, add a daily **Schedule Trigger** and a **Set** node named `Sort settings` (keep `dry_run` on until you've read the first report):

```json
{
  "store_domain": "your-store.myshopify.com",
  "keyword_namespace": "seo",
  "skip_handles": [
    "frontpage",
    "new-arrivals",
    "sale"
  ],
  "min_secondary": 0.5,
  "stopwords": [
    "for",
    "with",
    "and",
    "the",
    "a"
  ],
  "synonyms": {
    "wireless": "wifi",
    "recorder": "nvr"
  },
  "dry_run": true,
  "slack_channel": "#log-sort"
}
```

Copy `Read store` and `Get copy rows` into this lane as `Sort read store` and `Sort copy rows` (node names must be unique), then add a **Code** node named `Collections to sort`, which takes each collection's keyword from its row or its `seo.target_keyword` metafield:

```javascript
const s = $('Sort settings').first().json;
const rows = Object.fromEntries($('Sort copy rows').all().filter(i => i.json.handle).map(i => [i.json.handle, i.json]));
return $('Sort read store').all().flatMap(i => i.json.data.collections.nodes)
  .map(c => ({ c, kw: rows[c.handle]?.keyword || c.keyword?.value }))
  .filter(({ c, kw }) => kw && !s.skip_handles.includes(c.handle))
  .map(({ c, kw }) => ({ json: { gid: c.id, handle: c.handle, keyword: kw,
    other_searches: (rows[c.handle]?.other_searches || '').split(',').map(x => x.trim()).filter(Boolean) } }));
```

An **HTTP Request** node pulls each collection's `sortOrder` and products, in their current order:

```text
={{ JSON.stringify({ query: `query($id: ID!) { collection(id: $id) { sortOrder products(first: 250, sortKey: COLLECTION_DEFAULT) { nodes {
    id title status totalInventory tracksInventory featuredMedia { id } priceRangeV2 { minVariantPrice { amount } } } } } }`, variables: { id: $json.gid } }) }}
```

A **Code** node named `Order products` scores active, in-stock products by keyword overlap, places sold-out, no-image, unpriced and draft items last, and returns only the diff `moves` (up to 250):

```javascript
const s = $('Sort settings').first().json;
const cols = $('Collections to sort').all().map(i => i.json);
const tokens = t => [...new Set(String(t).toLowerCase().replace(/wi-?fi/g, 'wifi').split(/[^a-z0-9]+/)
  .filter(w => w && !s.stopwords.includes(w))
  .map(w => w.length > 4 && w.endsWith('ies') ? w.slice(0, -3) + 'y' : (w.length > 3 && /[^siu]s$/.test(w) ? w.slice(0, -1) : w))
  .map(w => s.synonyms[w] || w))];
const score = (kw, title) => { const k = tokens(kw), t = tokens(title); return k.length ? k.filter(w => t.includes(w)).length / k.length : 0; };
const buyable = p => p.status === 'ACTIVE' && p.featuredMedia && Number(p.priceRangeV2.minVariantPrice.amount) > 0
  && (!p.tracksInventory || p.totalInventory > 0);

return $input.all().map((it, i) => {
  const c = cols[i], col = it.json.data.collection, now = col.products.nodes;
  const ids = now.map(p => p.id);
  const byKeyword = list => list.map(p => ({ p, sc: score(c.keyword, p.title), at: ids.indexOf(p.id) }))
    .sort((a, b) => b.sc - a.sc || a.at - b.at).map(x => x.p);
  const ready = now.filter(buyable), placed = [];
  for (const kw of c.other_searches) {       // one best product per other search, in your order
    const best = ready.filter(p => !placed.includes(p)).map(p => ({ p, sc: score(kw, p.title) })).sort((a, b) => b.sc - a.sc)[0];
    if (best && best.sc >= s.min_secondary) placed.push(best.p);
  }
  const order = [...placed, ...byKeyword(ready.filter(p => !placed.includes(p))), ...byKeyword(now.filter(p => !buyable(p)))];

  // Shopify re-sorts a collection after it switches to Manual, so a switch gets this run and the moves wait for the next.
  if (col.sortOrder !== 'MANUAL') return { json: { gid: c.gid, handle: c.handle, was: col.sortOrder, switch: true, moves: [],
    page1: order.slice(0, 8).map(p => p.title), last: order.filter(p => !buyable(p)).map(p => p.title) } };
  const arr = ids.slice(), moves = [];
  order.forEach((p, pos) => {                // only the moves that change the live order
    if (arr[pos] === p.id) return;
    const at = arr.indexOf(p.id);
    if (at >= 0) arr.splice(at, 1);
    arr.splice(pos, 0, p.id);
    moves.push({ id: p.id, newPosition: String(pos) });
  });
  return { json: { gid: c.gid, handle: c.handle, was: col.sortOrder, switch: false, moves: moves.slice(0, 250), page1: order.slice(0, 8).map(p => p.title),
    last: order.filter(p => !buyable(p)).map(p => p.title) } };
});
```

Shopify's [`collectionReorderProducts` mutation](https://shopify.dev/docs/api/admin-graphql/2026-10/mutations/collectionReorderProducts) requires the collection's `sortOrder` to be `MANUAL`, and switching it starts Shopify re-sorting the collection in the background. When we sent the switch and the moves in one request, the re-sort won on 5 of 13 collections and undid the moves. So `Order products` only switches a collection on the run that finds it isn't Manual, and moves its products on the next run. Behind a **Filter** named `Live run?` (`dry_run` off, and a switch or moves to send), one **HTTP Request** sends whichever is due:

```text
={{ JSON.stringify($json.switch
  ? { query: 'mutation($id: ID!) { collectionUpdate(collection: { id: $id, sortOrder: MANUAL }) { userErrors { message } } }', variables: { id: $json.gid } }
  : { query: 'mutation($id: ID!, $moves: [MoveInput!]!) { collectionReorderProducts(id: $id, moves: $moves) { job { id } userErrors { field message } } }',
      variables: { id: $json.gid, moves: $json.moves } }) }}
```

The `Live run?` branch sits above the report on the canvas, because n8n runs branches top to bottom and the report reads the reorder results. A **Slack** report lists, for each collection that changes, its move count, the first products and how many sit last, kept short because Slack cuts a message at 4,000 characters:

![The Slack sort report: PoE Switches switches to Manual and its products move on the next run, then 1 move on that run, with 12 collections already in order](./_attachments/ecommerce-category-page-seo/sort-report.png "The Slack sort report: PoE Switches switches to Manual and its products move on the next run, then 1 move on that run, with 12 collections already in order")

## Step 8: Audit links weekly

As you launch new collections, it's easy to forget to add them to your navigation menu or link them from their parent collection's intro. In the Audit lane, add a weekly **Schedule Trigger**, a **Set** node named `Audit settings`, and a copy of the `Read store` query from Step 2 named `Audit read store`:

```json
{
  "store_domain": "your-store.myshopify.com",
  "keyword_namespace": "seo",
  "tree_menu": "main-menu",
  "ignore": [
    "frontpage"
  ],
  "theme_linked": [],
  "slack_channel": "#log-seo"
}
```

Connect a **Code** node named `Find orphans` that builds the set of all `/collections/<handle>` links present in your navigation menus and in the `href`s of every collection's `descriptionHtml`, plus the `theme_linked` pages your theme links in code. Parents come from the menu and, for collections outside it, from their rules, as in the Write lane:

```javascript
const s = $('Audit settings').first().json;   // { tree_menu, ignore: ["frontpage"], theme_linked: [] }
const cols = $('Audit read store').all().flatMap(i => i.json.data.collections.nodes).filter(c => !s.ignore.includes(c.handle));
const menus = $('Audit read store').first().json.data.menus.nodes;
const handleById = Object.fromEntries(cols.map(c => [c.id, c.handle]));
const handleOf = it => handleById[it.resourceId] || (it.url || '').match(/\/collections\/([^/?#]+)/)?.[1];
const linksTo = (html, h) => new RegExp(`href="[^"]*/collections/${h}(["?#/])`).test(html || '');

const inMenu = new Set(), parent = {};
const walk = (items, up, tree) => {
  for (const it of items || []) {
    const h = handleOf(it);
    if (h) inMenu.add(h);
    if (tree && h && up && h !== up && !parent[h]) parent[h] = up;
    walk(it.items, h && h !== up ? h : up, tree);
  }
};
for (const m of menus) walk(m.items, null, m.handle === s.tree_menu);
// Collections outside the menu sit under the page whose only rule is their type, as in the Write lane.
const typePage = Object.fromEntries(cols.filter(c => c.ruleSet?.rules.length === 1 && c.ruleSet.rules[0].column === 'TYPE')
  .map(c => [c.ruleSet.rules[0].condition.toLowerCase(), c.handle]));
for (const c of cols) if (!parent[c.handle] && c.ruleSet && !c.ruleSet.appliedDisjunctively && c.ruleSet.rules.length > 1) {
  const t = c.ruleSet.rules.find(r => r.column === 'TYPE' && r.relation === 'EQUALS');
  const up = t && typePage[t.condition.toLowerCase()];
  if (up && up !== c.handle) parent[c.handle] = up;
}

const desc = Object.fromEntries(cols.map(c => [c.handle, c.descriptionHtml]));
const today = new Date().toISOString().slice(0, 10);
const orphans = cols.filter(c => !inMenu.has(c.handle) && !s.theme_linked.includes(c.handle)
  && !cols.some(o => o.handle !== c.handle && linksTo(o.descriptionHtml, c.handle)));
const empty = cols.filter(c => inMenu.has(c.handle) && c.productsCount.count === 0);
const unlinked = Object.entries(parent).filter(([kid, up]) => kid in desc && up in desc && !linksTo(desc[up], kid));

return [
  ...orphans.map(c => ({ json: { run_date: today, handle: c.handle, issue: 'orphan', parent_handle: parent[c.handle] || '' } })),
  ...empty.map(c => ({ json: { run_date: today, handle: c.handle, issue: 'menu links to empty collection', parent_handle: '' } })),
  ...unlinked.map(([kid, up]) => ({ json: { run_date: today, handle: kid, issue: 'not linked from parent', parent_handle: up } })),
];
```

Insert the findings into `link_audit`. A **Code** node named `Parents to redraft` lists each parent missing a child link once, and a **Data table** Update sets those parents from `approved` to `refresh` in `collection_copy`, so the next Write run redrafts them and its "Shop by type" line adds the missing link. A Slack message sums it up.

![The weekly link audit in Slack: eight orphans (the brand pages and 8 Channel NVRs), two children not linked from their parent, and NVRs queued for a redraft](./_attachments/ecommerce-category-page-seo/link-audit.png "The weekly link audit in Slack: eight orphans (the brand pages and 8 Channel NVRs), two children not linked from their parent, and NVRs queued for a redraft")

## What it costs

Here is how the monthly cost of running all five lanes compares against a paid Shopify SEO app or freelance copywriting at 500, 2,000, and 10,000 products, assuming about one collection per ten products (prices checked November 2026):

| Item | 500 products (50 collections) | 2,000 products (200 collections) | 10,000 products (1,000 collections) |
|---|---|---|---|
| n8n self-hosted | Free | Free | Free |
| n8n Cloud Starter (2,500 executions a month) | €20 a month billed annually | €20 | €20 |
| Model: one draft per collection, `gemma4` on Ollama Cloud pay as you go | about US$0.01 | about US$0.04 | about US$0.20 |
| Sort and audit (Shopify API calls) | Free | Free | Free |
| SEO Content AI app (copy and meta only) | about US$7 a month | about US$7 a month | about US$7 a month |
| Freelance copywriter, about 100 words a page | US$250 to US$2,500 | US$1,000 to US$10,000 | US$5,000 to US$50,000 |


[Ollama Cloud](https://ollama.com/pricing) pay as you go has no monthly fee, and [n8n Cloud](https://n8n.io/pricing/) Starter's executions cover every lane plus a few hundred approvals. The copywriter row uses NewMedia's [US$0.05 to US$0.50 a word](https://newmedia.com/blog/copywriting-rates) for simple copy, and [SEO Content AI](https://apps.shopify.com/seo-meta-ai) doesn't link, sort, or audit. A draft measured about 1,000 input and 160 output tokens in testing, which is about US$0.0002 at US$0.14 and US$0.40 per million tokens. Because the Sort and Audit lanes run entirely in deterministic JavaScript and GraphQL, your daily ongoing token cost is zero until you add new categories or queue a parent category for a redraft.

## Common mistakes

### Copy written from the keyword alone

Prompting from the keyword `"nvr"` alone produces textbook definitions. Always pass the collection's live brands, product count, and shared specs.

### Naming models or sizes that go out of date

Naming `TP-Link VIGI NVR1004H-4P` in the intro makes your copy stale the day that model sells out. Describe the overall range (PoE NVRs from TP-LINK and Ubiquiti) instead.

### Letting the model write links

Models guess slugs and invent 404 URLs. Have the model write plain text and let Step 5 inject verified `<a href="/collections/...">` tags.

### Fixing samey copy with more banned phrases

Long lists of banned words make copy stiff without teaching good structure. Retrieve two or three approved intros from the same parent category instead.

### Sold-out products on page 1

Out-of-stock cameras in row one send shoppers straight back to Google. Run the daily Sort lane so sold-out items always move last.

### Long copy pushing the products below the fold

A [case study in Ahrefs' category page guide](https://ahrefs.com/blog/seo-ecommerce-category-pages/) showed rankings improved after cutting an 800-word intro above the fold to ~70 words. Keep your top intro between 55 and 75 words.

### Filter URLs indexed by the thousand

Letting Google index every filter combination wastes crawl budget and cannibalises your main category. Block non-SEO filter parameters in `robots.txt` or apply `noindex`, but not both on the same URL.

### Counting orphans without counting menu links

Checking only collection body HTML falsely flags top-level categories as orphans. Always union links from Shopify's `menus` query with body HTML links, and treat collections outside the menu as children of the collection whose only rule is their product type.

## Make it your own

### Add buyer guides

For categories where shoppers compare specs before buying, add a `buyer_guide_html` output field (150 to 250 words), check it with Step 5's brand and number rules, and write it to a collection metafield rendered *below* the product grid. Only draft one when the collection has real specs or manuals to work from, because otherwise the guide makes claims nobody has checked.

### Sort by sales or margin and swap platforms

Add a margin or 30-day sales bonus to the `score` function in Step 7 so bestsellers lead row one. For WooCommerce or BigCommerce, swap the Shopify GraphQL nodes for the WooCommerce REST API or BigCommerce Catalog API.

### Use filters instead of thin collections

A cut with no search demand of its own works better as a filter on the parent page than as another collection to write, link, and audit. The [Shopify collections SEO guide](/shopify-collections-seo/) and its template propose which collections should exist.

### When Data Tables aren't enough

Data tables on an instance [share a 200 MiB limit](https://docs.n8n.io/build/work-with-data/data-tables/) by default (self-hosters can raise `N8N_DATA_TABLES_MAX_SIZE_BYTES`), and past it, inserts and updates fail. At roughly 3 KB a collection, even 2,000 collections use about 6 MB, so other workflows are the likelier cause. If you hit it, swap the Data table nodes for Postgres nodes on Supabase's free plan, which has [500 MB per project](https://supabase.com/pricing) (checked November 2026).

## Frequently asked questions

### How long should category page copy be?

Keep the intro above your product grid to 55 to 75 words (two or three sentences). That gives shoppers and Google clear context on your brands and specs without pushing products below the fold.

### Should text go above or below the products?

Put the 55-to-75-word intro and 1 to 3 internal links directly under the H1 above the grid. Place longer buyer guides or FAQs below the product grid via a metafield.

### Does product sort order affect SEO?

Indirectly. Products on page 1 are the ones crawlers and most shoppers reach first, and keeping relevant, in-stock items in the first rows stops shoppers from bouncing back to search results.

### Will manual sorting affect my theme's filters?

No. Manual sort only sets the collection's default order, so your filters still work, and a shopper who picks "Price, low to high" still gets that order.

### Should filtered pages be indexed?

Usually no. Keep standard filter parameters unindexed, and create dedicated child collections only for attribute combinations with proven search volume. Don't block a URL in `robots.txt` and also rely on `noindex`, because Google [can't read the noindex tag](https://developers.google.com/search/docs/crawling-indexing/block-indexing) on a page it isn't allowed to crawl.

## Next steps

Import the template into n8n, run the Setup lane once, and test the Write and Sort lanes on one category to review your first Slack card and product reorder diff.

<TemplateCallout
  repo="https://github.com/reganmcgregor/n8n-ecommerce-templates/tree/main/category-page-seo"
>

- Complete 5-lane workflow: Setup, Write, Review, Daily Sort, and Weekly Link Audit.
- Built for Shopify Admin GraphQL API `2026-10` (`collectionUpdate`, `collectionReorderProducts`, and `menus`).
- Uses Ollama Cloud (`gemma4`) by default and swaps to any Chat Model.

</TemplateCallout>
