Guide for building applications with React Server Components (RSC) using RedwoodSDK (rwsdk). Use when working with rwsdk projects that need: (1) Server components for data fetching and rendering, (2) Client components for interactivity, (3) Server functions for form handling and mutations, (4) Suspense boundaries for loading states, (5) Context sharing across server components, or (6) Manual rendering with renderToStream/renderToString.
rwsdk uses React Server Components by default. Components render on the server as HTML, then stream to the client.
Server components run on the server, have no client-side JavaScript, and can directly access databases and server resources.
// No directive needed - server component by default
export default function MyServerComponent() {
return <div>Hello, from the server!</div>;
}
Capabilities:
ctxAdd "use client" directive for interactivity. These hydrate in the browser.
"use client";
export default function MyClientComponent() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
Use client components when:
Server components fetch data directly. Wrap async components in Suspense for loading states.
// src/app/pages/todos/TodoPage.tsx
import { Suspense } from "react";
async function Todos({ ctx }) {
const todos = await db.todo.findMany({ where: { userId: ctx.user.id } });
return (
<ol>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ol>
);
}
export async function TodoPage({ ctx }) {
return (
<div>
<h1>Todos</h1>
<Suspense fallback={<div>Loading...</div>}>
<Todos ctx={ctx} />
</Suspense>
</div>
);
}
Key points:
ctx objectctx to child server components that need itExecute server code from client components using the "use server" directive.
// @/pages/todos/functions.tsx
"use server";
import { requestInfo } from "rwsdk/worker";
export async function addTodo(formData: FormData) {
const { ctx } = requestInfo;
const title = formData.get("title");
await db.todo.create({ data: { title, userId: ctx.user.id } });
}
// @/pages/todos/AddTodo.tsx
"use client";
import { addTodo } from "./functions";
export default function AddTodo() {
return (
<form action={addTodo}>
<input type="text" name="title" />
<button type="submit">Add</button>
</form>
);
}
How it works:
// Good: Direct DB access in server component
async function ProductPage({ ctx }) {
const product = await db.product.findUnique({ where: { id: ctx.params.id } });
return <ProductDetails product={product} />;
}
// Good: Server function for mutations
'use server';
export async function updateProduct(formData: FormData) {
const { ctx } = requestInfo;
await db.product.update({ where: { id: formData.get('id') }, data: { ... } });
}
// API route for polling/shared access
// src/app/api/notifications/route.ts
export async function GET(request: Request) {
const notifications = await db.notification.findMany({ ... });
return Response.json(notifications);
}
// Client component polling
'use client';
function Notifications() {
const [data, setData] = useState([]);
useEffect(() => {
const poll = () => fetch('/api/notifications').then(r => r.json()).then(setData);
const interval = setInterval(poll, 5000);
return () => clearInterval(interval);
}, []);
return <NotificationList items={data} />;
}
| Scenario | Approach | Why |
|---|---|---|
| Show user's dashboard on load | RSC direct | No JS needed, streams fast |
| Submit a form | Server function | Progressive enhancement, simple |
| Load more items on scroll | API + fetch | Client-initiated after render |
| Real-time notifications | API + polling/WS | Data changes post-render |
| Mobile app needs same data | API route | Shared contract across clients |
| Delete button click | Server function | Mutation from user action |
❌ Don't create API routes just to fetch in RSC — Access DB directly
// Bad: Unnecessary API hop
async function Page() {
const res = await fetch("/api/products"); // Why?
const products = await res.json();
}
// Good: Direct access
async function Page({ ctx }) {
const products = await db.product.findMany();
}
❌ Don't use RSC for frequently-changing data — Use client-side fetching
// Bad: Stock price in RSC (stale immediately)
async function StockTicker() {
const price = await getStockPrice(); // Stale after render
return <span>{price}</span>;
}
// Good: Client component with polling
("use client");
function StockTicker() {
const { data } = useSWR("/api/stock", fetcher, { refreshInterval: 1000 });
return <span>{data?.price}</span>;
}
Context shares data globally between server components per-request. Populated by middleware, accessible via:
ctx prop in Page componentsrequestInfo.ctx in server functionsFor advanced use cases, render components imperatively.
Returns a ReadableStream that decodes to HTML.
const stream = await renderToStream(<NotFound />, { Document });
const response = new Response(stream, {
status: 404,
});
Options:
Document: Wrapper component for the rendered elementinjectRSCPayload = false: Inject RSC payload for client hydrationonError: Error callback during renderingReturns an HTML string.
const html = await renderToString(<NotFound />, { Document });
const response = new Response(html, {
status: 404,
});
Limitation: renderToStream and renderToString generate HTML only. They don't handle Server Actions or client-side transitions. For fully interactive routes, use render() from defineApp.
| Need | Component Type | Directive |
|---|---|---|
| Display data | Server | (none) |
| Fetch from DB | Server | (none) |
| Click handlers | Client | "use client" |
| useState/useEffect | Client | "use client" |
| Form submission | Client + Server Function | "use client" + "use server" |
| Browser APIs | Client | "use client" |
| Polling / real-time | Client + API route | "use client" + fetch |
| Shared external API | API route | Route handler |
src/app/pages/todos/
├── TodoPage.tsx # Server component (page)
├── AddTodo.tsx # Client component
└── functions.tsx # Server functions