writing/tutorial/2026/07
TutorialJul 10, 2026·28 min read

RedwoodSDK: Build a Full-Stack React App on Cloudflare Workers

Learn RedwoodSDK, the React framework built for Cloudflare. Build a complete snippet-sharing app with React Server Components, server functions, D1, middleware, Durable Object sessions, and a one-command deploy to the edge.

Most React meta-frameworks were designed for Node.js servers and later retrofitted onto the edge. RedwoodSDK goes the other way. It starts as a Vite plugin, targets Cloudflare Workers as its only runtime, and hands you the raw Request and Response objects instead of hiding them behind conventions. React Server Components, server functions, streaming, and realtime all sit on top of that foundation rather than beside it.

In this tutorial you will build Snippets, a small code-sharing app: a home page that lists snippets from a Cloudflare D1 database, a detail page at a dynamic route, a client component that submits new snippets through a server function, a middleware guard that protects an admin route, and cookie sessions backed by a Durable Object. Then you will deploy the whole thing to Cloudflare with a single command.

By the end you will understand where RedwoodSDK's server/client boundary sits, how ctx flows through a request, and why "it's just a Worker" is the framework's most important design decision.

Why RedwoodSDK, and When to Reach for It

RedwoodSDK is not a general-purpose Next.js replacement. It makes a specific bet — that if you are deploying to Cloudflare anyway, the framework should expose the platform rather than abstract it. That bet has consequences worth understanding before you commit.

  • There is no hidden request lifecycle. Your app is a single defineApp call that receives a request and returns a response. Routes are functions. Middleware is functions. If you can read a Worker, you can read a RedwoodSDK app.
  • Platform primitives are first-class, not adapters. D1, R2, KV, Queues, and Durable Objects are accessed through the standard env binding from cloudflare:workers. There is no compatibility shim translating a Node API into a Cloudflare one.
  • Local development mirrors production. Because vite dev runs your code inside the actual Workers runtime via Miniflare, the D1 database you query locally behaves like the D1 database you query in production.
  • You own the HTML document. There is no magic root layout. You write a Document component that emits html, head, and body yourself.

Reach for RedwoodSDK when Cloudflare is your deployment target and you want full control over requests, responses, and streaming. Reach for something larger when you need a broad plugin ecosystem, built-in image optimization, or the ability to move to a different host next quarter without a rewrite.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ and pnpm installed (npm works too, but the RedwoodSDK docs standardize on pnpm)
  • A free Cloudflare account — you need one to create a D1 database and to deploy
  • Comfort with React 19 fundamentals: components, props, and hooks
  • A working understanding of what React Server Components are: components that render on the server and never ship their JavaScript to the browser
  • A code editor and a terminal

You do not need prior Cloudflare Workers experience. The tutorial introduces each binding as it becomes necessary.

What You'll Build

Snippets is a single Worker exposing four routes:

RouteTypeWhat it does
/Server ComponentLists all snippets from D1
/s/:keyServer ComponentRenders one snippet by its key
/newServer Component + client islandForm that calls a server function
/adminGuarded routeBlocked by middleware unless a session exists

Total application code is a few hundred lines. Every line runs on Cloudflare's edge network.

Step 1: Scaffold the Project

RedwoodSDK ships a starter generator. Create the project and install dependencies:

npx create-rwsdk snippets
cd snippets
pnpm install
pnpm dev

Open the printed localhost URL. You should see the starter page. Now look at what the generator produced — the interesting parts are few:

snippets/
├── src/
│   ├── worker.tsx          # the entry point: defineApp lives here
│   ├── client.tsx          # hydration entry for client components
│   └── app/
│       ├── Document.tsx    # the HTML shell you control
│       └── pages/
│           └── Home.tsx
├── wrangler.jsonc          # Cloudflare configuration and bindings
└── vite.config.mts

That is the whole framework surface. There is no app/ directory convention, no file-based router, and no build manifest to reason about.

Step 2: Understand worker.tsx and the Document

Open src/worker.tsx. This file is the request handler for your entire application:

// src/worker.tsx
import { defineApp } from "rwsdk/worker";
import { route, render } from "rwsdk/router";
 
import { Document } from "@/app/Document";
import { Home } from "@/app/pages/Home";
 
export default defineApp([
  render(Document, [
    route("/", Home),
  ]),
]);

Three functions do all the work:

  • defineApp(handlers) takes an array of handlers and returns a Worker. Handlers run top to bottom.
  • route(path, handler) matches a path. The handler can return a React element, a Response, or anything in between.
  • render(Document, routes) wraps a group of routes so that any React element they return is rendered inside the given HTML document and streamed to the browser.

The key insight is that route handlers are ordinary functions returning ordinary responses. Nothing forces you into React:

export default defineApp([
  // A plain text response — no React involved
  route("/health", () => new Response("ok")),
 
  // An XML response
  route("/sitemap.xml", async () => {
    const xml = await buildSitemap();
    return new Response(xml, {
      status: 200,
      headers: { "Content-Type": "application/xml" },
    });
  }),
 
  // A redirect
  route("/docs", () =>
    new Response(null, {
      status: 301,
      headers: { Location: "https://docs.rwsdk.com" },
    })
  ),
 
  // React routes, wrapped in the Document
  render(Document, [route("/", Home)]),
]);

Now open src/app/Document.tsx. Unlike a Next.js root layout, this is literally the page shell, and you write every tag:

// src/app/Document.tsx
export const Document = ({ children }: { children: React.ReactNode }) => (
  <html lang="en">
    <head>
      <meta charSet="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <title>Snippets</title>
      <link rel="modulepreload" href="/src/client.tsx" />
    </head>
    <body>
      <div id="root">{children}</div>
      <script>import("/src/client.tsx")</script>
    </body>
  </html>
);

That final script tag loads src/client.tsx, which calls initClient() to hydrate any client components on the page. If you delete it, your app still renders — it simply becomes fully static HTML with no interactivity. That is a useful thing to be able to reason about.

// src/client.tsx
import { initClient } from "rwsdk/client";
 
initClient();

Step 3: Write Your First Server Component

Replace src/app/pages/Home.tsx with a Server Component. Because it runs on the server, it can read anything the Worker can read — no useEffect, no fetch waterfall, no loading state.

RedwoodSDK passes every page a RequestInfo object as props, containing request, params, ctx, and response:

// src/app/pages/Home.tsx
import type { RequestInfo } from "rwsdk/worker";
 
export function Home({ request }: RequestInfo) {
  const url = new URL(request.url);
 
  return (
    <main>
      <h1>Snippets</h1>
      <p>Serving from {url.hostname}</p>
      <a href="/new">Create a snippet</a>
    </main>
  );
}

Important: inside a Server Component, take requestInfo from props. Do not call getRequestInfo() there — that helper exists for server functions, which have no props to receive. Calling it inside a Server Component throws at runtime. This is the single most common mistake newcomers make.

Dynamic segments work the way you would expect, and land in params:

// src/worker.tsx
route("/s/:key", SnippetPage),
// src/app/pages/SnippetPage.tsx
import type { RequestInfo } from "rwsdk/worker";
 
export function SnippetPage({ params }: RequestInfo) {
  return <h1>Snippet: {params.key}</h1>;
}

Step 4: Add a Database with D1

Cloudflare D1 is a SQLite database that runs at the edge. Create one:

npx wrangler d1 create snippets-db

Wrangler prints a database_id. Add the binding to wrangler.jsonc:

{
  "name": "snippets",
  "main": "src/worker.tsx",
  "compatibility_date": "2026-01-01",
  "compatibility_flags": ["nodejs_compat"],
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "snippets-db",
      "database_id": "paste-the-id-wrangler-printed"
    }
  ]
}

Create a migration file at migrations/0001_create_snippets.sql:

CREATE TABLE IF NOT EXISTS snippets (
  key        TEXT PRIMARY KEY,
  title      TEXT NOT NULL,
  language   TEXT NOT NULL DEFAULT 'text',
  body       TEXT NOT NULL,
  created_at INTEGER NOT NULL
);

Apply it to the local database, then to production:

# local (Miniflare) database used by pnpm dev
npx wrangler d1 migrations apply snippets-db --local
 
# the real, remote D1 database
npx wrangler d1 migrations apply snippets-db --remote

Now write a thin data layer. The env object comes from cloudflare:workers and is fully typed:

// src/db.ts
import { env } from "cloudflare:workers";
 
export type Snippet = {
  key: string;
  title: string;
  language: string;
  body: string;
  created_at: number;
};
 
export async function listSnippets(): Promise<Snippet[]> {
  const { results } = await env.DB
    .prepare("SELECT * FROM snippets ORDER BY created_at DESC LIMIT 50")
    .all<Snippet>();
 
  return results;
}
 
export async function getSnippet(key: string): Promise<Snippet | null> {
  return await env.DB
    .prepare("SELECT * FROM snippets WHERE key = ?")
    .bind(key)
    .first<Snippet>();
}
 
export async function createSnippet(input: Omit<Snippet, "created_at">) {
  await env.DB
    .prepare(
      "INSERT INTO snippets (key, title, language, body, created_at) VALUES (?, ?, ?, ?, ?)"
    )
    .bind(input.key, input.title, input.language, input.body, Date.now())
    .run();
}

Note the .bind() calls. D1's prepared statements are parameterized, which is what keeps SQL injection off the table — never interpolate user input into a query string.

Wire the queries into your pages. This is where Server Components earn their keep: the database call happens during render, on the same machine, with no client round trip.

// src/app/pages/Home.tsx
import { listSnippets } from "@/db";
 
export async function Home() {
  const snippets = await listSnippets();
 
  return (
    <main>
      <h1>Snippets</h1>
      <a href="/new">Create a snippet</a>
 
      <ul>
        {snippets.map((s) => (
          <li key={s.key}>
            <a href={`/s/${s.key}`}>{s.title}</a>
            <span> — {s.language}</span>
          </li>
        ))}
      </ul>
    </main>
  );
}
// src/app/pages/SnippetPage.tsx
import type { RequestInfo } from "rwsdk/worker";
import { getSnippet } from "@/db";
 
export async function SnippetPage({ params }: RequestInfo) {
  const snippet = await getSnippet(params.key);
 
  if (!snippet) {
    return new Response("Not found", { status: 404 });
  }
 
  return (
    <main>
      <h1>{snippet.title}</h1>
      <pre>
        <code>{snippet.body}</code>
      </pre>
    </main>
  );
}

That return new Response(...) inside a page component is not a trick — a route handler may return either a React element or a Response, and RedwoodSDK does the right thing with each. A real 404 status code, from a component, with no special API.

Step 5: Client Components and Server Functions

Server Components cannot use state or event handlers. For the "create snippet" form you need a client island, marked with the 'use client' directive:

// src/app/components/SnippetForm.tsx
"use client";
 
import { useState, useTransition } from "react";
import { saveSnippet } from "@/app/actions/saveSnippet";
 
export function SnippetForm() {
  const [error, setError] = useState<string | null>(null);
  const [isPending, startTransition] = useTransition();
 
  function onSubmit(formData: FormData) {
    startTransition(async () => {
      const result = await saveSnippet(formData);
 
      if (result.ok) {
        window.location.href = `/s/${result.key}`;
      } else {
        setError(result.error);
      }
    });
  }
 
  return (
    <form action={onSubmit}>
      <input name="title" placeholder="Title" required />
      <input name="language" placeholder="typescript" defaultValue="text" />
      <textarea name="body" placeholder="Paste your code" required rows={12} />
 
      <button type="submit" disabled={isPending}>
        {isPending ? "Saving…" : "Save snippet"}
      </button>
 
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

The form calls saveSnippet, which lives in a file marked 'use server'. This function never ships to the browser. React replaces the import with an RPC stub, and calling it issues a POST back to your Worker:

// src/app/actions/saveSnippet.ts
"use server";
 
import { createSnippet } from "@/db";
 
export async function saveSnippet(formData: FormData) {
  const title = String(formData.get("title") ?? "").trim();
  const body = String(formData.get("body") ?? "");
  const language = String(formData.get("language") ?? "text").trim();
 
  if (title.length === 0) {
    return { ok: false as const, error: "Title is required." };
  }
 
  if (body.length > 100_000) {
    return { ok: false as const, error: "Snippet is too large." };
  }
 
  const key = crypto.randomUUID().slice(0, 8);
  await createSnippet({ key, title, language, body });
 
  return { ok: true as const, key };
}

Two things deserve emphasis. First, validate inside the server function, not only in the form. The 'use server' boundary is a public HTTP endpoint; anyone can call it with any payload. Client-side validation is a convenience for users, never a security control.

Second, if a server function needs request context — the current user, a cookie, the URL — it imports requestInfo rather than receiving it as props:

"use server";
 
import { requestInfo } from "rwsdk/worker";
 
export async function whoAmI() {
  const { ctx, request } = requestInfo;
  return ctx.user?.username ?? "anonymous";
}

Finally, render the island from a Server Component page:

// src/app/pages/NewSnippet.tsx
import { SnippetForm } from "@/app/components/SnippetForm";
 
export function NewSnippet() {
  return (
    <main>
      <h1>New snippet</h1>
      <SnippetForm />
    </main>
  );
}

Only SnippetForm and its dependencies are sent to the browser. The page shell, the layout, and your database code stay on the server.

Step 6: Middleware, ctx, and Interruptors

RedwoodSDK has no separate middleware file. A middleware is just a function you put earlier in the defineApp array. It receives the same request info, and it can do one of two things: mutate ctx and return nothing (letting the request continue), or return a Response (short-circuiting everything after it).

// src/worker.tsx
import { defineApp, ErrorResponse } from "rwsdk/worker";
import { route, render } from "rwsdk/router";
 
export default defineApp([
  // Runs on every request, before route matching
  async function sessionMiddleware({ request, ctx }) {
    const session = await sessionStore.load(request);
    ctx.session = session ?? { userId: null };
  },
 
  async function userMiddleware({ ctx }) {
    if (ctx.session.userId) {
      ctx.user = await getUser(ctx.session.userId);
    }
  },
 
  render(Document, [
    route("/", Home),
    route("/new", NewSnippet),
    route("/s/:key", SnippetPage),
 
    // Route-level interruptors: an array of handlers, run in order
    route("/admin", [
      function requireUser({ ctx }) {
        if (!ctx.user) {
          throw new ErrorResponse(401, "Unauthorized");
        }
      },
      AdminPage,
    ]),
  ]),
]);

The /admin route shows RedwoodSDK's interruptor pattern. Instead of a single handler, pass an array. Each function runs in order; the first one to return a Response (or throw an ErrorResponse) stops the chain. AdminPage only ever runs for an authenticated user, and it can read ctx.user with confidence.

This composes well. An interruptor is a plain function, so requireUser can be exported from a shared module and reused across a dozen routes without any framework registration step.

To type ctx, extend the framework's AppContext interface:

// src/types.d.ts
declare module "rwsdk/worker" {
  interface AppContext {
    session: { userId: string | null };
    user?: { id: string; username: string };
  }
}

Step 7: Sessions Backed by a Durable Object

Cookies alone cannot hold server-side session state, and Workers have no memory between requests. RedwoodSDK's answer is a Durable Object — a single-threaded, strongly consistent object with its own storage, addressed by ID.

Define the object:

// src/sessions/UserSession.ts
interface SessionData {
  userId: string | null;
}
 
export class UserSession implements DurableObject {
  private storage: DurableObjectStorage;
  private session: SessionData | undefined;
 
  constructor(state: DurableObjectState) {
    this.storage = state.storage;
  }
 
  async getSession() {
    if (!this.session) {
      this.session =
        (await this.storage.get<SessionData>("session")) ?? { userId: null };
    }
    return { value: this.session };
  }
 
  async saveSession(data: Partial<SessionData>) {
    this.session = { userId: data.userId ?? null };
    await this.storage.put("session", this.session);
    return this.session;
  }
 
  async revokeSession() {
    await this.storage.delete("session");
    this.session = undefined;
  }
}

Bind it in wrangler.jsonc. The migrations block tells Cloudflare that this class needs SQLite-backed storage:

{
  "durable_objects": {
    "bindings": [
      { "name": "USER_SESSION_DO", "class_name": "UserSession" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["UserSession"] }
  ]
}

Then create the session store in your worker and re-export the class so Wrangler can find it:

// src/worker.tsx
import { defineDurableSession } from "rwsdk/auth";
import { env } from "cloudflare:workers";
import { UserSession } from "./sessions/UserSession";
 
export const sessionStore = defineDurableSession({
  sessionDurableObject: env.USER_SESSION_DO,
});
 
export { UserSession };

sessionStore.load(request) reads the signed session cookie, routes to the right Durable Object instance, and returns its data. That is the sessionMiddleware from the previous step, now fully wired.

For real authentication, RedwoodSDK ships a passkey addon that layers WebAuthn on top of this exact session store. Because it is built from the same primitives you just saw, you can read its source and understand it end to end.

Step 8: Deploy to the Edge

Deployment is one command:

pnpm release

That builds the client and server bundles, uploads static assets, applies your Worker configuration, and publishes to your workers.dev subdomain (or your custom domain, if configured). Remember to run remote migrations first — a fresh production D1 database has no tables:

npx wrangler d1 migrations apply snippets-db --remote
pnpm release

For a staging environment, define an env.staging block in wrangler.jsonc and select it with an environment variable:

CLOUDFLARE_ENV=staging pnpm release

Testing Your Implementation

Verify the app end to end before you call it done:

  1. Run pnpm dev and confirm the home page renders an empty list without errors.
  2. Visit /new, submit a snippet, and confirm the browser redirects to /s/:key with your content.
  3. Confirm the server function is a real endpoint. Open your browser's network tab while submitting. You should see a POST request to the current page — that is the RPC call. Server functions are HTTP endpoints, and seeing that request makes the boundary concrete.
  4. Check what shipped to the client. View source on /s/:key. Your database code and saveSnippet must not appear anywhere in the JavaScript bundle.
  5. Test the guard. Visit /admin with no session. You should get a 401, not a rendered page.
  6. Verify persistence. Restart pnpm dev and confirm your snippets survive, since they live in the local D1 file rather than in memory.

A quick query confirms the data landed where you think it did:

npx wrangler d1 execute snippets-db --local \
  --command "SELECT key, title FROM snippets"

Troubleshooting

getRequestInfo() throws inside a page component. Server Components receive requestInfo as props. Destructure request, params, and ctx from the component's props instead. Reserve getRequestInfo() and the requestInfo import for 'use server' functions.

env.DB is undefined. The binding name in wrangler.jsonc must match the property you access exactly. A binding named DB gives you env.DB, never env.db. Restart the dev server after editing wrangler.jsonc, since bindings are read at startup.

no such table: snippets in development. You applied migrations remotely but not locally. Run the migration command again with the --local flag.

A client component fails with "cannot read state of undefined". The 'use client' directive must be the very first line of the file, before any import. A misplaced directive silently makes the file a Server Component.

Hydration fails, or nothing is interactive. Confirm your Document still contains the script tag that imports /src/client.tsx, and that src/client.tsx calls initClient().

Durable Object class not found on deploy. The class must be exported from your Worker entry point, and it needs an entry in the migrations array of wrangler.jsonc. Both are required.

Next Steps

You now have a full-stack React app running entirely on Cloudflare's network, with a database, sessions, server functions, and route guards. Some directions worth exploring:

  • Add realtime. RedwoodSDK exposes a realtime layer built on Durable Objects and WebSockets, so a new snippet can appear on every open tab without polling.
  • Stream long pages. Because responses are streamed, you can wrap a slow section in React's Suspense and let the shell paint immediately.
  • Add an ORM. Both Prisma and Drizzle work over D1 when you want migrations and types generated from a schema rather than hand-written SQL.
  • Store files in R2. Add an R2 binding and let users attach images to snippets. The pattern is identical to the D1 one: bind it, access it through env, call it from a server function.

If you want to compare approaches, our tutorials on Waku and OpenNext on Cloudflare Workers cover two very different takes on the same underlying React model. The Cloudflare Workers and Hono guide is a good companion if you want to build a pure API alongside this app.

Conclusion

RedwoodSDK's central argument is that a framework should make the platform legible instead of hiding it. Everything in this tutorial followed from that: routes are functions, middleware is functions, the HTML document is yours, and the database is reached through the same env binding a bare Worker would use.

The trade is explicit. You give up portability and a large plugin ecosystem. In exchange you get a codebase where nothing important happens off-screen — where you can trace a request from defineApp through middleware, into a Server Component, out through a server function, and down to a D1 prepared statement, reading only your own code the entire way.

For teams already committed to Cloudflare, that transparency is worth a great deal. Start with the snippet app you just built, add realtime or R2 when you need them, and let the framework stay out of your way.