Communication between presentation and BFF
How the SHOPin storefront accelerator presentation app reaches the NestJS BFF through one BFF client—no direct commerce API calls from the browser or server UI code, and shared types from @core/contracts.
The presentation app lives in apps/presentation. Server code builds requests through bff-fetch-server.ts (createBffFetchServer); client code uses bff-fetch-client.ts (useBffFetchClient). Use the BFF client for all BFF traffic—avoid raw fetch and hand-built URLs (see Error handling for resilience).
Central wrapper
The client applies a central wrapper on every request: it sets x-data-source (Remove demo functionality when using optional demo switching), Accept-Language (Translations), and cookies (forwarded on the server, sent by the browser on the client), and it resolves the base URL and normalises path and payload shape. Feature code passes method and body/query only. For extra headers (e.g. x-user-id), see Custom headers between frontend and BFF.
URL selection
| Variable | When used | Purpose |
|---|---|---|
NEXT_BFF_INTERNAL_URL | Server only (Node) | Server-to-BFF (SSR, route handlers); not exposed to the browser. |
NEXT_PUBLIC_BFF_EXTERNAL_URL | Browser (and SSR where applicable) | Public BFF endpoint. |
No server-vs-client branching inside features: the client picks the base URL by context. Configure both variables in the environment for each deployment; for hosting layout see Deploy project apps.
Contracts
Request and response shapes live in @core/contracts; BFF and presentation import the same definitions. When you add or change a call: (1) update Maintain contracts, (2) implement the route in the BFF (BFF), (3) call through the BFF client in the presentation with those types—no parallel ad-hoc types or unchecked JSON.
Where calls live in the presentation
Server — createBffFetchServer
- Feature lib —
features/<feature>/lib/get-*.ts: cached async helpers that create the client, wrap a feature Service, and return data for Server Components (e.g. product page, navigation, store config—the exact names are illustrative). - Shared server entry —
lib/bff/services/server-service.ts:getServerBffClient(locale)and server-side helpers such as i18n.
Client — useBffFetchClient
- Service hooks —
features/<feature>/hooks/use-*-service.ts: calluseBffFetchClient(), construct a feature Service, return it (e.g.useCartService→{ cartService }). Components and data hooks use that Service, not the raw client. - Data hooks — Reads/writes go through React Query
queryFn/mutationFnon those Services; keep query keys in feature key modules and invalidate after mutations where needed.
Examples
The flows below mirror common patterns; file names, routes, and hook names are illustrative—verify against the current repo.
Server-side: product page
Feature lib (features/product/lib/get-product-page.ts). Server Components call getProductPage(slug, variantId); the helper creates the BFF client and delegates to ProductService.
import { cache } from 'react'
import type { ProductPageResponse } from '@core/contracts/product/product-page'
import { createBffFetchServer } from '@/lib/bff/core/bff-fetch-server'
import { ProductService } from './product-service'
export const getProductPage = cache(
async (slug: string, variantId?: string): Promise<ProductPageResponse> => {
const bffFetch = await createBffFetchServer()
const productService = new ProductService(bffFetch)
return productService.getProductPage(slug, variantId)
}
)
Feature service (features/product/lib/product-service.ts). Extends BaseService, calls /product/slug/${slug}/page, parses with the contract schema.
async getProductPage(slug: string, variantId?: string): Promise<ProductPageResponse> {
ProductSlugSchema.parse(slug)
if (variantId) VariantIdSchema.parse(variantId)
const data = await this.get<ProductPageResponse>(
`/product/slug/${slug}/page`,
{
queryParams: variantId ? { variantId } : undefined,
next: { revalidate: PRODUCT_PAGE_CACHE_TIME },
}
)
return ProductPageResponseSchema.parse(data)
}
BFF — GET /product/slug/:productSlug/page (controller under apps/bff).
@Controller('product')
@Get('slug/:productSlug/page')
@ApiQuery({ name: 'variantId', required: false, type: String })
async getProductPage(
@ZodParam(ProductSlugSchema, 'productSlug') productSlug: string,
@ZodQuery(VariantIdSchema, 'variantId') variantId?: string
): Promise<ProductPageResponse> {
return ProductPageResponseSchema.strip().parse(
await this.productService.getProductPage(productSlug, variantId)
)
}
Client-side: cart query
Service hook (features/cart/hooks/use-cart-service.ts).
'use client'
import { useBffFetchClient } from '@/lib/bff/core/bff-fetch-client'
import { CartService } from '../lib/cart-bff-service'
export function useCartService() {
const bffFetch = useBffFetchClient()
return { cartService: new CartService(bffFetch) }
}
Query keys (features/cart/cart-keys.ts).
export const cartKeys = {
all: ['cart'] as const,
mutations: {
add: () => [...cartKeys.all, 'mutations', 'add'] as const,
remove: () => [...cartKeys.all, 'mutations', 'remove'] as const,
update: () => [...cartKeys.all, 'mutations', 'update'] as const,
},
}
Query hook (features/cart/cart-use-cart.ts). CartService.getCart() → BFF GET /cart.
'use client'
import { useQuery } from '@tanstack/react-query'
import { useCartService } from './hooks/use-cart-service'
import { cartKeys } from './cart-keys'
export function useCart() {
const { cartService } = useCartService()
const { data: cart, isLoading, error, refetch } = useQuery({
queryKey: cartKeys.all,
queryFn: async () => await cartService.getCart(),
staleTime: 0,
gcTime: 5 * 60 * 1000,
retry: false,
})
return { cart: cart ?? null, isLoading, error, refetch }
}
Component (features/cart/cart-content.tsx).
'use client'
import { useCart } from './cart-use-cart'
export function CartContent() {
const { cart, isLoading, error } = useCart()
// ... loading, error, empty states and cart UI
}
BFF — GET /cart.
@Controller('cart')
async getCart(): Promise<CartResponse | null> {
const cart = await this.cartService.getCart()
if (!cart) return null
return CartResponseSchema.strip().parse(cart)
}
Client-side: add to cart mutation
Mutation hook (features/cart/hooks/use-add-to-cart.ts). cartService.addItem() → POST /cart/items; invalidates cartKeys.all on success (plus any UI the real hook adds).
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCartService } from './use-cart-service'
import { cartKeys } from '../cart-keys'
export function useAddToCart() {
const { cartService } = useCartService()
const queryClient = useQueryClient()
const mutation = useMutation({
mutationKey: cartKeys.mutations.add(),
mutationFn: async ({ productId, variantId, quantity }) =>
await cartService.addItem({ productId, variantId, quantity }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: cartKeys.all })
// ... modal, toast
},
})
return {
handleAddToCart: (params) => mutation.mutate({ ...params, quantity: params.quantity ?? 1 }),
isPending: mutation.isPending,
}
}
Component (features/cart/cart-add-to-cart.tsx).
'use client'
import { useAddToCart } from './hooks/use-add-to-cart'
export function AddToCart({ productId }: AddToCartProps) {
const { handleAddToCart, isPending } = useAddToCart()
// ... handleAddToCart({ productId, quantity: 1, variantId })
}
BFF — POST /cart/items.
@Post('items')
@UseCsrfGuard()
async addToCart(
@ZodBody(AddToCartRequestSchema) request: AddToCartRequest
): Promise<CartResponse> {
return CartResponseSchema.strip().parse(
await this.cartService.addToCart(request)
)
}
Summary
| Concern | Approach |
|---|---|
| Calling the BFF | BFF client only, via feature Services—not raw fetch or manual URLs. |
| Central wrapper | Sets x-data-source, Accept-Language, cookies, and normalises URL/request shape. |
| Base URL | Server: NEXT_BFF_INTERNAL_URL. Browser: NEXT_PUBLIC_BFF_EXTERNAL_URL. |
| Types | @core/contracts for payloads and schemas end-to-end. |
| Server | features/<feature>/lib/get-*.ts and server-service.ts. |
| Client | use*Service hooks + React Query; keys and invalidation colocated with the feature. |