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.
bun create @tanstack/start@latest my-tanstack-appIniciar el servidor de desarrollo
Cambia al directorio del proyecto y ejecuta el servidor de desarrollo con Bun.
cd my-tanstack-app
bun --bun run devEsto 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.
{
"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.
bun add nitroActualizar tu archivo vite.config.ts
Actualiza tu archivo vite.config.ts para incluir los complementos necesarios para TanStack Start con Bun.
// 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:
{
"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.
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:
/**
* 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:
{
"scripts": {
"build": "bun --bun vite build",
"start": "bun run server.ts"
}
}Compilar y ejecutar
Compila tu aplicación e inicia el servidor:
bun run build
bun run startEl 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.