---
title: "Crea una App de Tareas con TanStack Start, Bunny Database y Drizzle ORM"
description: "Crea una app de tareas con tipado completo usando TanStack Start, Bunny Database (libSQL gestionado) y Drizzle ORM."
date: 2026-06-18
categories: ["web-development"]
tags: ["tanstack","bunny-net","self-hosted"]
---

import Button from "@components/widgets/Button.astro";
import Notice from "@components/widgets/Notice.astro";
import ListCheck from "@components/widgets/ListCheck.astro";
import Accordion from "@components/widgets/Accordion.astro";
import Tabs from "@components/widgets/Tabs.astro";
import Tab from "@components/widgets/Tab.astro";

Buscaba una forma de lanzar una pequeña app TypeScript full-stack sin montar un servidor Postgres ni pagar por una base de datos que me factura mientras está inactiva. TanStack Start me da renderizado del lado del servidor y funciones de servidor con tipado seguro, Drizzle me da un esquema en el que puedo confiar, y [Bunny.net](https://go.bitdoze.com/bunny) añadió hace poco una base de datos libSQL gestionada que entra en reposo cuando nadie la consulta. Juntando las tres consigues seguridad de tipos desde la fila de la base de datos hasta el botón del navegador.

Esta es exactamente la configuración que monté yo mismo: crear la base de datos en el panel, generar un proyecto TanStack Start con shadcn, definir un esquema Drizzle, generar y ejecutar las migraciones, y luego construir una interfaz que lista, añade, marca y borra tareas. Nada sofisticado: solo una guía clara que puedes copiar línea por línea.

<Notice type="success" title="Prueba Bunny.net gratis durante 14 días">
  Necesitarás una cuenta de Bunny para seguir la guía. [Regístrate en Bunny.net](https://go.bitdoze.com/bunny) sin tarjeta de crédito y obtén una prueba de 14 días. Mi [reseña completa de Bunny.net](/es/resena-bunny-net/) cubre el resto de la plataforma si quieres primero una visión general.
</Notice>




<YouTubeEmbed
  url="https://www.youtube.com/embed/ry0oQlbm_mc"
  label="¿El stack moderno definitivo? TanStack Start, Bunny DB, Drizzle y Shadcn UI"
/>

## Qué vamos a construir

Una app de tareas de una sola página con:

<ListCheck>
- Una tabla `todos` real almacenada en Bunny Database (libSQL gestionado)
- Una capa ORM de Drizzle con migraciones SQL generadas y versionadas
- Funciones de servidor mediante `createServerFn` de TanStack Start, para que el acceso a la base de datos nunca salga del servidor
- Una interfaz React estilizada con shadcn y Tailwind, hidratada desde un renderizado SSR
</ListCheck>

El resultado final es una interfaz pequeña donde escribes una tarea, pulsas enter, la marcas como hecha o la borras. Cada acción hace un viaje de ida y vuelta a Bunny Database.

## Requisitos previos

Antes de empezar, necesitas:

- Una [cuenta de Bunny.net](https://go.bitdoze.com/bunny) con acceso a Bunny Database (actualmente en vista previa pública)
- Node.js o Bun instalado. Aquí usé Bun porque las instalaciones y los scripts van rápido
- Conocimientos prácticos de TypeScript, React y SQL

## Paso 1: Genera el proyecto TanStack Start con shadcn

El camino más rápido a un proyecto TanStack Start que ya tenga shadcn configurado es la CLI de shadcn con la plantilla de TanStack Start. Obtienes Tailwind, el sistema de componentes de shadcn y enrutado basado en archivos listos para usar.

```bash
bunx --bun shadcn@latest init \
  --preset b1aIcEaeG \
  --base base \
  --template start \
  --pointer
```

Qué hace cada flag:

- `--preset b1aIcEaeG` descarga un starter de TanStack Start seleccionado (router, SSR, configuración de Vite)
- `--base base` elige el tema de color base neutro
- `--template start` genera TanStack Start en lugar de Next.js o Vite puro
- `--pointer` activa la utilidad pointer para las interacciones del cursor

Cuando termina, el proyecto queda más o menos así:

```
start-app/
├── src/
│   ├── components/
│   │   └── ui/           # shadcn components (button, etc.)
│   ├── lib/
│   │   └── utils.ts      # cn() helper for class merging
│   ├── routes/
│   │   ├── __root.tsx    # root layout + providers
│   │   └── index.tsx     # home route
│   ├── router.tsx
│   ├── routeTree.gen.ts  # generated, do not edit
│   └── styles.css        # Tailwind entry
├── package.json
├── vite.config.ts
└── tsconfig.json
```

Instala las dependencias y arranca el servidor de desarrollo para confirmar que inicia:

```bash
bun install
bun run dev
```

Eso te da una app estilizada y lista para SSR. Lo único que falta es una base de datos.

<Notice type="info" title="Desplegar TanStack Start en otro lugar">
  Esta guía aloja la base de datos en Bunny. Si prefieres ejecutar toda la app en tu propio servidor, tengo otra guía sobre [cómo desplegar TanStack Start en un VPS con Dokploy](/tanstack-start-dokploy-deploy/) que usa Postgres.
</Notice>

## Paso 2: Crea tu Bunny Database

Bunny Database es una base de datos relacional gestionada construida sobre libSQL, un fork de SQLite. Obtienes SQL estándar, replicación global opcional, facturación por uso (entra en reposo cuando nada la consulta) y varias formas de conectarte: API HTTP, SDKs nativos y ORMs como Drizzle.

### Crea la base de datos en el panel

1. Inicia sesión en el [panel de Bunny.net](https://go.bitdoze.com/bunny)
2. En la barra lateral izquierda, haz clic en **+ Add** y luego en **Database**
3. Ponle un nombre (por ejemplo `todotest`). Este nombre aparece en tu URL de conexión
4. Elige un modo de despliegue:
   - **Automatic** - Bunny elige las regiones según tu ubicación. Un buen valor por defecto para desarrollo
   - **Single region** - sin replicación, la opción más barata, ideal para un solo VPS o para pruebas
   - **Manual** - tú eliges la región primaria y las réplicas, útil por latencia o cumplimiento normativo
5. Haz clic en **Add Database**

### Obtén tus credenciales

Abre la base de datos y ve a la pestaña **Access**:

- **Database URL**: tiene el formato `libsql://<your-database-id>.lite.bunnydb.net`. Todas las librerías cliente usan este endpoint
- **Access Token**: haz clic en **Generate Tokens**. Obtienes un token **Full Access** (lectura y escritura, usa este para la app) y un token **Read Only** (limitado a `SELECT`, práctico para analíticas)

Copia la URL y el token Full Access de inmediato. Los tokens solo se muestran una vez. Si pierdes uno, generas un par nuevo desde el panel.

<Notice type="warning" title="Nunca subas las credenciales al repositorio">
  Mantén los tokens fuera del control de versiones. Los pondremos en un archivo `.env` ignorado por git.
</Notice>

### Guarda las credenciales en .env

Crea un `.env` en la raíz del proyecto:

```bash
BUNNY_DATABASE_URL="libsql://your-database-id.lite.bunnydb.net/"
BUNNY_DATABASE_AUTH_TOKEN="your-full-access-token"
BUNNY_DATABASE_READ_ONLY_AUTH_TOKEN="your-read-only-token"
```

Añade un `.env.example` (seguro de subir al repo) para que la siguiente persona sepa qué espera la app:

```bash
BUNNY_DATABASE_URL="libsql://your-database-id.lite.bunnydb.net/"
BUNNY_DATABASE_AUTH_TOKEN="your-full-access-token"
BUNNY_DATABASE_READ_ONLY_AUTH_TOKEN="your-read-only-token"
```

El starter ya ignora `.env*` en git, pero revísalo de todos modos:

```gitignore
# .gitignore
.env*
```

## Paso 3: Instala Drizzle y el cliente libSQL

Bunny Database habla libSQL, así que usamos el cliente oficial `@libsql/client` como driver y el adaptador libSQL de Drizzle para consultas con tipado seguro.

```bash
bun add drizzle-orm @libsql/client
bun add -d drizzle-kit
```

- `drizzle-orm` es el ORM
- `@libsql/client` es el driver que habla el protocolo libSQL sobre HTTP
- `drizzle-kit` es una dependencia de desarrollo para generar migraciones, hacer push del esquema y abrir Drizzle Studio

Añade algunos scripts a `package.json` para trabajar más cómodo con la base de datos:

```json
{
  "scripts": {
    "dev": "vite dev --port 3000 --host",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "bun run src/db/migrate.ts",
    "db:studio": "drizzle-kit studio"
  }
}
```

Fíjate en el flag `--host` de `dev`. Vincula el servidor a tus interfaces de red, lo cual importa cuando desarrollas en un VPS y quieres acceder desde el navegador de tu portátil.

## Paso 4: Configura Drizzle

Crea `drizzle.config.ts` en la raíz del proyecto. Le dice a drizzle-kit dónde vive el esquema, dónde van las migraciones y cómo conectar con la base de datos:

```ts
// drizzle.config.ts
import { defineConfig } from "drizzle-kit"

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "turso",
  dbCredentials: {
    url: process.env.BUNNY_DATABASE_URL!,
    authToken: process.env.BUNNY_DATABASE_AUTH_TOKEN!,
  },
})
```

Un par de cosas a destacar:

- `dialect: "turso"` le dice a drizzle-kit que emita SQL compatible con SQLite, que es exactamente lo que libSQL (y Bunny Database) espera
- `out: "./drizzle"` es donde caen los archivos de migración
- `dbCredentials` lee las mismas variables de entorno del `.env`

## Paso 5: Define el esquema

Crea `src/db/schema.ts`. Es la única fuente de verdad sobre la forma de la base de datos:

```ts
// src/db/schema.ts
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core"

export const todos = sqliteTable("todos", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  title: text("title").notNull(),
  completed: integer("completed", { mode: "boolean" }).notNull().default(false),
  createdAt: integer("created_at", { mode: "timestamp" })
    .notNull()
    .$defaultFn(() => new Date()),
})

export type Todo = typeof todos.$inferSelect
export type NewTodo = typeof todos.$inferInsert
```

La tabla `todos` tiene cuatro columnas:

- `id` - clave primaria entera autoincremental
- `title` - el texto de la tarea, no nulo
- `completed` - un booleano almacenado como 0/1 en SQLite pero expuesto como `boolean` real en TypeScript gracias a `mode: "boolean"`
- `created_at` - un timestamp Unix entero expuesto como `Date` de JavaScript mediante `mode: "timestamp"`

Los dos tipos exportados, `Todo` y `NewTodo`, se infieren directamente del esquema. Nunca los escribes a mano y se mantienen sincronizados a medida que el esquema cambia.

## Paso 6: Crea el cliente de base de datos

Crea `src/db/index.ts`. Inicializa el cliente libSQL y lo envuelve con Drizzle:

```ts
// src/db/index.ts
import { drizzle } from "drizzle-orm/libsql"
import { createClient } from "@libsql/client/web"
import * as schema from "./schema"

const client = createClient({
  url: process.env.BUNNY_DATABASE_URL!,
  authToken: process.env.BUNNY_DATABASE_AUTH_TOKEN!,
})

export const db = drizzle(client, { schema })
export { schema }
```

Usamos `@libsql/client/web` (el transporte HTTP) en lugar del socket nativo. Es la elección correcta para Bunny Database, al que accedes por HTTPS, y funciona igual en Node, Bun y runtimes edge.

## Paso 7: Genera la primera migración

Con el esquema listo, genera la migración SQL:

```bash
bun run db:generate
```

drizzle-kit lee `src/db/schema.ts`, lo compara con el historial de migraciones y escribe un archivo nuevo en `./drizzle/`. Para la tabla `todos` produce algo como:

```sql
-- drizzle/0000_certain_bug.sql
CREATE TABLE `todos` (
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
  `title` text NOT NULL,
  `completed` integer DEFAULT false NOT NULL,
  `created_at` integer NOT NULL
);
```

Es sintaxis SQLite pura, que Bunny Database acepta sin cambios. El sufijo aleatorio (`certain_bug` aquí) mantiene las migraciones ordenadas sin conflictos de nombre.

## Paso 8: Aplica la migración

Para ejecutar las migraciones de forma programática, crea `src/db/migrate.ts`:

```ts
// src/db/migrate.ts
import { migrate } from "drizzle-orm/libsql/migrator"
import { db } from "./index"

async function main() {
  console.log("Running migrations...")
  await migrate(db, { migrationsFolder: "./drizzle" })
  console.log("Migrations complete.")
  process.exit(0)
}

main().catch((err) => {
  console.error("Migration failed:", err)
  process.exit(1)
})
```

Ejecuta:

```bash
bun run db:migrate
```

Deberías ver:

```
Running migrations...
Migrations complete.
```

La tabla `todos` ya existe de verdad en Bunny Database. Puedes confirmarlo mediante la API HTTP:

```bash
HTTP_URL=$(echo $BUNNY_DATABASE_URL | sed 's/^libsql:/https:/')
curl -X POST "${HTTP_URL%/}/v2/pipeline" \
  -H "Authorization: Bearer $BUNNY_DATABASE_AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"requests":[{"type":"execute","stmt":{"sql":"SELECT name FROM sqlite_master WHERE type='"'"'table'"'"'"}}]}'
```

La respuesta lista `todos`, `__drizzle_migrations` y `sqlite_sequence`. Esa tabla `__drizzle_migrations` es como Drizzle registra qué migraciones se han ejecutado.

<Notice type="info" title="La shell bsql">
  Bunny también incluye una shell SQL interactiva llamada `bsql`. Si tienes la CLI de Bunny, conéctate con `bsql libsql://your-database-id.lite.bunnydb.net --token your-token` y ejecuta `.tables`, `SELECT * FROM todos;` y similares. Es una forma rápida de curiosear los datos sin escribir código.
</Notice>

## Paso 9: Construye las funciones de servidor

Con la capa de base de datos lista, exponemos las operaciones al frontend mediante las funciones de servidor de TanStack Start. Se ejecutan solo en el servidor, aunque las importas y llamas como funciones async normales desde el código cliente. Así el cliente de base de datos y tus credenciales se quedan completamente fuera del bundle del navegador.

Crea `src/db/queries.ts`:

```ts
// src/db/queries.ts
import { eq } from "drizzle-orm"
import { createServerFn } from "@tanstack/react-start"
import { db, schema } from "./index"
import type { NewTodo } from "./schema"

export const getTodos = createServerFn({ method: "GET" }).handler(async () => {
  const rows = await db.select().from(schema.todos).orderBy(schema.todos.id)
  return rows
})

export const addTodo = createServerFn({ method: "POST" })
  .validator((title: string) => ({ title }))
  .handler(async ({ data }) => {
    const newTodo: NewTodo = { title: data.title }
    const [created] = await db.insert(schema.todos).values(newTodo).returning()
    return created
  })

export const toggleTodo = createServerFn({ method: "POST" })
  .validator((input: { id: number; completed: boolean }) => ({
    id: input.id,
    completed: input.completed,
  }))
  .handler(async ({ data }) => {
    const [updated] = await db
      .update(schema.todos)
      .set({ completed: data.completed })
      .where(eq(schema.todos.id, data.id))
      .returning()
    return updated
  })

export const deleteTodo = createServerFn({ method: "POST" })
  .validator((id: number) => ({ id }))
  .handler(async ({ data }) => {
    await db.delete(schema.todos).where(eq(schema.todos.id, data.id))
    return { id: data.id }
  })
```

Todas las funciones siguen la misma forma:

1. `.validator()` parsea y valida el argumento entrante, dándote un objeto `data` tipado
2. `.handler()` ejecuta la consulta Drizzle contra Bunny Database y devuelve el resultado
3. La función es async y devuelve el resultado tipado, así que quien llama desde React obtiene seguridad de tipos completa

## Paso 10: Conecta el QueryClientProvider

TanStack Start hace SSR por defecto. Como la interfaz usa TanStack Query para obtener datos, necesitas un `QueryClientProvider` en la raíz para que los renderizados del servidor y del cliente compartan el mismo query client.

Actualiza `src/routes/__root.tsx`:

```tsx
// src/routes/__root.tsx
import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"
import { TanStackDevtools } from "@tanstack/react-devtools"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"

import appCss from "../styles.css?url"

const queryClient = new QueryClient()

export const Route = createRootRoute({
  head: () => ({
    meta: [
      { charSet: "utf-8" },
      { name: "viewport", content: "width=device-width, initial-scale=1" },
      { title: "Todos" },
    ],
    links: [{ rel: "stylesheet", href: appCss }],
  }),
  notFoundComponent: () => (
    <main className="container mx-auto p-4 pt-16">
      <h1>404</h1>
      <p>The requested page could not be found.</p>
    </main>
  ),
  shellComponent: RootDocument,
  defaultPreload: "intent",
})

function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        <QueryClientProvider client={queryClient}>
          {children}
        </QueryClientProvider>
        <TanStackDevtools
          config={{ position: "bottom-right" }}
          plugins={[
            { name: "Tanstack Router", render: <TanStackRouterDevtoolsPanel /> },
          ]}
        />
        <Scripts />
      </body>
    </html>
  )
}
```

Si omites este provider, el SSR lanza `No QueryClient set, use QueryClientProvider to set one` y luego cae a renderizado solo en cliente. Eso anula el propósito del SSR y ralentiza tu primer pintado.

## Paso 11: Construye la interfaz de tareas

Último paso: sustituye `src/routes/index.tsx` por la interfaz de tareas. Usa `useQuery` para cargar la lista y `useMutation` para cada acción, invalidando la query `todos` después de cada mutación para que la lista se refresque.

El layout es una columna centrada: un encabezado, una fila de input y botón para añadir tareas, y la lista debajo. Cada elemento tiene un botón circular para marcarla, el texto de la tarea (tachado cuando está completada) y un icono de papelera. Cada mutación llama a `queryClient.invalidateQueries({ queryKey: ["todos"] })` al tener éxito, lo que vuelve a pedir los datos a través de la función de servidor y mantiene la interfaz fiel a lo que hay realmente en la base de datos.

<Accordion label="src/routes/index.tsx completo" group="code" expanded="false">

```tsx
// src/routes/index.tsx
import { useState } from "react"
import { createFileRoute } from "@tanstack/react-router"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Check, Plus, Trash2 } from "lucide-react"
import { getTodos, addTodo, toggleTodo, deleteTodo } from "@/db/queries"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

export const Route = createFileRoute("/")({ component: TodoApp })

function TodoApp() {
  const queryClient = useQueryClient()
  const [newTitle, setNewTitle] = useState("")

  const { data: todos = [], isLoading } = useQuery({
    queryKey: ["todos"],
    queryFn: () => getTodos(),
  })

  const addMutation = useMutation({
    mutationFn: (title: string) => addTodo({ data: title }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] })
      setNewTitle("")
    },
  })

  const toggleMutation = useMutation({
    mutationFn: (vars: { id: number; completed: boolean }) =>
      toggleTodo({ data: vars }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
  })

  const deleteMutation = useMutation({
    mutationFn: (id: number) => deleteTodo({ data: id }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
  })

  return (
    <div className="flex min-h-svh justify-center p-6">
      <div className="flex w-full max-w-md flex-col gap-6">
        <div>
          <h1 className="text-2xl font-semibold tracking-tight">Todos</h1>
          <p className="text-sm text-muted-foreground">
            Bunny Database + Drizzle + TanStack Start
          </p>
        </div>

        <form
          className="flex gap-2"
          onSubmit={(e) => {
            e.preventDefault()
            const title = newTitle.trim()
            if (!title) return
            addMutation.mutate(title)
          }}
        >
          <input
            value={newTitle}
            onChange={(e) => setNewTitle(e.target.value)}
            placeholder="What needs to be done?"
            className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
          />
          <Button type="submit" disabled={addMutation.isPending} size="icon">
            <Plus className="size-4" />
          </Button>
        </form>

        {isLoading ? (
          <p className="text-sm text-muted-foreground">Loading...</p>
        ) : todos.length === 0 ? (
          <p className="text-sm text-muted-foreground">
            No todos yet. Add one above.
          </p>
        ) : (
          <ul className="flex flex-col gap-1">
            {todos.map((todo) => (
              <li
                key={todo.id}
                className="flex items-center gap-3 rounded-md border border-border px-3 py-2"
              >
                <button
                  type="button"
                  onClick={() =>
                    toggleMutation.mutate({
                      id: todo.id,
                      completed: !todo.completed,
                    })
                  }
                  className={cn(
                    "flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors",
                    todo.completed
                      ? "border-primary bg-primary text-primary-foreground"
                      : "border-input hover:border-primary",
                  )}
                  aria-label={todo.completed ? "Mark incomplete" : "Mark complete"}
                >
                  {todo.completed && <Check className="size-3" />}
                </button>

                <span
                  className={cn(
                    "flex-1 text-sm",
                    todo.completed && "text-muted-foreground line-through",
                  )}
                >
                  {todo.title}
                </span>

                <button
                  type="button"
                  onClick={() => deleteMutation.mutate(todo.id)}
                  className="text-muted-foreground hover:text-destructive"
                  aria-label="Delete todo"
                >
                  <Trash2 className="size-4" />
                </button>
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  )
}
```

</Accordion>

## Ejecutarlo en un VPS

Las funciones de servidor necesitan un runtime Node o Bun, así que no puedes subir esto a un hosting estático. Un VPS o una plataforma de contenedores es el hogar adecuado.

Cuando desarrollas en el propio VPS, el flag `--host` vincula Vite a `0.0.0.0` para que puedas acceder desde tu portátil:

```bash
bun run dev
# Local:   http://localhost:3000/
# Network: http://your-vps-ip:3000/
```

Para producción, compila y previsualiza:

```bash
bun run build
bun run preview
```

Para cualquier cosa de larga duración, envuelve el comando de arranque en un gestor de procesos como PM2 o systemd, o mételo en un contenedor. Los Magic Containers de Bunny encajan muy bien aquí, en parte porque puedes adjuntar las credenciales de la base de datos como variables de entorno directamente desde el panel de la base de datos.

<Notice type="info" title="Vincular credenciales a un Magic Container">
  Despliega en un Magic Container de Bunny y puedes olvidarte del archivo `.env`. Desde el panel de la base de datos, haz clic en **Add Secrets to Magic Container Apps**, elige tu app contenedora y Bunny inyecta `BUNNY_DATABASE_URL` y `BUNNY_DATABASE_AUTH_TOKEN` por ti. Tu código ya lee `process.env`, así que nada cambia.
</Notice>

## Por qué funciona este stack

Algunas razones por las que los tres encajan bien:

<ListCheck>
- **Seguridad de tipos de extremo a extremo**: el esquema Drizzle es la fuente de verdad y esos tipos fluyen a las funciones de servidor y a React. Renombrar una columna se convierte en un error de compilación, no en una sorpresa a las 2 de la madrugada
- **SQL que ya conoces**: Bunny Database es libSQL, compatible con SQLite, así que no hay ningún dialecto propietario que aprender
- **En reposo cuando está inactiva**: la base de datos se apaga cuando nada la consulta y factura por uso, lo que mantiene los proyectos personales baratos
- **SSR de serie**: TanStack Start renderiza el primer HTML en el servidor, así que la lista de tareas está ahí antes de la hidratación
- **El código de la UI es tuyo**: shadcn copia los componentes a tu proyecto en lugar de esconderlos detrás de un paquete
- **Cambios de esquema baratos**: edita `schema.ts`, ejecuta `db:generate` y luego `db:migrate`, y el cambio está en producción en segundos
- **Credenciales portables**: el patrón `.env` es estándar y los Magic Containers pueden inyectar las mismas variables, así que pasar de local a producción no requiere cambios de código
</ListCheck>

## Solución de problemas

Algunas cosas me dieron problemas mientras lo construía:

<Accordion label="«No QueryClient set» durante el SSR" group="troubleshoot" expanded="true">

Olvidaste envolver la app en `QueryClientProvider` dentro de `__root.tsx`. Añádelo como se muestra en el paso 10.

</Accordion>

<Accordion label="«Protocol libsql not supported» en libcurl" group="troubleshoot">

Aparece cuando intentas hacer curl directamente a la URL de la base de datos. El esquema `libsql://` es solo para librerías cliente. Para llamadas HTTP directas, conviértelo a `https://` y haz POST a `/v2/pipeline`, como en el paso 8.

</Accordion>

<Accordion label="Los tokens solo se muestran una vez" group="troubleshoot">

Si pierdes un token de acceso de Bunny, generas uno nuevo desde el panel. Regenerar invalida los tokens existentes, así que actualiza todas las apps que los usaban.

</Accordion>

<Accordion label="La migración falla con un error de esquema" group="troubleshoot">

Comprueba que `dialect: "turso"` está configurado en `drizzle.config.ts`. Bunny Database usa el dialecto libSQL/SQLite, no PostgreSQL.

</Accordion>

<Accordion label="Puerto ya en uso" group="troubleshoot">

Vite prueba el siguiente puerto libre (3001, 3002, etc.). Lee la salida de arranque para ver qué puerto eligió realmente.

</Accordion>

## Preguntas frecuentes

<Accordion label="¿Bunny Database está lista para producción?" group="faq">

Está en vista previa pública, así que la trataría como cualquier producto en preview: genial para proyectos personales, herramientas internas y apps de mucha lectura, pero lee los términos actuales antes de poner cargas críticas en ella. Como es libSQL, tus datos y consultas siguen siendo portables a cualquier hosting compatible con SQLite si necesitas migrar. Mi [reseña de Bunny.net](/es/resena-bunny-net/) explica mejor dónde está madura la plataforma y dónde sigue madurando.

</Accordion>

<Accordion label="¿Puedo usar Postgres en su lugar?" group="faq">

No con Bunny Database, que es solo libSQL. Si quieres Postgres con TanStack Start y Drizzle, mi [guía de despliegue con Dokploy](/tanstack-start-dokploy-deploy/) recorre exactamente eso en un VPS autoalojado.

</Accordion>

<Accordion label="¿Por qué funciones de servidor en lugar de una API REST?" group="faq">

Las funciones de servidor mantienen el cliente de base de datos y las credenciales en el servidor mientras las llamas como funciones async normales desde el cliente. Te ahorras escribir y mantener una capa de API aparte, y los tipos se propagan automáticamente. Para una app pequeña como esta, es muchísimo menos boilerplate.

</Accordion>

<Accordion label="¿Bunny cobra por consulta?" group="faq">

Bunny Database factura por uso (lecturas, escrituras y almacenamiento por región activa) y entra en reposo cuando nada la consulta, así que una app tranquila cuesta muy poco. Consulta la página de [precios de Bunny](https://go.bitdoze.com/bunny) para las tarifas actuales de la preview, ya que pueden cambiar mientras siga en vista previa.

</Accordion>

<Accordion label="¿Puedo añadir más tablas después?" group="faq">

Sí, esa es la parte buena. Añade una tabla a `src/db/schema.ts`, ejecuta `bun run db:generate` para crear la migración y luego `bun run db:migrate` para aplicarla. Los tipos nuevos están disponibles al instante y el cambio está activo en Bunny en segundos.

</Accordion>

## Para terminar

En aproximadamente una hora pasé de una carpeta vacía a una app de tareas tipada con SSR, respaldada por una base de datos gestionada que casi no cuesta nada en reposo, con migraciones versionadas y una interfaz que es toda mía. Ampliar el esquema es una operación de dos comandos, y el patrón de funciones de servidor mantiene las credenciales y la lógica de consultas donde deben estar.

Si quieres llevarla más lejos, los siguientes pasos obvios son autenticación de usuarios con tareas por usuario, fechas límite y filtros, actualizaciones optimistas en lugar de invalidación de queries, y un despliegue en Magic Container con credenciales inyectadas automáticamente. La [documentación de Bunny Database](https://docs.bunny.net/database) y la [guía libSQL de Drizzle](https://orm.drizzle.team/docs/get-started-sqlite) cubren los rincones que yo no cubrí.

<Button text="Prueba Bunny.net gratis durante 14 días" link="https://go.bitdoze.com/bunny" variant="solid" color="blue" size="lg" />

## Artículos relacionados

- [Reseña de Bunny.net](/es/resena-bunny-net/) - la plataforma completa tras un año en producción
- [Astro DB con Bunny Database](/es/astro-db-bunny-database/) - la misma base de datos con el flujo local-first de Astro
- [Despliega TanStack Start en un VPS con Dokploy](/tanstack-start-dokploy-deploy/) - el mismo stack con Postgres, totalmente autoalojado
- [Monta un bucket S3 como sistema de archivos](/es/montar-bucket-s3-sistema-archivos-vps/) - ZeroFS y JuiceFS en Bunny Storage
- [Bunny Storage vs S3 vs Backblaze](/es/bunny-storage-vs-s3-vs-backblaze/) - comparativa de precios de almacenamiento en la nube
- [Despliega un sitio Astro en Bunny.net](/deploy-astro-bunny-net/) - hosting estático en Bunny Storage y CDN
- [Guía de Bunny Stream](/es/guia-bunny-stream/) - alojamiento de vídeo en Bunny