2. Database Setup
In this step we connect to Cloudflare D1 (a serverless SQLite database) using Drizzle ORM, and use @cfast/env to validate our Worker bindings at startup.
By the end of this step, the home page will load posts from the database instead of showing static text.
New dependencies
Section titled “New dependencies”| Package | Why |
|---|---|
@cfast/env | Type-safe, runtime-validated Worker bindings |
@cfast/db | Permission-aware Drizzle wrapper (we use raw Drizzle for now, permissions come in Step 4) |
drizzle-orm | SQL query builder and ORM for D1 |
drizzle-kit | Migration generator (dev dependency) |
New and changed files
Section titled “New and changed files”Directorystep-02-database/
Directoryapp/
- schema.ts — Drizzle table definitions (new)
- env.server.ts — binding validation with defineEnv (new)
- db.server.ts — database client factory (new)
- root.tsx
- routes.ts
Directoryroutes/
- home.tsx — updated with a loader
- package.json — updated
- react-router.config.ts
- tsconfig.json
- wrangler.jsonc
Walkthrough
Section titled “Walkthrough”-
Define the schema
Drizzle schemas are TypeScript-first. Each table is a plain function call that produces a typed table reference. We start with a single
poststable.app/schema.ts import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";export const posts = sqliteTable("posts", {id: integer("id").primaryKey({ autoIncrement: true }),title: text("title").notNull(),content: text("content").notNull(),authorId: text("author_id").notNull(),createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),});Design notes:
integer("created_at", { mode: "timestamp" })stores dates as Unix timestamps in SQLite but surfaces them asDateobjects in TypeScript.$defaultFn(() => new Date())provides a default at the application level, not in SQL. This runs in the Worker runtime.authorIdis a plaintextfor now. In Step 3 we add auserstable and a foreign key.
-
Validate bindings with
defineEnv()Cloudflare Workers receive bindings (D1 databases, KV namespaces, secrets) as an untyped object.
@cfast/envvalidates them once at startup and gives you a fully typed result.app/env.server.ts import { defineEnv } from "@cfast/env";export const env = defineEnv({DB: { type: "d1" },});export type Env = ReturnType<typeof env.get>;The
.server.tssuffix tells the React Router bundler to keep this file out of client bundles. Bindings are server-only — they do not exist in the browser. -
Create the database client
A thin wrapper that initializes Drizzle with our schema. In Step 4 this will become a permission-aware
createDb()call.app/db.server.ts import { drizzle } from "drizzle-orm/d1";import * as schema from "./schema";import { env } from "./env.server";export function getDb() {const { DB } = env.get();return drizzle(DB, { schema });}Passing
schematodrizzle()enables the relational query API, where you can usedb.query.posts.findMany()in addition to the SQL-likedb.select().from(posts). -
Add a loader to the home page
React Router loaders run on the server before the component renders. Here we query all posts, ordered by creation date.
app/routes/home.tsx import type { LoaderFunctionArgs } from "react-router";import { useLoaderData } from "react-router";import { desc } from "drizzle-orm";import { env } from "../env.server";import { getDb } from "../db.server";import { posts } from "../schema";export async function loader({ context }: LoaderFunctionArgs) {env.init(context.cloudflare);const db = getDb();const allPosts = await db.select().from(posts).orderBy(desc(posts.createdAt));return { posts: allPosts };}export default function Home() {const { posts: postList } = useLoaderData<typeof loader>();return (<main style={{ padding: "2rem", fontFamily: "system-ui, sans-serif", maxWidth: "48rem", margin: "0 auto" }}><h1>Team Blog</h1>{postList.length === 0 ? (<p style={{ color: "#666" }}>No posts yet. Check back later!</p>) : (<ul style={{ listStyle: "none", padding: 0 }}>{postList.map((post) => (<li key={post.id} style={{ marginBottom: "1.5rem" }}><h2>{post.title}</h2><p>{post.content}</p></li>))}</ul>)}</main>);}Key pattern:
env.init(context.cloudflare)is called in the loader, not in a global scope. Worker bindings are per-request via thecontextparameter. Theinit()call validates them once and caches the result; subsequent calls in the same request are no-ops.
Running migrations
Section titled “Running migrations”Drizzle Kit generates SQL migration files from your schema.
# Generate a migration from the schemanpx drizzle-kit generate
# Apply it to local D1npx wrangler d1 migrations apply DB --localThe first command creates a SQL file in a drizzle/ directory. The second runs it against your local D1 instance. In production, you would run the migration as part of your deploy pipeline.
How data flows
Section titled “How data flows”Browser → Worker → React Router loader → Drizzle → D1 (SQLite) ↓ typed query result ↓ useLoaderData() in componentThe loader’s return value is serialized to JSON and sent to the browser during SSR. On client-side navigations, React Router fetches the loader data via an API call instead of a full page reload.
What @cfast/env gives you
Section titled “What @cfast/env gives you”Without @cfast/env, your loader would look like:
const db = drizzle(context.cloudflare.env.DB); // 'DB' is untypedIf someone renames the binding in wrangler.jsonc but forgets the code, you get a runtime crash on the first database query. With defineEnv(), the mismatch is caught immediately at startup with a message like:
@cfast/env: 1 binding error(s): - DB: Missing required D1 binding 'DB'. Check your wrangler.toml.Next step
Section titled “Next step”In Step 3: Authentication, we add Better Auth for passwordless login with magic email links.