Skip to content
Slicekit
All posts
· Slicekit Team

One typed contract, and the one interceptor that makes auth invisible

The engineering case for a single typed client between .NET and React: one place for cookies, CSRF and a 401 silent-refresh interceptor, hand-mirrored types you keep honest against the API, and server validation errors mapped back onto form fields.

Two problems quietly eat a full-stack codebase from the inside, and neither one shows up on day one.

The first is drift. The API renames a field, drops a status code, tightens a response shape. The hand-written client still sends the old name and reads the old shape, because nothing connects the two except a developer’s memory and a stale Postman collection. It compiles. It ships. Then a 400 lands in production and you spend an afternoon discovering that ownerId became ownerUserId three sprints ago.

The second is leakage. Session handling (token expiry, refresh, the CSRF header on writes) is a cross-cutting concern by nature, but the easy implementation scatters it across every call site. Each feature reaches for the token, each feature handles the 401, each feature does it slightly differently. The logic that should live in one place lives in fifty, and every one of them is a place the next bug can hide.

Slicekit takes on both with the same move: one typed client between the .NET API and the React SPA, with the plumbing pushed down into it so feature code never sees it. It closes the leakage problem outright, and it reshapes the drift problem, which is worth being precise about rather than overselling.

The typed contract: one client, types you keep honest by hand

Every call from the SPA goes through a single typed client. The request and response types live in one place per feature and every call site reads from them, so within the frontend nothing passes the wrong shape: rename a field on the type and the compiler walks you to every call site that no longer matches.

// features/projects/api.ts
import { api } from '@/lib/api'

export const listProjects = () => api.get<ProjectDto[]>('/projects')
export const createProject = (body: CreateProject) => api.post<{ id: string }>('/projects', body)

Be precise about what that does and does not buy you, because this is exactly where templates oversell. These TypeScript types are hand-written to mirror the API’s records, not generated from its OpenAPI document. There is no codegen step (the frontend how-to is explicit about this), which is a deliberate trade: no generated-type churn and no build coupling to a spec, in exchange for the wire not being enforced automatically. Rename a field on the server and the TypeScript still compiles. The two sides are kept in step by a developer, with the interactive API reference at /scalar on the running API as the source of truth.

So the single client narrows where drift can hide and gives you one place to fix it, but it does not catch a server-side rename for you. If you want the wire itself to fail the build, that is a natural extension rather than a rewrite: point a generator like openapi-typescript at the published OpenAPI document and import the generated types instead of hand-writing them. Slicekit leaves that choice to you rather than baking a codegen step into every build. (Within the SPA you still get real type safety elsewhere: TanStack Router, for instance, turns a renamed route into a type error instead of a runtime 404.)

The one interceptor: silent refresh-and-retry

The interesting case is what happens when a short-lived access token expires mid-session. The naive answer is for every component to notice the 401 and react. The right answer is that no component ever finds out.

A single interceptor owns it. On a 401 it calls the refresh endpoint once, retries the original request, and returns the result. The component that asked for the project list does not know its request was ever retried; it just gets its data. State-changing requests carry the CSRF header the API expects, applied in the same layer, so writes are protected without any feature author thinking about it.

Client request

writes carry the CSRF header

401 from API

access token expired

Silent refresh

interceptor calls /refresh

Retry request

replays the original call

200 OK

invisible to the user

One interceptor turns expiry into a non-event: no component touches tokens, so auth stays invisible, while the shared typed contract means a renamed field breaks the build, not production.

Putting this in exactly one place is what keeps it correct. Expiry is not a per-feature problem, so it is not per-feature code, which means there is no second implementation to forget the retry or skip the CSRF header. That is what “invisible auth” means in practice: not that nothing happens, but that it happens somewhere you never have to look. (The deeper question of why the session lives in a cookie at all, and how rotation works, is its own topic; the passkeys and token rotation post covers it.)

Server state and server errors, each in their own lane

The raw client functions are wrapped in TanStack Query hooks, one concern per hook. TanStack Query owns server state: caching, loading, error state, and background refetch. Mutations invalidate the queries they affect, so the UI stays consistent without manual refetching, and that invalidation is declared right next to the mutation rather than remembered at each call site.

export function useCreateProject() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: createProject,
    onSuccess: () => qc.invalidateQueries({ queryKey: ['projects'] }),
  })
}

Input validation lives in a different lane. React Hook Form drives the form, Zod validates the input before it leaves the browser, and the server still has the final say. When the API rejects a write, it returns a consistent error shape with a code, and the client surfaces it as a typed error. A code like project_name_taken is not a generic toast: it is mapped straight back onto the form field it concerns, so server-side validation lands inline next to the input, exactly where client-side validation does. The server stays the single source of truth for what is valid, and the user still gets feedback on the right field.

The honest limit: types check shape, not behavior

A typed client is a strong guarantee about a narrow thing. It proves that, inside the frontend, every call site agrees with the shape you declared for an endpoint. It says nothing about whether that declared shape still matches the server (that is the manual sync above), whether the endpoint does the right thing with the data, whether the refresh interceptor retries the correct request under a race, or whether an error code is mapped to the field a user actually expects. It closes the type-mismatch-at-the-call-site class of bugs. It does not police the wire on its own, and it does not close the behavior gap at all.

So you still test. The point of the typed client is not zero bugs; it is to move an entire category of integration failures from runtime to build time, and to collapse auth and error handling into one layer instead of fifty. What is left is the logic worth writing tests for, which is the logic you actually wanted to spend your attention on.

That is the trade Slicekit makes deliberately: a little structure up front (route every call through one module) in exchange for the compiler doing your integration checking and the cross-cutting concerns living in a single place. Read the typed API client guide for the full picture, or the frontend overview for how it fits the rest of the SPA.