next-intl(のcreateMiddleware())を剝がしたい!

に公開

Hono、便利ですよね?

Next.js、便利ですよね?

Next.js middleware(proxy.jsに変わるらしい)、便利ですよね?

じゃあもっと使いましょう。
ということで、小ネタです。

出オチですが、next-intl自体は剥がせないです。(そのうち剥がしたい)

next-intlのcreateMiddlewareを剥がす

結論

middleware.ts
import { Hono } from 'hono'
import { languageDetector } from 'hono/language'
import { secureHeaders } from 'hono/secure-headers'
import { appendTrailingSlash } from 'hono/trailing-slash'
import { handle } from 'hono/vercel'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'

const locales = ['ja', 'en', 'ko']

export const config = {
    matcher: [], // 長いので省略
}

const LOCALE_COOKIE = 'NEXT_LOCALE'

const app = new Hono({ strict: true })

// セキュリティヘッダーの追加
app.use('*', secureHeaders())

// Trailing slashを追加(URLの正規化)
app.use(appendTrailingSlash())

// 言語検出のミドルウェア
app.use(
    '*',
    languageDetector({
        order: ['path', 'cookie', 'header'],
        supportedLanguages: locales,
        fallbackLanguage: 'ja',
        lookupCookie: LOCALE_COOKIE,
        lookupFromPathIndex: 0,
        caches: ['cookie'],
        cookieOptions: {
            httpOnly: false,
            maxAge: 31536000, // 1年
            sameSite: 'Strict',
            secure: process.env.NODE_ENV === 'production',
        },
    })
)

// URIをサニタイズしてオープンリダイレクト攻撃を防ぐ
function sanitizePathname(pathname: string): string {
    // decodeURIはエンコードされたバックスラッシュ(%5C, %5c)をエスケープしないため、
    // これを悪用したオープンリダイレクト攻撃を防ぐ
    // 例: '/en/\\example.org' → '/en/%5C%5Cexample.org'
    //     '/en////example.org' → '/en/example.org'
    return pathname.replace(/\\/g, '%5C').replace(/\/+/g, '/')
}

// ロケールベースのルーティング
app.all('*', (ctx) => {
    const req = ctx.req.raw as NextRequest
    const { search } = req.nextUrl

    // 不正なURIのデコードエラーをハンドリング
    let pathname: string
    try {
        // 外国語文字などを解決 (例: /ja/%E7%B4%84 → /ja/約)
        pathname = decodeURI(req.nextUrl.pathname)
    } catch {
        // 無効なpathnameの場合、Next.jsに転送して400エラーを返させる
        return NextResponse.next()
    }

    // 悪意のあるURIをサニタイズしてオープンリダイレクト攻撃を防ぐ
    pathname = sanitizePathname(pathname)

    const locale = ctx.get('language') || 'ja'
    const hasLocale = locales.some((l) =>
        pathname === `/${l}` ? true : pathname.startsWith(`/${l}/`)
    )

    // ロケールがない場合、リダイレクト
    if (!hasLocale) {
        return NextResponse.redirect(
            new URL(`/${locale}${pathname}${search}`, req.url)
        )
    }

    // 現在のパスからロケールを抽出
    const currentLocale =
        locales.find(
            (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
        ) || locale

    // ロケールがある場合、rewrite
    // SEO用のalternate linksヘッダーを追加
    if (locales.length > 1) {
        const normalizedUrl = req.nextUrl.clone()

        // プロトコルとホストの正規化
        const host = req.headers.get('host')
        if (host) {
            normalizedUrl.port = ''
            normalizedUrl.host = host
        }
        normalizedUrl.protocol =
            req.headers.get('x-forwarded-proto') ?? normalizedUrl.protocol

        const alternateLinks = locales
            .map((l) => {
                const url = new URL(normalizedUrl)
                // 現在のロケールを新しいロケールに置き換え
                url.pathname = pathname.replace(/^\/[^/]+/, `/${l}`)

                // basePathの適用
                if (req.nextUrl.basePath) {
                    url.pathname = req.nextUrl.basePath + url.pathname
                }

                return `<${url.toString()}>; rel="alternate"; hreflang="${l}"`
            })
            .concat([
                // x-defaultエントリを追加(デフォルトロケールを使用)
                (() => {
                    const url = new URL(normalizedUrl)
                    url.pathname = pathname.replace(/^\/[^/]+/, `/ja`)

                    if (req.nextUrl.basePath) {
                        url.pathname = req.nextUrl.basePath + url.pathname
                    }

                    return `<${url.toString()}>; rel="alternate"; hreflang="x-default"`
                })(),
            ])
            .join(', ')

        const headers = new Headers(req.headers)
        headers.set('Link', alternateLinks)
        // next-intlにロケールを伝えるためのヘッダーを追加
        headers.set('x-next-intl-locale', currentLocale)
        return NextResponse.rewrite(req.url, { request: { headers } })
    }

    // ロケールヘッダーを設定してnext-intlに伝える
    const headers = new Headers(req.headers)
    headers.set('x-next-intl-locale', currentLocale)
    return NextResponse.rewrite(req.url, { request: { headers } })
})

export const middleware = handle(app)

コレは何

おそらくApp Routerから使い始めた方は、i18nにはnext-intlを使っている方も多いと思います。

そこで私が毎回思うのは、export default createMiddleware(routing) 、剥がしたいなぁです。
Next.jsのミドルウェアにはHonoが扱えるので、それを考えると依存関係を最小にして実装できると嬉しいんです。

image
next-i18next vs next-intl

剥がす必要ないだろって?

ないですね。

でも、Hono使ってるとHonoの中だけで完結させたくなるんですよね。

ということで、next-intlcreateMiddleware()でやっていることを大雑把に再現できればいいのです。

パスベースしか基本的には自分は使っていないので、

  1. localeの判定

  2. localeを元にリダイレクト or リライト

  3. cookieにローケルをセットする

  4. ヘッダーを諸々設定する

  5. urlのエッジケースを処理する

  6. レスポンスを返す

という感じです。

コレくらいなら自分でも書けそうな気がしますね。

書きましょう。

その前に

実は、Honoにはビルトインで、hono/language というミドルウェアがあります。

2025年の2月に生えたばかりの、比較的新しいミドルウェアです。

https://hono.dev/docs/middleware/builtin/language

このミドルウェアから生えているlanguageDetecter() は、大体以下の処理をしています。

  1. パス、Cookie、header(Accept-Language)のいずれか、あるいは全てからのlocaleの判定

  2. 検出した言語を、contextに保存

  3. Cookieへ検出した言語を保存

  4. 言語を検出できない場合にフォールバック

あれ? なんかどこかで見たような...

書いていく!

languageDetecter() を使えば、next-intlcreateMiddleware()のうち、localeの判定と、cookieの設定は省くことができることがわかりました。

であれば、あとは2 / 4 / 5 / 6相当の処理があればいいわけです。

localeを元にリダイレクト or リライト

middleware.ts
    const locale = ctx.get('language') || 'ja'
    const hasLocale = locales.some((l) =>
        pathname === `/${l}` ? true : pathname.startsWith(`/${l}/`)
    )

    // ロケールがない場合、リダイレクト
    if (!hasLocale) {
        return NextResponse.redirect(
            new URL(`/${locale}${pathname}${search}`, req.url)
        )
    }

    // 現在のパスからロケールを抽出
    const currentLocale =
        locales.find(
            (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
        ) || locale

    // ロケールがある場合、リライト
    if (locales.length > 1) {
        const headers = new Headers(req.headers)
        // next-intlにロケールを伝えるためのヘッダーを追加
        headers.set('x-next-intl-locale', currentLocale)
        return NextResponse.rewrite(req.url, { request: { headers } })
    }

コンテキストの中にすでに検出した言語は持っているので、そこからとって、リダイレクトするかリライトするかを決めるだけです。

ヘッダーを諸々設定する

middleware.ts
    if (locales.length > 1) {
        const normalizedUrl = req.nextUrl.clone()

        // プロトコルとホストの正規化
        const host = req.headers.get('host')
        if (host) {
            normalizedUrl.port = ''
            normalizedUrl.host = host
        }
        normalizedUrl.protocol =
            req.headers.get('x-forwarded-proto') ?? normalizedUrl.protocol

        const alternateLinks = locales
            .map((l) => {
                const url = new URL(normalizedUrl)
                // 現在のロケールを新しいロケールに置き換え
                url.pathname = pathname.replace(/^\/[^/]+/, `/${l}`)

                // basePathの適用
                if (req.nextUrl.basePath) {
                    url.pathname = req.nextUrl.basePath + url.pathname
                }

                return `<${url.toString()}>; rel="alternate"; hreflang="${l}"`
            })
            .concat([
                // x-defaultエントリを追加(デフォルトロケールを使用)
                (() => {
                    const url = new URL(normalizedUrl)
                    url.pathname = pathname.replace(/^\/[^/]+/, `/ja`)

                    if (req.nextUrl.basePath) {
                        url.pathname = req.nextUrl.basePath + url.pathname
                    }

                    return `<${url.toString()}>; rel="alternate"; hreflang="x-default"`
                })(),
            ])
            .join(', ')

localesの数の分だけ、Link ヘッダー形式で文字列を生成してあげればいいので、ぶん回してあげましょう。

リダイレクト(if (!hasLocale))の場合は、リダイレクトされた際にここの処理を再度通るので、リライトの時にだけやってあげればいいです。

urlのエッジケースを処理する

sanitize-pathname.ts
function sanitizePathname(pathname: string): string {
    // decodeURIはエンコードされたバックスラッシュ(%5C, %5c)をエスケープしないため、
    // これを悪用したオープンリダイレクト攻撃を防ぐ
    return pathname.replace(/\\/g, '%5C').replace(/\/+/g, '/')
}
middleware.ts
    // 不正なURIのデコードエラーをハンドリング
    let pathname: string
    try {
        // 外国語文字などを解決 (例: /ja/%E7%B4%84 → /ja/約)
        pathname = decodeURI(req.nextUrl.pathname)
    } catch {
        // 無効なpathnameの場合、Next.jsに転送して400エラーを返させる
        return NextResponse.next()
    }

    // 悪意のあるURIをサニタイズしてオープンリダイレクト攻撃を防ぐ
    pathname = sanitizePathname(pathname)

まぁ必要かどうかは人によると思うのですが、自分は一応つけています。

Claude Codeくんがあったほうがいいよって言ってたので

レスポンスを返す

middleware.ts
// ロケールヘッダーを設定してnext-intlに伝える
const headers = new Headers(req.headers)
headers.set('x-next-intl-locale', currentLocale)
return NextResponse.rewrite(req.url, { request: { headers } })

NextResponseもWeb標準なので、これを返してあげれば良さそうです。

あとはcreateMiddleware()と合わせられるように、プロトコルとホストの正規化、basePathの適用、x-defaultエントリを追加します。
SEO対策とかですね。(たぶん)

以下が参考になります。
オススメです。

https://zenn.dev/coefont/articles/using-hono-in-next-middleware

なぜコレを書いたか

Next.jsをやめたいみたいな思いが最近少しずつ出ていて、まぁでも一気にやるのは無理だろうみたいな気持ち。
で、それならいざという時のために剥がしやすくしておけばいいだろうということで、godaiさんの、以下の記事を読みました。

https://zenn.dev/progate/articles/app-router-i18n-without-library

でもやっぱりこれでもプレースホルダの処理とかないからnext-intl は必要だしな~でした。

時は過ぎ(1週間)、middleware.tsをHonoに載せようとしていたので、別件でHonoのミドルウェアを探していたところ、hono/language を見つけました。

じゃあこれ共有して使ってもらえれば、ネタになるし、Hono on Next.jsの実用的な使い道になるじゃん!という感じでした。

godaiさんの処理見てて、middleware.ts長いなこれ...となったので、もうちょっとここだけでも削れたら実用的になりそうと思っていたのでちょうどよかったです。

ついでにNextRequestも削れた(型だけは残ってるけど)

結論

理由がないなら素直にcreateMiddleware()がいいかもしれません。
オチはありません。

最後に

ふだんはキーボードオタクをしています。
フォローしてもらえると嬉しいのかもしれません。

https://x.com/R0u9h

https://www.instagram.com/r0w9h/

https://github.com/L4Ph

Discussion