**TL;DR.** Redirects in ecommerce are about preserving SEO equity through URL changes. Use 301 for permanent moves (slug fixes, rebrands). Use 410 for permanent deletions. Track slug history per entity so old URLs auto-redirect. Detect and flatten chains weekly. Render gone pages with useful content, not generic 404.

## HTTP status codes for ecommerce

| Code | Name              | Meaning                       | Use case                                          |
| ---- | ----------------- | ----------------------------- | ------------------------------------------------- |
| 200  | OK                | Page exists                    | Normal product/category page                       |
| 301  | Moved Permanently | Use the new URL forever        | Slug changes, rebrands, domain migrations          |
| 302  | Found              | Temporary redirect             | A/B tests, geo-redirects, temporary outages         |
| 307  | Temporary Redirect | Same as 302 but preserves method | API redirects with POST                          |
| 308  | Permanent Redirect | Same as 301 but preserves method | API redirects with POST                          |
| 404  | Not Found         | Page doesn't exist, might come back | Typos, misremembered URLs                   |
| 410  | Gone               | Permanently removed            | Discontinued products, deleted content              |

For ecommerce, the common pair is **301** for moves and **410** for permanent deletes.

## When to use 301

Common 301 scenarios:

| Event                          | Pattern                                              |
| ------------------------------ | ---------------------------------------------------- |
| Product slug fix (typo)         | `/products/leather-bagg` → `/products/leather-bag`   |
| Product rebrand                 | `/products/leather-bag` → `/products/heritage-bag`    |
| Category restructure            | `/products/bags-leather` → `/categories/leather-bags` |
| Domain migration                | `oldbrand.com/...` → `newbrand.com/...`               |
| Locale URL change                | `/products/bag` → `/en/products/bag`                  |
| Trailing slash normalization     | `/products/bag/` → `/products/bag`                    |
| HTTP → HTTPS                     | `http://...` → `https://...`                          |

Implementation in Next.js:

```ts
// next.config.ts
export default {
  async redirects() {
    return [
      {
        source: '/products/leather-bagg',
        destination: '/products/leather-bag',
        permanent: true,  // 301
      },
    ];
  },
};
```

Or via a runtime redirect table:

```ts
// src/middleware.ts or proxy.ts
const redirect = await getRedirect(request.url);
if (redirect) {
  return NextResponse.redirect(redirect.to, redirect.statusCode);
}
```

## When to use 410

A 410 Gone is the right response when:

- A product is permanently discontinued.
- A blog post is removed for accuracy or legal reasons.
- A category was merged with another and the old slug should die permanently.
- A page is intentionally deleted and won't return.

```ts
// Pseudocode
const gone = await isGonePath(storeId, request.url.pathname);
if (gone) {
  return new Response(renderGonePage(gone.suggestions), {
    status: 410,
    headers: { 'Content-Type': 'text/html' },
  });
}
```

The 410 page should:

- Render `<meta name="robots" content="noindex">`.
- Suggest similar products (use pgvector cosine on the gone entity's embedding).
- Provide a search box.
- Have a clear "this product is no longer available" message.

## Slug history per entity

When a product slug changes, the old URL must redirect to the new URL. Storing this manually is fragile. Track slug history per entity:

```sql
CREATE TABLE product_slug_history (
  id SERIAL PRIMARY KEY,
  product_id UUID NOT NULL,
  old_slug TEXT NOT NULL,
  new_slug TEXT NOT NULL,
  changed_at TIMESTAMPTZ DEFAULT NOW()
);
```

On slug update:

```ts
async function updateProductSlug(productId: string, newSlug: string) {
  const product = await db.products.findOne({ id: productId });
  const oldSlug = product.slug;

  if (oldSlug === newSlug) return;

  await db.products.update({ id: productId, slug: newSlug });
  await db.productSlugHistory.insert({ productId, oldSlug, newSlug });
  await db.storeRedirects.insert({
    from: `/products/${oldSlug}`,
    to: `/products/${newSlug}`,
    statusCode: 301,
  });
  await enqueueIndexNow([`/products/${oldSlug}`, `/products/${newSlug}`]);
}
```

The slug history table lets you trace back a product's URL evolution (useful for support and for legal/compliance archives).

## Redirect chains

A chain: `A → B → C`.

Why chains are bad:

- Each hop loses 5–10% link equity (Google has confirmed this).
- Each hop adds 100–500ms latency.
- Three or more hops increase the chance Google gives up tracking.

Detection: HEAD-trace every redirect weekly.

```ts
async function traceRedirect(url: string, hops: number = 0): Promise<{ finalUrl: string; chainLength: number }> {
  if (hops > 5) return { finalUrl: url, chainLength: hops }; // loop guard

  const res = await fetch(url, { method: 'HEAD', redirect: 'manual' });
  if (res.status >= 300 && res.status < 400) {
    const location = res.headers.get('location');
    if (!location) return { finalUrl: url, chainLength: hops };
    return traceRedirect(location, hops + 1);
  }
  return { finalUrl: url, chainLength: hops };
}
```

Flatten chains: rewrite the source to point directly at the final URL.

```ts
// Before: A → B → C
await db.redirects.update({ from: '/a', to: '/b' });  // existing
// After flattening:
await db.redirects.update({ from: '/a', to: '/c' });  // rewritten
```

## Redirect loops

`A → B → A` is fatal. Detect at write time:

```ts
async function writeRedirect(from: string, to: string) {
  const wouldLoop = await detectLoop(from, to);
  if (wouldLoop) {
    throw new Error(`Redirect from ${from} to ${to} creates a loop`);
  }
  await db.redirects.insert({ from, to, statusCode: 301 });
}
```

## Ping IndexNow

After writing a redirect:

```ts
await enqueueIndexNow([oldUrl, newUrl]);
```

The engine re-fetches the old URL, sees the 301, and updates its index.

## Best practices

- Keep the redirect table tight. Over time, dozens of small slug edits create dozens of redirects. Periodically flatten chains.
- Don't redirect entire categories to the homepage. That's a 'soft 404' to Google. Redirect to the nearest equivalent category instead.
- Avoid redirecting to noindex pages. Defeats the purpose of preserving equity.
- Document redirect intent. Comment column in the redirects table helps future-you understand why.

## How Ordiko handles redirects

- `storeRedirects` table with `from`, `to`, `statusCode`, `tenant` columns.
- Slug history per entity: `productSlugHistory`, `categorySlugHistory`, etc.
- Auto-write of 301 on slug change.
- `productLifecycle.archive()` writes a 410 row into `storeRedirects`.
- Gone-paths layer renders `/products/[slug]/gone` with similarity recommendations.
- Trigger.dev weekly task `redirect-chain-verify.task.ts` HEAD-traces every redirect and flags chains and broken targets.
- One-click "Flatten chain" rewrites A → C directly.
- IndexNow pings on every redirect creation.

## FAQ

**What's the practical difference between 301 and 302?**
301 = permanent. Passes nearly all link equity. Caches the redirect heavily. Use for slug changes, domain migrations. 302 = temporary. Passes less equity. Doesn't cache as aggressively. Use for A/B tests or temporary outages. For most ecommerce redirects, 301 is correct.

**What's the difference between 404 and 410?**
404 = 'not found, might come back'. Google may keep the URL in its index for a while in case it returns. 410 = 'gone, permanently removed'. Google drops the URL faster. For permanently discontinued products, 410 is better SEO hygiene.

**How many redirect hops are too many?**
Two hops is the practical limit. Each hop loses 5–10% link equity and adds latency. Three or more hops is a smell. Detect and flatten them automatically.

**How does Ordiko handle slug history?**
Every entity table has a corresponding slug history table (e.g., product_slug_history). On slug change, a new row is inserted with the old slug; a 301 redirect is automatically written to storeRedirects. Trigger.dev weekly redirect-chain-verify task HEAD-traces redirects and flags chains and broken targets.
