Skip to content

TanStack Start は、TanStack Router をベースとしたフルスタックフレームワークです。完全なドキュメント SSR、ストリーミング、サーバー関数、バンドルなどをサポートし、TanStack Router と Vite によって動作します。


新しい TanStack Start アプリを作成

インタラクティブ CLI を使用して新しい TanStack Start アプリを作成します。

sh
bun create @tanstack/start@latest my-tanstack-app

開発サーバーを起動

プロジェクトディレクトリに移動し、Bun で開発サーバーを実行します。

sh
cd my-tanstack-app
bun --bun run dev

これにより、Bun で Vite 開発サーバーが起動します。

package.json のスクリプトを更新

package.json の scripts フィールドを、Vite CLI コマンドに bun --bun をプレフィックスとして追加して変更します。これにより、devbuildpreview などの一般的なタスクで Bun が Vite CLI を実行することが保証されます。

json
{
  "scripts": {
    "dev": "bun --bun vite dev", 
    "build": "bun --bun vite build", 
    "serve": "bun --bun vite preview"
  }
}

ホスティング

TanStack Start アプリをホストするには、Nitro を使用するか、本番デプロイ用にカスタム Bun サーバーを使用できます。

Nitro

Nitro をプロジェクトに追加

Nitro をプロジェクトに追加します。このツールを使用すると、TanStack Start アプリをさまざまなプラットフォームにデプロイできます。

sh
bun add nitro

vite.config.ts ファイルを更新

Bun で TanStack Start を使用するために必要なプラグインを含めるように、vite.config.ts ファイルを更新します。

ts
// 他のインポート...
import { nitro } from "nitro/vite"; 

const config = defineConfig({
  plugins: [
    tanstackStart(),
    nitro({ preset: "bun" }), 
    // 他のプラグイン...
  ],
});

export default config;

NOTE

`bun` プリセットはオプションですが、Bun ランタイム向けにビルド出力を特別に設定します。

開始コマンドを更新

package.json ファイルに buildstart スクリプトが含まれていることを確認します。

json
  {
    "scripts": {
      "build": "bun --bun vite build", 
      // .output ファイルは `bun run build` を実行するときに Nitro によって作成されます。
      // Vercel にデプロイする場合は不要です。
      "start": "bun run .output/server/index.mjs"
    }
  }

NOTE

Vercel にデプロイする場合は、カスタム `start` スクリプトは **不要** です。

アプリをデプロイ

アプリをホスティングプロバイダーにデプロイするためのガイドを参照してください。

NOTE

Vercel にデプロイする場合は、`vercel.json` ファイルに `"bunVersion": "1.x"` を追加するか、`vite.config.ts` ファイルの `nitro` 設定に追加できます。

警告

Vercel にデプロイする場合は、bun Nitro プリセットを使用 しないで ください。

ts
export default defineConfig({
  plugins: [
    tanstackStart(),
    nitro({
      preset: "bun", 
      vercel: { 
        functions: { 
          runtime: "bun1.x", 
        }, 
    }, 
    }),
  ],
});

カスタムサーバー

NOTE

このカスタムサーバーの実装は、[TanStack の Bun テンプレート](https://github.com/TanStack/router/blob/main/examples/react/start-bun/server.ts) に基づいています。静的アセットの提供を細かく制御でき、小さなファイルをメモリにプリロードして高速に提供し、大きなファイルはオンデマンドで提供する設定可能なメモリ管理を実装しています。このアプローチは、本番デプロイでリソース使用量とアセットの読み込み動作を正確に制御する必要がある場合に役立ちます。

本番サーバーを作成

プロジェクトルートに server.ts ファイルを作成し、次のカスタムサーバー実装を追加します。

ts
/**
* Bun 向け TanStack Start 本番サーバー
*
* 設定可能なメモリ管理を備えたインテリジェントな静的アセット読み込みを実装する
* 高性能な TanStack Start アプリケーション向けの本番サーバー。
*
* 機能:
* - ハイブリッド読み込み戦略(小さなファイルをプリロード、大きなファイルをオンデマンドで提供)
* - 含める/除外するパターンの設定可能なファイルフィルタリング
* - メモリ効率的なレスポンス生成
* - 本番対応のキャッシュヘッダー
*
* 環境変数:
*
* PORT (number)
*   - サーバーポート番号
*   - デフォルト:3000
*
* ASSET_PRELOAD_MAX_SIZE (number)
*   - メモリにプリロードする最大ファイルサイズ(バイト)
*   - これより大きいファイルはディスクからオンデマンドで提供されます
*   - デフォルト:5242880 (5MB)
*   - 例:ASSET_PRELOAD_MAX_SIZE=5242880 (5MB)
*
* ASSET_PRELOAD_INCLUDE_PATTERNS (string)
*   - 含めるファイルのグロブパターンのカンマ区切りリスト
*   - 指定された場合、一致するファイルのみがプリロードの対象になります
*   - パターンはファイル名(フルパスではない)と照合されます
*   - 例:ASSET_PRELOAD_INCLUDE_PATTERNS="*.js,*.css,*.woff2"
*
* ASSET_PRELOAD_EXCLUDE_PATTERNS (string)
*   - 除外するファイルのグロブパターンのカンマ区切りリスト
*   - 含めるパターンの後に適用されます
*   - パターンはファイル名(フルパスではない)と照合されます
*   - 例:ASSET_PRELOAD_EXCLUDE_PATTERNS="*.map,*.txt"
*
* ASSET_PRELOAD_VERBOSE_LOGGING (boolean)
*   - 読み込まれたファイルとスキップされたファイルの詳細なログを有効化
*   - デフォルト:false
*   - 詳細な出力を有効にするには "true" に設定
*
* ASSET_PRELOAD_ENABLE_ETAG (boolean)
*   - プリロードされたアセットの ETag 生成を有効化
*   - デフォルト:true
*   - ETag サポートを無効にするには "false" に設定
*
* ASSET_PRELOAD_ENABLE_GZIP (boolean)
*   - 対象アセットの Gzip 圧縮を有効化
*   - デフォルト:true
*   - Gzip 圧縮を無効にするには "false" に設定
*
* ASSET_PRELOAD_GZIP_MIN_SIZE (number)
*   - Gzip 圧縮に必要な最小ファイルサイズ(バイト)
*   - これより小さいファイルは圧縮されません
*   - デフォルト:1024 (1KB)
*
* ASSET_PRELOAD_GZIP_MIME_TYPES (string)
*   - Gzip 圧縮の対象となる MIME タイプのカンマ区切りリスト
*   - "/" で終わるタイプの部分一致をサポート
*   - デフォルト:text/,application/javascript,application/json,application/xml,image/svg+xml
*
* 使用法:
*   bun run server.ts
*/

import path from 'node:path'

// 設定
const SERVER_PORT = Number(process.env.PORT ?? 3000)
const CLIENT_DIRECTORY = './dist/client'
const SERVER_ENTRY_POINT = './dist/server/server.js'

// プロフェッショナルな出力のためのログユーティリティ
const log = {
  info: (message: string) => {
    console.log(`[INFO] ${message}`)
  },
  success: (message: string) => {
    console.log(`[SUCCESS] ${message}`)
  },
  warning: (message: string) => {
    console.log(`[WARNING] ${message}`)
  },
  error: (message: string) => {
    console.log(`[ERROR] ${message}`)
  },
  header: (message: string) => {
    console.log(`\n${message}\n`)
  },
}

// 環境変数からのプリロード設定
const MAX_PRELOAD_BYTES = Number(
  process.env.ASSET_PRELOAD_MAX_SIZE ?? 5 * 1024 * 1024, // デフォルト 5MB
)

// カンマ区切りの含めるパターンを解析(デフォルトなし)
const INCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
  .split(',')
  .map((s) => s.trim())
  .filter(Boolean)
  .map((pattern: string) => convertGlobToRegExp(pattern))

// カンマ区切りの除外パターンを解析(デフォルトなし)
const EXCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? '')
  .split(',')
  .map((s) => s.trim())
  .filter(Boolean)
  .map((pattern: string) => convertGlobToRegExp(pattern))

// 詳細ログフラグ
const VERBOSE = process.env.ASSET_PRELOAD_VERBOSE_LOGGING === 'true'

// オプションの ETag 機能
const ENABLE_ETAG = (process.env.ASSET_PRELOAD_ENABLE_ETAG ?? 'true') === 'true'

// オプションの Gzip 機能
const ENABLE_GZIP = (process.env.ASSET_PRELOAD_ENABLE_GZIP ?? 'true') === 'true'
const GZIP_MIN_BYTES = Number(process.env.ASSET_PRELOAD_GZIP_MIN_SIZE ?? 1024) // デフォルト 1KB
const GZIP_TYPES = (
  process.env.ASSET_PRELOAD_GZIP_MIME_TYPES ??
  'text/,application/javascript,application/json,application/xml,image/svg+xml'
)
  .split(',')
  .map((v) => v.trim())
  .filter(Boolean)

/**
* 単純なグロブパターンを正規表現に変換
* * ワイルドカードをサポートして任意の文字列と照合
*/
function convertGlobToRegExp(globPattern: string): RegExp {
  // * を除く正規表現の特殊文字をエスケープし、* を .* に置換
  const escapedPattern = globPattern
    .replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&')
    .replace(/\*/g, '.*')
  return new RegExp(`^${escapedPattern}$`, 'i')
}

/**
* 指定されたデータバッファの ETag を計算
*/
function computeEtag(data: Uint8Array): string {
  const hash = Bun.hash(data)
  return `W/"${hash.toString(16)}-${data.byteLength.toString()}"`
}

/**
* プリロードされた静的アセットのメタデータ
*/
interface AssetMetadata {
  route: string
  size: number
  type: string
}

/**
* ETag と Gzip サポートを備えたインメモリのアセット
*/
interface InMemoryAsset {
  raw: Uint8Array
  gz?: Uint8Array
  etag?: string
  type: string
  immutable: boolean
  size: number
}

/**
* 静的アセットのプリロードプロセスの結果
*/
interface PreloadResult {
  routes: Record<string, (req: Request) => Response | Promise<Response>>
  loaded: AssetMetadata[]
  skipped: AssetMetadata[]
}

/**
* 設定されたパターンに基づいて、ファイルがプリロードの対象かどうかを確認
*/
function isFileEligibleForPreloading(relativePath: string): boolean {
  const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath

  // 含めるパターンが指定されている場合、ファイルは少なくとも 1 つと一致する必要があります
  if (INCLUDE_PATTERNS.length > 0) {
    if (!INCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
      return false
    }
  }

  // 除外パターンが指定されている場合、ファイルはどのパターンとも一致してはいけません
  if (EXCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
    return false
  }

  return true
}

/**
* MIME 型が圧縮可能かどうかを確認
*/
function isMimeTypeCompressible(mimeType: string): boolean {
  return GZIP_TYPES.some((type) =>
    type.endsWith('/') ? mimeType.startsWith(type) : mimeType === type,
  )
}

/**
* サイズと MIME 型に基づいてデータを条件付きで圧縮
*/
function compressDataIfAppropriate(
  data: Uint8Array,
  mimeType: string,
): Uint8Array | undefined {
  if (!ENABLE_GZIP) return undefined
  if (data.byteLength < GZIP_MIN_BYTES) return undefined
  if (!isMimeTypeCompressible(mimeType)) return undefined
  try {
    return Bun.gzipSync(data.buffer as ArrayBuffer)
  } catch {
    return undefined
  }
}

/**
* ETag と Gzip サポートを備えたレスポンスハンドラー関数を作成
*/
function createResponseHandler(
  asset: InMemoryAsset,
): (req: Request) => Response {
  return (req: Request) => {
    const headers: Record<string, string> = {
      'Content-Type': asset.type,
      'Cache-Control': asset.immutable
        ? 'public, max-age=31536000, immutable'
        : 'public, max-age=3600',
    }

    if (ENABLE_ETAG && asset.etag) {
      const ifNone = req.headers.get('if-none-match')
      if (ifNone && ifNone === asset.etag) {
        return new Response(null, {
          status: 304,
          headers: { ETag: asset.etag },
        })
      }
      headers.ETag = asset.etag
    }

    if (
      ENABLE_GZIP &&
      asset.gz &&
      req.headers.get('accept-encoding')?.includes('gzip')
    ) {
      headers['Content-Encoding'] = 'gzip'
      headers['Content-Length'] = String(asset.gz.byteLength)
      const gzCopy = new Uint8Array(asset.gz)
      return new Response(gzCopy, { status: 200, headers })
    }

    headers['Content-Length'] = String(asset.raw.byteLength)
    const rawCopy = new Uint8Array(asset.raw)
    return new Response(rawCopy, { status: 200, headers })
  }
}

/**
* 含めるパターンから複合グロブパターンを作成
*/
function createCompositeGlobPattern(): Bun.Glob {
  const raw = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
    .split(',')
    .map((s) => s.trim())
    .filter(Boolean)
  if (raw.length === 0) return new Bun.Glob('**/*')
  if (raw.length === 1) return new Bun.Glob(raw[0])
  return new Bun.Glob(`{${raw.join(',')}}`)
}

/**
* インテリジェントなプリロード戦略で静的ルートを初期化
* 小さなファイルはメモリに読み込まれ、大きなファイルはオンデマンドで提供されます
*/
async function initializeStaticRoutes(
  clientDirectory: string,
): Promise<PreloadResult> {
  const routes: Record<string, (req: Request) => Response | Promise<Response>> =
    {}
  const loaded: AssetMetadata[] = []
  const skipped: AssetMetadata[] = []

  log.info(`${clientDirectory} から静的アセットを読み込んでいます...`)
  if (VERBOSE) {
    console.log(
      `最大プリロードサイズ:${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`,
    )
    if (INCLUDE_PATTERNS.length > 0) {
      console.log(
        `含めるパターン:${process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? ''}`,
      )
    }
    if (EXCLUDE_PATTERNS.length > 0) {
      console.log(
        `除外パターン:${process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? ''}`,
      )
    }
  }

  let totalPreloadedBytes = 0

  try {
    const glob = createCompositeGlobPattern()
    for await (const relativePath of glob.scan({ cwd: clientDirectory })) {
      const filepath = path.join(clientDirectory, relativePath)
      const route = `/${relativePath.split(path.sep).join(path.posix.sep)}`

      try {
        // ファイルのメタデータを取得
        const file = Bun.file(filepath)

        // ファイルが存在しないか空の場合はスキップ
        if (!(await file.exists()) || file.size === 0) {
          continue
        }

        const metadata: AssetMetadata = {
          route,
          size: file.size,
          type: file.type || 'application/octet-stream',
        }

        // ファイルがプリロードの対象かどうかを判断
        const matchesPattern = isFileEligibleForPreloading(relativePath)
        const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES

        if (matchesPattern && withinSizeLimit) {
          // 小さなファイルをメモリにプリロード(ETag と Gzip サポート付き)
          const bytes = new Uint8Array(await file.arrayBuffer())
          const gz = compressDataIfAppropriate(bytes, metadata.type)
          const etag = ENABLE_ETAG ? computeEtag(bytes) : undefined
          const asset: InMemoryAsset = {
            raw: bytes,
            gz,
            etag,
            type: metadata.type,
            immutable: true,
            size: bytes.byteLength,
          }
          routes[route] = createResponseHandler(asset)

          loaded.push({ ...metadata, size: bytes.byteLength })
          totalPreloadedBytes += bytes.byteLength
        } else {
          // 大きいまたはフィルタリングされたファイルはオンデマンドで提供
          routes[route] = () => {
            const fileOnDemand = Bun.file(filepath)
            return new Response(fileOnDemand, {
              headers: {
                'Content-Type': metadata.type,
                'Cache-Control': 'public, max-age=3600',
              },
            })
          }

          skipped.push(metadata)
        }
      } catch (error: unknown) {
        if (error instanceof Error && error.name !== 'EISDIR') {
          log.error(`${filepath} の読み込みに失敗しました:${error.message}`)
        }
      }
    }

    // 詳細なファイル概要は verbose モードが有効な場合のみ表示
    if (VERBOSE && (loaded.length > 0 || skipped.length > 0)) {
      const allFiles = [...loaded, ...skipped].sort((a, b) =>
        a.route.localeCompare(b.route),
      )

      // 揃えのために最大パス長を計算
      const maxPathLength = Math.min(
        Math.max(...allFiles.map((f) => f.route.length)),
        60,
      )

      // ファイルサイズを KB と実際の gzip サイズでフォーマット
      const formatFileSize = (bytes: number, gzBytes?: number) => {
        const kb = bytes / 1024
        const sizeStr = kb < 100 ? kb.toFixed(2) : kb.toFixed(1)

        if (gzBytes !== undefined) {
          const gzKb = gzBytes / 1024
          const gzStr = gzKb < 100 ? gzKb.toFixed(2) : gzKb.toFixed(1)
          return {
            size: sizeStr,
            gzip: gzStr,
          }
        }

        // 実際の gzip データがない場合の概算(通常 30-70% 圧縮)
        const gzipKb = kb * 0.35
        return {
          size: sizeStr,
          gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1),
        }
      }

      if (loaded.length > 0) {
        console.log('\n📁 メモリにプリロードされました:')
        console.log(
          'パス                                          │    サイズ │ Gzip サイズ',
        )
        loaded
          .sort((a, b) => a.route.localeCompare(b.route))
          .forEach((file) => {
            const { size, gzip } = formatFileSize(file.size)
            const paddedPath = file.route.padEnd(maxPathLength)
            const sizeStr = `${size.padStart(7)} kB`
            const gzipStr = `${gzip.padStart(7)} kB`
            console.log(`${paddedPath} │ ${sizeStr} │  ${gzipStr}`)
          })
      }

      if (skipped.length > 0) {
        console.log('\n💾 オンデマンドで提供:')
        console.log(
          'パス                                          │    サイズ │ Gzip サイズ',
        )
        skipped
          .sort((a, b) => a.route.localeCompare(b.route))
          .forEach((file) => {
            const { size, gzip } = formatFileSize(file.size)
            const paddedPath = file.route.padEnd(maxPathLength)
            const sizeStr = `${size.padStart(7)} kB`
            const gzipStr = `${gzip.padStart(7)} kB`
            console.log(`${paddedPath} │ ${sizeStr} │  ${gzipStr}`)
          })
      }
    }

    // 詳細な verbose 情報を有効な場合に表示
    if (VERBOSE) {
      if (loaded.length > 0 || skipped.length > 0) {
        const allFiles = [...loaded, ...skipped].sort((a, b) =>
          a.route.localeCompare(b.route),
        )
        console.log('\n📊 詳細なファイル情報:')
        console.log(
          'ステータス     │ パス                            │ MIME 型                       │ 理由',
        )
        allFiles.forEach((file) => {
          const isPreloaded = loaded.includes(file)
          const status = isPreloaded ? 'MEMORY' : 'ON-DEMAND'
          const reason =
            !isPreloaded && file.size > MAX_PRELOAD_BYTES
              ? '大きすぎる'
              : !isPreloaded
                ? 'フィルタリング'
                : 'プリロード済み'
          const route =
            file.route.length > 30
              ? file.route.substring(0, 27) + '...'
              : file.route
          console.log(
            `${status.padEnd(12)} │ ${route.padEnd(30)} │ ${file.type.padEnd(28)} │ ${reason.padEnd(10)}`,
          )
        })
      } else {
        console.log('\n📊 表示するファイルが見つかりませんでした')
      }
    }

    // ファイルリストの後にサマリーをログ出力
    console.log() // 区切りの空行
    if (loaded.length > 0) {
      log.success(
        `${String(loaded.length)} 個のファイルをメモリにプリロードしました (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB)`,
      )
    } else {
      log.info('メモリにプリロードされたファイルはありません')
    }

    if (skipped.length > 0) {
      const tooLarge = skipped.filter((f) => f.size > MAX_PRELOAD_BYTES).length
      const filtered = skipped.length - tooLarge
      log.info(
        `${String(skipped.length)} 個のファイルがオンデマンドで提供されます (${String(tooLarge)} 個が大きすぎる、${String(filtered)} 個がフィルタリング)`,
      )
    }
  } catch (error) {
    log.error(
      `${clientDirectory} から静的ファイルの読み込みに失敗しました:${String(error)}`,
    )
  }

  return { routes, loaded, skipped }
}

/**
* サーバーを初期化
*/
async function initializeServer() {
  log.header('本番サーバーを起動中')

  // TanStack Start サーバーハンドラーを読み込む
  let handler: { fetch: (request: Request) => Response | Promise<Response> }
  try {
    const serverModule = (await import(SERVER_ENTRY_POINT)) as {
      default: { fetch: (request: Request) => Response | Promise<Response> }
    }
    handler = serverModule.default
    log.success('TanStack Start アプリケーションハンドラーが初期化されました')
  } catch (error) {
    log.error(`サーバーハンドラーの読み込みに失敗しました:${String(error)}`)
    process.exit(1)
  }

  // インテリジェントなプリロードで静的ルートを構築
  const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY)

  // Bun サーバーを作成
  const server = Bun.serve({
    port: SERVER_PORT,

    routes: {
      // 静的アセットを提供(プリロード済みまたはオンデマンド)
      ...routes,

      // その他のすべてのルートは TanStack Start ハンドラーにフォールバック
      '/*': (req: Request) => {
        try {
          return handler.fetch(req)
        } catch (error) {
          log.error(`サーバーハンドラーエラー:${String(error)}`)
          return new Response('Internal Server Error', { status: 500 })
        }
      },
    },

    // グローバルエラーハンドラー
    error(error) {
      log.error(
        `キャッチされないサーバーエラー:${error instanceof Error ? error.message : String(error)}`,
      )
      return new Response('Internal Server Error', { status: 500 })
    },
  })

  log.success(`サーバーが http://localhost:${String(server.port)} でリッスンしています`)
}

// サーバーを初期化
initializeServer().catch((error: unknown) => {
  log.error(`サーバーの起動に失敗しました:${String(error)}`)
  process.exit(1)
})

package.json のスクリプトを更新

カスタムサーバーを実行する start スクリプトを追加します。

json
{
  "scripts": {
    "build": "bun --bun vite build",
    "start": "bun run server.ts"
  }
}

ビルドして実行

アプリケーションをビルドしてサーバーを起動します。

sh
bun run build
bun run start

サーバーはデフォルトでポート 3000 で起動します(PORT 環境変数で設定可能)。



テンプレート


ホスティングの詳細については、TanStack Start 公式ドキュメント を参照してください。

Bun by www.bunjs.com.cn 編集