Denshin / Blog / DevOps
Renaming a URL without losing what it earned
We moved /projects to /collaborations on a static S3 and CloudFront site. A real 301 from a CloudFront Function, a fallback stub because s3 sync never deletes, the deploy order that matters, and the two URLs we forgot.
Denshin Engineering · Engineering Team · 10 October 2026 · 5 min read
On 9 October we renamed a section of this website. What used to be "Work", and before that "Projects", became "Collaborations", and the address moved with it: /projects became /collaborations.
The rename itself was the easy part. Copy, nav labels, a route in the router. The part that needed thought was the old URL. It had been in the sitemap for months, it had been crawled, and it had been shared in places we do not control. A URL that has been public for a while picks up a small amount of value, and the cheapest way to throw that away is to let the old address return a 404.
How this site is served
Some context, because it shapes everything below. denshin.in is a React single page app built with Vite. The build goes to an S3 bucket and CloudFront serves it. There is no server of ours in the request path. We wrote about why we like that setup in S3 and CloudFront for an admin heavy SPA, and it still holds, but it does mean "just add a redirect rule" is not one line in an nginx config.
What we do have is a CloudFront Function. It runs on viewer request, before CloudFront looks at the cache or the bucket, and it already existed to map clean paths like /services onto pre-rendered files like /services/index.html. So it was the natural home for redirects.
The real redirect
We added a small map to the function:
var MOVED = {
'/projects': '/collaborations',
}
When the request path is a key in that map, the function returns a 301 with a Location header for the new path, and it rebuilds the query string so /projects?tag=saas lands on /collaborations?tag=saas and not on a bare page. A 301 is the signal search engines read as "this moved for good, carry the old URL's standing over". A JavaScript redirect inside the app does a similar job for people, but crawlers trust it less, and several AI crawlers do not run JavaScript at all.
Dropping the query string is the mistake we see most often in hand written redirects. It costs nothing to keep, and it is the difference between a filtered link working and a filtered link quietly showing everything.
A fallback, because S3 never forgets
Our deploy uses aws s3 sync, and sync does not delete objects that have left the build unless you ask it to. We do not ask, on purpose: someone halfway through a session on the previous bundle should not have their lazy loaded chunks vanish under them. The side effect is that the old /projects/index.html would have stayed in the bucket indefinitely, serving a stale copy of a page that no longer exists.
So the build now writes a tiny stub at every old path: a meta refresh to the new URL plus a canonical tag pointing there. If the function were ever detached or broken, the old address would still send people and crawlers to the right place, and the canonical would still tell search engines which URL to index. The 301 does the work. The stub is there for the day it does not.
The ordering trap
The function is shared between our dev and prod distributions. That is convenient most of the time and a hazard here. Publish a function that redirects /projects to /collaborations before the bundle that knows about /collaborations is live, and you have built a very efficient 404 machine, on both environments at once.
The order is written in a comment at the top of the function file, where the next person will actually read it: deploy the web build that serves the new path to both distributions, check the new URL loads, then publish the function.
Telling search engines
After the deploy we resubmitted the sitemap in Search Console, requested indexing for /collaborations, and pinged IndexNow, which our deploy script does for every URL in the sitemap anyway. None of this is strictly required. Crawlers find a 301 on their own eventually. It just makes "eventually" shorter.
The part we got wrong
A few days later two pages under collaborations became products and moved to /products/<slug>. We added them to the build's list of moved paths, so they got the refresh stub and an in-app redirect. We did not add them to the function's map. Those two old URLs therefore answer with a 200 and a meta refresh rather than a 301. People still end up in the right place and most crawlers will follow it, but it is a weaker signal than it should be.
It happened because the list of moved URLs lives in two places, the build config and the function, and we updated one. The fix we want is boring: generate the function's map from the same list the build uses, so a move can only be declared once.
If you are about to rename a URL
- Answer the old path with a real 301 from whatever sits in front of your app, and keep the query string.
- Leave something useful at the old path in storage too, in case the redirect layer is ever bypassed.
- Ship the new page before the redirect that points at it. Shared infrastructure makes this matter more, not less.
- Update internal links, the sitemap and canonicals, so your own site is not redirecting to itself.
- Keep one list of moved URLs. Two lists drift, as ours did.
Doing a rename properly costs maybe an extra hour over doing it quickly, and most of that hour is spent on ordering and checking rather than code.
Tags: SEO, Redirects, CloudFront, AWS, War Story
All posts · Work with Denshin