Appearance
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/uiv4 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 (
csis the primary locale) - Never use
any— the frontend ESLint config discourages it too