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