Data Fetching
Call the Hono backend from Next.js with a typed RPC client across Server and Client Components.
The app talks to the @repo/api Hono backend through a typed RPC client, hc<AppType> from hono/client. No flag toggles fetching - what matters is where the call runs: server-side for authenticated pages, client-side for marketing pages.
packages/api/src/index.ts exports export type AppType = typeof routes, so endpoints, params, and response shapes infer end to end - an unknown route like api.users.me would fail to typecheck.
The two clients
There are two RPC clients pointed at NEXT_PUBLIC_API_URL because cookie forwarding differs by context.
| File | Export | Context | Cookie forwarding |
|---|---|---|---|
lib/api/client.ts | api | Client Components ("use client") | Browser sends 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 |
The browser client is minimal - the cookie rides along automatically:
// lib/api/client.ts
"use client";
import { hc } from "hono/client";
import type { AppType } from "@repo/api";
export const api = hc<AppType>(process.env.NEXT_PUBLIC_API_URL!, {
init: { credentials: "include" }
});Import AppType as a type only. Never import("@repo/api") from app code - it re-initialises the renderer. Always go through the typed client.
Authenticated pages - Server Components
Dashboard, settings, admin, and onboarding pages render server-side so the session resolves before paint and never flashes.
- Fetch directly in a Server Component (or Server Action) with
getServerApi()fromlib/api/server.ts. - Resolve the user with
getServerSession()fromlib/auth/server.ts. - The
(dashboard)layout (app/[locale]/(dashboard)/layout.tsx) 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(); // typed from AppType - endpoint, params, response all inferred
return <DashboardOverview status={status} />;
}Marketing pages - client-side
Marketing pages prerender to static HTML, so any per-user UI mounts client-side after hydration.
- Use a Client Component (
"use client") and theapiclient fromlib/api/client.ts. - Read the session reactively with
authClient.useSession(); the rootSessionProvider(providers/session-provider.tsx) warms its shared cache once on mount. - For imperative loads, fetch 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;
}How server calls reach the API
getServerApi() always fetches the absolute NEXT_PUBLIC_API_URL (RSC fetch requires absolute URLs) and forwards the incoming request's cookie header on every call.
- Fullstack: a same-origin self-request that the forwarded cookie authenticates.
- Separate backend: a cross-origin call to the backend origin, same cookie forwarding.
- Never build the URL or fetch by hand for server calls - without the forwarded cookie the backend sees an anonymous request.
Feature flags gate what you fetch
Don't fetch behind a disabled feature - check the flag in @repo/config first. The route still exists when its feature is off, but returns an error or empty payload, so guarding the fetch avoids a wasted call and keeps SSR clean.
| Feature | Config flag | Disabled response |
|---|---|---|
| Notification feed | config.notifications.enabled | 400 |
Unseen count (/notifications/unseen-count) | config.notifications.enabled | { count: 0 } |
| Billing reads | config.payment.enabled | 400 (503 if enabled but the provider has no credentials) |
Frequently asked questions
Server Component or Client Component for a dashboard page?
Server Component. Use getServerApi() so cookies forward automatically and the session resolves before paint. Only drop to a Client Component for genuinely interactive, browser-only state.
My server fetch returns 401 while the browser call works - why?
A hand-rolled fetch in a Server Component does not carry the session cookie. Use getServerApi(), which forwards the incoming request's cookie header on every call.
Why two clients instead of one?
Cookie forwarding differs: the browser attaches the session cookie automatically via credentials: "include", while a Server Component must read cookies() and set the cookie header itself.