awesome-hacks
Docs

NestJS Cache-Control 実装

NestJS で Cache-Control ヘッダーを設定する方法を、デコレーター・インターセプター・ミドルウェアの3パターンで実装する

最終更新:2026/06/16

TL;DR

  • エンドポイント単位なら @Header() デコレーターで直接指定するのが最もシンプル
  • 複数のエンドポイントに横断的に適用するにはインターセプターを使う
  • @nestjs/cache-managerサーバーサイドのメモリキャッシュであり、Cache-Control(ブラウザ・CDN向け)とは別物

前提

  • NestJS の基本構造(Module / Controller / Service)を理解していること
  • Cache-Control ヘッダーの意味については Cache-Controlヘッダーの基礎 を参照

パターン1:@Header() デコレーター(エンドポイント単位)

最もシンプルな方法。特定のエンドポイントにだけ設定したい場合に使う。

import { Controller, Get, Header } from '@nestjs/common';

@Controller('products')
export class ProductsController {

  // 公開データ:CDNにも60秒キャッシュを許可
  @Get()
  @Header('Cache-Control', 'public, max-age=60')
  findAll() {
    return this.productsService.findAll();
  }

  // 認証不要だがユーザー固有でない集計データ:5分キャッシュ
  @Get('stats')
  @Header('Cache-Control', 'public, max-age=300')
  getStats() {
    return this.productsService.getStats();
  }
}

複数のエンドポイントで同じ値を使い回す場合は定数にまとめると管理しやすい。

const CacheHeaders = {
  NO_STORE: 'no-store',
  PRIVATE_SHORT: 'private, max-age=60',
  PUBLIC_LONG: 'public, max-age=3600',
  STATIC: 'public, max-age=31536000, immutable',
} as const;

@Get('me')
@Header('Cache-Control', CacheHeaders.NO_STORE)
getMe(@Req() req: Request) {
  // ログインユーザーの個人情報:キャッシュ禁止
  return this.usersService.findById(req.user.id);
}

パターン2:インターセプター(横断的に適用)

複数のコントローラー・エンドポイントに一括で適用したいときにはインターセプターを使う。

基本的なインターセプターの実装

// cache-control.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Response } from 'express';

@Injectable()
export class CacheControlInterceptor implements NestInterceptor {
  constructor(private readonly directive: string) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    return next.handle().pipe(
      tap(() => {
        const res = context.switchToHttp().getResponse<Response>();
        res.setHeader('Cache-Control', this.directive);
      }),
    );
  }
}

コントローラー単位で適用

import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { CacheControlInterceptor } from './cache-control.interceptor';

@Controller('articles')
@UseInterceptors(new CacheControlInterceptor('public, max-age=300'))
export class ArticlesController {

  @Get()
  findAll() {
    return this.articlesService.findAll();
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.articlesService.findOne(id);
  }

  // このエンドポイントだけ上書きしたい場合は @Header() で個別設定
  @Get('draft/:id')
  @Header('Cache-Control', 'no-store')
  findDraft(@Param('id') id: string) {
    return this.articlesService.findDraft(id);
  }
}

アプリ全体にデフォルトを設定(main.ts)

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { CacheControlInterceptor } from './interceptors/cache-control.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // デフォルトはキャッシュ禁止(APIサーバーとして安全側に倒す)
  app.useGlobalInterceptors(
    new CacheControlInterceptor('no-store'),
  );

  await app.listen(3000);
}
bootstrap();

全体に no-store を設定しておき、キャッシュを許可したいエンドポイントだけ @Header() で個別に上書きする方法は、「デフォルト安全」な設計になる。


パターン3:カスタムデコレーター(可読性の向上)

@Header('Cache-Control', '...') の文字列を毎回書くのが嫌な場合、意図が明確なカスタムデコレーターを定義できる。

// decorators/cache-control.decorator.ts
import { Header } from '@nestjs/common';

export const NoStore = () => Header('Cache-Control', 'no-store');
export const PublicCache = (maxAge: number) =>
  Header('Cache-Control', `public, max-age=${maxAge}`);
export const PrivateCache = (maxAge: number) =>
  Header('Cache-Control', `private, max-age=${maxAge}`);
import { NoStore, PublicCache, PrivateCache } from './decorators/cache-control.decorator';

@Controller('users')
export class UsersController {

  @Get()
  @PublicCache(300)           // 公開ユーザー一覧:5分
  findAll() { ... }

  @Get(':id/profile')
  @PublicCache(60)            // プロフィールページ:1分
  getProfile(@Param('id') id: string) { ... }

  @Get('me')
  @NoStore()                  // 自分のデータ:キャッシュ禁止
  getMe() { ... }

  @Get('me/settings')
  @PrivateCache(300)          // 設定情報:ブラウザのみ5分
  getSettings() { ... }
}

よくあるユースケース別の設定例

認証が必要なエンドポイント全般

@UseGuards(JwtAuthGuard)
@Controller('me')
@UseInterceptors(new CacheControlInterceptor('no-store'))
export class MeController {
  // ユーザー固有の情報はすべて no-store
}

公開APIで変化が少ないマスタデータ

@Get('categories')
@Header('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400')
getCategories() {
  return this.categoryService.findAll();
}

stale-while-revalidate は「max-age が切れても古いキャッシュを返しつつ、バックグラウンドで再検証する」ディレクティブ。レスポンス速度を維持しながら最終的に最新化される。

検索API(ユーザー固有の検索結果)

@Get('search')
@UseGuards(JwtAuthGuard)
@Header('Cache-Control', 'private, no-cache')
search(@Query('q') q: string, @Req() req: Request) {
  return this.searchService.search(q, req.user.id);
}

ユーザーごとに結果が変わる(private)。ただし最新性が重要なので毎回再検証する(no-cache)。

ファイルダウンロード(機密ファイル)

@Get('files/:id/download')
@UseGuards(JwtAuthGuard)
@Header('Cache-Control', 'no-store')
@Header('Content-Disposition', 'attachment')
download(@Param('id') id: string, @Res() res: Response) {
  const stream = this.filesService.getStream(id);
  stream.pipe(res);
}

@nestjs/cache-manager との違い

よく混同されるが、用途がまったく異なる。

Cache-Control ヘッダー@nestjs/cache-manager
キャッシュする場所ブラウザ・CDN・プロキシサーバーのメモリ(または Redis)
目的クライアント側の通信を減らすサーバー側の処理(DB等)を減らす
有効期限の管理ブラウザ・CDNが行うNestJSサーバーが行う
ユーザーをまたぐ共有public を指定した場合同一サーバーのリクエスト間で共有
// @nestjs/cache-manager はサーバーサイドのDBアクセスを減らすキャッシュ
@Get(':id')
async findOne(@Param('id') id: string) {
  // DBへのアクセスをサーバーメモリにキャッシュする
  const cached = await this.cacheManager.get(`product:${id}`);
  if (cached) return cached;

  const product = await this.productsService.findOne(id);
  await this.cacheManager.set(`product:${id}`, product, 300); // 5分
  return product;
}

この @nestjs/cache-manager によるキャッシュは、クライアントからは見えない。サーバーが速く返せるようにするための最適化であり、Cache-Control ヘッダーは別途必要。


よくある落とし穴

レスポンスオブジェクトを直接操作するときの注意

NestJS で @Res() を使うと、NestJS のレスポンスハンドリングをバイパスする。@Header() デコレーターは NestJS のレスポンスパイプラインに乗って動くため、@Res() と組み合わせる場合は res.setHeader() で直接設定する。

// ❌ @Res() と @Header() の組み合わせは意図通りに動かないことがある
@Get('file')
@Header('Cache-Control', 'no-store')  // 効かない場合がある
download(@Res() res: Response) { ... }

// ✓ @Res() を使う場合は直接 setHeader する
@Get('file')
download(@Res() res: Response) {
  res.setHeader('Cache-Control', 'no-store');
  // ...
}

インターセプターとデコレーターの優先順位

@Header() デコレーターはコントローラーの実行前にヘッダーを設定し、インターセプターはレスポンス後に tap で設定することが多い。両方を同じエンドポイントに使う場合は後から実行された方が上書きするため、順序に注意する。


関連