# Build a redirect map: one old URL, one new URL, tested

You need a one to one redirect map before you change domains, paths or HTTPS. This page shows how to build it, apply it and test it. It is for founders who ship their own sites.

Updated 2026-09-14 · Source: https://porteur.ai/guides/redirect-map

## What a redirect map is and the rule to follow

A redirect map is a two column table: old URL, new URL. Each old URL must 301 to the one new URL that replaces it.

Do not point old URLs to the home page. That looks like a soft 404 pattern to Google. Map to the specific new page or return 410 when there is no replacement.

Build and test the map before you switch. You will use it for domain changes, path changes and HTTP to HTTPS. Expect some fluctuation for some weeks after launch as of 2026.

> One old URL, one new URL, tested. No chains, no loops, no 302s.

## Where to find the old URLs

Collect every discoverable URL from the old site. Do not guess. Combine multiple sources and dedupe to absolute URLs.

- Crawl the old site: include http and https, www and non www, with and without trailing slash. Export every 200, 3xx and 404. A desktop tool or a CI crawler both work.
- The sitemap: fetch /sitemap.xml and any sitemap index. Expand and list every URL, even if some are already captured by the crawl.
- Search Console: export the Pages data for the old property. Use the Coverage or Pages reports, and the legacy Removals history if relevant. Include Discovered and Crawled states.
- Search Console Links report: export Top linked pages from external sites. These URLs you do not want to break.
- Your server logs if you have them: list recent 200s and 404s with counts. Old marketing pages often live only in logs.

Normalise case and strip fragments, but keep query strings. /post?id=ref and /post are different. Keep both in the list for mapping judgement later.

Include variants you plan to consolidate. For www vs non www and HTTP to HTTPS, include both hosts and schemes so you can ensure one hop to the chosen form.

## How to match old to new: rules and worked examples

Match by intent first, by path second. Use these rules in order and stop at the first that fits. Keep a comment column to note your choice.

- Same page: old /blog/how-to-export maps to new /guides/export-data if the content is the same page in a new structure.
- Closest page: old /features/analytics maps to /product/analytics if the content moved and merged with light edits.
- Category or index page: old /blog/tools-review-2019 maps to /blog if there is no 2026 replacement but the category is still useful.
- 410 Gone: old /jobs/frontend-intern-2018 returns 410 if the role is historic and has no useful successor.

Worked example: you move your docs from /docs/* to /guides/* and rename some pages. Map /docs/getting-started to /guides/getting-started. Map /docs/cli to /guides/command line if that is the renamed page, not to /guides.

Another: you close an old pricing experiment at /pricing/2021. If /pricing now covers it, map to /pricing. If not, 410 the 2021 page.

Parameter handling: if /search?q=abc now lives at /search?q=abc unchanged, force HTTP to HTTPS and www choice only. If parameters changed, map common patterns to the new form, and 410 tracking junk like ?utm_source when it leaks into indexed URLs via a rule, not the map table.

## Keep the map in your repository

Store the redirect map next to your infrastructure code. Version it, review it and test it in CI. A CSV is fine.

```
# /ops/redirects.csv
old_url,new_url,reason
http://yourproduct.com/,https://www.yourproduct.com/,scheme+host canonical
http://yourproduct.com/pricing,https://www.yourproduct.com/pricing,scheme+host canonical
https://yourproduct.com/docs/getting-started,https://www.yourproduct.com/guides/getting-started,section moved
https://www.yourproduct.com/docs/cli,https://www.yourproduct.com/guides/command-line,renamed page
https://www.yourproduct.com/jobs/frontend-intern-2018,,410
```

Use absolute URLs in both columns. Leave the new URL blank to mark 410. Keep comments terse. The table is the source of truth in reviews and audits.

Do not hide broad normalisation in the table. Configure trailing slash, case rules and host choice in your server or framework so they apply outside the migration too.

## Apply the map: server, Next.js, or an edge rule

Apply 301s at the first hop you control: your origin, your CDN edge or your framework. Aim for one hop from any old URL to the new URL.

```nginx
# nginx: map and return 301 in one hop
# redirects.csv compiled to an nginx map at deploy time
# 443 is the typical HTTPS port, not a measured figure
map $request_uri $redirect_target {
    default "";
    
    /docs/getting-started https://www.yourproduct.com/guides/getting-started;
    /docs/cli https://www.yourproduct.com/guides/command-line;
}

server {
    listen 443 ssl;
    server_name www.yourproduct.com yourproduct.com;

    # force host canonical
    if ($host = yourproduct.com) { return 301 https://www.yourproduct.com$request_uri; }

    # force HTTPS handled by separate HTTP server block: return 301 https://$host$request_uri

    # apply explicit redirects
    if ($redirect_target != "") { return 301 $redirect_target; }

    # 410s
    location = /jobs/frontend-intern-2018 { return 410; }

    # app routes...
}
```

```javascript
// Next.js (App Router): redirects in next.config.js
// You can generate this from /ops/redirects.csv at build time
/** @type {import('next').NextConfig} */
const nextConfig = {
  trailingSlash: false, // choose true or false and keep it consistent
  async redirects() {
    return [
      { source: '/docs/getting-started', destination: '/guides/getting-started', permanent: true },
      { source: '/docs/cli', destination: '/guides/command-line', permanent: true },
    ]
  },
}

module.exports = nextConfig;
```

For host and scheme moves in Next.js, set them at your proxy or CDN. Next.js redirects run after the request reaches the app, so the hop is already spent if you force HTTPS there.

```javascript
// Edge worker example (Cloudflare Workers like API)
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url)

    // force HTTPS
    if (url.protocol === 'http:') {
      url.protocol = 'https:'
      return Response.redirect(url.toString(), 301)
    }

    // force host canonical
    if (url.hostname === 'yourproduct.com') {
      url.hostname = 'www.yourproduct.com'
      return Response.redirect(url.toString(), 301)
    }

    // explicit redirects from a KV or JSON map
    const map = {
      '/docs/getting-started': 'https://www.yourproduct.com/guides/getting-started',
      '/docs/cli': 'https://www.yourproduct.com/guides/command-line'
    }
    const target = map[url.pathname]
    if (target) return Response.redirect(target, 301)

    return fetch(request)
  }
}
```

Whichever approach you use, confirm that the HTTP to HTTPS hop and the host normalisation do not add extra hops for mapped URLs. One hop total from the old URL to the final 200 page is the goal.

## Update canonicals, hreflang, sitemaps and metadata

After you apply redirects, update internal signals to the new URLs. Keep them consistent with the chosen host and trailing slash form.

- Internal links: update nav, footers, templates and any hard coded links in markdown or CMS fields.
- Canonical tags: point to the new URL form everywhere. Do not canonicalise to the old host.
- Hreflang: update each hreflang href to the new URL and keep language pairs intact.
- Structured data: update any URL fields, for example in Organization, Article or Product objects.
- Sitemap: publish a new sitemap with the new URLs. Keep the old sitemap listed for a while so Google can recrawl the old URLs and find the redirects.
- Next.js App Router: set alternates.canonical in generateMetadata, and update app/sitemap.ts and app/robots.ts to the new host with metadataBase.

A fixed page looks like /guides/getting-started with self canonical, correct hreflang, and every internal link already pointing to it, not relying on a redirect to fix the path.

## Test every row: one hop, final 200

Test your map before the switch. Then test it again after the DNS cut. The test must request every old URL and assert the final status and hop count.

```bash
# Simple bash: follow redirects, print final code and hops
# redirects.csv has headers
while IFS=, read -r old new reason; do
  [[ $old == old_url* ]] && continue
  if [[ -z "$new" ]]; then
    code=$(curl -s -o /dev/null -w "%{http_code}" -I "$old")
    echo "$old -> 410? $code"
    continue
  fi
  # -L follows, -I head only, -s silent, -o discard, -w prints vars
  out=$(curl -s -o /dev/null -I -L -w "%{http_code} %{url_effective} %{num_redirects}" "$old")
  code=$(echo $out | awk '{print $1}')
  final=$(echo $out | awk '{print $2}')
  hops=$(echo $out | awk '{print $3}')
  if [[ "$final" != "$new" || "$hops" != "1" || "$code" != "200" ]]; then
    echo "FAIL $old -> $final code=$code hops=$hops expected=$new"
  else
    echo "OK   $old -> $final"
  fi
done < ops/redirects.csv
```

```javascript
// Node script: stricter checks, fails on 302s mid chain
// 300 and 400 denote HTTP status class boundaries, typical, not measured
import fs from 'node:fs/promises'
import fetch from 'node-fetch'

async function check(url, expected) {
  let hops = 0
  let current = url
  let lastStatus = 0
  const seen = new Set()
  while (hops <= 5) {
    if (seen.has(current)) throw new Error(`Loop at ${current}`)
    seen.add(current)
    const res = await fetch(current, { redirect: 'manual' })
    lastStatus = res.status
    if (res.status >= 300 && res.status < 400) {
      const loc = res.headers.get('location')
      if (!loc) throw new Error(`No Location on ${current}`)
      if (res.status !== 301 && res.status !== 308) throw new Error(`Non permanent ${res.status} at ${current}`)
      current = new URL(loc, current).toString()
      hops++
      continue
    }
    break
  }
  if (hops !== 1) throw new Error(`Expected 1 hop, got ${hops} for ${url}`)
  if (current !== expected) throw new Error(`Final URL mismatch ${current} != ${expected}`)
  if (lastStatus !== 200) throw new Error(`Final status ${lastStatus} for ${current}`)
}

const csv = await fs.readFile('ops/redirects.csv', 'utf8')
for (const line of csv.split('\n').slice(1)) {
  if (!line.trim()) continue
  const [oldUrl, newUrl] = line.split(',')
  if (!newUrl) continue // 410s tested separately
  try {
    await check(oldUrl, newUrl)
    console.log('OK', oldUrl)
  } catch (e) {
    console.error('FAIL', oldUrl, e.message)
  }
}
```

Run this in CI on pull requests that change ops/redirects.csv. Fail the build on any row with more than one hop, a 302, a loop, or a final non 200. Test 410s with a separate pass that expects 410 at the old URL with no Location header.

Spot check with a browser and with a redirect checker for a few high value pages like /, /pricing and /guides/getting-started. You are looking for one hop and the right final URL.

## Special cases: domain change, www, trailing slash, HTTPS

Domain change: use the Change of address tool in Search Console under Settings. Verify both the old and the new properties first. Only use it for domain level moves, not path changes or HTTPS only. Have 301s in place before you submit.

Keep the old domain’s redirects for as long as possible. At least a year. Google’s guidance says keep them permanently when you can. Keep the old sitemap listed for a while so Google recrawls and finds the redirects.

www vs non www: these are different hosts to Google. Pick one. 301 every path from the other host to the chosen host. Set canonicals to the chosen host. Verify a Domain property in Search Console to cover both.

Trailing slash: /page and /page/ are different URLs, except the root /. Choose one form, redirect the other, and keep your internal links consistent. In Next.js, set trailingSlash to true or false and stick to it.

HTTP to HTTPS: this is a site move. 301 every URL to its HTTPS twin in one hop. Update canonicals, sitemaps, hreflang and internal links. Avoid mixed content. Consider HSTS and, for the strictest, the preload list later. Verify the HTTPS property in Search Console.

## Launch plan and monitoring

1. **Freeze content and export the map** Freeze changes to the old site. Finalise ops/redirects.csv from the sources above. Get a second pair of eyes on high traffic and high link equity URLs.
2. **Apply redirects in staging** Put the rules on a staging host. Run the test script against staging old URLs. Fix any extra hops, 302s or mismatches.
3. **Cut DNS or switch routes** Switch to the new host or app. Keep the old infrastructure capable of serving 301s. Do not drop the old domain.
4. **Verify Search Console setup** Verify the new HTTPS property and, if a domain change, submit the Change of address. Resubmit sitemaps on both properties. Keep the old sitemap live for a while.
5. **Update internal signals** Push updated canonicals, hreflang, structured data and internal links. In Next.js, update metadataBase and alternates.canonical, and regenerate app/sitemap.ts.
6. **Monitor and fix** Check Search Console’s Pages report, Crawl stats and Links after a few days and weekly. Crawl the site to spot redirect chains and stray 404s. Patch the map and redeploy as needed.

Expect ranking and traffic to bounce for some weeks. Moving one thing at a time helps you attribute any drop. Do not change the domain, the design and the URL structure on the same day if you can avoid it.

## Common mistakes to avoid

- Redirecting everything to the home page. This throws away relevance and looks like a soft 404 pattern. Map to the exact new page or 410 it.
- Letting chains exist. /a to /b to /c costs crawl budget and users feel it. Make /a to /c in one hop.
- Mixing 301 and 302. Use permanent 301 or 308 for the move. Leave 302s for true temporary tests only.
- Forgetting non canonical variants. You fixed https and www on the main paths but left /Page and /page/ variants to bounce around.
- Blocking old URLs in robots.txt. Robots cannot see the redirects when blocked. Keep them crawlable so Google can discover the 301s.
- Dropping query parameters. If parameters are used by users or indexed, map common cases. Use server rules to strip tracking parameters before they index.
- Leaving old internal links. A page that relies on a redirect for its own nav wastes the hop and slows users. Update the links.
- Not testing 410s. If you have real removals, assert 410 for them. Do not leave them as 200 soft 404s or 301s to unrelated pages.

When you fix a mistake, add a test. For example, if /old-pricing chained, add a row to ops/redirects.csv with /old-pricing mapped straight to /pricing and watch the CI turn green.

## Questions

### Do I need a redirect map for a small site?

Yes, if any URLs change. Even ten pages can lose their rankings if you guess and miss two. A two column CSV takes an hour now and saves weeks of recovery later. You can keep it light, but do not skip testing one hop and a final 200.

### Should I use 301, 302, 307 or 308 for a migration?

Use 301 or 308 for permanent moves. 302 and 307 are for temporary moves. 301 is the common choice and well supported. Keep it consistent across the site so crawlers do not see mixed signals.

### How long should I keep redirects active?

Keep them for as long as possible. At least a year. If you can, keep them permanently. This helps users with old bookmarks and keeps link equity flowing. You can retire edge cases after they see no requests for a long time, but keep the core set.

### What about the Change of address tool in Search Console?

Use it when you change the domain only. Verify both properties and have your 301s in place. It does not apply to path changes or HTTPS only moves. It is a signal to Google about the move, not a replacement for correct redirects.

### How do I handle UTM parameters and other tracking query strings?

Do not add UTM variants to the map. Fix them in your server rules: strip or ignore tracking parameters so they do not index. Map only meaningful parameterised pages, like /search?q=abc, to their new forms. Test that your rules do not add extra hops.

### What should the sitemap show during and after the move?

Publish a new sitemap with only the new URLs. Keep the old sitemap accessible for a while so Google can fetch it and see the old URLs now return 301s. Remove the old sitemap later when logs show it is no longer being fetched.

## Read next

- [Website migration SEO checklist: before, during, after](https://porteur.ai/guides/website-migration-seo-checklist): Run a safe website migration: crawl and export, build and test one-to-one redirects, switch cleanly, submit sitemaps, use Search Console, and watch the data.
- [Changing your domain without losing search traffic](https://porteur.ai/guides/domain-change-seo): Change domains without tanking your rankings. Map 301s one to one, use Change of Address, update canonicals and hreflang, and keep redirects for a year.
- [Changing your URL structure: when it is worth it and how to do it](https://porteur.ai/guides/changing-url-structure): When to change URLs for SEO, how to plan the map and redirects, how to keep equity, and what to expect in Search Console after the switch.
- [Moving from HTTP to HTTPS: the checklist that keeps rankings](https://porteur.ai/guides/http-to-https-migration): Move from HTTP to HTTPS without losing rankings. Use one-hop 301s, update canonicals and sitemaps, fix mixed content, and verify the HTTPS property.
- [www or non-www: pick one, redirect the other](https://porteur.ai/guides/www-vs-non-www): Pick www or non-www, there is no SEO gain either way. 301 redirect every path to the chosen host, set canonicals, and verify a Domain property.
- [Trailing slash: two URLs for one page unless you decide](https://porteur.ai/guides/trailing-slash-seo): Google sees /page and /page/ as different. Pick one form, redirect the other, and keep links and your sitemap consistent. Here is how to do it.
- [Keyword mapping](https://porteur.ai/glossary/keyword-mapping): Keyword mapping assigns each cluster of searches to exactly one page. The table, how to build it, and the cannibalisation it prevents on a small site.

Planning a move? Paste your site URL and get a free check from Porteur in about thirty seconds, reading your site, the searches around it and rivals, with three findings shown. Free check: https://porteur.ai/
