Skip to content

Building a Frontend Feature

Follow this process to implement frontend features in the Nuxt 4 SPA.

Architecture overview

The frontend is a Nuxt 4 SPA (ssr: false) using:

  • @nuxt/ui v4 — UI components prefixed U* (UButton, UTable, UModal, etc.)
  • Pinia Colada — Async data fetching via defineQueryOptions / useQuery
  • @tanstack/vue-table — Complex data tables
  • VeeValidate + Zod — Form validation
  • @devizovaburza/txs-sdk — Typed Hono RPC API client

The primary UI locale is Czech (cs).

Step 1: Define the query (data fetching)

Create or update a file in apps/frontend/app/queries/:

typescript
// queries/my-entities.ts
import useTxsApi, { res200, type TxsApi, type TxsApiRequest } from '~/composables/txs-api'
import { serializeQueryForKey } from '@/utils/query'

// Derive types from the SDK — never define API types manually
export type GetMyEntitiesQuery = TxsApiRequest<TxsApi['v1']['myEntities']['$get']>['query']

// Query key factory for cache management
export const MY_ENTITY_QUERY_KEYS = {
  root: ['my-entities'] as const,
  lists: ['my-entities', 'list'] as const,
  list: (q: GetMyEntitiesQuery) => [
    ...MY_ENTITY_QUERY_KEYS.root, 'list', { q: serializeQueryForKey(q) },
  ] as const,
  detail: (id: string) => [...MY_ENTITY_QUERY_KEYS.root, 'detail', id] as const,
} as const

// Query function factory
export const myEntityListQuery = (
  query: GetMyEntitiesQuery | Ref<GetMyEntitiesQuery>,
  enabled?: Ref<boolean>,
) => () => {
  const q = toValue(query)
  return defineQueryOptions({
    key: MY_ENTITY_QUERY_KEYS.list(q),
    enabled: enabled === undefined ? true : enabled.value,
    query: async () => useTxsApi().v1.myEntities.$get({ query: q }).then(res200),
    placeholderData: (previousData) => previousData,
    staleTime: Infinity,
  })
}

Step 2: Create the page

Create a page in apps/frontend/app/pages/:

vue
<script lang="ts" setup>
const query = useQueryBuilder()
const { data, isLoading } = useQuery(myEntityListQuery(query))
</script>

<template>
  <div class="flex flex-col gap-4">
    <h1 class="text-2xl font-bold">My Entities</h1>
    <MyEntitiesTable :data="data" :is-loading="isLoading" />
  </div>
</template>

Step 3: Create components

Components go in apps/frontend/app/components/ and are auto-imported by Nuxt.

Table component

vue
<script lang="ts" setup>
import { createColumnHelper, useVueTable, getCoreRowModel } from '@tanstack/vue-table'

type Props = {
  data: MyEntity[] | undefined
  isLoading: boolean
}

const props = defineProps<Props>()

const columnHelper = createColumnHelper<MyEntity>()

const columns = [
  columnHelper.accessor('name', { header: 'Název' }),
  columnHelper.accessor('status', { header: 'Stav' }),
]

const table = useVueTable({
  get data() { return props.data ?? [] },
  columns,
  getCoreRowModel: getCoreRowModel(),
})
</script>

Naming conventions for components

  • *Table.vue — Data table
  • *AddModal.vue — Creation modal
  • *EditModal.vue — Edit modal
  • *DetailSlideover.vue — Detail side panel
  • *FilterForm.vue — Filter/search form

Step 4: Create form schemas (if needed)

Form schemas go in apps/frontend/app/schemas/:

typescript
// schemas/my-entity.schema.ts
import { z } from 'zod'
import { toTypedSchema } from '@vee-validate/zod'

export const myEntityFormSchema = toTypedSchema(
  z.object({
    name: z.string().min(1, 'Required field'),
    status: z.enum(['ACTIVE', 'INACTIVE']),
  }),
)

Use with VeeValidate:

typescript
const { handleSubmit, errors } = useForm({
  validationSchema: myEntityFormSchema,
})

Step 5: Use the query builder for list pages

typescript
// Provides: search (debounced 300ms), page, limit, sortColumn, sortDirection, sort
const query = useQueryBuilder<{ status?: string }>({ status: 'ACTIVE' })

API client usage

The typed client auto-generates from gateway route exports:

typescript
// GET requests
useTxsApi().v1.orders.$get({ query: { page: '1', limit: '10' } })

// GET with params
useTxsApi().v1.orders[':orderId'].$get({ param: { orderId: 'uuid' } })

// POST requests
useTxsApi().v1.orders.$post({ json: { /* body */ } })

// DELETE requests
useTxsApi().v1.orders[':orderId'].$delete({ param: { orderId: 'uuid' } })

Always use .then(res200) to extract the typed response body.

Key rules

  • Use ~/ or @/ for intra-frontend imports, never deep relative paths
  • API types come from @devizovaburza/txs-sdk/v1 — never define them manually
  • Use @nuxt/ui v4 components — don't build custom versions of existing ones
  • All composables and components in app/ directories are auto-imported
  • Use Czech labels in UI where appropriate (cs is the primary locale)
  • Never use any — the frontend ESLint config discourages it too