SHOPin Logo
Skip to main documentation content

Presentation input validation

In the SHOPin storefront accelerator presentation app (apps/presentation), parse untrusted input (URLs, storage, BFF JSON) with Zod before use, and validate forms before calling the BFF for UX (immediate feedback). The BFF remains authoritative for security and business rules.

The examples below are illustrative—they are not specific files in the repo. Wire the same ideas into your routes and features (see apps/presentation/app and apps/presentation/features).


Incoming (BFF responses, search params, storage)

Why: Avoid rendering or branching on malformed data if a bug or contract drift slips through.

How: Use shared schemas from @core/contracts (or a local Zod schema) with .parse() / .safeParse() when reading JSON or normalising params.

Search params

TypeScript
import { z } from 'zod'

const SearchParamsSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  sort: z.enum(['relevance', 'price-asc', 'price-desc', 'newest']).default('relevance'),
  q: z.string().trim().optional(),
})

export async function parseSearchParams(raw: Record<string, string | string[] | undefined>) {
  return SearchParamsSchema.parse({
    page: raw.page,
    sort: raw.sort,
    q: raw.q,
  })
}

Use the result when calling your data loader (see real pages under apps/presentation/app).

BFF JSON response

response.json() inside the BFF client is still untyped at runtime. The presentation app typically validates in feature services that extend BaseService: use the request schema before the call and the response schema on the payload you get back. Schemas live in @core/contracts (see Maintain contracts).

This matches cart-bff-service.ts—a GET with an empty body and a POST that validates both sides:

TypeScript
import {
  AddToCartRequestSchema,
  CartResponseSchema,
  type CartResponse,
} from '@core/contracts/cart/cart'
import { BaseService } from '@/lib/bff/services/base-service'

export class CartService extends BaseService {
  async getCart(): Promise<CartResponse | null> {
    const data = await this.get<CartResponse | null>('/cart', {
      allowEmpty: true,
    })
    if (!data) return null
    return CartResponseSchema.parse(data)
  }

  async addItem(request: {
    productId: string
    variantId?: string
    quantity: number
  }): Promise<CartResponse> {
    const validatedRequest = AddToCartRequestSchema.parse(request)
    const data = await this.post<CartResponse>('/cart/items', validatedRequest)
    return CartResponseSchema.parse(data)
  }
}

Use .safeParse instead of .parse when you want to log or degrade without throwing.

localStorage

TypeScript
import { z } from 'zod'

const SettingsSchema = z.object({
  theme: z.enum(['light', 'dark']).default('light'),
  itemsPerPage: z.number().int().min(1).max(100).default(24),
})

export type Settings = z.infer<typeof SettingsSchema>

export function readSettings(): Settings {
  if (typeof window === 'undefined') return SettingsSchema.parse({})
  try {
    const raw = localStorage.getItem('settings')
    const json = raw ? JSON.parse(raw) : {}
    const parsed = SettingsSchema.safeParse(json)
    return parsed.success ? parsed.data : SettingsSchema.parse({})
  } catch {
    return SettingsSchema.parse({})
  }
}

export function writeSettings(input: unknown): void {
  if (typeof window === 'undefined') return
  const data = SettingsSchema.parse(input)
  localStorage.setItem('settings', JSON.stringify(data))
}

Outgoing (forms → BFF)

Why: Field-level errors without waiting on the server.

How: react-hook-form + @hookform/resolvers/zod with schemas aligned to your BFF contract.

TSX
'use client'

import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'

const schema = z.object({
  email: z.string().email({ message: 'validation.email.invalid' }),
})

type FormInput = z.infer<typeof schema>

export function TinyEmailForm({ initialEmail = '' }: { initialEmail?: string }) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormInput>({
    resolver: zodResolver(schema),
    defaultValues: { email: initialEmail },
  })

  const onSubmit = (values: FormInput) => {
    // Call your BFF client / server action here
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} placeholder="email" />
      {errors.email?.message && <p>{errors.email.message}</p>}
      <button type="submit">Submit</button>
    </form>
  )
}

Related


Back to Input validation · Back to Validation & resilience · Back to How to work with SHOPin