Skip to content

TanStack Start es un framework full-stack impulsado por TanStack Router. Soporta SSR de documento completo, streaming, funciones del servidor, empaquetado y más, impulsado por TanStack Router y Vite.


Crear una nueva aplicación TanStack Start

Usa el CLI interactivo para crear una nueva aplicación TanStack Start.

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

Iniciar el servidor de desarrollo

Cambia al directorio del proyecto y ejecuta el servidor de desarrollo con Bun.

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

Esto inicia el servidor de desarrollo de Vite con Bun.

Actualizar scripts en package.json

Modifica el campo scripts en tu package.json prefijando los comandos CLI de Vite con bun --bun. Esto asegura que Bun ejecute el CLI de Vite para tareas comunes como dev, build y preview.

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

Hosting

Para alojar tu aplicación TanStack Start, puedes usar Nitro o un servidor personalizado de Bun para despliegues de producción.

Nitro

Agregar Nitro a tu proyecto

Agrega Nitro a tu proyecto. Esta herramienta te permite desplegar tu aplicación TanStack Start a diferentes plataformas.

sh
bun add nitro

Actualizar tu archivo vite.config.ts

Actualiza tu archivo vite.config.ts para incluir los complementos necesarios para TanStack Start con Bun.

ts
// otras importaciones...
import { nitro } from "nitro/vite";

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

export default config;

NOTE

El preset `bun` es opcional, pero configura la salida de compilación específicamente para el runtime de Bun.

Actualizar el comando de inicio

Asegúrate de que los scripts build y start estén presentes en tu archivo package.json:

json
  {
    "scripts": {
      "build": "bun --bun vite build",
      // Los archivos .output son creados por Nitro cuando ejecutas `bun run build`.
      // No es necesario al desplegar en Vercel.
      "start": "bun run .output/server/index.mjs"
    }
  }

NOTE

**No** necesitas el script `start` personalizado al desplegar en Vercel.

Desplegar tu aplicación

Consulta una de nuestras guías para desplegar tu aplicación en un proveedor de hosting.

NOTE

Al desplegar en Vercel, puedes agregar `"bunVersion": "1.x"` a tu archivo `vercel.json`, o agregarlo a la configuración `nitro` en tu archivo `vite.config.ts`:

Advertencia

No uses el preset bun de Nitro al desplegar en Vercel.

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

Servidor Personalizado

NOTE

Esta implementación de servidor personalizado está basada en la [plantilla de Bun de TanStack](https://github.com/TanStack/router/blob/main/examples/react/start-bun/server.ts). Proporciona un control preciso sobre el servicio de activos estáticos, incluyendo gestión de memoria configurable que precarga archivos pequeños en memoria para un servicio rápido mientras sirve archivos más grandes bajo demanda. Este enfoque es útil cuando necesitas un control preciso sobre el uso de recursos y el comportamiento de carga de activos en despliegues de producción.

Crear el servidor de producción

Crea un archivo server.ts en la raíz de tu proyecto con la siguiente implementación de servidor personalizado:

ts
/**
* Servidor de Producción de TanStack Start con Bun
*
* Un servidor de producción de alto rendimiento para aplicaciones TanStack Start que
* implementa carga inteligente de activos estáticos con gestión de memoria configurable.
*
* Características:
* - Estrategia de carga híbrida (precargar archivos pequeños, servir archivos grandes bajo demanda)
* - Filtrado de archivos configurable con patrones de inclusión/exclusión
* - Generación de respuestas eficiente en memoria
* - Cabeceras de caché listas para producción
*
* Variables de Entorno:
*
* PORT (número)
*   - Número de puerto del servidor
*   - Predeterminado: 3000
*
* ASSET_PRELOAD_MAX_SIZE (número)
*   - Tamaño máximo de archivo en bytes para precargar en memoria
*   - Archivos más grandes que este se servirán bajo demanda desde el disco
*   - Predeterminado: 5242880 (5MB)
*   - Ejemplo: ASSET_PRELOAD_MAX_SIZE=5242880 (5MB)
*
* ASSET_PRELOAD_INCLUDE_PATTERNS (cadena)
*   - Lista separada por comas de patrones glob para archivos a incluir
*   - Si se especifica, solo los archivos coincidentes son elegibles para precarga
*   - Los patrones se comparan con nombres de archivo solamente, no rutas completas
*   - Ejemplo: ASSET_PRELOAD_INCLUDE_PATTERNS="*.js,*.css,*.woff2"
*
* ASSET_PRELOAD_EXCLUDE_PATTERNS (cadena)
*   - Lista separada por comas de patrones glob para archivos a excluir
*   - Se aplica después de los patrones de inclusión
*   - Los patrones se comparan con nombres de archivo solamente, no rutas completas
*   - Ejemplo: ASSET_PRELOAD_EXCLUDE_PATTERNS="*.map,*.txt"
*
* ASSET_PRELOAD_VERBOSE_LOGGING (booleano)
*   - Habilitar registro detallado de archivos cargados y omitidos
*   - Predeterminado: false
*   - Establecer a "true" para habilitar salida detallada
*
* ASSET_PRELOAD_ENABLE_ETAG (booleano)
*   - Habilitar generación de ETag para activos precargados
*   - Predeterminado: true
*   - Establecer a "false" para deshabilitar soporte de ETag
*
* ASSET_PRELOAD_ENABLE_GZIP (booleano)
*   - Habilitar compresión Gzip para activos elegibles
*   - Predeterminado: true
*   - Establecer a "false" para deshabilitar compresión Gzip
*
* ASSET_PRELOAD_GZIP_MIN_SIZE (número)
*   - Tamaño mínimo de archivo en bytes requerido para compresión Gzip
*   - Archivos más pequeños que este no se comprimirán
*   - Predeterminado: 1024 (1KB)
*
* ASSET_PRELOAD_GZIP_MIME_TYPES (cadena)
*   - Lista separada por comas de tipos MIME elegibles para compresión Gzip
*   - Soporta coincidencia parcial para tipos que terminan con "/"
*   - Predeterminado: text/,application/javascript,application/json,application/xml,image/svg+xml
*
* Uso:
*   bun run server.ts
*/

import path from 'node:path'

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

// Utilidades de registro para salida profesional
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`)
  },
}

// Configuración de precarga desde variables de entorno
const MAX_PRELOAD_BYTES = Number(
  process.env.ASSET_PRELOAD_MAX_SIZE ?? 5 * 1024 * 1024, // 5MB predeterminado
)

// Analizar patrones de inclusión separados por comas (sin predeterminados)
const INCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
  .split(',')
  .map((s) => s.trim())
  .filter(Boolean)
  .map((pattern: string) => convertGlobToRegExp(pattern))

// Analizar patrones de exclusión separados por comas (sin predeterminados)
const EXCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? '')
  .split(',')
  .map((s) => s.trim())
  .filter(Boolean)
  .map((pattern: string) => convertGlobToRegExp(pattern))

// Bandera de registro detallado
const VERBOSE = process.env.ASSET_PRELOAD_VERBOSE_LOGGING === 'true'

// Característica opcional de ETag
const ENABLE_ETAG = (process.env.ASSET_PRELOAD_ENABLE_ETAG ?? 'true') === 'true'

// Característica opcional de 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)

/**
* Convertir un patrón glob simple a una expresión regular
* Soporta * wildcard para coincidir con cualquier carácter
*/
function convertGlobToRegExp(globPattern: string): RegExp {
  // Escapar caracteres especiales de regex excepto *, luego reemplazar * con .*
  const escapedPattern = globPattern
    .replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&')
    .replace(/\*/g, '.*')
  return new RegExp(`^${escapedPattern}$`, 'i')
}

/**
* Calcular ETag para un buffer de datos dado
*/
function computeEtag(data: Uint8Array): string {
  const hash = Bun.hash(data)
  return `W/"${hash.toString(16)}-${data.byteLength.toString()}"`
}

/**
* Metadatos para activos estáticos precargados
*/
interface AssetMetadata {
  route: string
  size: number
  type: string
}

/**
* Activo en memoria con soporte de ETag y Gzip
*/
interface InMemoryAsset {
  raw: Uint8Array
  gz?: Uint8Array
  etag?: string
  type: string
  immutable: boolean
  size: number
}

/**
* Resultado del proceso de precarga de activos estáticos
*/
interface PreloadResult {
  routes: Record<string, (req: Request) => Response | Promise<Response>>
  loaded: AssetMetadata[]
  skipped: AssetMetadata[]
}

/**
* Verificar si un archivo es elegible para precarga basado en patrones configurados
*/
function isFileEligibleForPreloading(relativePath: string): boolean {
  const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath

  // Si se especifican patrones de inclusión, el archivo debe coincidir con al menos uno
  if (INCLUDE_PATTERNS.length > 0) {
    if (!INCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
      return false
    }
  }

  // Si se especifican patrones de exclusión, el archivo no debe coincidir con ninguno
  if (EXCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
    return false
  }

  return true
}

/**
* Verificar si un tipo MIME es compresible
*/
function isMimeTypeCompressible(mimeType: string): boolean {
  return GZIP_TYPES.some((type) =>
    type.endsWith('/') ? mimeType.startsWith(type) : mimeType === type,
  )
}

/**
* Comprimir datos condicionalmente basado en tamaño y 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
  }
}

/**
* Crear función manejadora de respuestas con soporte de ETag y 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 })
  }
}

/**
* Crear patrón glob compuesto a partir de patrones de inclusión
*/
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(',')}}`)
}

/**
* Inicializar rutas estáticas con estrategia inteligente de precarga
* Archivos pequeños se cargan en memoria, archivos grandes se sirven bajo demanda
*/
async function initializeStaticRoutes(
  clientDirectory: string,
): Promise<PreloadResult> {
  const routes: Record<string, (req: Request) => Response | Promise<Response>> =
    {}
  const loaded: AssetMetadata[] = []
  const skipped: AssetMetadata[] = []

  log.info(`Cargando activos estáticos desde ${clientDirectory}...`)
  if (VERBOSE) {
    console.log(
      `Tamaño máximo de precarga: ${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`,
    )
    if (INCLUDE_PATTERNS.length > 0) {
      console.log(
        `Patrones de inclusión: ${process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? ''}`,
      )
    }
    if (EXCLUDE_PATTERNS.length > 0) {
      console.log(
        `Patrones de exclusión: ${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 {
        // Obtener metadatos del archivo
        const file = Bun.file(filepath)

        // Omitir si el archivo no existe o está vacío
        if (!(await file.exists()) || file.size === 0) {
          continue
        }

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

        // Determinar si el archivo debe ser precargado
        const matchesPattern = isFileEligibleForPreloading(relativePath)
        const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES

        if (matchesPattern && withinSizeLimit) {
          // Precargar archivos pequeños en memoria con soporte de ETag y 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 {
          // Servir archivos grandes o filtrados bajo demanda
          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(`Falló al cargar ${filepath}: ${error.message}`)
        }
      }
    }

    // Mostrar descripción detallada de archivos solo cuando el modo detallado está habilitado
    if (VERBOSE && (loaded.length > 0 || skipped.length > 0)) {
      const allFiles = [...loaded, ...skipped].sort((a, b) =>
        a.route.localeCompare(b.route),
      )

      // Calcular longitud máxima de ruta para alineación
      const maxPathLength = Math.min(
        Math.max(...allFiles.map((f) => f.route.length)),
        60,
      )

      // Formatear tamaño de archivo con KB y tamaño gzip real
      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,
          }
        }

        // Estimación aproximada de gzip (típicamente 30-70% de compresión) si no hay datos gzip reales
        const gzipKb = kb * 0.35
        return {
          size: sizeStr,
          gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1),
        }
      }

      if (loaded.length > 0) {
        console.log('\n📁 Precargados en memoria:')
        console.log(
          'Ruta                                          │   Tamaño │ Tamaño 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💾 Servidos bajo demanda:')
        console.log(
          'Ruta                                          │   Tamaño │ Tamaño 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}`)
          })
      }
    }

    // Mostrar información detallada si está habilitado
    if (VERBOSE) {
      if (loaded.length > 0 || skipped.length > 0) {
        const allFiles = [...loaded, ...skipped].sort((a, b) =>
          a.route.localeCompare(b.route),
        )
        console.log('\n📊 Información detallada de archivos:')
        console.log(
          'Estado       │ Ruta                            │ Tipo MIME                    │ Razón',
        )
        allFiles.forEach((file) => {
          const isPreloaded = loaded.includes(file)
          const status = isPreloaded ? 'MEMORIA' : 'BAJO-DEMANDA'
          const reason =
            !isPreloaded && file.size > MAX_PRELOAD_BYTES
              ? 'muy grande'
              : !isPreloaded
                ? 'filtrado'
                : 'precargado'
          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📊 No se encontraron archivos para mostrar')
      }
    }

    // Mostrar resumen después de la lista de archivos
    console.log() // Línea vacía para separación
    if (loaded.length > 0) {
      log.success(
        `Precargados ${String(loaded.length)} archivos (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) en memoria`,
      )
    } else {
      log.info('No se precargaron archivos en 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)} archivos se servirán bajo demanda (${String(tooLarge)} muy grandes, ${String(filtered)} filtrados)`,
      )
    }
  } catch (error) {
    log.error(
      `Falló al cargar archivos estáticos desde ${clientDirectory}: ${String(error)}`,
    )
  }

  return { routes, loaded, skipped }
}

/**
* Inicializar el servidor
*/
async function initializeServer() {
  log.header('Iniciando Servidor de Producción')

  // Cargar manejador del servidor 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('Manejador de aplicación TanStack Start inicializado')
  } catch (error) {
    log.error(`Falló al cargar el manejador del servidor: ${String(error)}`)
    process.exit(1)
  }

  // Construir rutas estáticas con precarga inteligente
  const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY)

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

    routes: {
      // Servir activos estáticos (precargados o bajo demanda)
      ...routes,

      // Fallback al manejador de TanStack Start para todas las demás rutas
      '/*': (req: Request) => {
        try {
          return handler.fetch(req)
        } catch (error) {
          log.error(`Error del manejador del servidor: ${String(error)}`)
          return new Response('Internal Server Error', { status: 500 })
        }
      },
    },

    // Manejador de errores global
    error(error) {
      log.error(
        `Error no capturado del servidor: ${error instanceof Error ? error.message : String(error)}`,
      )
      return new Response('Internal Server Error', { status: 500 })
    },
  })

  log.success(`Servidor escuchando en http://localhost:${String(server.port)}`)
}

// Inicializar el servidor
initializeServer().catch((error: unknown) => {
  log.error(`Falló al iniciar el servidor: ${String(error)}`)
  process.exit(1)
})

Actualizar scripts de package.json

Agrega un script start para ejecutar el servidor personalizado:

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

Compilar y ejecutar

Compila tu aplicación e inicia el servidor:

sh
bun run build
bun run start

El servidor se iniciará en el puerto 3000 por defecto (configurable mediante la variable de entorno PORT).


Plantillas


→ Consulta la documentación oficial de TanStack Start para más información sobre hosting.

Bun por www.bunjs.com.cn editar