Deploy TanStack Start with SQLite to Your Own Server

Published: (December 14, 2025 at 04:33 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

Why TanStack Start + SQLite?

  • Simplicity – SQLite runs in the same process as your app, eliminating network latency and complex database clusters. Development is as easy as pnpm install and start coding.
  • Performance – Reads and writes hit the local file system, delivering speeds that often outpace network‑attached databases for typical web requests.
  • Easy Deployment – Deploy the app server and the database file (mounted as a persistent volume) to a single region with Haloy.

What You’ll Build

A full‑stack React application using:

  • TanStack Start – React meta‑framework with file‑based routing and server functions
  • SQLite – Lightweight, file‑based database
  • Drizzle ORM – TypeScript ORM for type‑safe database queries
  • Haloy – Simple deployment to your own server

Prerequisites

  • Node.js 20+ installed
  • Haloy installed (see Haloy Quickstart)
  • A Linux server (VPS or dedicated)
  • A domain or subdomain
  • Basic familiarity with React and TypeScript

Setup

mkdir my-tanstack-app
cd my-tanstack-app
pnpm init

tsconfig.json

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "moduleResolution": "Bundler",
    "module": "ESNext",
    "target": "ES2022",
    "skipLibCheck": true,
    "strictNullChecks": true
  }
}

Install core dependencies

pnpm add @tanstack/react-start @tanstack/react-router react react-dom nitro

Install development dependencies

pnpm add -D vite @vitejs/plugin-react typescript @types/react @types/react-dom @types/node vite-tsconfig-paths

Install Drizzle and SQLite client

pnpm add drizzle-orm @libsql/client dotenv drizzle-kit

Note: drizzle-kit is installed as a production dependency (not -D) because it must be available in the Docker container to run migrations at startup.

package.json scripts

{
  // ...
  "type": "module",
  "scripts": {
    "dev": "vite dev",
    "build": "vite build",
    "start": "node .output/server/index.mjs",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate"
  }
}

Configuration

vite.config.ts

import { defineConfig } from "vite";
import { nitro } from "nitro/vite";
import tsConfigPaths from "vite-tsconfig-paths";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";

export default defineConfig({
  server: {
    port: 3000,
  },
  plugins: [
    tsConfigPaths(),
    tanstackStart(),
    nitro(),
    // React's Vite plugin must come after Start's Vite plugin
    viteReact(),
  ],
  nitro: {},
});

TanStack Start uses Nitro as its server engine. For this deployment we use the default Node.js preset, which works perfectly with Haloy. No additional Nitro configuration is required; the empty nitro: {} object is sufficient.

drizzle.config.ts

import { config } from "dotenv";
import { defineConfig } from "drizzle-kit";

config();

const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
  throw new Error("DATABASE_URL is not set");
}

export default defineConfig({
  out: "./drizzle",
  schema: "./src/db/schema.ts",
  dialect: "sqlite",
  dbCredentials: {
    url: databaseUrl,
  },
});

Database client (src/db/index.ts)

import "dotenv/config";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";

const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
  throw new Error("DATABASE_URL is not set");
}

const client = createClient({ url: databaseUrl });
const db = drizzle({ client });
export { client, db };

Schema (src/db/schema.ts)

import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const todos = sqliteTable("todos", {
  id: integer("id", { mode: "number" }).primaryKey({
    autoIncrement: true,
  }),
  title: text("title").notNull(),
  createdAt: integer("created_at", { mode: "timestamp" }).default(
    sql`(unixepoch())`
  ),
});

Local development environment (.env)

DATABASE_URL=file:local.db

Generate and run migrations:

pnpm db:generate
pnpm db:migrate

These commands create migration files in the drizzle/ directory that will be used in production.

Routing

src/router.tsx

import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";

export function getRouter() {
  const router = createRouter({
    routeTree,
    scrollRestoration: true,
    defaultNotFoundComponent: () => 404 - not found,
  });

  return router;
}

Note: You might see a TypeScript error about ./routeTree.gen not being found. This is expected—TanStack Start automatically generates this file when you run the dev server.

Root route (src/routes/__root.tsx)

/// 

import {
  createRootRoute,
  HeadContent,
  Outlet,
  Scripts,
} from "@tanstack/react-router";
import type { ReactNode } from "react";

export const Route = createRootRoute({
  head: () => ({
    meta: [
      { charSet: "utf-8" },
      { name: "viewport", content: "width=device-width, initial-scale=1" },
      { title: "TanStack Start Starter" },
    ],
  }),
  component: RootComponent,
});

function RootComponent() {
  return (
    
      
    
  );
}

function RootDocument({ children }: Readonly) {
  return (
    
      
        
      
      
        {children}
        
      
    
  );
}

Todo route (src/routes/index.tsx)

import { createFileRoute, useRouter } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { eq } from "drizzle-orm";
import { db } from "../db";
import { todos } from "../db/schema";

const getTodos = createServerFn({
  method: "GET",
}).handler(async () => await db.select().from(todos));

const addTodo = createServerFn({ method: "POST" })
  .inputValidator((data: FormData) => {
    if (!(data instanceof FormData)) {
      throw new Error("Expected FormData");
    }
    return {
      title: data.get("title")?.toString() || "",
    };
  })
  .handler(async ({ data }) => {
    await db.insert(todos).values({ title: data.title });
  });

const deleteTodo = createServerFn({ method: "POST" })
  .inputValidator((data: number) => data)
  .handler(async ({ data }) => {
    await db.delete(todos).where(eq(todos.id, data));
  });

export const Route = createFileRoute("/")({
  component: RouteComponent,
  loader: async () => await getTodos(),
});

function RouteComponent() {
  const router = useRouter();
  const todos = Route.useLoaderData();

  return (
    
      

        {todos.map((todo) => (
          
            {todo.title}
             {
                await deleteTodo({ data: todo.id });
                router.invalidate();
              }}
            >
              X
            
          
        ))}
      

      
## Add todo

       {
          e.preventDefault();
          const form = e.currentTarget;
          const data = new FormData(form);
          await addTodo({ data });
          router.invalidate();
          form.reset();
        }}
      >
        
        Add
      
    
  );
}

You now have a complete TanStack Start + SQLite starter ready for local development and production deployment with Haloy. Follow Haloy’s documentation to build the Docker image, configure a persistent volume for local.db, and expose the service with automatic HTTPS. Happy coding!

Back to Blog

Related posts

Read more »