Naoki Ioroi published this on GiFT’s site on 5 August 2026. GiFT Inc. moved the corporate site off Firebase Hosting onto Vercel, and in the same piece off Contentful onto microCMS, and added English under /en. He did the work with Claude Code. The post is about the constraints that showed up on real content.
The Firebase Hosting → Vercel playbook matches the hosting half. There is no microCMS playbook here, so the CMS section below is context from the same write-up, not a second guide.
A hand-built Remix pipeline that the preset deleted
Firebase Hosting rewrote every request to Cloud Functions v2. The Remix build was shuffled by hand. build/client/assets was copied into public/. build/server was copied into functions/. That functions/ directory had its own package.json, so dependencies were maintained twice. Deploys were firebase deploy from GitHub Actions.
@vercel/remix as a Vite preset removed that copy step. The diff he prints is 11,664 lines removed and 706 added. The build script became sass:build && remix vite:build. Production deploys moved to Vercel’s Git integration. The region he names is hnd1.
Loaders had been reading import.meta.env.VITE_CONTENTFUL_*. Vite inlines anything with the VITE_ prefix at build time. An env change means a rebuild, and a value that a client module can see is published in the bundle. They switched those to runtime process.env, and stopped passing the space id and token in as arguments at every call. The client module reads them itself.
Sharing pages between Japanese and English meant pulling components into app/features/. Putting the loader in the same file as the component failed the build:
[commonjs--resolver] Server-only module referenced by client
'~/client.server' imported by 'app/features/article-list.tsx'
Remix strips loaders out of route modules. A plain module imported by a route is not treated the same way, so the server client leaked into the browser bundle. The fix was a *.server.ts file for the loader, with the route importing both the component and the server module.
One hundred articles, one uppercase slug, one photo over the cap
microCMS lets you choose the content id on list content. They reused the Contentful slug so /articles/:slug stayed put. Content ids allow only lowercase letters, digits, hyphens, and underscores. Exactly one of the 100 slugs had an uppercase letter. They lowercased it and added a 301 in the loader.
They had planned a publishDate field. microCMS lets the API write the system field publishedAt, so Contentful’s dates moved without a new field. createdAt is not writable. The error is 'createdAt' is unexpected key. The admin now shows the migration date as the creation date. Display and sort use publishedAt, so he says that does not matter.
Contentful bodies were Markdown, rendered with marked. microCMS’s rich editor drops any element outside its allow list on save. Before the import they ran all 100 bodies through marked and listed what would not survive. 187 code blocks and 281 inline code spans were kept by allowing codeBlock and code. Headings h1 through h4 were in use, and the editor covers headerOne through headerFive. A single h6 falls outside. Five articles contain raw HTML outside code fences. Those stayed as follow-up, because editing them could break the layout. The useful output of that pass is the five, not a feeling that Markdown would “probably” be fine.
Contentful held 312 assets. 107 were referenced. They also collected image URLs inside bodies. One upload failed with request body too large. The media cap is 5 MB, and one photo was 5.4 MB. The script downscaled only the oversized files through Contentful’s image API, ?w=2400&q=80, and then uploaded.
The script was supposed to be safe to rerun, so it PUT /api/v1/{endpoint}/{contentId}. The second run returned:
400 {"message":"Content is already exists. If you want update, please use PATCH request."}
PUT on microCMS creates. It does not update. Falling back to PATCH when the content already exists is what made the rerun safe.
English pages, and a check that the machines did not eat the code
English lives under /en. The root loader reads the locale from the URL. Components call useLocale() and useT(). Navigation, cards, and forms sit in a shared dictionary. Body copy for one page stays with that page, so the dictionary does not grow with every new URL.
Each language gets its own canonical. Pointing both at one URL would tell Google they are duplicates, and the one you point away from drops out of the index. He prints a self-canonical on https://gift-tech.co.jp/en/about, alternates for ja and en, and x-default back at the Japanese about page.
The bodies of 78 articles and 22 case studies came to 270,000 characters. Twelve Claude subagents translated them in parallel. The check after that was mechanical. Code fence counts matched on all 100 files, so code was not rewritten. Nothing was dropped. Fourteen substituted URLs were requested, and none were dead. The same check caught one inconsistent spelling of a colleague’s name. He still wants a person to read the English before it is treated as published.
An unfamiliar id in the article list looked like a bad import. A verification script showed, within a minute, that an over-eager regex had matched an external link inside a body. That is the habit the post is actually about. Idempotent writes, a full pass over the real 100 articles before you trust the editor, and a check you can run again when the output looks wrong. The hosting win is easier to count. 11,664 lines of copy-and-deploy glue gone, and a region pin at hnd1 instead of a function that every request was rewritten into.