Database & backend
jorvel add db scaffolds a Drizzle ORM backend into an app — schema, a typed client, drizzle-kit migrations, a seed script, and an example server data loader wired to defineLoader. Two drivers: sqlite (better-sqlite3, zero-infra local file) and libsql (Turso / edge-friendly).
Scaffold
bash
# into the host app, SQLite (default)
jorvel add db
# into a specific app, libsql/Turso
jorvel add db dashboard --driver libsqlIt writes (TypeScript app shown; a JS app gets .js equivalents):
| File | Purpose |
|---|---|
src/db/schema.ts | Drizzle tables (users, posts) + inferred User/Post types |
src/db/client.ts | The db client bound to the driver |
src/db/seed.ts | Sample-data seeder |
src/server/posts.data.ts | A defineLoader reading rows — hydration-ready |
drizzle.config.ts | drizzle-kit config (schema path, out dir, credentials) |
Plus drizzle-orm + the driver added to the app's dependencies, db:generate / db:migrate / db:push / db:studio / db:seed scripts, and a DATABASE_URL entry appended to .env.example.
Generate & migrate
bash
pnpm install # pick up the new deps
cd apps/shell
pnpm db:generate # emit SQL migration into ./drizzle
pnpm db:migrate # apply it to the database
pnpm db:seed # optional: insert sample rows
pnpm db:studio # browse data in Drizzle StudioSchema
apps/shell/src/db/schema.ts
tsimport { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
email: text('email').notNull().unique(),
name: text('name').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
export const posts = sqliteTable('posts', {
id: integer('id').primaryKey({ autoIncrement: true }),
authorId: integer('author_id').notNull().references(() => users.id),
title: text('title').notNull(),
body: text('body').notNull(),
});
export type User = typeof users.$inferSelect;
export type Post = typeof posts.$inferSelect;Read in a loader
The generated data module pairs the query with a loader, so the rows are fetched on the server and hydrated without a second client request.
apps/shell/src/server/posts.data.ts
tsimport { defineLoader } from '@jorvel/ssr';
import { desc } from 'drizzle-orm';
import { db } from '../db/client.js';
import { posts } from '../db/schema.js';
export const recentPostsLoader = defineLoader({
key: 'recentPosts',
load: async () => db.select().from(posts).orderBy(desc(posts.id)).limit(20),
});tsx
import { useLoaderData } from '@jorvel/ssr';
import type { Post } from '../db/schema.js';
export default function Posts() {
const posts = useLoaderData<Post[]>('recentPosts') ?? [];
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}Write with an action
Mutations go through a server action — symmetric to the loader.
ts
import { defineAction } from '@jorvel/runtime';
import { db } from '../db/client.js';
import { posts } from '../db/schema.js';
export const createPost = defineAction(async (input: { authorId: number; title: string; body: string }) => {
const [row] = await db.insert(posts).values(input).returning();
return row;
});Driver choice
sqlite (better-sqlite3) is synchronous, fast, and perfect for Node servers and local dev. libsql targets Turso and edge runtimes (HTTP-based) — use it when you deploy to Cloudflare/Vercel edge or want a managed replica.
Server-only
src/db/* and src/server/* import a native/HTTP driver and read DATABASE_URL — keep them out of client bundles. Import them only from loaders, actions, and SSR/edge handlers, never from a component that ships to the browser.