Deploy TanStack Start with SQLite to Your Own Server
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 installand 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-kitis 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.gennot 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!