Data Fetching
Call the Hono backend from Next.js with typed RPC clients - server-side for authenticated pages, client-side for marketing pages.
The app calls the @repo/api Hono backend through typed hc<AppType> RPC clients. No flag toggles fetching - what matters is where the call runs: getServerApi() in Server Components, api in Client Components.
The clients
All three point at NEXT_PUBLIC_API_URL and differ only in how the session cookie travels.
| File | Export | Use in | Cookie |
|---|---|---|---|
lib/api/client.ts | api | Client Components ("use client") | Browser attaches it via credentials: "include" |
lib/api/server.ts | getServerApi() | Server Components, Server Actions | Reads cookies(), sets the cookie header per call |
lib/auth-client.ts | authClient | Browser session | Better Auth createAuthClient, pointed at <API_URL>/auth |
packages/api/src/index.ts exports AppType, so endpoints, params, and response shapes infer end to end - an unknown route like api.users.me fails to typecheck.
Import AppType as a type only. A runtime import("@repo/api") from app code re-initialises the renderer.
Authenticated pages: Server Components
Dashboard, settings, admin, and onboarding render server-side so the session resolves before paint.
- Fetch with
getServerApi()(lib/api/server.ts) in a Server Component or Server Action. - Resolve the user with
getServerSession()(lib/auth/server.ts). - The
(dashboard)layout guards auth and onboarding first, so child pages can assume a signed-in user.
// app/[locale]/(dashboard)/page.tsx - Server Component (no "use client")
import { getServerApi } from "@/lib/api/server";
export default async function DashboardPage() {
const api = await getServerApi();
const res = await api.dashboard.status.$get({});
const status = await res.json(); // endpoint, params, response all inferred
return <DashboardOverview status={status} />;
}Marketing pages: Client Components
Marketing pages prerender to static HTML, so per-user UI mounts after hydration.
- Use
"use client"plus theapiclient fromlib/api/client.ts. - Read the session with
authClient.useSession(); the rootSessionProviderwarms its shared cache once on mount. - Load imperatively inside
useEffect.
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import { authClient } from "@/lib/auth-client";
export function LiveWidget() {
const { data: session } = authClient.useSession();
const [items, setItems] = useState([]);
useEffect(() => {
api.items.$get().then((r) => r.json()).then(setItems);
}, []);
return session ? <List items={items} /> : null;
}Cookie forwarding on server calls
getServerApi() always fetches the absolute NEXT_PUBLIC_API_URL, because RSC fetch requires absolute URLs, and forwards the incoming request's cookie header on every call.
| Deployment | Call shape |
|---|---|
| Fullstack | Same-origin self-request that the forwarded cookie authenticates |
| Separate backend | Cross-origin call to the backend origin, same forwarding |
Never hand-roll a fetch for a server call: with no forwarded cookie the backend sees an anonymous request and answers 401.
Feature-flag gating
Check the flag in @repo/config before fetching a gated feature - routes stay mounted when their feature is off and answer with an error or an empty payload.
| Feature | Flag | Response when off |
|---|---|---|
| Notifications | config.notifications.enabled | 400 |
/notifications/unseen-count | config.notifications.enabled | { count: 0 } |
| Billing reads | config.payment.enabled | 400 |
Client Architecture
How client code is layered - apps hold thin views, @repo/ui draws shared components, and @repo/app-core owns headless behavior behind host seams.
Theming
Set the default color mode, the switcher, the selectable modes, and the decorative background from config.theme, and rebrand colors in the shared token sheet.