# Adding a frontend feature

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

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](/docs/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:

```ts

  id: string;
  name: string;
  keyHint: string;
  isActive: boolean;
  expiresAtUtc: string | null;
  createdAtUtc: string;
  scopedPermissions: string[];
};

  name?: string;
  expiresAtUtc?: string | null;
  scopedPermissions?: string[];
};

  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` 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](/docs/api-client) for what it handles.

```ts

  list: (page = 1, pageSize = 20) =>
    apiFetch<PagedResponse>(`/api/v1/api-keys/?page=${page}&pageSize=${pageSize}`),
  create: (body: CreateApiKeyRequest) =>
    apiFetch('/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:

```ts

  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')),
  });

```

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:

```ts

  return useQuery({
    queryKey: [...apiKeysQueryKey, page, pageSize],
    queryFn: () => apiKeysApi.list(page, pageSize),
  });
}

  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')`:

```tsx

  const { t } = useTranslation();
  const schema = useMemo(() => createApiKeySchema(t), [t]);
  const form = useForm({ 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:

```tsx
// frontend/src/routes/_app.settings.api-keys.tsx

  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](/docs/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).
