Migrated from Next.js + Vercel to HonoX + Cloudflare Workers

Published on 2026/03/20

This post is also available in 日本語.

Introduction

I migrated my personal website from Next.js + Vercel to HonoX + Cloudflare Workers.

The trigger was that Vercel's Fluid Active CPU significantly exceeded the free tier, and I started receiving daily upgrade warnings to Pro. Fluid Active CPU is a metric Vercel uses to charge based on CPU usage time, consumed every time the server runs.

The cause was switching from SSG to SSR for i18n support. SSG (Static Site Generation) is a method that generates HTML at build time, and since it delivers the generated HTML as is, there is no execution cost. On the other hand, SSR (Server Side Rendering) generates HTML on the server for each request, consuming CPU with every access.

To switch languages, it was necessary to refer to cookies, and simply checking cookies in middleware to route requests fixed it to SSR. Since the server runs with every request, costs skyrocketed.

I also had doubts about "being fixed to SSR just by inserting middleware," so I decided to migrate.

Why I Chose Cloudflare Workers + HonoX

The reasons for choosing Cloudflare Workers as the migration destination are simple: edge execution, high speed, and a generous free tier. For a personal website, it can be operated virtually for free.

Edge execution is a mechanism that runs code on servers (edges) around the world close to the user. If accessed from Japan, a Japanese server responds, reducing latency.

I also looked into the option of running Next.js on Cloudflare. The official documentation shows that App Router and SSR are supported via the OpenNext adapter. However, Next.js's architecture changes significantly with each version, giving me the impression that it would be difficult for Cloudflare to continuously keep up. In fact, Node.js Middleware introduced in 15.2 is still unsupported at the time of writing. Since this site was running middleware that checked cookies to route languages using Node.js, it couldn't be directly brought over to Cloudflare. This was one of the reasons I decided to rewrite the entire framework rather than just a simple migration.

The idea was to try Hono if migrating to Cloudflare, and HonoX had just been released. Yusukebe, the author of Hono, wrote an article About HonoX, so I decided to give it a try.

HonoX is a meta-framework that combines Hono as a server framework and Vite as a build system. It's easy to understand if you think of it as Hono alone with "file-based routing" and "Islands architecture" added. Code written with HonoX is essentially a Hono application, with the Hono instance residing in app/server.ts.

Basic Structure of a HonoX Project

The directory structure is as follows:

app/
├── server.ts          # Server entry point (Hono instance)
├── client.ts          # Client entry point
├── style.css          # Global styles
├── routes/            # File-based routing
│   ├── _renderer.tsx  # HTML template
│   ├── _middleware.ts # Middleware (locale detection & redirect)
│   ├── _404.tsx       # 404 page
│   ├── _error.tsx     # Error page
│   ├── index.tsx      # /
│   ├── [lang]/        # Routes under /{lang}/*
│   │   ├── index.tsx
│   │   ├── blogs/
│   │   ├── contact/
│   │   └── ...
│   ├── api/           # API routes
│   ├── sitemap.xml.ts
│   └── robots.txt.ts
├── components/        # UI components (SSR)
├── islands/           # Interactive components (client)
├── i18n/              # Custom i18n implementation
└── renderer/          # Shared renderer logic
content/
├── ja/blogs/          # Japanese blog posts (Markdown)
└── en/blogs/          # English blog posts (Markdown)

app/server.ts is the entry point for Hono, and file-based routing is enabled simply by calling createApp(). Files under routes/ are automatically registered as routes.

Files with a _ prefix are not registered as routes and have special roles. _renderer.tsx is the HTML skeleton, and _middleware.ts is the middleware applied to that directory and its subdirectories.

The islands/ directory is key. Islands architecture is a design where most of the page is generated as HTML on the server, while only the parts requiring interaction run as client-side JavaScript. Only components placed here are hydrated in the browser (the process of attaching JavaScript events to server-generated HTML). Other components are SSR-only, and no JS is delivered to the browser. This design places only interactive parts, such as contact forms and search boxes, in islands/.

Key Points of Vite Configuration

vite.config.ts is as follows:

import build from '@hono/vite-build/cloudflare-workers'
import adapter from '@hono/vite-dev-server/cloudflare'
import tailwindcss from '@tailwindcss/vite'
import honox from 'honox/vite'

export default defineConfig({
  plugins: [
    honox({
      devServer: { adapter },
      client: { input: ['/app/client.ts', '/app/style.css'] },
    }),
    tailwindcss(),
    build(),
  ],
})

Both adapter and build() are necessary. adapter is for running the development server in a Cloudflare Workers compatible environment, and build() is for outputting the production build for Workers. Using only one will lead to issues where "it works locally but breaks in production."

Explicitly defining client.input is also necessary. Without it, app/client.ts (the hydration script for islands) and app/style.css (Tailwind CSS) will not be bundled for the client (the process of combining multiple files into a format that browsers can read).

Tailwind CSS v4 is integrated as the @tailwindcss/vite plugin, so PostCSS configuration is not required.

i18n: Type-Safe Custom Implementation Without External Libraries

I was using next-intl, but once I moved away from Next.js, there was no longer a reason to carry it over. The structure of translation files and the locale detection logic are simple, so I decided that a custom implementation would be lighter and easier to control than increasing dependency on an external library.

As a result, I achieved dot notation key completion leveraging TypeScript's type inference, allowing typos in translation keys to be detected at build time. Dot notation keys are a way to represent nested objects by connecting them with dots, like 'contactForm.divisions.informationSystems'.

const t = createT('ja')
t('contactForm.divisions.informationSystems') // Non-existent keys cause a compile error

Markdown Processing: SSR Without Runtime Parsing

Blog posts are managed as Markdown files under content/ja/blogs/. They are converted at build time using a custom Vite plugin, so there is no runtime parsing.

When imported with a query like *.md?blog, the plugin processes the Markdown at build time and returns it as a JS object.

// Example import in a route component
import blogData from '../../content/ja/blogs/my-post.md?blog'

// Contents of blogData
{
  title: 'Article Title',
  excerpt: 'Article excerpt',
  html: '<p>Rendered HTML</p>',
  tags: ['honox', 'cloudflare'],
  createdAt: '2026-03-20',
  // ...
}

The plugin's processing is simple: it parses the frontmatter (YAML-formatted metadata enclosed by --- at the beginning of a Markdown file) with gray-matter and converts the body to HTML with zenn-markdown-html.

// vite-plugin-blog-md.ts (excerpt)
const { data, content } = matter(raw)
const html = await markdownHtml(content, { embedOrigin: 'https://embed.zenn.studio' })

Canonical refers to the <link rel="canonical"> tag used to tell search engines "this is the canonical URL" when the same content appears at multiple URLs. Hreflang is a tag that tells search engines the URL for each language, such as "this URL is the Japanese version, this one is the English version." Both must be included in the <head> for multilingual sites.

Hono JSX uses href as the deduplication key for <link> elements. If rel="canonical" and hreflang="en" share the same href, the canonical tag is treated as a duplicate and removed.

// NG: canonical disappears when hreflang and href share the same URL
<link rel="canonical" href={pageUrl} />
<link rel="alternate" hreflang="en" href={pageUrl} />

// OK: use raw() to bypass Hono JSX processing and output directly
{raw(`<link rel="canonical" href="${pageUrl}">`)}
<link rel="alternate" hreflang="en" href={pageUrl} />

Test Infrastructure (Playwright)

E2E (End-to-End) testing automates user operations such as "open a page → fill in a form → submit" using a real browser. Since there were many areas prone to breakage during migration—i18n, redirects, OGP, form submissions—I introduced E2E testing with Playwright. There were a few things that required some thought during implementation: blocking GA/GTM, waiting for Islands hydration, and intercepting form submissions.

Blocking GA/GTM

If requests to Google Analytics or GTM are sent during testing, page views get inflated. I block these requests using a Playwright fixture (a mechanism for grouping pre/post test processing), so simply importing test from this fixture instead of @playwright/test in each test file is all that's needed.

// e2e/fixtures.ts
export const test = base.extend({
  page: async ({ page }, use) => {
    await page.route(
      /https?:\/\/(www\.google-analytics\.com|www\.googletagmanager\.com)\/.*/,
      (route) => route.abort(),
    )
    await use(page)
  },
})

Waiting for Islands Hydration to Complete

Since Islands hydrate asynchronously, you need to wait for completion before clicking or typing. waitForLoadState('networkidle') is slow, so I use the form's autofocus firing as a signal that hydration is complete.

await page.goto('/ja/contact')
// autofocus firing = island hydration complete
await expect(page.locator('input[type="text"]').first()).toBeFocused()

Testing Form Submission

I intercept requests to SendGrid using page.route() to verify the request body without actually sending an email.

let requestBody: Record<string, string> | null = null

await page.route('/api/contact', async (route) => {
  requestBody = route.request().postDataJSON()
  await route.fulfill({ status: 200, body: JSON.stringify({ ok: true }) })
})

// Verify request body after form submission
expect(requestBody?.division).toBe('Information Systems') // Localized label, not the raw key

Local Development Environment with Docker

Setting up matching versions of Node.js and pnpm locally is tedious, so I unified everything in a Docker container. Frequently used commands are organized in a Makefile.

make dev        # Start development server
make check      # Run format check, lint, type check, and dependency check
make test       # Unit tests + E2E tests
make test-prod  # E2E tests against production environment
make fmt        # Auto-fix formatting

In CI environments, the Makefile branches with ifdef CI to run pnpm directly without Docker, avoiding the Docker-in-Docker problem (where Docker ends up running inside a Docker container).

Tool Selection

The formatter and linter configuration changed between the old and new setup.

Biome

I continue using Biome as the formatter from before the migration. I like it for being faster than Prettier and having simpler configuration.

oxlint

I added oxlint as a linter. ESLint tends to be slow and become a CI bottleneck, while oxlint requires almost no configuration and is fast. Biome handles formatting, oxlint handles linting—the roles are clearly separated.

dependency-cruiser

I introduced dependency-cruiser to detect circular dependencies. A circular dependency is a state where "A uses B, and B uses A," which tends to occur unnoticed as code grows more complex. Since it's built into make check, CI fails as soon as a circular dependency appears.

Conclusion

I'm glad I made the migration. Not only did the Vercel warnings stop, but the code became simpler and it's easier to understand what's running where. Next.js has many features, but there's a lot hidden behind the scenes. HonoX is closer to a plain Hono application, making it easier to trace behavior.

There are still challenges. Right after the migration, PageSpeed Insights showed a mobile LCP of 4.5 seconds (target: under 2.5 seconds). The causes were CSS render blocking, GTM loading, and unspecified image sizes—addressed with preload, deferred loading, and explicit size declarations respectively. Vercel's CDN caching had been helping behind the scenes, and switching to SSR meant facing performance head-on again. I plan to continue measuring and improving going forward.

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.

Go to inquiry form