Skip to content

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.

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.

  • Node.js 20+
  • pnpm (latest) — npm install -g pnpm
  • Wrangler CLInpm install -g wrangler
  • A Cloudflare account (free tier works)
  • 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
  1. Create package.json

    The 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"
    }
    }
  2. Configure Wrangler

    wrangler.jsonc tells 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_compat flag enables Node.js-compatible APIs in the Workers runtime, which some dependencies require.

  3. 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(),
    ],
    });
  4. 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.

  5. Create the Worker entry point

    workers/app.ts is 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 },
    });
    },
    };
  6. Create the server entry

    app/entry.server.tsx handles 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,
    });
    }
  7. Create the root layout

    app/root.tsx provides the <html> shell that wraps every page. It also includes an ErrorBoundary so 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 />;
    }
  8. Define routes

    React Router v7 uses a routes.ts file 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;
  9. 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>
    );
    }
Terminal window
pnpm install
pnpm dev

Open http://localhost:5173 and you should see “Team Blog” rendered by your Worker.

The Team Blog home page showing the heading and latest posts

When a request hits your Worker:

  1. Wrangler routes it to the React Router server handler
  2. React Router matches the URL to a route (here, home.tsx)
  3. If the route has a loader, it runs on the server and returns data
  4. The component renders with that data, and HTML is streamed to the browser
  5. 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.

In Step 2: Database Setup, we add Drizzle ORM and @cfast/env to query Cloudflare D1.