家計簿アプリを作る #25:収支データのCSVエクスポート

家計簿アプリ作成シリーズの第25回です。月別の収支データをCSVファイルとしてダウンロードできる機能を実装します。

実装方針

SvelteKit の Form Action ではファイルダウンロードが難しいため、+server.ts(APIエンドポイント)を使います。GET リクエストでCSVを生成して返し、+page.svelte からは通常のリンクでアクセスします。

APIエンドポイントの配置

src/routes/(auth)/api/export/+server.ts

(auth) グループ内に配置することで、(auth)/+layout.server.ts の認証チェックが効きます。(auth) の外に置くと認証チェックが効かなくなるため注意が必要です。

+server.ts の実装

import type { RequestHandler } from "./$types";
import { createDb } from '$lib/server/db';
import { transactions } from '$lib/server/db/schema';
import { eq, and, like } from 'drizzle-orm';

export const GET: RequestHandler = async ({ locals, platform, url }) => {
  const selectedMonth = url.searchParams.get('month') ?? '';

  const db = createDb(platform!.env.DB);
  const monthTransactions = await db.select().from(transactions)
    .where(
      and(
        eq(transactions.userId, locals.user!.id),
        like(transactions.date, `${selectedMonth}%`)
      )
    );

  const header = '日付,種類,カテゴリ,メモ,金額';
  const rows = monthTransactions.map(t =>
    `${t.date},${t.type === 'income' ? '収入' : '支出'},${t.category},${t.memo},${t.amount}`
  ).join('\n');
  const csv = `${header}\n${rows}`;

  return new Response(csv, {
    headers: {
      'Content-Type': 'text/csv',
      'Content-Disposition': `attachment; filename="transactions-${selectedMonth}.csv"`
    }
  });
};

Content-Disposition: attachment を指定することでブラウザがファイルとしてダウンロードします。filename= で保存時のファイル名を指定します。

CSVのヘッダ行

スキーマからDB列名を取得することも技術的には可能ですが、user_idamount などの英語列名がそのまま出てしまうため、日本語ヘッダは自分で定義する方が実用的です。

+page.svelte の実装

Buttonhref を渡すだけでリンクとして動作します。

<Button href={`/api/export?month=${data.selectedMonth}`}>CSVエクスポート</Button>

ハマりポイント:テンプレートリテラルのミス

// ❌ $が抜けるとファイル名に } が混入する
href={`/api/export?month=${data.selectedMonth}`}  // → /api/export?month=2026-06}

// ✅ 正しい
href={`/api/export?month=${data.selectedMonth}`}

まとめ

ポイント 内容
ファイルダウンロード Form Action ではなく +server.tsGET で実装
認証チェック (auth) グループ内に配置して認証を共通化
CSVヘッダ スキーマからではなく日本語で自前定義
ダウンロード Content-Disposition: attachment でブラウザにファイル保存させる
← トップページに戻る