Skip to content
Slicekit

Frontend

Adding a frontend feature

Build a new feature slice in the React SPA: route, data hooks, components and types.

View .md
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: one apiFetch or apiBlob wrapper per endpoint.
  • schemas.ts: Zod schema factories taking TFunction (forms only).
  • hooks.ts: TanStack query and mutation hooks, with query keys exported.
  • components/: UI via React Hook Form plus the schema, every string through t(...).
  • A route under src/routes/, letting the router regenerate routeTree.gen.ts.
  • The permission constant mirrored in shared/auth/permissions.ts if the UI gates on it.
  • i18n keys added to both shared/i18n/locales/en.json and nl.json.
  • Verify: pnpm typecheck, pnpm lint, then a manual pass in pnpm dev (happy path, invalid input, and a language toggle to confirm labels and validation messages re-localise).