「サイトのセキュリティ診断で X-Content-Type-Options が無いと指摘された」「特定のパスだけ Access-Control-Allow-Origin を返したい」——こうしたHTTPレスポンスヘッダーの追加は、Next.js なら next.config.js の headers() で設定できます。サーバーの設定ファイルを触らずに、アプリのコードとして管理できるのが利点です。この記事では headers() の基本形から、パスの絞り込み、条件付きヘッダー、代表的なセキュリティヘッダーの意味、Cache-Control を扱うときの注意点、middleware との使い分けまでを順に解説します。
目次
headers() で何ができるか
headers() は、Next.js が返すレスポンスに好きなヘッダーを追加するための設定です。どの URL に対して、どんな名前と値のヘッダーを付けるかを配列で並べて書きます。ブラウザに「このページはフレームに埋め込ませない」「参照元はここまでしか送らない」といった指示を出すセキュリティヘッダーの設定が、一番よくある用途です。
混同しやすいのですが、これはレスポンスにヘッダーを付ける側の機能です。ブラウザから送られてきたリクエストのヘッダーを読み取りたい場合は next/headers の headers() 関数という別の仕組みを使います。名前が同じで紛らわしいので、「next.config.js に書くほうは付ける側」と覚えておくと迷いません。
また、この設定は Next.js のサーバーがレスポンスを返すときに適用されます。output: 'export' で静的な HTML として書き出す構成ではレスポンスを返すサーバーが存在しないため、headers() は使えません。その場合は配信する Web サーバーや CDN 側でヘッダーを設定することになります。
headers() の基本の書き方
headers() は設定オブジェクトのプロパティとして定義する async 関数で、オブジェクトの配列を返します。各オブジェクトは、対象の URL パターンを表す source と、付けたいヘッダーを key / value の形で並べた headers の2つを持ちます。
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
// 対象の URL パターン(すべてのパス)
source: '/(.*)',
headers: [
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
],
},
];
},
};
module.exports = nextConfig;
TypeScript で next.config.ts を使っている場合も中身は同じです。NextConfig 型を付けておくと、プロパティ名のタイプミスをエディタが教えてくれます。
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
],
},
];
},
};
export default nextConfig;
配列にはいくつでもエントリを追加できます。「全ページ共通のセキュリティヘッダー」と「/api 以下だけの CORS ヘッダー」のように、目的ごとにエントリを分けて書くと見通しが良くなります。配列は上から順に評価され、マッチしたものがすべて適用されます。
ひとつ気をつけたいのが、next.config は開発サーバーの起動時に読み込まれることです。ページのコードのように保存しただけでは反映されないので、編集したら npm run dev を一度止めて起動し直してください。
source でヘッダーを付けるパスを絞り込む
source は必ず / で始まるパスパターンで書きます。単純な文字列だけでなく、パラメータやワイルドカード、正規表現も使えます。
全ページに付けたいとき
サイト全体に共通のセキュリティヘッダーを付けたいなら /(.*) と書きます。これは「任意の文字列にマッチする」という正規表現で、トップページを含むすべてのパスが対象になります。同じ意味で /:path* という書き方もでき、こちらは path という名前のパラメータが0個以上のセグメントにマッチする、という指定です。全ページ対象にしたいだけならどちらでも構いません。
特定の階層だけに付けたいとき
:名前 は1つのパスセグメントにマッチするパラメータ、末尾に * を付けると複数のセグメントにマッチします。たとえば /blog/:slug は /blog/hello にマッチしますが /blog/2026/hello にはマッチしません。後者も含めたいときは /blog/:slug* と書きます。
async headers() {
return [
{
// /blog/hello などの記事ページだけ
source: '/blog/:slug',
headers: [{ key: 'X-Article', value: 'true' }],
},
{
// /api 以下すべて(/api/posts/1 のような深い階層も含む)
source: '/api/:path*',
headers: [{ key: 'X-Robots-Tag', value: 'noindex' }],
},
];
}
一部のパスを除きたいとき
「API 以外の全ページ」のように除外条件を書きたい場合は、正規表現の否定先読みを使います。/((?!api).*) と書くと、api で始まるパス以外のすべてにマッチします。複数除外したいときは | で並べます。
{
// /api と /_next/static、favicon.ico 以外のすべてのパス
source: '/((?!api|_next/static|favicon.ico).*)',
headers: [
{ key: 'Content-Security-Policy', value: "frame-ancestors 'self'" },
],
}
正規表現の特殊文字をパスの一部として書きたいときは注意が必要です。( や )、?、+ といった文字は、リテラルとして扱いたい場合は前にバックスラッシュを2つ重ねて \\( のようにエスケープします。パターンが複雑になったら、まずは /(.*) のような単純な形で動作を確認してから絞り込んでいくのが安全です。
代表的なセキュリティヘッダーとその効果
headers() の主役はセキュリティヘッダーです。名前だけ見ても何をするものか分かりにくいので、よく設定されるものを整理しておきます。
| ヘッダー | 値の例 | 効果 |
|---|---|---|
X-Content-Type-Options | nosniff | ブラウザが Content-Type を無視して中身から種類を推測するのを止める。テキストが誤ってスクリプトとして実行されるのを防ぐ |
Referrer-Policy | strict-origin-when-cross-origin | 他サイトへ遷移するときに送る参照元情報の量を制限する。外部にはオリジンだけを送る |
Strict-Transport-Security | max-age=63072000; includeSubDomains | 指定期間、そのドメインへは必ず HTTPS で接続するようブラウザに記憶させる |
X-Frame-Options | SAMEORIGIN / DENY | ページを他サイトの iframe に埋め込ませない。クリックジャッキング対策 |
Content-Security-Policy | frame-ancestors 'self' | 読み込みを許可する資源を細かく指定する。frame-ancestors は X-Frame-Options の後継にあたる指定 |
Permissions-Policy | camera=(), microphone=() | カメラや位置情報などブラウザ機能の利用可否を指定する。空の括弧はどこにも許可しない意味 |
X-DNS-Prefetch-Control | on | ページ内リンク先ドメインの DNS 解決を先読みさせ、遷移を速くする |
これらをまとめて設定すると次のようになります。ヘッダーの配列を定数として切り出しておくと、複数の source で使い回せて読みやすくなります。
const securityHeaders = [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
{
// HTTPS 環境でのみ意味を持つ。約2年間 HTTPS 接続を強制する
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains',
},
];
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
];
},
};
module.exports = nextConfig;
HSTS とサブドメインの関係には気をつける
Strict-Transport-Security(HSTS)は、一度ブラウザに記憶されると max-age の期間中は HTTP でのアクセスができなくなります。includeSubDomains を付けると、その効果がすべてのサブドメインにも及びます。社内向けに HTTP で運用しているサブドメインがあると、それらにアクセスできなくなる可能性があるということです。まずは短めの max-age で試し、問題がないことを確認してから期間を延ばすのが無難です。preload の指定はブラウザ側のリストに載せる申請とセットのもので、解除に時間がかかるため、仕組みを理解したうえで判断してください。
Content-Security-Policy はいきなり全部を絞らない
CSP は読み込みを許可するスクリプトやスタイル、画像の配信元を細かく指定できる強力なヘッダーですが、その分ページが壊れやすいヘッダーでもあります。特に script-src を厳しくすると、Next.js が出力するインラインスクリプトや解析タグが読み込めなくなり、画面が真っ白になることがあります。
そのため、まずは影響範囲の小さい frame-ancestors(埋め込みの制限)から始めるか、違反を検出するだけでブロックはしない Content-Security-Policy-Report-Only ヘッダーで様子を見るのが現実的です。本格的にスクリプトを制限したい場合は、後述する middleware でリクエストごとに nonce を発行する方法が必要になります。
has / missing で条件付きにヘッダーを付ける
「特定のクッキーを持っているときだけ」「あるヘッダーが付いていないときだけ」といった条件を加えたい場合は、エントリに has と missing を書けます。どちらも配列で、type(header / cookie / host / query)と key、必要に応じて value を指定します。
async headers() {
return [
{
// ログイン用クッキーがあるページは検索エンジンに登録させない
source: '/(.*)',
has: [{ type: 'cookie', key: 'session_id' }],
headers: [{ key: 'X-Robots-Tag', value: 'noindex' }],
},
{
// 特定のホスト名で配信されているときだけ付ける
source: '/(.*)',
has: [{ type: 'host', value: 'staging.example.com' }],
headers: [{ key: 'X-Environment', value: 'staging' }],
},
{
// Authorization ヘッダーが「無い」リクエストにだけ付ける
source: '/api/:path*',
missing: [{ type: 'header', key: 'authorization' }],
headers: [{ key: 'X-Guest-Access', value: 'true' }],
},
];
}
has は「その条件を満たすときだけ適用」、missing は「その条件を満たさないときだけ適用」です。value を省略すると、キーが存在するかどうかだけが判定されます。value を書いた場合はその値との一致が条件になり、正規表現も使えます。
ただし、これはあくまでリクエストの形による分岐です。クッキーの値が正しいかどうかを検証してアクセス制御をする、といった用途には使えません。認証が絡む処理は middleware やサーバー側のコードで行ってください。
API に CORS ヘッダーを付ける
別のドメインのフロントエンドから自分の API を叩けるようにするには、CORS 用のヘッダーが必要です。/api 以下すべてに同じ設定を付けるなら、next.config.js にまとめて書くのが手軽です。
async headers() {
return [
{
source: '/api/:path*',
headers: [
// 許可するオリジン。* にすると誰でも呼べるので用途に応じて絞る
{ key: 'Access-Control-Allow-Origin', value: 'https://app.example.com' },
{ key: 'Access-Control-Allow-Methods', value: 'GET, POST, OPTIONS' },
{
key: 'Access-Control-Allow-Headers',
value: 'Content-Type, Authorization',
},
],
},
];
}
一方で、許可するオリジンをリクエストごとに変えたい場合はこの書き方では対応できません。next.config.js に書けるのはあらかじめ決まった固定の値だけだからです。「許可リストに含まれるオリジンならそれをそのまま返す」といった動的な処理は、Route Handlers 側でレスポンスを組み立てるときにヘッダーを付けます。
const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];
export async function GET(request: Request) {
const origin = request.headers.get('origin') ?? '';
// 許可リストに含まれるオリジンだけ、そのオリジンを返す
const corsHeaders: Record<string, string> = allowedOrigins.includes(origin)
? { 'Access-Control-Allow-Origin': origin, Vary: 'Origin' }
: {};
return Response.json({ posts: [] }, { headers: corsHeaders });
}
使い分けの目安はシンプルで、全リクエストで値が同じなら next.config.js、リクエストの内容で値が変わるなら Route Handlers か middleware です。なお、プリフライトリクエスト(ブラウザが本番のリクエストの前に送る OPTIONS)に応える必要がある場合は、Route Handlers 側で OPTIONS という名前の関数を export して対応します。
Cache-Control を設定するときの注意
headers() でつまずきやすいのが Cache-Control です。Next.js はページや静的アセットに対して、レンダリング方式に応じた Cache-Control を自分で付けています。ここに手で書いた値をかぶせると、本来効いていたキャッシュの最適化が崩れることがあります。公式ドキュメントでも、ページやアセットに対する Cache-Control は本番環境で上書きされるため next.config.js で設定しないよう案内されています。
とくに /_next/static 以下のビルド成果物には、すでに長期キャッシュのための immutable 付きの Cache-Control が付いています。これらのファイル名にはビルドごとに変わるハッシュが含まれていて、内容が変われば URL も変わる前提で長期キャッシュが成立しています。ここを自前の値で上書きすると、キャッシュが効かなくなって表示が遅くなるか、逆に古いファイルが残り続けるといった問題につながります。
それでも Cache-Control を明示したい場面はあります。たとえば public ディレクトリに置いた画像やフォントのように、Next.js のビルド対象ではない静的ファイルです。こうしたファイルには既定で長期キャッシュが付かないため、自分で指定する意味があります。
async headers() {
return [
{
// public/fonts に置いた、内容が変わらないフォントファイル
source: '/fonts/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
];
}
immutable を付けたファイルはブラウザに長期間そのまま保持されるので、差し替えの予定があるファイルには使わないでください。更新する可能性があるなら、ファイル名にバージョンを含めるなどして URL 自体を変える運用にします。ページ単位でキャッシュの挙動を変えたい場合は、headers() ではなく revalidate の設定や fetch のキャッシュオプションといった Next.js 側の仕組みを使うのが本筋です。
middleware.ts でヘッダーを付ける方法との違い
レスポンスヘッダーは middleware でも付けられます。NextResponse.next() で作ったレスポンスに対して headers.set() を呼ぶだけです。
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// リクエストごとに使い捨ての nonce を作る
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
"frame-ancestors 'none'",
].join('; ');
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', csp);
response.headers.set('X-Request-Id', crypto.randomUUID());
return response;
}
export const config = {
matcher: '/((?!_next/static|_next/image|favicon.ico).*)',
};
どちらでも同じことができるように見えますが、判断基準は値が固定かどうかです。next.config.js は設定ファイルなので、書けるのは起動時に決まる静的な値だけです。その代わり、リクエストごとに JavaScript を実行するコストがかかりません。nosniff や SAMEORIGIN のように毎回同じ値を返すヘッダーは、こちらで書くのが素直です。
一方 middleware はリクエストのたびに実行されるコードなので、上の例のように毎回値が変わるヘッダーを作れます。CSP の nonce はリクエストごとにランダムな値でなければ意味がないため、この用途では middleware が必要です。リクエスト ID の付与や、クッキーの中身を検証した結果でヘッダーを変える処理も同様です。
なお両方で同じヘッダー名を設定すると、どちらの値が最終的に残るかが分かりにくくなります。ヘッダーごとに「これは next.config、これは middleware」と担当を分けておき、重複して書かないようにするのが安全です。
設定が効いているかを確認する
curl でヘッダーだけを見る
手っ取り早いのはターミナルからの確認です。curl -I を使うとレスポンスヘッダーだけが表示されます。
# 開発サーバーのトップページのヘッダーを見る
curl -I http://localhost:3000/
# 特定のヘッダーだけを抜き出す(大文字小文字を無視して検索)
curl -sI http://localhost:3000/ | grep -i 'content-security-policy'
# GET リクエストのままヘッダーを見る(本文は捨てる)
curl -s -D - -o /dev/null http://localhost:3000/blog/hello
curl -I が送るのは HEAD リクエストです。ほとんどの場合は同じ結果になりますが、HEAD と GET で挙動が違う API を確認するときは、3つ目の書き方のように -D - でヘッダーを標準出力に出し、本文を /dev/null に捨てる形にすると確実です。
開発者ツールのネットワークタブで見る
ブラウザで確認するなら、開発者ツールを開いた状態でページを再読み込みし、ネットワークタブでいちばん上の HTML ドキュメントのリクエストを選びます。「Headers」の中の「Response Headers」に、設定したヘッダーが並んでいれば成功です。CSP を設定した場合は、コンソールタブに違反の警告が出ていないかも合わせて確認してください。ブロックされた資源があれば、どのディレクティブに引っかかったかがメッセージに出ます。
ヘッダーが反映されないときに見るところ
開発サーバーを再起動していない
いちばん多い原因がこれです。next.config は起動時に読み込まれるため、保存しただけでは反映されません。設定を変えたら必ず開発サーバーを止めて起動し直します。設定ファイル自体に構文エラーがある場合は、起動時のログにエラーが出ているはずなので、そこも確認してください。
source がそのパスにマッチしていない
パターンの書き間違いも定番です。/blog/:slug のつもりで /blog/:slug* が必要だった、末尾のスラッシュの有無で外れていた、といったケースがあります。切り分けるには、いったん source を /(.*) にして全パスに付くかどうかを見ます。それで付くならパターンの問題、付かないなら設定の書き方自体を見直す、という順で追うと原因が絞れます。
手前のサーバーや CDN に上書きされている
ローカルでは付いているのに本番で消えている、あるいは違う値になっている場合は、Next.js より手前にいるものを疑います。リバースプロキシとして置いた Nginx、CDN、ホスティングサービス側のヘッダー設定などが、同じ名前のヘッダーを付け直していることがあります。ローカルと本番の両方に curl -I を投げて差分を見れば、どこで変わっているかの当たりが付きます。
同じヘッダーを複数の場所で設定している
headers() の配列の中で複数のエントリが同じパスにマッチし、同じヘッダー名を設定していると、意図しない値が残ることがあります。middleware や Route Handlers でも同じヘッダーを設定している場合はさらに追いにくくなります。ヘッダー名で全体を検索して、設定箇所が1つに絞れているかを確認してください。
まとめ
next.config.js の headers() は、source(対象パス)と headers(key / value の配列)を持つオブジェクトの配列を返す async 関数です。全ページ対象なら /(.*)、階層を絞るなら /blog/:slug や /api/:path*、除外したいものがあれば /((?!api).*) のように否定先読みを使います。has と missing を足せば、クッキーやホスト名の有無で適用を切り替えることもできます。
用途としては X-Content-Type-Options や Referrer-Policy、X-Frame-Options といったセキュリティヘッダーの一括設定が中心です。Strict-Transport-Security と Content-Security-Policy は影響が大きいので、短い max-age やレポート専用ヘッダーで段階的に導入します。Cache-Control は Next.js が自分で管理している領域なので、ページやビルド成果物に対しては触らず、public の静的ファイルなど自分で管理すべきものに限って指定してください。
値が常に同じヘッダーは next.config.js、nonce 付き CSP のようにリクエストごとに変わるものは middleware.ts、レスポンス内容と一体で決まるものは Route Handlers、と担当を分けておくと重複による混乱を避けられます。設定したら curl -I や開発者ツールのネットワークタブで実際に付いているかを必ず確認し、本番で消えている場合は CDN やリバースプロキシ側も疑ってみてください。