# Data fetching in the Expo app | Expo Supabase SaaS Kit

> Learn how the native app reads and writes data through the versioned /api/v1 endpoints, how the api() client and React Query are wired, and how to add your own endpoint and hook end to end.

*Canonical: https://makerkit.dev/docs/react-native-supabase/data-fetching*

---

Every screen in `apps/native` that shows server data gets it the same way: a React Query hook calls `api()`, which calls a versioned route handler in `apps/web/app/api/v1/*`. This page explains that path, what the query provider already handles for you, and then walks through adding a new endpoint and the hook that consumes it, from the database to the screen.

## Which door to use

Two clients reach the backend from the app, and picking the wrong one is the most common mistake when extending it.

| Concern | Client | Where |
| --- | --- | --- |
| Reading and writing your data | `api()` → `/api/v1/*` | `apps/native/features/core/api-client.ts` |
| Sign-in, sign-up, sign-out, session refresh | `@supabase/supabase-js` directly | `apps/native/features/core/supabase.ts` |
| MFA: enroll, challenge, verify, list factors | `@supabase/supabase-js` directly | `supabase.auth.mfa.*` |
| Uploading avatars and team logos | `@supabase/supabase-js` directly | `supabase.storage.from('account_image')` |

The rule behind the table: the Supabase SDK is used only where an HTTP wrapper would make things worse. Auth needs the session **in** the client so the SDK can refresh it; MFA challenge and enrollment are GoTrue APIs with no data of yours in them; and pushing an image through a JSON endpoint would mean base64 in a request body. Everything else — anything that is your product's data or your product's rules — goes through `/api/v1`.

### Why data goes through `/api/v1` rather than `supabase.from(...)`

Supabase's JS SDK queries tables from React Native perfectly well. The kit deliberately does not do that, for one reason: **a mobile binary is not a deployment you control.**

Installed copies update on the user's schedule, and old versions stay in the field for months. If the rules about which roles a caller may assign, when a seat count changes, or whether team creation is allowed lived in the app, then every rule would exist twice — once in web's server actions and once in the app — and an old binary would keep enforcing an old copy of them. Putting them behind a route handler means:

- **One implementation per rule.** The `/api/v1` handlers call the same services as web's server actions. `POST /api/v1/accounts/teams/[slug]/invitations` and web's `createInvitationsAction` both end up in `createAccountInvitationsService.sendInvitations`, so a change to invitation behaviour lands on both platforms at once.
- **Rules you can fix without a store release.** A server-side gate is redeployed with `apps/web`.
- **A versioned contract.** Fields may be added within `v1`; removing or renaming one means `/api/v2`. An app from six months ago keeps working.
- **Server-side enforcement of things web only gates in the UI.** `POST /api/v1/accounts/teams` re-checks `enableTeamAccounts` and `enableTeamCreation`, and `POST /api/v1/accounts/teams/[slug]/leave` refuses the primary owner — both places where web relies on the UI not offering the action.

RLS has not gone anywhere. `enhanceRouteHandler` builds the Supabase client from the caller's own JWT, so a handler that queries a table is subject to exactly the same policies the app would have been. The endpoints add authorization and business rules **on top of** RLS; they do not replace it.

{% alert type="info" title="One read still goes straight to the table" %}
`usePersonalAccount` (`apps/native/features/accounts/hooks/queries/use-personal-account.ts`) calls `fetchPersonalAccount` from `@kit/accounts/shared`, which selects the user's own `accounts` row through RLS — the same shared helper web uses. It is a rule-free read of a row the user owns, so there is nothing for a handler to enforce. Treat it as the exception, not the pattern: anything with a rule attached to it belongs behind `/api/v1`.
{% /alert %}

## The `api()` client

`apps/native/features/core/api-client.ts` is roughly a hundred lines and is the only place in the app that calls `fetch`. Its signature:

```ts
export async function api<T>(path: string, init?: RequestInit): Promise<T>
```

`path` is relative to the API version prefix — you pass `/me/workspace`, not `/api/v1/me/workspace`. Everything else is the `RequestInit` you already know.

| Behaviour | Detail |
| --- | --- |
| Base URL | `process.env.EXPO_PUBLIC_API_BASE_URL`, wrapped in `resolveDevHost()`, with `/api/v1` appended before your `path`. |
| Auth | `supabase.auth.getSession()` on every call; the `access_token` becomes `Authorization: Bearer <jwt>`. Nothing is cached, so a token the SDK refreshed in the background is picked up immediately. |
| Headers | Built with the `Headers` API from `init.headers`, then `Content-Type: application/json` and the bearer are `set()` — a caller cannot accidentally shadow either with a differently-cased key. |
| Timeout | 15 seconds, via an `AbortController`. |
| Empty responses | A `204`, or a `content-length: 0`, returns `undefined` rather than throwing on an empty JSON parse. Type it as `api<undefined>(...)`. |
| Failures | Any non-`ok` response becomes an `ApiError` — never a raw `Response`. |

The `resolveDevHost` wrap comes from `apps/native/features/core/dev-host.ts`. On Android in dev it rewrites a loopback host to `10.0.2.2`, the emulator's fixed alias for the host machine's localhost, so a single committed `.env.development` serves the iOS simulator and the Android emulator without an override. LAN IPs and remote URLs pass through untouched. `docs/native/running-locally.mdoc` covers the device and env setup.

### Signals compose

The timeout controller does not replace a caller's signal — the two are wired together:

```ts
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
const onCallerAbort = () => controller.abort();

if (init?.signal?.aborted) {
  controller.abort();
} else {
  init?.signal?.addEventListener('abort', onCallerAbort);
}
```

The request aborts on whichever fires first, and both the timer and the listener are cleaned up in a `finally`. This matters because React Query passes a signal into every `queryFn`: pass it through and an unmounted screen's in-flight request is cancelled instead of running to its full 15 seconds.

```ts
queryFn: ({ signal }) => api<Project[]>('/accounts/teams/' + slug + '/projects', { signal }),
```

### Two guard clauses

`api()` throws before it reaches the network in two cases.

**No base URL.** A missing `EXPO_PUBLIC_API_BASE_URL` throws with the file to set it in. An unset `EXPO_PUBLIC_*` variable is `undefined` at runtime rather than a build error, so without this guard the first request would fail as `fetch('undefined/api/v1/...')`.

**Cleartext in a production build.** When `__DEV__` is false and the base URL does not start with `https://`, `api()` refuses to run:

```ts
if (!__DEV__ && !API_BASE_URL.startsWith('https://')) {
  throw new Error(
    'EXPO_PUBLIC_API_BASE_URL must use https:// in a production build.',
  );
}
```

Every request carries a Supabase access token in an `Authorization` header. Over plain HTTP that token is readable by anything on the network path, and it is a working credential for the user's whole account until it expires. The default `.env.development` points at `http://localhost:3000`, which is correct locally and catastrophic in a release build — and because `EXPO_PUBLIC_*` values are baked in at build time, a stale one ships silently. The guard turns that into a loud failure on the first request instead. `docs/native/config-requirements.mdoc` documents `EXPO_PUBLIC_API_BASE_URL` and what changing it forces elsewhere; `docs/native/building-shipping.mdoc` covers per-environment builds.

## Errors: `ApiError` and the `code` contract

Every failed request throws an `ApiError` carrying three things:

| Field | Meaning |
| --- | --- |
| `status` | The HTTP status. |
| `code` | The machine-readable discriminator from the response body's `error` field, or `'unknown'` for a non-JSON body. |
| `message` | The handler's human-readable `message` when it supplies one (policy denials do), otherwise the `code` itself. |

`code` is the contract you branch on. It mirrors the discriminator `enhanceRouteHandler` produces for auth failures — `unauthenticated` or `mfa-required` — and, beyond that, whatever string a handler returns in `{ error: '...' }`. The per-endpoint codes are listed in `apps/web/app/api/v1/AGENTS.md`; branch on them rather than on `status`, since one status covers several distinct causes.

The invitations screen shows the pattern (`apps/native/app/(home)/invitations.tsx`):

```tsx
onError: (error) => {
  if (isAuthError(error)) {
    return; // the query provider already handled it
  }

  if (error instanceof ApiError && error.code === 'already-member') {
    goToTeam(invitation.account.slug);
    return;
  }

  toast.error(tAuth('acceptInviteError'));
}
```

`isAuthError(error)` returns true for exactly the two auth discriminators. Use it to *bail out* of local error handling, because those two are owned globally — see the next section.

For display, `describeError` in `apps/native/features/core/errors.ts` renders `"401 · mfa-required"` for an `ApiError` and the message for anything else. It is a **diagnostic**, not a user-facing string: `ErrorState` shows the generic `common.genericError` to users and only renders `describeError` behind a `__DEV__` check, matching web's rule that error detail never leaks to a user.

## What the query provider does for you

`apps/native/lib/react-query-provider.tsx` wraps the app and configures the `QueryClient` once. Understanding what it already handles keeps it out of your hooks.

| Wiring | Effect |
| --- | --- |
| `QueryCache` / `MutationCache` `onError` | Any `ApiError` with `code === 'unauthenticated'` triggers `supabase.auth.signOut()`; `code === 'mfa-required'` does `router.replace(ROUTES.mfaChallenge)`. Applies to every query and every mutation in the app. |
| `retry` predicate | Returns `false` for those same two codes, `failureCount < 3` otherwise. A rejected token is not retried three times. |
| `staleTime: 60 * 1000` | The same 60-second default as web's provider, so a screen re-entered within a minute paints from cache. |
| `onlineManager` | Driven by `expo-network` — `Network.addNetworkStateListener`, set once at module scope. React Query pauses and resumes with the device's connectivity. |
| `focusManager` | Driven by React Native's `AppState`, so returning from the background refetches stale queries the way tab focus does on web. |

The first row is the important one: **you do not write 401 handling in a hook or a screen.** A signed-out token anywhere in the app flips auth state, the `(home)` guard redirects to sign-in, and the authenticated stack unmounts — which dismisses any modal presented above it. An expired MFA session lands on the challenge screen. Your `onError` only handles the codes that are specific to your feature, and calls `isAuthError` first to skip the rest. `docs/native/authentication.mdoc` covers the state machine those two paths feed into.

## Hook conventions

Hooks live with their feature, split by kind:

```
apps/native/features/<domain>/hooks/queries/use-<thing>.ts
apps/native/features/<domain>/hooks/mutations/use-<verb>-<thing>.ts
```

One-off hooks that are neither (`use-resend-cooldown.ts`) sit in `hooks/` directly. There are no barrel files — import the deep path. The layering rules are in `docs/native/project-structure.mdoc`.

### A query hook

`apps/native/features/team-accounts/hooks/queries/use-team-members.ts`, in full:

```ts
import { useQuery } from '@tanstack/react-query';

import type { Database } from '@kit/supabase/database';
import { teamAccountKeys } from '@kit/team-accounts/shared';

import { api } from '~/features/core/api-client';
import { useActiveWorkspace } from '~/lib/workspace/workspace-context';

export type TeamMember =
  Database['public']['Functions']['get_account_members']['Returns'][number];

export function useTeamMembers() {
  const active = useActiveWorkspace();
  const slug = active.kind === 'team' ? active.slug : undefined;

  return useQuery({
    queryKey: teamAccountKeys.members(slug ?? ''),
    queryFn: () => api<TeamMember[]>('/accounts/teams/' + slug + '/members'),
    enabled: !!slug,
    staleTime: 30_000,
  });
}
```

Four conventions are doing work here:

- **The response type is derived, not hand-written.** `Database` comes from `@kit/supabase/database`, the same generated types web uses, so a column change breaks the typecheck instead of the screen.
- **The key comes from `@kit/team-accounts/shared`.** `teamAccountKeys` and `accountKeys` are plain objects (`detail`, `members`, `invitations`; `data`) that both platforms import, so a key shape is defined once. Where no shared key exists yet the hooks use a local literal — `['user-workspace', userId]`, `['team-account-roles', slug]` — which is fine, but keep the *literal* out of two files: `useMyInvitations` exports `myInvitationsKey` for `useAcceptInvitation` to invalidate.
- **`enabled: !!slug`** keeps the query from firing with `undefined` in the URL while no team is active. Any hook whose path depends on a value that can be absent needs this.
- **`staleTime` is overridden per hook** where the default is wrong. Members are 30 s (they change during a session); the role list is 30 minutes (`useAccountRoles`, it barely changes); pending invitations are 5 minutes in `useMyInvitations` because it is mounted in the header on every screen.

### A mutation hook

`apps/native/features/team-accounts/hooks/mutations/use-invite-members.ts`, in full:

```ts
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { teamAccountKeys } from '@kit/team-accounts/shared';

import { api } from '~/features/core/api-client';

export interface InviteMembersPayload {
  invitations: { email: string; role: string }[];
}

export function useInviteMembers(slug: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationKey: ['accounts', 'invite-members', slug],
    mutationFn: (payload: InviteMembersPayload) =>
      api<{ success: boolean }>('/accounts/teams/' + slug + '/invitations', {
        method: 'POST',
        body: JSON.stringify(payload),
      }),
    onSuccess: () => {
      void queryClient.invalidateQueries({
        queryKey: teamAccountKeys.invitations(slug),
      });
    },
  });
}
```

Notes:

- **`body` is stringified by the caller.** `api()` sets the content type but does not serialize for you.
- **`onSuccess` invalidates, it does not write to the cache.** Re-reading through the endpoint is the only way to see what the server actually did — a role the service normalized, a seat count it changed.
- **Invalidate everything the mutation moved.** `useUpdatePersonalAccount` invalidates both `accountKeys.data(userId)` and `['user-workspace', userId]`, because the display name appears in the workspace switcher too.
- **Use `onSettled` when a failure is also a change.** `useAcceptInvitation` does, because `already-member` means the membership exists regardless of the 409.
- **The mutation is slug-scoped by argument.** Passing `slug` into the hook, rather than reading the active workspace inside it, keeps a mutation targeting the team the screen is about.

## Adding an endpoint end to end

The walkthrough below adds a per-team **projects** list: any member can read it, and a member with `settings.manage` can create one. Follow the same five steps for any resource.

### 1. The table and its RLS policies

Add the table under `apps/web/supabase/schemas/` and generate a migration, as described in `docs/development/database-schema.mdoc` and `docs/development/migrations.mdoc`. Scope it to an account and gate it with `public.has_role_on_account(account_id)` — this is the floor your endpoint sits on, and it is what makes a bug in the handler a failed request rather than a tenant leak. Regenerate the Supabase types afterwards so `Database` knows about the table.

### 2. Shared schema and query keys

Anything both platforms need — a Zod schema, a response type, a query key — must live in a `@kit/*` package under a `/shared` subpath. `apps/native` depends on `@kit/accounts`, `@kit/auth`, `@kit/team-accounts`, `@kit/supabase`, `@kit/shared` and `@kit/mobile-ui`; **it cannot import from `apps/web`**, and the `/shared` entrypoints are the ones free of React DOM and Next.js imports, which is what makes them safe in a Metro bundle.

For a team-scoped resource, add the schema to `packages/features/team-accounts/src/schema/` and export it from `packages/features/team-accounts/src/shared/index.ts`. For a domain of your own, create a package with its own `./shared` export — `docs/development/adding-turborepo-package.mdoc` covers that.

```ts
// packages/features/team-accounts/src/schema/create-project.schema.ts
import * as z from 'zod';

export const CreateProjectSchema = z.object({
  name: z.string().min(2).max(100),
});
```

Add the key alongside the existing ones in `packages/features/team-accounts/src/shared/keys.ts`:

```ts
export const teamAccountKeys = {
  detail: (slug: string) => ['team-account:detail', slug] as const,
  members: (slug: string) => ['team-account:members', slug] as const,
  invitations: (slug: string) => ['team-account:invitations', slug] as const,
  projects: (slug: string) => ['team-account:projects', slug] as const,
};
```

Now web's form and the native form validate against the same schema, and both invalidate the same key.

### 3. The service

Business logic goes in a service — a factory taking a Supabase client and returning an object of methods, with no knowledge of HTTP, React or Next.js. That is what lets web's server action and the native-facing route handler share it instead of reimplementing it. `apps/web/lib/server/` is the place for logic specific to your app; `packages/features/<domain>/src/server/services/` is the place for a reusable domain. The `/service-builder` skill scaffolds one.

```ts
// apps/web/lib/server/projects/projects.service.ts
import 'server-only';

import type { SupabaseClient } from '@supabase/supabase-js';

import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';

export function createProjectsService(client: SupabaseClient<Database>) {
  return new ProjectsService(client);
}

class ProjectsService {
  private readonly namespace = 'projects';

  constructor(private readonly client: SupabaseClient<Database>) {}

  async getProjects(params: { accountId: string }) {
    const { data, error } = await this.client
      .from('projects')
      .select('id, name, created_at')
      .eq('account_id', params.accountId)
      .order('created_at', { ascending: false });

    if (error) {
      throw error;
    }

    return data;
  }

  async createProject(params: { accountId: string; name: string }) {
    const logger = await getLogger();
    const ctx = { name: this.namespace, accountId: params.accountId };

    const { data, error } = await this.client
      .from('projects')
      .insert({ account_id: params.accountId, name: params.name })
      .select('id, name, created_at')
      .single();

    if (error) {
      logger.error({ ...ctx, error }, 'Failed to create project');

      throw error;
    }

    logger.info(ctx, 'Project created');

    return data;
  }
}
```

Two rules from `packages/features/AGENTS.md` apply: use `getLogger()` from `@kit/shared/logger` rather than `console.log`, and mark the file `import 'server-only'` so it can never be pulled into a client bundle.

The payoff is on the web side — the server action for the same feature calls the identical service:

```ts
export const createProjectAction = authActionClient
  .inputSchema(CreateProjectActionSchema)
  .action(async ({ parsedInput: data }) => {
    const client = getSupabaseServerClient();

    return createProjectsService(client).createProject({
      accountId: data.accountId,
      name: data.name,
    });
  });
```

`authActionClient` from `@kit/next/safe-action` is web's entry point, `enhanceRouteHandler` is native's, and the service in the middle is the same object. `packages/next/AGENTS.md` and the `/server-action-builder` skill cover the action side.

### 4. The route handler

Create `apps/web/app/api/v1/accounts/teams/[slug]/projects/route.ts`. The conventions for this directory — no `[locale]` segment, JSON only, never `redirect()` — are in `apps/web/app/api/v1/AGENTS.md`.

```ts
import { NextResponse } from 'next/server';

import { enhanceRouteHandler } from '@kit/next/routes';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
import { CreateProjectSchema } from '@kit/team-accounts/shared';

import { createProjectsService } from '~/lib/server/projects/projects.service';

/**
 * GET /api/v1/accounts/teams/[slug]/projects
 *
 * Returns the team's projects. RLS scopes the read to teams the caller
 * belongs to, so a non-member gets an empty array.
 */
export const GET = enhanceRouteHandler(
  async ({ client, params }) => {
    const slug = params.slug;

    if (!slug) {
      return NextResponse.json({ error: 'team-not-found' }, { status: 404 });
    }

    const { data: account, error } = await client
      .from('accounts')
      .select('id')
      .match({ slug, is_personal_account: false })
      .single();

    if (error || !account) {
      return NextResponse.json({ error: 'team-not-found' }, { status: 404 });
    }

    try {
      const projects = await createProjectsService(client).getProjects({
        accountId: account.id,
      });

      return NextResponse.json(projects);
    } catch (error) {
      const logger = await getLogger();

      logger.error({ name: 'projects.list', slug, error }, 'Failed to list projects');

      return NextResponse.json(
        { error: 'projects-fetch-failed' },
        { status: 500 },
      );
    }
  },
  { auth: true },
);

/**
 * POST /api/v1/accounts/teams/[slug]/projects
 *
 * Creates a project. Requires `settings.manage` on the team.
 */
export const POST = enhanceRouteHandler(
  async ({ user, body, client, params }) => {
    const logger = await getLogger();
    const slug = params.slug;

    if (!slug) {
      return NextResponse.json({ error: 'team-not-found' }, { status: 404 });
    }

    const { data: account, error } = await client
      .from('accounts')
      .select('id')
      .match({ slug, is_personal_account: false })
      .single();

    if (error || !account) {
      return NextResponse.json({ error: 'team-not-found' }, { status: 404 });
    }

    const { data: allowed } = await client.rpc('has_permission', {
      user_id: user.id,
      account_id: account.id,
      permission_name:
        'settings.manage' as Database['public']['Enums']['app_permissions'],
    });

    if (!allowed) {
      return NextResponse.json(
        {
          error: 'projects-forbidden',
          message: 'You do not have permission to create projects',
        },
        { status: 403 },
      );
    }

    try {
      const project = await createProjectsService(client).createProject({
        accountId: account.id,
        name: body.name,
      });

      return NextResponse.json(project, { status: 201 });
    } catch (createError) {
      logger.error(
        { name: 'projects.create', slug, userId: user.id, error: createError },
        'Failed to create project',
      );

      return NextResponse.json(
        { error: 'project-create-failed' },
        { status: 500 },
      );
    }
  },
  { auth: true, schema: CreateProjectSchema },
);
```

What `enhanceRouteHandler` gives you, and what it does not:

- **`auth: true`** verifies the caller. It detects `Authorization: Bearer <jwt>` and builds an RLS-bound client from that token; with no bearer it falls back to the cookie client, so the same handler serves the browser. A bearer caller that fails gets `401 { error: 'unauthenticated' | 'mfa-required' }` as JSON, because a native client cannot follow a redirect to a sign-in page.
- **`schema`** parses the JSON body and returns `400` on a mismatch, so `body` arrives typed. Path segments are **not** validated — `params` is `Record<string, string>`, so validate anything you use, as the kit's handlers do for numeric invitation ids and uuids.
- **`client` and `user` are handed to you.** Do not rebuild either.
- **Nothing else is authorized for you.** RLS is the floor; a permission gate like the `has_permission` call above is yours to add. If your service needs the admin client to do its work, then RLS is *not* protecting the write and every check has to be explicit — that is why the invitations handler ports web's permission checks in full.

Resolve the account id with the RLS-bound `client` before anything else, as above. A slug the caller has no membership in fails that lookup, so the `404` falls out of RLS rather than out of a check you had to remember to write.

Return codes your client can branch on. `{ error: 'projects-forbidden' }` becomes `ApiError.code === 'projects-forbidden'` on the device; a `message` alongside it becomes `ApiError.message`.

### 5. The native hooks

```ts
// apps/native/features/projects/hooks/queries/use-team-projects.ts
import { useQuery } from '@tanstack/react-query';

import type { Database } from '@kit/supabase/database';
import { teamAccountKeys } from '@kit/team-accounts/shared';

import { api } from '~/features/core/api-client';
import { useActiveWorkspace } from '~/lib/workspace/workspace-context';

export type Project = Pick<
  Database['public']['Tables']['projects']['Row'],
  'id' | 'name' | 'created_at'
>;

// Fetches the active team's projects from
// `/api/v1/accounts/teams/[slug]/projects`. Enabled only while a team is active
export function useTeamProjects() {
  const active = useActiveWorkspace();
  const slug = active.kind === 'team' ? active.slug : undefined;

  return useQuery({
    queryKey: teamAccountKeys.projects(slug ?? ''),
    queryFn: ({ signal }) =>
      api<Project[]>('/accounts/teams/' + slug + '/projects', { signal }),
    enabled: !!slug,
    staleTime: 30_000,
  });
}
```

```ts
// apps/native/features/projects/hooks/mutations/use-create-project.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type * as z from 'zod';

import type { CreateProjectSchema } from '@kit/team-accounts/shared';
import { teamAccountKeys } from '@kit/team-accounts/shared';

import { api } from '~/features/core/api-client';
import type { Project } from '~/features/projects/hooks/queries/use-team-projects';

type CreateProjectPayload = z.infer<typeof CreateProjectSchema>;

export function useCreateProject(slug: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationKey: ['projects', 'create', slug],
    mutationFn: (payload: CreateProjectPayload) =>
      api<Project>('/accounts/teams/' + slug + '/projects', {
        method: 'POST',
        body: JSON.stringify(payload),
      }),
    onSuccess: () => {
      void queryClient.invalidateQueries({
        queryKey: teamAccountKeys.projects(slug),
      });
    },
  });
}
```

### 6. The screen

Consume it with the shared states from `apps/native/features/makerkit/components/`. `LoadingState` is a centred `ActivityIndicator`; `ErrorState` renders the generic message, the `__DEV__`-only diagnostic, and a retry — with `InlineErrorState` for a failed section inside a screen that otherwise rendered.

```tsx
import { FlatList, RefreshControl, View } from 'react-native';

import { Text } from '@kit/mobile-ui/text';

import { ErrorState } from '~/features/makerkit/components/error-state';
import { LoadingState } from '~/features/makerkit/components/loading-state';
import { useTeamProjects } from '~/features/projects/hooks/queries/use-team-projects';

export default function ProjectsScreen() {
  const projects = useTeamProjects();

  if (projects.isPending) {
    return <LoadingState />;
  }

  if (projects.isError) {
    return (
      <ErrorState
        error={projects.error}
        onRetry={() => void projects.refetch()}
      />
    );
  }

  return (
    <FlatList
      data={projects.data}
      keyExtractor={(project) => project.id}
      className="bg-background"
      contentContainerClassName="p-4 gap-2"
      refreshControl={
        <RefreshControl
          refreshing={projects.isFetching}
          onRefresh={() => void projects.refetch()}
        />
      }
      renderItem={({ item }) => (
        <View className="border-border rounded-lg border p-4">
          <Text className="text-foreground font-medium">{item.name}</Text>
        </View>
      )}
    />
  );
}
```

Three things worth copying from the real screens:

- **`isPending`, not `isLoading`,** for a query with `enabled` gating: a disabled query never loads, but it is pending, so a screen keyed on `isLoading` renders an empty list before the slug arrives. `apps/native/app/(home)/[account]/(tabs)/members/index.tsx` gates on `isPending`.
- **A `RefreshControl` on the list, driven by `isFetching`,** giving pull-to-refresh for free.
- **Degrade a section, not the screen.** The members screen renders `ErrorState` when members fail, but an `InlineErrorState` row when only the invitations query fails, so a working list is not replaced by an error page.

Where the create form goes, and how `useCreateProject` binds to `react-hook-form` and the toast, is in `docs/native/forms.mdoc`. Adding the route the screen lives at is in `docs/native/routing-navigation.mdoc`.

### Checklist

| Step | File |
| --- | --- |
| Table + RLS + regenerated types | `apps/web/supabase/schemas/` |
| Schema and query key, both platforms import them | `packages/features/<domain>/src/shared/` |
| Service holding the business rules | `apps/web/lib/server/` or `packages/features/<domain>/src/server/services/` |
| Route handler calling the service | `apps/web/app/api/v1/**/route.ts` |
| Query and mutation hooks | `apps/native/features/<domain>/hooks/{queries,mutations}/` |
| Screen with loading and error states | `apps/native/app/` |

Then run `pnpm typecheck`, which covers `apps/web`, `apps/native` and `@kit/mobile-ui` in one pass.

## Endpoint reference

Every `/api/v1` route in the kit and the hook that calls it. The request and response shapes, the full list of error codes per endpoint, and the authorization invariants to re-verify after touching one are in `apps/web/app/api/v1/AGENTS.md`; the services underneath are documented in `docs/api/team-account-api.mdoc`, `docs/api/account-api.mdoc`, `docs/api/user-workspace-api.mdoc`, `docs/api/account-workspace-api.mdoc` and `docs/api/otp-api.mdoc`.

| Endpoint | Method | Native hook |
| --- | --- | --- |
| `/me/workspace` | GET | `useUserWorkspace` — `features/accounts/hooks/queries/use-user-workspace.ts` |
| `/me/invitations` | GET | `useMyInvitations` — `features/team-accounts/hooks/queries/use-my-invitations.ts` |
| `/me/invitations/[id]/accept` | POST | `useAcceptInvitation` — `features/team-accounts/hooks/mutations/use-accept-invitation.ts` |
| `/accounts/personal` | PATCH | `useUpdatePersonalAccount` — `features/accounts/hooks/mutations/use-update-personal-account.ts` |
| `/accounts/personal` | DELETE | `useDeletePersonalAccount` — `features/accounts/hooks/mutations/use-delete-personal-account.ts` |
| `/otp/send` | POST | `useSendOtpEmail` — `features/auth/hooks/mutations/use-send-otp-email.ts` |
| `/accounts/teams` | POST | `useCreateTeamAccount` — `features/team-accounts/hooks/mutations/use-create-team-account.ts` |
| `/accounts/teams/[slug]` | GET | `useTeamWorkspace`, `useTeamPermissions` — `features/team-accounts/hooks/queries/use-team-workspace.ts` |
| `/accounts/teams/[slug]` | PATCH | `useUpdateTeamAccount` — `features/team-accounts/hooks/mutations/use-update-team-account.ts` |
| `/accounts/teams/[slug]/leave` | POST | `useLeaveTeamAccount` — `features/team-accounts/hooks/mutations/use-leave-team-account.ts` |
| `/accounts/teams/[slug]/members` | GET | `useTeamMembers` — `features/team-accounts/hooks/queries/use-team-members.ts` |
| `/accounts/teams/[slug]/members/[userId]` | PATCH, DELETE | `useUpdateMemberRole`, `useRemoveMember` — `features/team-accounts/hooks/mutations/use-manage-members.ts` |
| `/accounts/teams/[slug]/invitations` | GET | `useTeamInvitations` — `features/team-accounts/hooks/queries/use-team-invitations.ts` |
| `/accounts/teams/[slug]/invitations` | POST | `useInviteMembers` — `features/team-accounts/hooks/mutations/use-invite-members.ts` |
| `/accounts/teams/[slug]/invitations/[id]` | PATCH, DELETE | `useUpdateInvitationRole`, `useDeleteInvitation` — `features/team-accounts/hooks/mutations/use-manage-invitations.ts` |
| `/accounts/teams/[slug]/invitations/[id]/renew` | POST | `useRenewInvitation` — `features/team-accounts/hooks/mutations/use-manage-invitations.ts` |
| `/accounts/teams/[slug]/roles` | GET | `useAccountRoles` — `features/team-accounts/hooks/queries/use-account-roles.ts` |

Paths in the third column are relative to `apps/native/`.

{% alert type="warning" title="Changing an endpoint changes two apps" %}
`/api/v1` is consumed by installed binaries you cannot update. Adding a field is safe within `v1`; removing or renaming one breaks every copy of the app in the field that reads it, so that belongs in `/api/v2`. `apps/web/app/api/v1/AGENTS.md` lists the authorization invariants to re-verify after any change to a handler's gate or a wrapped service.
{% /alert %}

## Realtime

`supabase.channel()` works from React Native with no extra setup — the same client in `apps/native/features/core/supabase.ts` that handles auth and storage. Nothing in the app subscribes to it today, and the notification surface that would be its first consumer is not built. `docs/native/covered-features.mdoc` is the authority on what is and is not implemented.
