For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
Server / client boundaries
Anything passed into a Client Component is public. Use package ./server and ./client entry points so secrets and DB clients never reach the browser.
The most important Next.js rule still applies in TurboStarter: whatever you pass into a Client Component is serialized and sent to the browser.
That includes nested objects. If a server component builds a config with an API key and hands the whole object to a 'use client' child, the key leaks - even if the child never renders it.
Never pass secrets into client components
async function ServerPage() {
const config = {
apiKey: process.env.STRIPE_SECRET_KEY,
storeId: process.env.NEXT_PUBLIC_URL,
};
const data = await loadBillingData(config);
// Leaks STRIPE_SECRET_KEY to the client bundle / RSC payload
return <BillingClient data={data} config={config} />;
}Keep secrets inside server-only modules. Return only the fields the UI needs:
import { getBillingSummary } from "~/modules/billing/lib/server";
async function ServerPage() {
const summary = await getBillingSummary();
return <BillingClient summary={summary} />;
}Props are a public API
Treat every prop on a Client Component as public. Prefer narrow DTOs over passing database rows, full session objects, or provider configs.
Use the right package entry points
Shared packages expose separate entry points for server and client code. Import from those - do not deep-import into files that pull in secrets or Node-only SDKs.
| Package | Server / privileged | Client-safe |
|---|---|---|
@workspace/auth | @workspace/auth/server | @workspace/auth/client/web |
@workspace/db | @workspace/db/server | schema types via @workspace/db/schema when needed |
@workspace/billing-web | @workspace/billing-web/server | schemas via @workspace/billing-web/schema |
@workspace/storage | @workspace/storage/server | - (call the API instead) |
@workspace/i18n | @workspace/i18n/server | @workspace/i18n |
Example from the web app: session helpers import the auth server entry, never the Better Auth instance into client code:
import { auth } from "@workspace/auth/server";
export const getSession = cache(async () => {
const data = await auth.api.getSession({
headers: await getHeaders(),
});
return {
session: data?.session ?? null,
user: data?.user ?? null,
};
});On the client, use the dedicated client builders:
import { createAuthClient } from "@workspace/auth/client/web";Do not mix server and client in one barrel
Avoid a single index.ts that re-exports both a server SDK and a Client Component. That pattern makes it easy for a client import to accidentally pull server code into the browser bundle.
TurboStarter already splits exports in package.json:
{
"exports": {
".": "./src/index.ts",
"./env": "./src/env.ts",
"./server": "./src/server.ts",
"./client/*": "./src/client/*.ts"
}
}When you add a new shared package:
- Put privileged code behind
./server(or similarly named) exports - Put browser-safe helpers behind
./client(or the package root if it is truly isomorphic) - Keep env presets in
./envand only import them from server code - Never re-export server modules from a client entry
Server Components vs Client Components
Default to Server Components. Add 'use client' only when you need interactivity, browser APIs, or client-only libraries.
When a layout or page is a Server Component:
- Fetch with
getSession()from~/lib/auth/server - Call Hono through
~/lib/api/serverso cookies and headers stay on the server - Pass serializable, non-sensitive props down to client islands
Dashboard layouts already follow this pattern - they resolve the session on the server and redirect before rendering protected UI:
const { user } = await getSession();
if (!user) {
return redirect(pathsConfig.auth.login);
}UI gates are useful UX, but they are not security. Always enforce the same rules again in the API.
How is this guide?
Last updated on