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.
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.
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.
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.
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.
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.
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:
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:
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.
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.
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.
-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:
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:
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.
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.
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.