Backend service integration
Overview
The frontend talks to the backend through the generated OpenAPI client under
shared/api-client. Every downstream call should be
wrapped in a thin service that:
- Injects the runtime configuration with
createBackendApiConfig()so theX-Shared-Tokenheader is always present. - Accepts the caller's domain language (
'en' | 'fr') and forwards it to the backend. - Lazily instantiates the generated API so the client only exists on the server (or in Vitest) and can be reused across calls.
- Guards against accidental client-side usage to keep secrets such as
MACHINE_TOKENout of the browser bundle. - Treats every response as domain-sensitive and reuses the shared helper for cache headers so CDNs keep hostname-specific variants.
The blog feature provides a good end-to-end example of the pattern: the
useBlogService wrapper feeds
Nuxt server routes, those routes are consumed by the
useBlog composable, and components such
as TheArticle.vue display the
resulting data. The sections below break that flow down.
1. Create a service wrapper (server only)
Service wrappers live next to the generated APIs. They enforce authentication, domain language propagation, and lazy client instantiation. The blog service is representative:
// shared/api-client/services/blog.services.ts
import { BlogApi } from '..'
import type { DomainLanguage } from '../../utils/domain-language'
import { createBackendApiConfig } from './createBackendApiConfig'
export const useBlogService = (domainLanguage: DomainLanguage) => {
const isVitest =
typeof process !== 'undefined' && process.env?.VITEST === 'true'
const isServerRuntime = import.meta.server || isVitest
let api: BlogApi | undefined
const resolveApi = () => {
if (!isServerRuntime) {
throw new Error(
'useBlogService() is only available on the server runtime.'
)
}
if (!api) {
api = new BlogApi(createBackendApiConfig())
}
return api
}
const getArticleBySlug = async (slug: string) => {
return await resolveApi().post({ slug, domainLanguage })
}
const getArticles = async (params?: {
tag?: string
pageNumber?: number
pageSize?: number
}) => {
return await resolveApi().posts({ ...params, domainLanguage })
}
const getTags = async () => {
return await resolveApi().tags({ domainLanguage })
}
return { getArticleBySlug, getArticles, getTags }
}
Key points to preserve in every service wrapper:
- Configuration - always use
createBackendApiConfig()so authentication and base URLs stay aligned with runtime configuration. - Domain language - accept the language from callers and pass it to every OpenAPI call so backend responses match the current hostname.
- Lazy instantiation - keep the generated client in a closure and reuse it.
- Server-only guards - block browser execution paths; only SSR and Vitest may talk to the backend directly.
2. Expose the service through a Nuxt server route
Nuxt server routes translate incoming HTTP requests into service calls. The blog article endpoint mirrors the recommended structure:
// server/api/blog/articles/[slug].ts
import { setDomainLanguageCacheHeaders } from '../../utils/cache-headers'
export default defineEventHandler(async event => {
setDomainLanguageCacheHeaders(event, 'public, max-age=3600, s-maxage=3600')
const slug = getRouterParam(event, 'slug')
if (!slug) {
throw createError({
statusCode: 400,
statusMessage: 'Article slug is required',
})
}
const rawHost =
event.node.req.headers['x-forwarded-host'] ?? event.node.req.headers.host
const { domainLanguage } = resolveDomainLanguage(rawHost)
const blogService = useBlogService(domainLanguage)
try {
return await blogService.getArticleBySlug(slug)
} catch (error) {
const backendError = await extractBackendErrorDetails(error)
console.error(
'Error fetching blog article:',
backendError.logMessage,
backendError
)
throw createError({
statusCode: backendError.statusCode,
statusMessage: backendError.statusMessage,
cause: error,
})
}
})
This keeps request validation, caching, localisation, and error translation close
to the network boundary while delegating the backend call to the wrapper. Use
setDomainLanguageCacheHeaders() in every server route that surfaces backend
data so the response carries both Cache-Control and the host-aware Vary
header-mixing manual header calls risks dropping one of them and breaking
multi-domain caching.
3. Consume the server route from a composable
Composables hide the transport details from components and pages. They call the
Nuxt server routes with $fetch, manage loading/error state, and expose a typed
API. The blog composable demonstrates the pattern:
// app/composables/blog/useBlog.ts
export const useBlog = () => {
const currentArticle = useState('blog-current-article', () => null)
const loading = useState('blog-loading', () => false)
const error = useState('blog-error', () => null)
const fetchArticle = async (slug: string) => {
loading.value = true
error.value = null
try {
const article = await $fetch(`/api/blog/articles/${slug}`)
currentArticle.value = article
return article
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch article'
console.error('Error in fetchArticle:', err)
return null
} finally {
loading.value = false
}
}
return {
currentArticle: readonly(currentArticle),
loading: readonly(loading),
error: readonly(error),
fetchArticle,
}
}
When a feature needs pagination, filters, or reset helpers, expose them from the composable so the UI remains declarative.
4. Render data in a page or component
With the composable in place, pages and components can focus on presentation.
The blog article page fetches data in setup() and passes the resolved article
to TheArticle.vue:
<!-- app/pages/blog/[slug].vue -->
<script setup lang="ts">
const { currentArticle, loading, error, fetchArticle } = useBlog()
const slug = computed(() => /* derive slug from the route */)
await useAsyncData(
() => (slug.value ? `blog-article-${slug.value}` : 'blog-article'),
() => (slug.value ? fetchArticle(slug.value) : Promise.resolve(null)),
{ server: true, immediate: true, watch: [slug] },
)
const article = computed(() => currentArticle.value)
</script>
<template>
<TheArticle v-if="article" :article="article" />
<v-skeleton-loader
v-else-if="loading"
type="heading, image, paragraph, paragraph"
/>
<v-alert v-else-if="error" type="error" variant="tonal">{{ error }}</v-alert>
</template>
TheArticle.vue itself focuses purely on rendering: it receives the DTO, builds
computed properties (title, dates, SEO metadata), sanitises HTML, and exposes the
final markup without concerning itself with backend access.
Following this workflow-service wrapper → Nuxt route → composable → component- keeps backend integrations testable, SSR-safe, and easy to reason about. When adding a new feature, mirror the blog example so the team can recognise the control flow instantly.