Skip to content

TanStack Start è un framework full-stack basato su TanStack Router. Supporta SSR completo dei documenti, streaming, funzioni server, bundling e altro, grazie a TanStack Router e Vite.


Creare una nuova app TanStack Start ​

Usa la CLI interattiva per creare una nuova app TanStack Start.

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

Avviare il server di sviluppo ​

Cambia la directory del progetto e avvia il server di sviluppo con Bun.

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

Questo avvia il server di sviluppo Vite con Bun.

Aggiornare gli script in package.json ​

Modifica il campo scripts nel tuo package.json aggiungendo il prefisso bun --bun ai comandi Vite CLI. Questo assicura che Bun esegua Vite CLI per le attività comuni come dev, build e preview.

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

Hosting ​

Per ospitare la tua app TanStack Start, puoi usare Nitro o un server Bun personalizzato per le distribuzioni di produzione.

Nitro ​

Aggiungere Nitro al tuo progetto ​

Aggiungi Nitro al tuo progetto. Questo strumento ti consente di distribuire la tua app TanStack Start su diverse piattaforme.

sh
bun add nitro

Aggiornare il file vite.config.ts ​

Aggiorna il tuo file vite.config.ts per includere i plugin necessari per TanStack Start con Bun.

ts
// altri import...
import { nitro } from "nitro/vite"; 

const config = defineConfig({
  plugins: [
    tanstackStart(),
    nitro({ preset: "bun" }), 
    // altri plugin...
  ],
});

export default config;

NOTE

Il preset `bun` è opzionale, ma configura l'output di build specificamente per il runtime Bun.

Aggiornare il comando di avvio ​

Assicurati che gli script build e start siano presenti nel tuo file package.json:

json
  {
    "scripts": {
      "build": "bun --bun vite build", 
      // I file .output sono creati da Nitro quando esegui `bun run build`.
      // Non necessario quando si distribuisce su Vercel.
      "start": "bun run .output/server/index.mjs"
    }
  }

NOTE

**Non** hai bisogno dello script `start` personalizzato quando distribuisci su Vercel.

Distribuisci la tua app ​

Consulta una delle nostre guide per distribuire la tua app su un provider di hosting.

NOTE

Quando distribuisci su Vercel, puoi aggiungere `"bunVersion": "1.x"` al tuo file `vercel.json`, o aggiungerlo alla configurazione `nitro` nel tuo file `vite.config.ts`:

Attenzione

Non usare il preset Nitro bun quando distribuisci su Vercel.

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

Server Personalizzato ​

NOTE

Questa implementazione del server personalizzato è basata sul [template Bun di TanStack](https://github.com/TanStack/router/blob/main/examples/react/start-bun/server.ts). Fornisce un controllo granulare sul serving di asset statici, inclusa una gestione della memoria configurabile che precarica i file piccoli in memoria per un serving veloce mentre serve i file più grandi on-demand. Questo approccio è utile quando hai bisogno di un controllo preciso sull'uso delle risorse e sul comportamento di caricamento degli asset nelle distribuzioni di produzione.

Creare il server di produzione ​

Crea un file server.ts nella root del tuo progetto con la seguente implementazione del server personalizzato:

ts
/**
* Server di Produzione TanStack Start con Bun
*
* Un server di produzione ad alte prestazioni per applicazioni TanStack Start che
* implementa un caricamento intelligente di asset statici con gestione della memoria configurabile.
*
* Caratteristiche:
* - Strategia di caricamento ibrida (precarica file piccoli, servi file grandi on-demand)
* - Filtraggio file configurabile con pattern include/exclude
* - Generazione di risposte efficiente in termini di memoria
* - Header di caching pronti per la produzione
*
* Variabili d'Ambiente:
*
* PORT (number)
*   - Numero di porta del server
*   - Default: 3000
*
* ASSET_PRELOAD_MAX_SIZE (number)
*   - Dimensione massima del file in byte da precaricare in memoria
*   - I file più grandi di questo verranno serviti on-demand dal disco
*   - Default: 5242880 (5MB)
*   - Esempio: ASSET_PRELOAD_MAX_SIZE=5242880 (5MB)
*
* ASSET_PRELOAD_INCLUDE_PATTERNS (string)
*   - Elenco separato da virgole di pattern glob per i file da includere
*   - Se specificato, solo i file corrispondenti sono idonei per il precaricamento
*   - I pattern sono confrontati con i nomi dei file, non con i percorsi completi
*   - Esempio: ASSET_PRELOAD_INCLUDE_PATTERNS="*.js,*.css,*.woff2"
*
* ASSET_PRELOAD_EXCLUDE_PATTERNS (string)
*   - Elenco separato da virgole di pattern glob per i file da escludere
*   - Applicato dopo i pattern include
*   - I pattern sono confrontati con i nomi dei file, non con i percorsi completi
*   - Esempio: ASSET_PRELOAD_EXCLUDE_PATTERNS="*.map,*.txt"
*
* ASSET_PRELOAD_VERBOSE_LOGGING (boolean)
*   - Abilita logging dettagliato dei file caricati e saltati
*   - Default: false
*   - Imposta su "true" per abilitare l'output verbose
*
* ASSET_PRELOAD_ENABLE_ETAG (boolean)
*   - Abilita la generazione di ETag per asset precaricati
*   - Default: true
*   - Imposta su "false" per disabilitare il supporto ETag
*
* ASSET_PRELOAD_ENABLE_GZIP (boolean)
*   - Abilita la compressione Gzip per asset idonei
*   - Default: true
*   - Imposta su "false" per disabilitare la compressione Gzip
*
* ASSET_PRELOAD_GZIP_MIN_SIZE (number)
*   - Dimensione minima del file in byte richiesta per la compressione Gzip
*   - I file più piccoli di questo non verranno compressi
*   - Default: 1024 (1KB)
*
* ASSET_PRELOAD_GZIP_MIME_TYPES (string)
*   - Elenco separato da virgole di tipi MIME idonei per la compressione Gzip
*   - Supporta corrispondenza parziale per tipi che terminano con "/"
*   - Default: text/,application/javascript,application/json,application/xml,image/svg+xml
*
* Utilizzo:
*   bun run server.ts
*/

import path from 'node:path'

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

// Utility di logging per output professionale
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`)
  },
}

// Configurazione precaricamento da variabili d'ambiente
const MAX_PRELOAD_BYTES = Number(
  process.env.ASSET_PRELOAD_MAX_SIZE ?? 5 * 1024 * 1024, // 5MB default
)

// Analizza pattern include separati da virgole (nessun default)
const INCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
  .split(',')
  .map((s) => s.trim())
  .filter(Boolean)
  .map((pattern: string) => convertGlobToRegExp(pattern))

// Analizza pattern exclude separati da virgole (nessun default)
const EXCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? '')
  .split(',')
  .map((s) => s.trim())
  .filter(Boolean)
  .map((pattern: string) => convertGlobToRegExp(pattern))

// Flag logging verbose
const VERBOSE = process.env.ASSET_PRELOAD_VERBOSE_LOGGING === 'true'

// Funzionalità ETag opzionale
const ENABLE_ETAG = (process.env.ASSET_PRELOAD_ENABLE_ETAG ?? 'true') === 'true'

// Funzionalità Gzip opzionale
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)

/**
* Converte un semplice pattern glob in un'espressione regolare
* Supporta * wildcard per corrispondere a qualsiasi carattere
*/
function convertGlobToRegExp(globPattern: string): RegExp {
  // Escape caratteri speciali regex tranne *, poi sostituisci * con .*
  const escapedPattern = globPattern
    .replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&')
    .replace(/\*/g, '.*')
  return new RegExp(`^${escapedPattern}$`, 'i')
}

/**
* Calcola ETag per un dato buffer di dati
*/
function computeEtag(data: Uint8Array): string {
  const hash = Bun.hash(data)
  return `W/"${hash.toString(16)}-${data.byteLength.toString()}"`
}

/**
* Metadata per asset statici precaricati
*/
interface AssetMetadata {
  route: string
  size: number
  type: string
}

/**
* Asset in memoria con supporto ETag e Gzip
*/
interface InMemoryAsset {
  raw: Uint8Array
  gz?: Uint8Array
  etag?: string
  type: string
  immutable: boolean
  size: number
}

/**
* Risultato del processo di precaricamento asset statici
*/
interface PreloadResult {
  routes: Record<string, (req: Request) => Response | Promise<Response>>
  loaded: AssetMetadata[]
  skipped: AssetMetadata[]
}

/**
* Controlla se un file è idoneo per il precaricamento in base ai pattern configurati
*/
function isFileEligibleForPreloading(relativePath: string): boolean {
  const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath

  // Se sono specificati pattern include, il file deve corrispondere ad almeno uno
  if (INCLUDE_PATTERNS.length > 0) {
    if (!INCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
      return false
    }
  }

  // Se sono specificati pattern exclude, il file non deve corrispondere a nessuno
  if (EXCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
    return false
  }

  return true
}

/**
* Controlla se un tipo MIME è comprimibile
*/
function isMimeTypeCompressible(mimeType: string): boolean {
  return GZIP_TYPES.some((type) =>
    type.endsWith('/') ? mimeType.startsWith(type) : mimeType === type,
  )
}

/**
* Comprime dati in modo appropriato in base a dimensione e tipo 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
  }
}

/**
* Crea funzione handler di risposta con supporto ETag e 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 })
  }
}

/**
* Crea pattern glob composito dai pattern include
*/
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(',')}}`)
}

/**
* Inizializza rotte statiche con strategia intelligente di precaricamento
* I file piccoli vengono caricati in memoria, i file grandi vengono serviti on-demand
*/
async function initializeStaticRoutes(
  clientDirectory: string,
): Promise<PreloadResult> {
  const routes: Record<string, (req: Request) => Response | Promise<Response>> =
    {}
  const loaded: AssetMetadata[] = []
  const skipped: AssetMetadata[] = []

  log.info(`Caricamento asset statici da ${clientDirectory}...`)
  if (VERBOSE) {
    console.log(
      `Dimensione massima precaricamento: ${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`,
    )
    if (INCLUDE_PATTERNS.length > 0) {
      console.log(
        `Pattern include: ${process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? ''}`,
      )
    }
    if (EXCLUDE_PATTERNS.length > 0) {
      console.log(
        `Pattern exclude: ${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 {
        // Ottieni metadati file
        const file = Bun.file(filepath)

        // Salta se il file non esiste o è vuoto
        if (!(await file.exists()) || file.size === 0) {
          continue
        }

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

        // Determina se il file deve essere precaricato
        const matchesPattern = isFileEligibleForPreloading(relativePath)
        const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES

        if (matchesPattern && withinSizeLimit) {
          // Precarica file piccoli in memoria con supporto ETag e 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 {
          // Servi file grandi o filtrati on-demand
          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(`Caricamento fallito di ${filepath}: ${error.message}`)
        }
      }
    }

    // Mostra panoramica dettagliata dei file solo quando la modalità verbose è abilitata
    if (VERBOSE && (loaded.length > 0 || skipped.length > 0)) {
      const allFiles = [...loaded, ...skipped].sort((a, b) =>
        a.route.localeCompare(b.route),
      )

      // Calcola lunghezza massima percorso per allineamento
      const maxPathLength = Math.min(
        Math.max(...allFiles.map((f) => f.route.length)),
        60,
      )

      // Formatta dimensione file con KB e dimensione gzip effettiva
      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,
          }
        }

        // Stima gzip approssimativa (tipicamente compressione 30-70%) se nessun dato gzip effettivo
        const gzipKb = kb * 0.35
        return {
          size: sizeStr,
          gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1),
        }
      }

      if (loaded.length > 0) {
        console.log('\n📁 Precaricati in memoria:')
        console.log(
          'Percorso                                        │ Dimensione │ Dim. 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💾 Serviti on-demand:')
        console.log(
          'Percorso                                        │ Dimensione │ Dim. 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}`)
          })
      }
    }

    // Mostra info verbose dettagliate se abilitato
    if (VERBOSE) {
      if (loaded.length > 0 || skipped.length > 0) {
        const allFiles = [...loaded, ...skipped].sort((a, b) =>
          a.route.localeCompare(b.route),
        )
        console.log('\n📊 Informazioni dettagliate sui file:')
        console.log(
          'Stato        │ Percorso                        │ Tipo MIME                    │ Motivo',
        )
        allFiles.forEach((file) => {
          const isPreloaded = loaded.includes(file)
          const status = isPreloaded ? 'MEMORY' : 'ON-DEMAND'
          const reason =
            !isPreloaded && file.size > MAX_PRELOAD_BYTES
              ? 'troppo grande'
              : !isPreloaded
                ? 'filtrato'
                : 'precaricato'
          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📊 Nessun file trovato da visualizzare')
      }
    }

    // Log riepilogo dopo l'elenco dei file
    console.log() // Riga vuota per separazione
    if (loaded.length > 0) {
      log.success(
        `Precaricati ${String(loaded.length)} file (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) in memoria`,
      )
    } else {
      log.info('Nessun file precaricato in memoria')
    }

    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)} file verranno serviti on-demand (${String(tooLarge)} troppo grandi, ${String(filtered)} filtrati)`,
      )
    }
  } catch (error) {
    log.error(
      `Caricamento asset statici fallito da ${clientDirectory}: ${String(error)}`,
    )
  }

  return { routes, loaded, skipped }
}

/**
* Inizializza il server
*/
async function initializeServer() {
  log.header('Avvio Server di Produzione')

  // Carica handler server 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('Handler applicazione TanStack Start inizializzato')
  } catch (error) {
    log.error(`Caricamento handler server fallito: ${String(error)}`)
    process.exit(1)
  }

  // Costruisci rotte statiche con precaricamento intelligente
  const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY)

  // Crea server Bun
  const server = Bun.serve({
    port: SERVER_PORT,

    routes: {
      // Servi asset statici (precaricati o on-demand)
      ...routes,

      // Fallback all'handler TanStack Start per tutte le altre rotte
      '/*': (req: Request) => {
        try {
          return handler.fetch(req)
        } catch (error) {
          log.error(`Errore handler server: ${String(error)}`)
          return new Response('Internal Server Error', { status: 500 })
        }
      },
    },

    // Handler errori globale
    error(error) {
      log.error(
        `Errore server non gestito: ${error instanceof Error ? error.message : String(error)}`,
      )
      return new Response('Internal Server Error', { status: 500 })
    },
  })

  log.success(`Server in ascolto su http://localhost:${String(server.port)}`)
}

// Inizializza il server
initializeServer().catch((error: unknown) => {
  log.error(`Avvio server fallito: ${String(error)}`)
  process.exit(1)
})

Aggiornare gli script package.json ​

Aggiungi uno script start per eseguire il server personalizzato:

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

Costruire ed eseguire ​

Costruisci la tua applicazione e avvia il server:

sh
bun run build
bun run start

Il server si avvierà sulla porta 3000 per impostazione predefinita (configurabile tramite la variabile d'ambiente PORT).


Template ​


→ Consulta la documentazione ufficiale di TanStack Start per ulteriori informazioni sull'hosting.

Bun a cura di www.bunjs.com.cn