.../articles/
From Firebase to Vercel, Contentful to microCMS — a migration log written with Claude Code

From Firebase to Vercel, Contentful to microCMS — a migration log written with Claude Code

2026.08.05

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 /en)

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 codeBlock and code
  • Headings h1–h4 in use — covered by headerOneheaderFive; 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.

written by

.../article/

Articles

All articles

From Firebase to Vercel, Contentful to microCMS — a migration log written with Claude Code

From Firebase to Vercel, Contentful to microCMS — a migration log written with Claude Code

We moved our corporate site's hosting and CMS, and made it bilingual along the way. The constraints we only found by running against real data were more useful than the migration itself, so this post focuses on where we got stuck.

Can't Read POST Data with Firebase Functions × Remix?

Can't Read POST Data with Firebase Functions × Remix?

How to read POST data from a Remix action when running on Firebase Functions.

Generative AI for Executives and Leaders: An Approach to Self-Driven DX

Generative AI for Executives and Leaders: An Approach to Self-Driven DX

Building a structure where executives and leaders themselves can identify issues and evaluate solutions using generative AI. We introduce how combining this with our hands-on support dramatically improves both the quality and speed of digital transformation.

Deploying a Monorepo Next.js App (App Router) to AWS Amplify

Deploying a Monorepo Next.js App (App Router) to AWS Amplify

Notes on the obstacles we hit while deploying a Next.js app managed in a monorepo to AWS Amplify.

Keeping Production Running Smoothly with Remote Work and Online Meetings [Documentation]

Keeping Production Running Smoothly with Remote Work and Online Meetings [Documentation]

Many production companies have adopted remote work as a result of the pandemic, and we are one of them.

Designing an E-Commerce Site That Sells: How to Find Great Reference Examples

Designing an E-Commerce Site That Sells: How to Find Great Reference Examples

There is no single formula for e-commerce design that sells. Driving revenue requires a solid concept, and getting to that concept requires thorough research.

Productivity Tools We Recommend as a Production Company, Including Services That Work Well Solo

Productivity Tools We Recommend as a Production Company, Including Services That Work Well Solo

With remote work becoming the norm during the COVID-19 pandemic, our team now works from home most days of the week.

We Released Thought Recorder, a Figma Plugin for Keeping a Commit History of Your Designs

We Released Thought Recorder, a Figma Plugin for Keeping a Commit History of Your Designs

We hope this helps web designers who work in Figma. Read on for how to use it.

How to Build an E-Commerce Site, and Which Platforms We Recommend

How to Build an E-Commerce Site, and Which Platforms We Recommend

Shopping online for fashion, appliances, and even groceries is now routine. With the pandemic accelerating the shift, we receive a steady stream of questions about which platform to use and how much it costs.

Generating FastAPI Schema Classes from OpenAPI

Generating FastAPI Schema Classes from OpenAPI

We chose FastAPI, a relatively modern framework, for a Python API project. FastAPI can generate an OpenAPI definition from your backend code, but here we do the opposite: generating FastAPI schema classes from an OpenAPI definition prepared in advance.

View all articles

Contact us