Keep Nuxt server and client boundaries explicit
A technical guide to Nuxt server routes, runtime configuration, API contracts, authentication and avoiding secrets or server-only dependencies in browser bundles.
One repository contains two execution environments
Nuxt makes server and browser code feel close, but they run with different capabilities and trust. The server can hold credentials and call protected services. Browser code is delivered to users and every value inside it must be treated as public.
Make imports reflect the boundary. Database clients, private SDK keys and filesystem access belong in server routes or server utilities. Components should consume typed application responses rather than importing infrastructure directly.
export default defineNuxtConfig({
runtimeConfig: {
openAiApiKey: process.env.OPENAI_API_KEY,
auth0ClientSecret: process.env.AUTH0_CLIENT_SECRET,
public: {
appBaseUrl: process.env.NUXT_PUBLIC_APP_BASE_URL,
},
},
})
// Values under public are included in the client payload.Server routes are application boundaries
A file under server/api becomes an HTTP handler. Parse untrusted input, establish identity, call application behaviour and map the result to a stable response. Avoid placing the complete workflow in a page component simply because $fetch can call an external API.
A server route can also keep third-party API shapes away from the client. Translate the external response into the smaller contract the interface actually uses and retain diagnostic detail in server-side logs.
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id")
if (!id) {
throw createError({ statusCode: 400, statusMessage: "Account id required" })
}
const account = await accountService(event).find(id)
if (!account) {
throw createError({ statusCode: 404, statusMessage: "Account not found" })
}
return AccountResponse.from(account)
})TypeScript types do not validate network data
A generic such as $fetch<AccountResponse> tells TypeScript how code intends to use the result. It does not prove that the server or third party returned that shape at runtime.
Validate at boundaries where data is untrusted or compatibility matters. Keep runtime schemas next to the transport contract and translate valid input into domain-oriented application values.
const raw: unknown = await $fetch("/api/account")
const parsed = AccountResponseSchema.safeParse(raw)
if (!parsed.success) {
throw new Error("Account response did not match its contract")
}
const account: AccountResponse = parsed.dataAuthentication is more than hiding a page
A client-side route guard improves navigation but does not protect server data. Every server route that returns private information must authenticate the request and authorise the requested operation.
Keep access tokens out of logs and avoid exposing provider secrets through public runtime configuration. Test missing, expired and insufficient credentials at the server boundary, not only the successful browser redirect.
Shared code should genuinely be environment-neutral
Pure types, validation and formatting can often be shared. A shared module that conditionally checks whether window exists is frequently an environment-specific module whose ownership has become unclear.
Keep server-only and client-only entry points obvious. This also prevents a harmless component import from pulling a Node dependency or private configuration path into the browser build.
Review the built application, not just source folders
Inspect client bundles for accidental secrets and unexpectedly large server libraries. Exercise a fresh SSR request, hydrated navigation and direct API calls with invalid credentials.
The boundary is healthy when a reader can trace browser input through a server contract to application behaviour and back without guessing which runtime owns authentication, validation or external integration.