Skip to content

Frontend app layers

Backends have a layering everyone recognizes: router → controller → service → repository. Frontends have the same problem and almost never the same care — the result is the component that validates a document number, builds the URL, calls fetch, handles the error, formats a date and renders a table. Six reasons to change in one file.

This page defines the layers of a Tempest app and the one rule that makes them worth anything.

The six layers

flowchart TD
    subgraph edge["App edge"]
        BOOT["main.tsx — bootstrap"]
        PROV["providers — composition root"]
        ROUTES["routes.tsx — URL map"]
    end
    subgraph screen["Screen"]
        PAGE["pages/ — orchestration"]
        FEAT["features/ — domain + domain UI"]
        UI["components/ + SDK — UI without domain"]
    end
    subgraph data["Data"]
        SVC["services/ — talks to the backend"]
        INFRA["lib/ — apiClient, storage, logger"]
    end

    BOOT --> PROV --> ROUTES --> PAGE
    PAGE --> FEAT
    FEAT --> UI
    FEAT --> SVC
    SVC --> INFRA
# Layer Responsibility Never does
1 Bootstrap createRoot, import the CSS, mount <App /> logic of any kind
2 Providers Compose global context (query, theme, i18n, auth, flags, telemetry) know about any feature
3 Routes Map URL → page, guards, code splitting fetch data
4 Pages Read URL params, compose features, define screen layout business rules, fetch, formatting
5 Features One domain: its components, hooks, types and services import another feature's internals
6 UI Render props, emit events, accessibility, styling know what an "order" is
7 Services Call the endpoint, validate the response, return a domain type touch React
8 Infra apiClient, storage, logger, telemetry — the "how", not the "what" know a domain resource name

Eight rows, six layers

Bootstrap/providers/routes are the edge — code that exists once in the app. The layers you edit every day are pages, features, UI and services.

The one-way arrow rule

A layer only imports layers below it. Never the other way around.

That's the whole thing. Without this rule, "layer" is just a folder name.

// features/orders/OrderList.tsx
import { DataTable } from "tempest-react-sdk";

import { useOrders } from "./use-orders";

The feature imports UI (below) and its own hook. Arrow points down.

// components/StatusBadge.tsx
import { useOrders } from "@/features/orders/use-orders";

UI importing a feature. Now StatusBadge only works where orders exist, you can't reuse it for invoices, and testing it requires a server.

Practical consequences of honoring the arrow:

  • Testing is cheap. A service tests with fetch mocked, no React. UI tests with props, no server.
  • Moving code is cheap. A domain-free UI component moves up into the SDK without a rewrite.
  • Deleting a feature is cheap. Deleting features/orders/ leaves no hole in components/.

The import that breaks a layer is the start of every tangle

No app rots all at once. It rots with one "just this one" import that nobody removes. Treat a wrong-layer import as an error, not a style detail — that is what tempest lint and code review are for.

What each layer looks like, in code

A complete example: the orders screen. Eight files, none over 40 lines.

1. Infra — the HTTP client exists once

// src/lib/api.ts
import { createApiClient } from "tempest-react-sdk";

import { useAuth } from "@/stores/auth";

/**
 * Single HTTP client for the app. Every service goes through it, so bearer
 * token, request id and 401 handling are configured in exactly one place.
 */
export const api = createApiClient({
  baseURL: import.meta.env.VITE_API_URL,
  getToken: () => useAuth.getState().token,
  onUnauthorized: () => useAuth.getState().logout(),
});

2. Service — talks to the backend, returns a domain type

// src/features/orders/orders.service.ts
import { parseResponse } from "tempest-react-sdk";

import { api } from "@/lib/api";

import { orderListSchema, orderSchema } from "./orders.schema";
import type { Order } from "./orders.schema";

/**
 * Read the paginated order list. The raw payload is validated against the
 * schema, so every consumer downstream can trust the shape.
 */
export async function listOrders(page: number): Promise<Order[]> {
  const raw = await api.get<unknown>("/orders", { params: { page } });
  return parseResponse(orderListSchema, raw, "listOrders");
}

/** Advance an order to the paid state. */
export async function payOrder(id: string): Promise<Order> {
  const raw = await api.post<unknown>(`/orders/${id}/pay`);
  return parseResponse(orderSchema, raw, "payOrder");
}

3. Feature hook — glues the service to React

// src/features/orders/use-orders.ts
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "tempest-react-sdk";

import { listOrders } from "./orders.service";

export const orderKeys = createQueryKeys("orders", {
  list: (page: number) => ["list", page] as const,
  detail: (id: string) => ["detail", id] as const,
});

/** Order list for a page, cached by TanStack Query. */
export function useOrders(page: number) {
  return useQuery({
    queryKey: orderKeys.list(page),
    queryFn: () => listOrders(page),
  });
}

4. Feature component — knows orders, doesn't know HTTP

// src/features/orders/OrderTable.tsx
import { Badge, Button, DataTable, type DataTableColumn } from "tempest-react-sdk";

import type { Order } from "./orders.schema";

interface OrderTableProps {
  orders: Order[];
  onPay: (id: string) => void;
}

/**
 * Presentational table for orders. Receives data, emits intent — no fetching,
 * no mutation, no knowledge of where the rows came from.
 */
export function OrderTable({ orders, onPay }: OrderTableProps) {
  const columns: DataTableColumn<Order>[] = [
    { key: "code", header: "Code", sortable: true },
    { key: "status", header: "Status", render: (o) => <Badge>{o.status}</Badge> },
    {
      key: "id",
      header: "",
      align: "right",
      render: (o) => (
        <Button size="sm" onClick={() => onPay(o.id)}>
          Pay
        </Button>
      ),
    },
  ];

  return <DataTable data={orders} columns={columns} rowKey={(o) => o.id} searchable />;
}

key is keyof T — on purpose

The actions column reuses key: "id" because the type forces you to point at a real row field. That closes the door on the phantom column (key: "actions") that breaks silently when the field is renamed — it is typing enforcing the design.

5. Page — orchestrates, doesn't implement

// src/pages/Orders.tsx
import { Page, Spinner, useSearchParams } from "tempest-react-sdk";

import { OrderTable } from "@/features/orders/OrderTable";
import { useOrders } from "@/features/orders/use-orders";
import { usePayOrder } from "@/features/orders/use-pay-order";

/** Orders screen: reads the page from the URL and wires the feature together. */
export function Orders() {
  const [params] = useSearchParams();
  const page = Number(params.get("page") ?? 1);

  const { data = [], isLoading } = useOrders(page);
  const { mutate: pay } = usePayOrder();

  return (
    <Page title="Orders">
      {isLoading ? <Spinner /> : <OrderTable orders={data} onPay={pay} />}
    </Page>
  );
}

Notice what the page does not have: no API URL, no useState of remote data, no formatting, no rules. It reads the URL, calls the feature and positions things. That is the complexity ceiling of a well-designed page.

Allowed-imports table

Paste this into code review:

From ↓ / May import → Infra Services UI Features Pages Routes
Infra (lib/)
Services ⚠️
UI (components/)
Features ⚠️
Pages ⚠️
Routes / Providers ⚠️
  • ✅ free.
  • ⚠️ with judgement: a service may compose another service; a feature imports another feature only through its index.ts; a page may read infra for global things (logger, flags).
  • ❌ is an error. No "temporary" exception.

UI imports nothing from the app — not even lib/

A component in components/ that imports @/lib/api stopped being UI. If it needs data, it receives it as a prop. That rigor is what later turns the component into a candidate to move up into the SDK.

Where the SDK fits

You don't implement layers 1–3 and 8 by hand:

Layer Use
Providers <AppProviders>
Routes defineRoutes + <AppRouter> + <RouteGuard>
Services createApiClient + parseResponse
Plain CRUD createDataProvider + useList/useOne/useCreate
UI Components catalogue
Infra Logger, Telemetry, Feature Flags

And create-tempest-app already generates the app with lib/, stores/, layouts/, pages/ and routes.tsx in place.

CRUD with no rules? Skip the service

When the resource is predictable REST with no transformation at all, the Data Provider replaces the service+hook pair. Writing a service that only forwards is a pass-through — indirection with no logic.

Recap

  • Eight roles, six layers you edit: page → feature → UI on one side, service → infra on the other.
  • The one-way arrow rule is what turns a folder name into architecture.
  • A page orchestrates and nothing else; UI knows no domain; a service knows no React.
  • A wrong-layer import is a review error, not a detail.
  • The SDK ships the edge (providers/routes), services (HTTP) and UI — you keep the boundaries.

Next: Folder structure — where each of these layers lives on disk.