This site used to have a page at /ko/tests/fitness-type. It now lives at /ko/lab/fitness-type/. The word tests was ambiguous — assessment or test suite? — and we needed room for more experimental pages later.

Locally, you move the folder, run the build, fix a few links, and the site works perfectly. The problem is everything that isn't local.

Things that know the old URL: the search index links other sites have already published messages already shared in chat browser bookmarks and history

None of them consult your repository. Move the file and they still arrive at the old address.

1. 301 vs 302 Is Not a Style Choice

The first thing you pick when writing a redirect is permanent or temporary, and it's easy to be fuzzy about what that actually decides.

301 (permanent) "this address is now over there" → search engines move the index to the new URL → browsers cache the result (aggressively) 302 (temporary) "go there for now, but the address is here" → search engines keep the index on the old URL → browsers ask again every time

The part that bites is browser caching. A 301 is remembered for a very long time. Ship a wrong one and, even after you fix it, anyone who already received it keeps going to the wrong place until they clear their cache by hand. Fixing the server does nothing for them.

When in doubt, start with a 302.

Promoting a 302 to a 301 is always available. The reverse means recalling a cache you no longer control.

2. There Are Five Codes to Choose From, Not Two

Most of the debate stops at 301 versus 302, but a migration actually puts five codes on the table.

Code Meaning Index Browser cache Method 301 moved permanently new URL indefinite may flip POST→GET 308 moved permanently (strict) new URL indefinite preserved 302 found / temporary old URL not cached may flip POST→GET 307 temporary (strict) old URL not cached preserved 410 gone permanently deindexed - -

They come in pairs. 301 and 308 have identical caching semantics and differ only in method handling, and the same holds for 302 and 307. The original specs told browsers to preserve the request method across 301 and 302; browsers turned POST into GET anyway. 307 and 308 exist to say "this time we mean it."

On a static site nearly every redirected request is a GET, so the method distinction never surfaces. We have never once had to decide between 301 and 308 here. What does matter is that the number you write in the config is not the number that goes out on the wire.

permanent is not a status code.

Vercel emits 308 for "permanent": true and 307 for false. The caching semantics match 301 and 302 respectively, but the digits in the response differ. Don't read the config and assert "we return a 301" — check the wire with curl -I.

410 is the odd one out. It isn't a redirect at all; it's a statement that the page didn't move, it ended. Force a 301 to the homepage for a page with no successor and search engines will treat the homepage as that URL's replacement. With no replacement, 404 or 410 is the honest answer. In practice the two behave much alike — a repeatedly-404ing URL falls out of the index too — and most static hosts give you no way to emit a 410, so 404 is where you land.

3. Which Is Why Only the Root Is a 302

Nearly every redirect on this site is a 301. Exactly one — / — is not.

{ "source": "/", "has": [{ "type": "header", "key": "x-vercel-ip-country", "value": "KR" }], "destination": "/ko/", "permanent": false // 302 }, { "source": "/", "destination": "/en/", "permanent": false // 302 }

The root's destination depends on who is asking. Visitors from Korea get /ko/; everyone else gets /en/. Make that a 301 and a browser that once loaded it from Korea goes to /ko/ forever — the server-side check never runs again. Not when they travel, not when they change their language settings.

A redirect whose answer must be recomputed can't be cached. A 301 means "this address moved," not "go there right now."

4. One Slash Makes Two URLs

After the first pass of redirects, /ko/tests/fitness-type worked and /ko/tests/fitness-type/ returned a 404.

To a human they're the same address. Over HTTP they are two entirely separate paths — and you have no way of knowing which form of the old URL is out there. A search engine may have indexed the one with the slash while somebody's blog linked the one without.

{ "source": "/ko/tests/fitness-type", "destination": "/ko/lab/fitness-type/", "permanent": true }, { "source": "/ko/tests/fitness-type/", "destination": "/ko/lab/fitness-type/", "permanent": true }

We wrote both. It doubles the rule count, but that beats guessing which variant survived out there. The destination stays single — whether you settle on trailing slashes or not is taste, but it has to be one direction across the whole site or the index splits in two.

The Other Ways One Page Splits Into Several

The slash is only one axis. Collect every case where a human sees one address and HTTP sees two:

Axis Example Fix with protocol http:// vs https:// redirect (301) host ceanlab.com vs www.ceanlab.com redirect (301) trailing slash /lab/x vs /lab/x/ redirect (301) path case /Lab/x vs /lab/x redirect (301) index document /lab/x/ vs /lab/x/index.html redirect (301) query string /lab/x/?utm_source=kakao canonical fragment /lab/x/#result nothing needed

The top five and the bottom two need different tools. The first group is the server picking one correct address; the second is where it can't or shouldn't.

Query strings trip people up. ?utm_source=kakao is the classic case of same content, different URL — and stripping it with a redirect is the wrong move. Strip parameters at the server and everything that needed to read them dies with them: attribution, and any deep link handing off to the app. That's a canonical problem — declare the parameter-free address as the original — not a redirect problem.

Fragments (everything after #) are never sent to the server at all. You can't write a rule for them and you don't need to. What you do need to know is that whether a fragment survives a redirect varies by browser, so if a moved link has to land on a specific anchor, open it and look.

Case has one extra wrinkle: hostnames are case-insensitive but paths are not. That makes /Lab/ a genuinely separate address in theory, and enumerating every casing as a rule is not realistic. Check whether any old path ever shipped with capitals; if none did, move on.

5. Even a Korean-Language Path Was Still Live

The list also contains this:

{ "source": "/홈", "destination": "/ko/", "permanent": true }

A path that existed briefly in the earliest days. Not a shape we'd choose today — but a URL that has already shipped stays alive regardless of taste.

There's a recurring urge to tidy a messy redirect list. That urge misreads the situation: the list isn't messy, the past is. Delete a rule and everyone still arriving at that URL gets a 404. The bar for deletion is not "it's ugly" but "we held it long enough and traffic is zero."

So When Is It Safe to Delete?

Turn "long enough" and "zero" into actual numbers and the judgement stops wobbling every time. Here's the bar we use.

Check Safe to delete when age of the rule at least 12 months Search Console impressions 0 in the last 90 days server log hits 0 in the last 90 days external backlinks 0 (keep it if even one exists) is it a hop in another chain? no

Twelve months, because how long a search engine takes to move an index has nothing to do with when you deployed. Low-traffic pages are recrawled rarely, so it's normal for an old URL to still be sitting in search results weeks after the redirect shipped. Delete the rule in that window and someone clicking a live search result gets a 404.

The last row is the one that causes quiet accidents. If rule A points at B and B points at C, deleting B leaves A intact with a destination that no longer exists. Before removing anything, check whether some other rule uses that path as its destination.

Conversely, "there are too many rules" is not by itself a reason to delete any. vercel.json holds up to 1,024 redirects and this site is in the double digits. The real ceiling isn't file size, it's whether a person can read the list and understand it — and that's solved with comments and ordering, not deletion.

6. The Redirect Is Only Half the Job

Moving a path drags other things with it. Skip them and you land in the worst state: the site works fine and only search engines are confused.

  • canonical — must point at the new URL. If a request to the old URL redirects to a page that still declares the old address as canonical, search engines get two contradictory signals at once: "it moved" and "this is the original."
  • hreflang — travels with canonical. Fix one locale only and ko points at the new address while en still advertises the old one.
  • sitemap — must not list the old URL. Keeping a redirecting URL in the sitemap tells search engines to index an address that you then answer with "that's over there."
  • internal links — use the new address directly. A link inside your own site that travels through your own redirect is pure waste.

On this site the third item is the easy one to miss. Post URLs land in the sitemap automatically from a collection, but non-post pages come from staticPages.js — a hand-maintained list. Move a page and that list has to move too, and it is not among the things the build guards check for you.

7. Chains Are Invisible in the Config File

Every rule can be individually correct and the composition still wrong. That's a redirect chain.

The rules (each one fine on its own) /ko/tests/fitness-type → /ko/lab/fitness-type /ko/lab/fitness-type → /ko/lab/fitness-type/ What an actual request goes through /ko/tests/fitness-type → 301 → /ko/lab/fitness-type → 301 → /ko/lab/fitness-type/ → 200 (2 hops)

Add the trailing-slash rule after the move rule and this is the shape you get. In the config the two lines simply sit next to each other, so reading it will never show you the chain. The only way to know is to send the request.

curl -sIL https://ceanlab.com/ko/tests/fitness-type \ | grep -i '^HTTP\|^location' HTTP/2 301 location: /ko/lab/fitness-type HTTP/2 301 location: /ko/lab/fitness-type/ HTTP/2 200

-L follows to the end and -I keeps it to headers. How many hops, whether the last one is a 200, and whether any intermediate location points somewhere you didn't expect — it's all in that one output.

The hop budget we work to:

0 hops best — every internal link belongs here 1 hop fine — external traffic arriving at an old URL 2 hops fix it if you can (point the source at the final stop) 3+ hops fix it — crawlers may give up partway 5+ hops treat it as broken

The fix is not deleting rules but repointing the source directly at the final destination. In the example above, change the first rule's destination to the slashed form. The middle rule stays in place with nobody travelling through it — which is exactly what you want, because external links to the unslashed form may still exist.

Internal links that incur a hop are a different problem. A link inside your own site travelling through your own redirect is slow, and more importantly it's a signal that you haven't finished the migration. After moving, grep the whole repository for the old path and confirm nothing is left.

8. The Order That Worked

The practical sequence this migration settled into:

1. Pick the new path — will this name hold for three years? 2. Write down every old path — with and without trailing slash 3. Ship the redirects first (files still in the old place) 4. Move the files and fix internal links 5. Point canonical, hreflang, and sitemap at the new address 6. Open the old URLs yourself — all the way to the final stop

Splitting 3 from 4 is the important part. With redirects already live, there is no gap at the moment the files move. Do it the other way and for the seconds or minutes between deploys neither address resolves to anything.

Step 6 has to happen in an actual browser. Redirect chains — one rule landing on a path handled by another rule — form easily and are invisible in the config file. Chains are slow, and worse, one missing link breaks the whole path.

A redirect is not new code. It's an old promise.

Since it isn't a feature, it rarely attracts tests or review. So it breaks quietly, and you find out months later when search traffic has already dropped.

9. What We Actually Checked Afterwards

Following the order still leaves gaps. This is the list we walked after deploying.

□ every old URL reaches a final 200 curl -sIL (count the hops) □ the slashed variant works too same command, again □ the new URL is its own canonical view source □ ko and en canonical/hreflang pair up compare both sources □ sitemap.xml no longer lists the old URL search the build output □ sitemap.xml does list the new URL did staticPages.js change? □ no old path strings left in the repo grep everything □ every redirect target is a sitemap URL cross-check both lists

That last line is the one that actually caught us. Post URLs reach the sitemap automatically from a collection, but non-post pages come from staticPages.js, a hand-maintained list. When you move a page you remember the redirect; you do not remember that list.

It goes missing precisely because the build doesn't check it. This repo's build will fail on ko/en post asymmetry and on a post linking to something published later — which is why letting the build enforce your invariants works as well as it does — but staticPages.js sits outside that net. Anything a human has to remember eventually goes unremembered. Short of a guard, writing it down is all you have.

10. Questions That Keep Coming Up

We already shipped a wrong 301. Can we take it back?
Not the cache. Fixing the server does nothing for a browser that already holds the 301, because it won't ask again. There is exactly one practical recovery: add a redirect from the wrong destination to the right one. That costs a hop, but people end up in the correct place. The way to avoid needing it is to start with a 302.

Can we just use canonical instead of a redirect?
They carry different force. A canonical tag is a hint to search engines and can be ignored; a redirect turns the request itself around. The rule of thumb: canonical if the old address still serves a page, redirect if it doesn't. An address that no longer resolves has nowhere to put a canonical tag.

Does leaving the old URL in the sitemap speed up reindexing?
No. Listing a redirecting URL tells the crawler to index an address you then answer with "it's over there." A sitemap should contain only addresses that return 200. If you want faster reindexing, keep an accurate lastmod on the new URL instead.

How many redirect rules is too many?
vercel.json accepts up to 1,024, and this site is nowhere near that. What breaks before the count does is ordering and overlap — put a broad pattern above a specific rule and the specific one never runs.

Where do we send a page that's simply gone?
To its replacement with a 301 if one exists; nowhere at all if one doesn't. A 301 to the homepage tells search engines the homepage succeeds that URL, and it drops the visitor onto a page that never explains why the thing they wanted isn't there. A 404 looks unhelpful but it is accurate.

Can we move ko first and do en later?
No. canonical and hreflang point at each other, so moving one side leaves ko claiming the new address is the original while en claims the old one is. This repo's build enforces ko/en symmetry for posts but not for anything else — when a landing or lab page moves, a human has to handle both sides in the same change.

11. Summary

  • 302 when the destination varies by visitor; 301 when the address itself moved. A 301 can't be recalled.
  • Write both the slashed and unslashed form. You don't get to know which one is out there.
  • Five more axes split a URL besides the slash. Protocol, host, case, and index document are redirect work; query strings are canonical work.
  • Keep embarrassing old paths until their traffic is zero. Twelve months minimum, no backlinks, and not a hop in someone else's chain.
  • Chains don't show up in the config file. Count hops with curl -sIL, and past two, repoint the source at the final stop.
  • Deploy redirects first, move files second. That's what removes the gap.
  • Move canonical, hreflang, sitemap, and internal links along with it. The redirect alone is half the job.

A URL is less like code and more like a contract the site signed with the outside world. How you arrange files internally is entirely up to you, but an address that has left the building is no longer yours. You can always move it — what remains is the duty to say where it went.