GenerateSaaS

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.

FileExportContextCookie forwarding
lib/api/client.tsapiClient Components ("use client")Browser sends it via credentials: "include"
lib/api/server.tsgetServerApi()Server Components & Server ActionsReads cookies(), sets the cookie header per call
lib/auth-client.tsauthClientBrowser sessionBetter 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() from lib/api/server.ts.
  • Resolve the user with getServerSession() from lib/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 the api client from lib/api/client.ts.
  • Read the session reactively with authClient.useSession(); the root SessionProvider (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.

FeatureConfig flagDisabled response
Notification feedconfig.notifications.enabled400
Unseen count (/notifications/unseen-count)config.notifications.enabled{ count: 0 }
Billing readsconfig.payment.enabled400 (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.

On this page