🫖

Next.js でも 418 I'm a teapot したい

2024/04/01に公開1

418 I'm a teapot って?

HTTP の 418 I'm a teapot クライアントエラーレスポンスコードは、サーバーが、自身がティーポットであることを理由としてコーヒーを入れることを拒否することを示します。[1]

Google が 418 I'm a teapot を返却するエンドポイントを持っていることで有名です.

Googleのteapotエンドポイント
Error 418 (I’m a teapot)!? | google.com

ということで, Next.js で 418 I'm a teapot を返却するエンドポイントを作ってみます.

環境

Next.js App Router を使用します.

  • Next.js 14.1.0
  • React 18

ErrorPage を使った実装

Next.js には ErrorPage というコンポーネントが用意されているため, これを用いることで Next.js のデフォルトのエラーページをカスタマイズすることができます.

https://github.com/vercel/next.js/blob/canary/packages/next/src/pages/_error.tsx

import DefaultErrorPage from "next/error";

export default function Home() {
  return <DefaultErrorPage statusCode={418} title="I'm a teapot" />;
}

https://github.com/toms74209200/nextjs-teapot/blob/master/next-teapot/app/page.tsx

実際にサーバーを立ててブラウザで確認すると, "418 I'm a teapot" が表示されています.

"418 I'm a teapot" が表示されている

しかし, HTTPリクエストを見てみると, レスポンスのステータスコードは 200 になっています.

デベロッパーツールでネットワークを確認すると200を返していることがわかる

$ curl -I localhost:3000
HTTP/1.1 200 OK
Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url, Accept-Encoding
Cache-Control: no-store, must-revalidate
X-Powered-By: Next.js
Content-Type: text/html; charset=utf-8
Connection: keep-alive
Keep-Alive: timeout=5

ここで改めて Google の 418 I'm a teapot を返却するエンドポイント[2]を見てみると, レスポンスのステータスコードは 418 になっています[3].

Googleのteapotエンドポイントのネットワーク. 418 I'm a teapot を返していることがわかる

$ curl -i -L google.com/teapot
HTTP/1.1 301 Moved Permanently
Location: https://www.google.com/teapot
~~~
HTTP/2 418 
content-type: text/html; charset=ISO-8859-1
content-security-policy: object-src 'none';base-uri 'self';script-src 'nonce-Ie5OxLrEPqkNSmMmBTstIA' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/xsrp
server: gws
cache-control: private
x-xss-protection: 0
x-frame-options: SAMEORIGIN
alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
accept-ranges: none
vary: Accept-Encoding

そこで Next.js でもHTTPステータスコードとして 418 を返却できるようにします.

route.js によるHTTPレスポンスのカスタマイズ

App Router では page.js を持つエンドポイントは全て 200 または 404 を返します. 現状では pages.js を使ってHTTPステータスコードを変更することはできません[5]. そこで route.js を使ってHTTPレスポンスをカスタマイズします.

route.js では NextResponse を使ってHTTPレスポンスをカスタマイズすることができます.

export async function GET(request: Request) {
  return new NextResponse(body, {
    status: 418,
    statusText: "I'm a teapot.",
    headers: {
      "Content-Type": "text/html",
    },
  });
}

https://github.com/toms74209200/nextjs-teapot/blob/459bb743b9e4cb05516e84538b2c7966299bbc70/next-teapot/app/teapot/route.ts#L34-L42

次にレスポンスボディを作成します. JSXを使ってHTMLを生成したいところですが, Next.js では react-dom/server を使ってHTMLテキストを生成するとビルドエラーになります. そのため, react-dom/server を使わずに生のHTMLテキストを使ってそのままレスポンスとして返しましょう. HTMLテキストは先ほど ErrorPage を使ってレンダリングされたものをそのままコピペして使います.

const body = `
<html>
<head>
  <meta charSet="utf-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
  <title>I'm a teapot</title>
  <meta name="description" content="I'm a teapot"/>
  <link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16"/>
  <script src="/_next/static/chunks/polyfills.js" noModule=""></script>
</head>
<body style="margin: 0;line-height: inherit;">
<div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center">
<div style="line-height:48px">
  <style>
    body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}
  </style>
  <h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding-right:23px;font-size:24px;font-weight:500;vertical-align:top">${error.code}</h1>
  <div style="display:inline-block">
    <h2 style="font-size:14px;font-weight:400;line-height:28px">${error.message}</h2>
  </div>
</div>
</div>
</body>
</html>
`;

https://github.com/toms74209200/nextjs-teapot/blob/459bb743b9e4cb05516e84538b2c7966299bbc70/next-teapot/app/teapot/route.ts#L8-L32

これでHTTPステータスコードとして 418 を返しつつ, HTMLテキストも返すことができます.

react-dom/server を使ったHTMLテキストの生成

とはいえHTMLテキストを生で扱うのはさすがにつらすぎる, というか React の意味がないので, JSXからHTMLテキストを生成できるようにします. リクエストハンドラの中で react-dom/server を動的にインポートすることで, ごまかすことができます[6].

export async function GET(request: Request) {
  const ReactDOMServer = (await import("react-dom/server")).default;
  const component = <BodyElement />;
  const bodyHtml = Buffer.from(ReactDOMServer.renderToString(component));

  return new NextResponse(bodyHtml, {
    status: error.code,
    statusText: error.message,
    headers: {
      "Content-Type": "text/html",
    },
  });
}

https://github.com/toms74209200/nextjs-teapot/blob/master/next-teapot/app/teapot/route.tsx#L75-L87

HTMLテキストからJSXへの変換は GitHub Copilot なんかを使って翻訳すると楽です.

これでHTTPステータスコードとして 418 を返却しつつ, レスポンスボディでHTMLを返却することができました.

"418 I'm a teapot" が表示されており, ネットワークから418が返っていることが確認できる

$ curl -I localhost:3000/teapot
HTTP/1.1 418 I'm a teapot.
vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url
content-type: text/html
Connection: keep-alive
Keep-Alive: timeout=5

Next.js App Router はまだまだ知見が少なく, 少し凝ったことをしようとすると苦労することが多いです. Pages Router でできたことが App Router ではできないことも多いため, 慎重に使うことが求められそうです.

脚注
  1. 418 I'm a teapot - HTTP | MDN ↩︎

  2. Error 418 (I’m a teapot)!? | google.com* ↩︎

  3. cURL コマンドを使う場合, -I オプションを使うと 200 が返ります. ↩︎

  4. Functions: getServerSideProps | Next.js ↩︎

  5. 以下でHTTPステータスコードを変更できないか議論されていますが, 2024年4月現在では変更できないようです. Support custom `HTTP Status Code` for Server Components · vercel/next.js · Discussion #53225 ↩︎

  6. 以下を参考. https://github.com/vercel/next.js/issues/43810#issuecomment-1462075524 ↩︎

Discussion

tksmrkmtksmrkm

Middlewareを使用したほうがスマートに実装できそうな気がしました。

/middleware.ts
import { NextResponse, NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
    if (request.nextUrl.pathname === '/teapot') {
        const response = NextResponse.next()
        return new NextResponse(response.body, {
            headers: response.headers,
            url: response.url,
            statusText: "I'm a Teapot",
            status: 418
        })
    }
}

export const config = {
    matcher: ['/teapot'],
}

これでページコンポーネント/app/teapot/page.tsxの方も自由に実装できそうです。