1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JSON APIと違う?TypeScriptでExcel(バイナリ)ダウンロードを実装する

Last updated at Posted at 2025-11-27

はじめに

Excelダウンロード機能を実装するとき、普段のJSON APIと同じ感覚で設計すると前提が少しズレる部分がありました。

特に 「レスポンスがJSONではなくバイナリになる」ことに伴う
ヘッダ・型・Swagger/OpenAPI
このまわりの扱いは、少し特殊なので記事にまとめることにしました。

今回のアーキテクチャはこんな感じです。

Next.js(フロント)
   ↓
PHP(中継サーバー)
   ↓
Nest.js(API / BFF サーバー)
  • 認証のため中継サーバーに寄せる必要がある
  • その後ろで Nest.jsのAPIがExcelのバイナリを返す
  • フロントはそれをブラウザ標準のUIでダウンロードさせる

という前提で設計・実装しました。

大まかな方針

  • バイナリデータを返却するAPIを作成する
  • ブラウザのメモリからダウンロードする

Excel生成は重い処理なのでバックエンドに任せます。
また、ダウンロードUIはリッチな機能がすでにあるブラウザの標準機能を活用します。

細かい制約

通信形式

よく利用するjsonのやりとりのAPIとは、少し異なる実装が必要です。

普段のJSON APIでは

- Request: JSON
- Response: JSON

の世界で完結します。

一方、Excelダウンロードでは

- Request: JSON
- Response: binary(Excel)

になるので、ヘッダと型の考え方が変わります。

Requestヘッダ

POSTで検索条件を送りたいので、普通のJSONと同じく

  • Content-Type: application/json

はそのままにします。

ただし レスポンスとしてExcelも許可したいのでAcceptヘッダーを意識します。
エラーの場合はjsonを返すようにしているので、'application/json'も配置します。

  • Accept: 'application/json'
  • Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',

例:

const res = await fetch('/api/excel', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': [
      'application/json',
      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    ].join(','),
  },
  body: JSON.stringify(params),
});

こうすることで、明示的にサーバー側にレスポンス形式を伝えることができ、
リクエストとレスポンスにの形式に互換性が生まれます。

Responseヘッダ

レスポンスヘッダが特に重要です。

Content-Typeにはエクセル形式のバイナリであることを明示的に伝えます。
これがあることで、ブラウザがファイル種別を確定できます。

'Content-Type':'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',

Content-Dispotionはコンテンツを受け取った側がどう扱うか、ということを示すヘッダです。
下記の2種類のオプションがあり、今回はダウンロードさせたいのでattachmentを使用します。

  • inline:ブラウザが表示できるなら、そのまま画面に表示してOK
  • attachment:表示できても ファイルとして保存(ダウンロード)させたい
'Content-Disposition':'attachment; filename="sales_list.xlsx"',

filenameに保存したいファイル名と形式を記載します。

こうするとブラウザは画面表示じゃなく、
sales_list.xlsxという名前で保存ダイアログを出すようになります。

最終このような感じです。

return new Response(buffer, {
  headers: {
    'Content-Type':'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    'Content-Disposition':'attachment; filename="sales_list.xlsx"',
  },
});

型・DTO

OpenAPIは JSON Schemaベースなので、
JSONで表現できない型(バイナリ)は持てません。

type: string
format: binary

今回のAPIは同一エンドポイントでJSONかExcelを返すパターンなので、
「検索条件によってJSONとExcelのどちらかを返す」設計です。

そのためレスポンスは Content-Type ごとに定義します。

@ApiResponse({
  status: 200,
  description: '商品一覧 or Excel',
  content: {
    'application/json': {
      schema: { $ref: getSchemaPath(ListResponseDto) },
    },
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
      schema: { type: 'string', format: 'binary' },
    },
  },
})

このcontentは Content-Typeに応じたレスポンスの分岐定義です。

OpenAPIを元に自動型生成を使って、フロントとバックエンドの型を揃える設計ですが、
Jsonで定義できないので、自動型生成の対象外です。

そのため、フロンではBinaryは下記のように定義します。
ListResponseDtoはOpenAPIから自動型生成したもの、Blobはフロント側でのみ使えるJS用の型です。

type ListApiResponse =  ListResponseDto | Blob;

参考ドキュメント

ブラウザUI(標準機能をどう使うか)

ブラウザ標準のダウンロード機能を利用します。

下記のようなやつです。

image.png

window.location.href = "/api/excel"

window.href="api/excel"でブラウザにバイナリ用のAPIを叩かせるのが、ダウンロード機能をすべて任せられるので一番シンプルです。
キャンセルや停止、再ダウンロードなどストリーミングにしつつ状態管理をすべてブラウザに任せれます

ただ、この条件には制約があって、GETじゃないといけません。

今回の場合、POSTで検索条件を送る既存APIのロジックを利用してパラメータを加えて、実装する設計でした。

なのでその方法は諦めました。

PostでStreamingダウンロードできる方法を他にもあり、form形式にしてブラウザにFetchさせる方法です。
ですがこれにも制約があります。

  • form送信になって Content-Type: application/x-www-form-urlencoded を要求され、既存APIとの併用が複雑になる
  • PHP中継サーバーが絡んでストリーミング対応しないといけなく範囲が広い
  • そもそもそこまで巨大データでもない

最終的に、下記の方法を取りました。

バイナリをfetch → Blob化 → メモリ上にURL生成 → aタグクリックでブラウザダウンロード

古くからある方法で、実装も簡単です。

Blobをfetch仕切ってからダウンロードするので、ユーザーからみると一瞬でダウンロードしたように見えるのが難点です。
しかし、ファイルサイズが小さいことと、制約の中での落とし所はここと考えました。

ダウンロード用関数
export function downloadBlob(blob: Blob, filename: string) {
  const url = window.URL.createObjectURL(blob);

  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();

  window.URL.revokeObjectURL(url);
}
利用側
const result = await listProducts(params);

if (result instanceof Blob) {
  downloadBlob(result, 'sales_list.xlsx');
}

参考ドキュメント

ライブラリ選定

Excel.jsを利用しました。

Excelの生成処理は NestJS 側で行いました。
要件は 「DBから取得したデータをそのままExcelに変換し、スタイルやシート構成も制御したい」 というものです。

ExcelJSを採用した理由です。

  • 行・列の追加、セル書式、シート分割などの柔軟性が高い
  • 無料でスタイルも含めて制御できる
  • 故に書き込みに強い

Node.js / NestJS での利用実績が多い SheetJS(xlsx)も検討しましたが、

  • 読み取りに強いが、本件では読み取りはしない
  • 細かいスタイルやリッチな機能は有料プラン依存になりがち
  • 公式npmはメンテされず、非公式でCND経由の利用になる

という理由で ExcelJS を採用しました。

(※ここはプロジェクトの要件次第で変わると思います)

参考ドキュメント

タイムアウト

バイナリファイルは通信に時間がかかります。
インフラ側の調査と実測値で最大時間を推定する必要があります。
調査したところ、このような値で設定されていました。

CloudFront: 30 秒 (デフォルト)
ALB: 60 秒 (デフォルト)
ECS: 30秒 (Nodeの設定)

APIは最大期間を指定した場合、15秒程度でした。

つまりインフラの設定には依存しなさそうです。

まとめ

  • Excelダウンロードは JSON APIと違い、レスポンスがバイナリになる
  • OpenAPIはBlob型を表現できないため、フロント側で union(DTO | Blob)補正が必要
  • Content-Typeごとに content.schema を分けるとSwaggerの定義が整理できる
  • ダウンロードUIは ブラウザ標準機能を最大限使うのが楽&安全
  • Excel生成は NestJS側でExcelJSを使ってDB結果を変換する方針が現実的
  • タイムアウトの考慮は必要

ダウンロード機能は意外と考えることが多く、フロント・バックエンド・インフラと考慮する必要がありますが、すごく知識の深堀りができました。

課題としては、ストリーミング対応できてないので、そちら挑戦したいです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?