Server routes
Decide when a Nuxt API route should call Convex and return a safe server response.
Do not wrap every Convex function in a Nuxt API route. Browser queries and mutations already call Convex with identity and type safety.
Use a Nitro route when
- the operation requires a server-held third-party secret;
- an external client calls your Nuxt API rather than Convex directly;
- the route verifies a webhook;
- Nuxt must control the response format, a cookie, or a header;
- the operation composes multiple server-only systems.
Validated route example
import { api } from '#convex/api'
import { serverConvex, toConvexH3Error } from '#convex/server'
import { z } from 'zod'
const bodySchema = z.object({
projectId: z.string().min(1),
})
export default defineEventHandler(async (event) => {
const parsed = bodySchema.safeParse(await readBody(event))
if (!parsed.success) {
throw createError({ status: 400, statusText: 'Invalid request' })
}
try {
return await serverConvex(event, { auth: 'required' }).action(api.reports.generate, {
projectId: parsed.data.projectId,
})
} catch (error) {
throw toConvexH3Error(error)
}
})Validate at the HTTP boundary and again through Convex argument validators. The Convex function still enforces product authorization.
Return safe errors
toConvexH3Error(error) turns any thrown value into an H3 error. The HTTP status follows the error kind:
| Kind | HTTP status |
|---|---|
authentication | 401, or 403 when the error carries status 403 |
transport | 502 |
server | The application's 4xx status, otherwise 400 |
unknown | 500 |
The response data is the serialized ConvexCallError without functionName: kind, message, code, status, and data. Function paths reveal how the application is built, so log functionName from the original error on the server instead. The body never contains a raw cause, stack, cookie, or token. A server error keeps the application's ConvexError data, so put only data the caller may see into it.
Revive the error in the browser with normalizeConvexError:
import { isConvexCallError, normalizeConvexError } from '@lupinum/better-convex-nuxt/errors'
try {
await $fetch('/api/reports', { method: 'POST', body: { projectId } })
} catch (raw) {
const error = normalizeConvexError(raw)
if (isConvexCallError(error, 'UNAUTHENTICATED')) await navigateTo('/auth/signin')
else reportError.value = error.message
}Map the error to your own response instead when a route must not reveal the Convex function it called or the application error data. Do not serialize raw upstream errors, request objects, or credentials. ConvexCallError deliberately does not retain the raw upstream cause. Log only sanitized diagnostics.
Require a signed-in user
requireConvexUser(event) throws the same serialized 401 error, with code UNAUTHENTICATED, when the request has no valid session:
export default defineEventHandler(async (event) => {
const user = await requireConvexUser(event)
setHeader(event, 'cache-control', 'private, no-store')
return { id: user.id }
})Use getConvexUser(event) when an anonymous request is valid; it returns null. Both helpers are auto-imported in Nitro code and exchange the session cookie at most once per request; serverConvex(event) reuses that exchange, so it authorizes the same identity. The user always has id; profile fields such as name and email are present only when defineSessionClaims adds them. The user is display identity: product data still comes from Convex functions that check identity themselves.
Server middleware
Middleware can create a caller for request-scoped checks, but avoid turning every page request into an extra Convex call. Prefer route metadata and page queries when they already express the requirement.
Direct call or route
| Requirement | Use |
|---|---|
| Live page data | useConvexQuery |
| Interactive write | useConvexMutation |
| Server-side user | getConvexUser |
| Server-held secret | Nitro route |
| External webhook | Nitro route |
| Scheduled Convex work | Convex scheduler/cron |