We migrated our corporate site's hosting from Firebase to Vercel, moved the CMS from Contentful to microCMS, and made the whole site bilingual. The work was done together with Claude Code.
The migration itself is less interesting than the constraints we only discovered by running against real data, so this post focuses on where we got stuck.
Before and after
Before | After | |
|---|---|---|
Hosting | Firebase Hosting, rewriting every request to Cloud Functions v2 | Vercel Functions (hnd1) |
CMS | Contentful (GraphQL, Markdown body) | microCMS (REST, rich editor body) |
Languages | Japanese only | Japanese + English (under |
Deploys | firebase deploy from GitHub Actions | Vercel Git integration |
Moving to Vercel: an entire custom pipeline disappeared
Before the move we shuffled Remix build output around by hand: copy build/client/assets into public/, copy build/server into functions/, then deploy that as a Cloud Function. The functions/ directory had its own package.json, so dependencies were maintained twice.
Adding the @vercel/remix Vite preset made all of it unnecessary. The diff was 11,664 lines removed, 706 added, and the build script collapsed to sass:build && remix vite:build.
From build-time env vars to runtime
Loaders had been reading import.meta.env.VITE_CONTENTFUL_*. Vite's VITE_ prefix inlines values into the bundle at build time, which means every env change requires a rebuild, and anything referenced from the client is simply published.
We switched to runtime process.env and, while we were there, collapsed the duplicated "pass the space ID and token as arguments" pattern into the client module itself.
Gotcha: server-only modules leaking into the client bundle
To share page implementations between languages we extracted components into app/features/. Putting the loader in the same file as the component breaks the build:
[commonjs--resolver] Server-only module referenced by client
'~/client.server' imported by 'app/features/article-list.tsx'Remix only strips loaders from route modules. Ordinary modules imported by them are not treated the same way. Splitting loaders into *.server.ts and importing both from the route file fixed it.
Contentful to microCMS: schema design and validating against real data
Fitting into the Hobby plan's five-API limit
Contentful had five content types: blogPost, work, person, tag and category. Moving them one-for-one would use the entire quota with no headroom.
Reading the code told us something useful: tags (43 of them, 98 assignments) and categories were never used anywhere on the site. We kept the tag data as a comma-separated text field on articles so nothing is lost, and dropped categories entirely. Three APIs, two slots to spare.
Using the slug as the contentId
microCMS lets you choose the contentId for list-type content. Reusing the Contentful slug keeps every /articles/:slug URL intact.
One caveat: contentIds allow only lowercase letters, digits, hyphens and underscores. Exactly one of our 100 slugs contained an uppercase letter, so we lowercased it and added a 301 redirect in the loader.
No publish-date field needed
We planned to add a publishDate field, but microCMS lets you write the system field publishedAt directly through the API. Carrying over Contentful's publish dates saved us a field.
createdAt, on the other hand, is not writable ('createdAt' is unexpected key.). The creation date in the admin shows the migration date, but since display and ordering use publishedAt, it does not matter.
Measuring how the rich editor would break things, first
Contentful stored bodies as Markdown strings that the site converted with marked. We wanted microCMS's rich editor (richEditorV2), but the rich editor strips any element outside its allowed set on save.
So before migrating we converted all 100 bodies with marked and mechanically listed every piece of content that would still contain a disallowed element.
- 187 code blocks and 281 inline code spans — preserved by allowing
codeBlockandcode - Headings h1–h4 in use — covered by
headerOne–headerFive; a single h6 falls outside - Five articles contain raw HTML outside code fences — filed as follow-up work, since editing them could break the layout
Narrowing "probably fine" down to five specific articles was the valuable part.
107 images and a 5MB ceiling
Contentful held 312 assets, but only 107 were actually referenced. We also collected image URLs embedded inside article bodies.
One upload failed with request body too large. microCMS caps media uploads at 5MB per file, and one photo was 5.4MB. We made the script downscale only the oversized files through Contentful's image API (?w=2400&q=80) before uploading.
Gotcha: PUT only creates
We wanted the migration script to be safe to re-run, so it used PUT /api/v1/{endpoint}/{contentId}. The second run said:
400 {"message":"Content is already exists. If you want update, please use PATCH request."}microCMS's PUT is create-only. Falling back to PATCH when the content already exists restored idempotency.
Going bilingual
English pages live under /en. The root loader resolves the locale from the URL and hands it to every page, so components only need useLocale() and useT().
Copy is split between a shared dictionary and per-page modules. Navigation, cards and forms go in the shared dictionary; body copy used by a single page lives with that page. Without this split, the shared dictionary grows unbounded as pages are added.
Do not point canonical at one language
When each language has its own URL, never canonicalise one to the other. They are not duplicates of the same content but language variants, and the one you point away from drops out of the index — throwing away the traffic you built it for.
Each version should self-reference its canonical, with hreflang describing the relationship:
<link rel="canonical" href="https://gift-tech.co.jp/en/about" />
<link rel="alternate" hrefLang="ja" href="https://gift-tech.co.jp/about" />
<link rel="alternate" hrefLang="en" href="https://gift-tech.co.jp/en/about" />
<link rel="alternate" hrefLang="x-default" href="https://gift-tech.co.jp/about" />Translating 270,000 characters
The bodies of 78 articles and 22 case studies came to 270,000 characters, translated by running twelve Claude subagents in parallel.
More important than the translation was confirming nothing broke. We machine-checked all 100 files afterwards: code fence counts matched exactly (no code was altered), no content was dropped, and all 14 substituted URLs were HTTP-checked with zero dead links. That check also caught a single inconsistent spelling of a colleague's name.
It is still machine translation, so human review before publishing remains a requirement.
What we took away
The thing that mattered most was not writing code but turning verification into infrastructure.
- Make migration scripts idempotent, so a failure halfway through is not a problem
- Run the full real dataset through and count the breakages before deciding anything
- Express the acceptance criteria as code — for example, "not a single Japanese character remains on any English page"
With those three in place you can widen what you delegate to AI and still verify the result. Without them, you cannot tell the difference between working and quietly broken. At one point an unfamiliar ID appeared in the article list and looked alarming; a quick verification script showed within a minute that it was an external link inside an article body being matched by an over-eager regex.