1. Project Setup
In this tutorial you will build a team blog from scratch using CFast. Each step adds one layer — project scaffolding, database, authentication, and permissions — so you can see how the pieces compose.
By the end of this first step you will have a React Router v7 application running on Cloudflare Workers with server-side rendering.
What we are building
Section titled “What we are building”A multi-author blog where:
- Authors write and manage their own posts
- Editors can publish and edit any post
- Readers see only published content
- Admins control everything
This step lays the foundation: a Workers project with React Router, a D1 database binding (not yet used), and a single home page.
Prerequisites
Section titled “Prerequisites”- Node.js 20+
- pnpm (latest) —
npm install -g pnpm - Wrangler CLI —
npm install -g wrangler - A Cloudflare account (free tier works)
Project structure
Section titled “Project structure”Directorystep-01-setup/
Directoryapp/
- entry.server.tsx — SSR stream handler
- root.tsx — HTML shell and error boundary
- routes.ts — route configuration
Directoryroutes/
- home.tsx — the home page component
Directoryworkers/
- app.ts — Cloudflare Worker entry point
- package.json
- react-router.config.ts
- tsconfig.json
- tsconfig.cloudflare.json
- tsconfig.node.json
- vite.config.ts
- wrangler.jsonc — Cloudflare Workers config
Walkthrough
Section titled “Walkthrough”-
Create
package.jsonThe project depends on React Router v7 and its Cloudflare adapter. Dev dependencies include Wrangler (the Workers CLI) and TypeScript.
package.json {"name": "team-blog-step-01","private": true,"type": "module","scripts": {"build": "react-router build","dev": "react-router dev","typecheck": "react-router typegen && tsc -b"},"dependencies": {"react": "^19.2.4","react-dom": "^19.2.4","react-router": "^7.13.1"},"devDependencies": {"@cloudflare/vite-plugin": "^1.27.0","@cloudflare/workers-types": "^4.20260310.1","@react-router/dev": "^7.13.1","@types/react": "^19.2.7","@types/react-dom": "^19.2.3","typescript": "^5.9.2","vite": "^6.3.0","wrangler": "^4.72.0"}} -
Configure Wrangler
wrangler.jsonctells Cloudflare how to run your Worker. We declare a D1 database binding now — even though we will not query it until Step 2 — because the binding needs to exist before the Worker starts.wrangler.jsonc {"name": "team-blog","compatibility_date": "2025-04-01","compatibility_flags": ["nodejs_compat"],"main": "./workers/app.ts","d1_databases": [{"binding": "DB","database_name": "team-blog-db","database_id": "local"}]}The
nodejs_compatflag enables Node.js-compatible APIs in the Workers runtime, which some dependencies require. -
Configure Vite
The Cloudflare Vite plugin runs your app inside the Workers runtime during development, so any Workers-incompatible code fails locally instead of in production.
vite.config.ts import { reactRouter } from "@react-router/dev/vite";import { cloudflare } from "@cloudflare/vite-plugin";import { defineConfig } from "vite";export default defineConfig({plugins: [cloudflare({ viteEnvironment: { name: "ssr" } }),reactRouter(),],}); -
Configure React Router
Enable server-side rendering:
react-router.config.ts import type { Config } from "@react-router/dev/config";export default {ssr: true,future: {v8_viteEnvironmentApi: true,},} satisfies Config;With
ssr: true, loaders run on the server (inside the Worker) and the rendered HTML is streamed to the browser. This is how we will fetch data from D1 in later steps. -
Create the Worker entry point
workers/app.tsis the Cloudflare Worker fetch handler. It bridges incoming requests to React Router’s server handler.workers/app.ts import { createRequestHandler } from "react-router";const requestHandler = createRequestHandler(() => import("virtual:react-router/server-build"),import.meta.env.MODE);export default {async fetch(request: Request, env: Record<string, unknown>, ctx: ExecutionContext) {return requestHandler(request, {cloudflare: { env, ctx },});},}; -
Create the server entry
app/entry.server.tsxhandles server-side rendering by streaming HTML to the browser.app/entry.server.tsx import type { AppLoadContext, EntryContext } from "react-router";import { ServerRouter } from "react-router";import { renderToReadableStream } from "react-dom/server";export default async function handleRequest(request: Request,responseStatusCode: number,responseHeaders: Headers,routerContext: EntryContext,_loadContext: AppLoadContext) {const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />);responseHeaders.set("Content-Type", "text/html");return new Response(body, {headers: responseHeaders,status: responseStatusCode,});} -
Create the root layout
app/root.tsxprovides the<html>shell that wraps every page. It also includes anErrorBoundaryso route errors render gracefully instead of showing a blank screen.app/root.tsx import {isRouteErrorResponse, Links, Meta, Outlet,Scripts, ScrollRestoration,} from "react-router";export function Layout({ children }: { children: React.ReactNode }) {return (<html lang="en"><head><meta charSet="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><Meta /><Links /></head><body>{children}<ScrollRestoration /><Scripts /></body></html>);}export default function App() {return <Outlet />;} -
Define routes
React Router v7 uses a
routes.tsfile for route configuration. We start with a single index route.app/routes.ts import { type RouteConfig, index } from "@react-router/dev/routes";export default [index("routes/home.tsx"),] satisfies RouteConfig; -
Create the home page
A static page that confirms everything is wired up.
app/routes/home.tsx export default function Home() {return (<main style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}><h1>Team Blog</h1><p>Welcome to the team blog.</p></main>);}
Running the dev server
Section titled “Running the dev server”pnpm installpnpm devOpen http://localhost:5173 and you should see “Team Blog” rendered by your Worker.
What is happening under the hood
Section titled “What is happening under the hood”When a request hits your Worker:
- Wrangler routes it to the React Router server handler
- React Router matches the URL to a route (here,
home.tsx) - If the route has a
loader, it runs on the server and returns data - The component renders with that data, and HTML is streamed to the browser
- Client-side JavaScript hydrates the page for interactivity
Right now there is no loader — the page is purely static. In the next step, we add a database and a loader that queries it.
Next step
Section titled “Next step”In Step 2: Database Setup, we add Drizzle ORM and @cfast/env to query Cloudflare D1.