Data Fetching
Fetch backend data in Nuxt with the typed Hono RPC client and SSR-safe session handling.
Nuxt talks to the backend through a typed Hono RPC client (hc<AppType>) built from @repo/api. There's no flag to toggle fetching - what matters is where the fetch runs (server for authenticated pages, client for marketing) and that the session cookie is forwarded during SSR.
The typed client
The client lives at apps/web-nuxt/app/utils/api.ts, built from AppType so endpoints, params, and response shapes are inferred end to end. It sets credentials: "include" (the browser attaches the cookie) and a custom apiFetch that handles SSR cookie forwarding. The base URL comes from the NUXT_PUBLIC_API_URL env var.
// apps/web-nuxt/app/utils/api.ts
import type { AppType } from "@repo/api";
import { hc } from "hono/client";
import { apiFetch } from "./api-fetch";
export type Client = ReturnType<typeof hc<AppType>>;
export const hcWithType = (...args: Parameters<typeof hc>): Client => hc<AppType>(...args);
export const api = hcWithType(import.meta.env.NUXT_PUBLIC_API_URL, {
init: { credentials: "include" },
fetch: apiFetch,
});Auth uses Better Auth's createAuthClient in app/utils/auth.ts, pointed at <NUXT_PUBLIC_API_URL>/auth. The same file exports ssrFetch, which forwards the cookie from useRequestHeaders(["cookie"]) during SSR.
Pick a pattern
The page's audience decides where the fetch runs. Authenticated UI renders server-side with the session resolved before paint; marketing UI stays static and hydrates session on the client.
| Page type | Data | Session | Render |
|---|---|---|---|
| Dashboard / settings / admin / onboarding | await useAsyncData(...) | await authClient.useSession(ssrFetch) | SSR |
| Landing / marketing | client | authClient.useSession() (no await) | static + <ClientOnly> |
Authenticated pages (SSR)
Render server-side so the session resolves before paint and never flashes. Use await useAsyncData(...) for data and await authClient.useSession(ssrFetch) for the session - ssrFetch injects the cookie, and the RPC client's apiFetch does the same for data calls. Gate access with the auth route middleware (it redirects unauthenticated visitors and enforces any roles in definePageMeta).
// apps/web-nuxt/app/pages/dashboard/index.vue <script setup>
definePageMeta({ layout: "dashboard", middleware: "auth" });
const { data: session } = await authClient.useSession(ssrFetch);
const { data: dashboardData } = await useAsyncData("dashboard-status", async () => {
const response = await api.dashboard.status.$get({});
if (!response.ok) return null;
return response.json();
});In SSR contexts always await navigateTo() and build paths with localePath() - hardcoded paths break non-default locales.
Marketing pages (client)
Marketing pages prerender to static HTML, so per-user UI mounts client-side after hydration. Call authClient.useSession() with no ssrFetch and no await, then wrap user-dependent UI in <ClientOnly> so SSR emits a stable, cacheable shell.
<script setup lang="ts">
const session = authClient.useSession();
</script>
<template>
<ClientOnly>
<UserMenu v-if="session.data.value" :user="session.data.value.user" />
<template #fallback><SignInButton /></template>
</ClientOnly>
</template>SSR self-request gotcha
SSR fetches to your own origin must use a relative path, never an absolute same-origin URL - on serverless platforms an absolute self-URL triggers an external HTTP hairpin that loses request context and pollutes parallel prerenders.
apiFetchstrips same-origin absolute URLs to relative and routes them through$fetch.raw(Nitro's in-process handler, no HTTP round-trip).ssrFetchdoes the same viauseFetch.- During prerender both always strip to relative, since renders run against a local dev server, not the production origin.
globalThis.fetchwould reject a relative URL - that's why the custom fetch exists. Cross-origin (separate-backend) URLs are left absolute, with the cookie still forwarded.- Never
import("@repo/api")fromapp/code - it re-initialises the renderer. Import only theAppTypetype; route all data throughapi.
Frequently asked questions
Why not import the API package directly and skip the HTTP call?
Importing @repo/api runtime into app/ re-initialises the renderer and breaks SSR. Apps import only the AppType type; data always flows over the typed RPC client.
My SSR fetch throws an "Invalid URL" error - what's wrong?
That's the self-request gotcha: a relative URL reached globalThis.fetch. Go through api (which uses apiFetch) or ssrFetch so same-origin requests resolve in-process.
Do I need ssrFetch on marketing pages?
No. Marketing pages prerender and resolve the session on the client - call authClient.useSession() with no argument and wrap user UI in <ClientOnly>.