Frontend
Adding a frontend feature
Build a new feature slice in the React SPA: route, data hooks, components and types.
On this page
A frontend feature is a single vertical slice under frontend/src/features/<slice>/. The folder
mirrors the API slice name (features/api-keys/ pairs with api/src/Slicekit.Core/Features/ApiKeys/),
so the contract on both sides reads as one feature. Nothing else in the codebase reaches inside a
slice: every import crosses the boundary through api.ts, hooks.ts, or a component.
If you have not read Frontend overview yet, start there for the stack (Vite, React 19, TanStack Router and Query, shadcn/ui on Tailwind v4). This page is the recipe for one slice.
Slice layout
frontend/src/features/<slice>/
├── api.ts Thin per-endpoint wrappers around apiFetch / apiBlob
├── types.ts Request and response shapes (handwritten, no codegen)
├── schemas.ts Zod schema factories taking TFunction (forms only)
├── hooks.ts TanStack useQuery / useMutation hooks
└── components/ Slice-specific UI
Not every slice needs all five files. features/data-export/ has no types.ts, features/features/
is just api.ts plus hooks.ts. Add a file when the slice earns it.
1. Types: the wire shapes
Mirror the API’s request and response records with camelCase fields. These are handwritten, there is no codegen step:
export type ApiKey = {
id: string;
name: string;
keyHint: string;
isActive: boolean;
expiresAtUtc: string | null;
createdAtUtc: string;
scopedPermissions: string[];
};
export type CreateApiKeyRequest = {
name?: string;
expiresAtUtc?: string | null;
scopedPermissions?: string[];
};
export type CreateApiKeyResponse = {
apiKeyId: string;
plainKey: string;
keyHint: string;
name: string;
expiresAtUtc: string | null;
};
2. API: endpoint wrappers
One function per endpoint, grouped into a single object. Use apiFetch<TResponse> for JSON and
apiBlob for binary downloads. The typed client owns cookies, CSRF and token refresh, so these
wrappers stay thin. See The API client for what it handles.
import { apiFetch } from '@/shared/api/client';
import type { PagedResponse } from '@/shared/api/types';
import type { ApiKey, CreateApiKeyRequest, CreateApiKeyResponse } from './types';
export const apiKeysApi = {
list: (page = 1, pageSize = 20) =>
apiFetch<PagedResponse<ApiKey>>(`/api/v1/api-keys/?page=${page}&pageSize=${pageSize}`),
create: (body: CreateApiKeyRequest) =>
apiFetch<CreateApiKeyResponse>('/api/v1/api-keys/', { method: 'POST', body }),
remove: (id: string) =>
apiFetch<void>(`/api/v1/api-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }),
};
apiFetch JSON-serialises body for you. For multipart/form-data uploads, pass a FormData
instance as body and do not set Content-Type: the browser fills in the boundary.
3. Schemas: Zod factories (forms only)
Form validation lives in schemas.ts as factory functions that take i18next’s TFunction. Passing
t in means validation messages re-render when the locale changes:
import { z } from 'zod';
import type { TFunction } from 'i18next';
export const createApiKeySchema = (t: TFunction) =>
z.object({
name: z.string().min(1, t('validation.required')).max(80, t('validation.maxLength', { max: 80 })),
scopedPermissions: z.array(z.string()).min(1, t('api_keys.permission_required')),
});
export type CreateApiKeyValues = z.infer<ReturnType<typeof createApiKeySchema>>;
Components build the schema with useMemo(() => createApiKeySchema(t), [t]) so it only rebuilds on a
locale change.
4. Hooks: TanStack Query and Mutation
Data fetching lives in query hooks, one concern per hook. Components call a hook and render; the hook owns caching, loading and error state. Export the query keys so adjacent slices can invalidate them:
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiKeysApi } from './api';
export const apiKeysQueryKey = ['api-keys'] as const;
export function useApiKeys(page = 1, pageSize = 20) {
return useQuery({
queryKey: [...apiKeysQueryKey, page, pageSize],
queryFn: () => apiKeysApi.list(page, pageSize),
});
}
export function useCreateApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: apiKeysApi.create,
onSuccess: () => queryClient.invalidateQueries({ queryKey: apiKeysQueryKey }),
});
}
Mutations invalidate the queries they affect, so the UI stays consistent without manual refetching.
Exporting query keys is the pattern that lets one slice refresh another: meQueryKey from
features/account/hooks.ts is invalidated by any slice that mutates the current user.
5. Components: the UI
Components compose shadcn/ui primitives, call the slice’s hooks, and wire forms with React Hook Form
plus the Zod schema. Every user-visible string goes through t('namespace.key'):
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { AppError } from '@/shared/api/errors';
import { useCreateApiKey } from '../hooks';
import { createApiKeySchema, type CreateApiKeyValues } from '../schemas';
export function CreateApiKeyForm() {
const { t } = useTranslation();
const schema = useMemo(() => createApiKeySchema(t), [t]);
const form = useForm<CreateApiKeyValues>({ resolver: zodResolver(schema) });
const createMutation = useCreateApiKey();
async function onSubmit(values: CreateApiKeyValues) {
try {
await createMutation.mutateAsync(values);
toast.success(t('api_keys.created'));
} catch (err) {
if (err instanceof AppError) err.applyToForm(form.setError);
else toast.error(t('errors.request_failed', { status: 0 }));
}
}
return <form onSubmit={form.handleSubmit(onSubmit)}>{/* fields */}</form>;
}
AppError.applyToForm maps the API’s validation problem details straight onto the matching form
fields, so server-side rules surface inline next to the input that failed.
6. Wire into a route
Routing is file-based under frontend/src/routes/. The filename is the path, dots are separators,
and underscore-prefixed segments are pathless layouts (_app is the authenticated shell). Add a file
that mounts your page component:
// frontend/src/routes/_app.settings.api-keys.tsx
import { createFileRoute } from '@tanstack/react-router';
import { ApiKeysPage } from '@/features/api-keys/components/ApiKeysPage';
export const Route = createFileRoute('/_app/settings/api-keys')({
component: ApiKeysPage,
});
TanStack Router regenerates routeTree.gen.ts automatically on save (or while pnpm dev runs). Never
edit that file by hand. Because routing is type-safe, a renamed route surfaces as a compile error, not
a runtime 404.
7. Gate on a permission
If the API enforces a permission, mirror the constant in shared/auth/permissions.ts and gate the UI
on it so the action hides for users who lack it. The full pattern, including how the constants stay in
sync with the backend, is in Adding a permission.
Checklist
-
types.ts: camelCase request and response shapes mirroring the API records. -
api.ts: oneapiFetchorapiBlobwrapper per endpoint. -
schemas.ts: Zod schema factories takingTFunction(forms only). -
hooks.ts: TanStack query and mutation hooks, with query keys exported. -
components/: UI via React Hook Form plus the schema, every string throught(...). - A route under
src/routes/, letting the router regeneraterouteTree.gen.ts. - The permission constant mirrored in
shared/auth/permissions.tsif the UI gates on it. - i18n keys added to both
shared/i18n/locales/en.jsonandnl.json. - Verify:
pnpm typecheck,pnpm lint, then a manual pass inpnpm dev(happy path, invalid input, and a language toggle to confirm labels and validation messages re-localise).