GenerateSaaS

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.

FileExportUse inCookie
lib/api/client.tsapiClient Components ("use client")Browser attaches 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

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 the api client from lib/api/client.ts.
  • Read the session with authClient.useSession(); the root SessionProvider warms 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;
}

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.

DeploymentCall shape
FullstackSame-origin self-request that the forwarded cookie authenticates
Separate backendCross-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.

FeatureFlagResponse when off
Notificationsconfig.notifications.enabled400
/notifications/unseen-countconfig.notifications.enabled{ count: 0 }
Billing readsconfig.payment.enabled400

On this page