---
title: "Network Requests"
url: https://develop.sentry.dev/frontend/network-requests/
---

# Network Requests

There are multiple ways to make network requests in the Sentry app. This guide covers the recommended approach using `apiOptions` with TanStack Query, as well as mutations and testing patterns.

## [Data Fetching with `apiOptions`](https://develop.sentry.dev/frontend/network-requests.md#data-fetching-with-apioptions)

`apiOptions` is a type-safe factory for creating [TanStack Query `queryOptions`](https://tanstack.com/query/latest/docs/framework/react/guides/query-options) that hit Sentry API endpoints. It replaces the legacy `useApiQuery` hook and is the recommended way to fetch data in this codebase.

### [Why not a hook?](https://develop.sentry.dev/frontend/network-requests.md#why-not-a-hook)

The key design decision behind `apiOptions` is that it produces a **query options object**, not a hook. This matters because a plain object is composable - you can pass it to any TanStack Query API that accepts options:

* `useQuery(apiOptions.as<T>()(...))`
* `useQueries({ queries: [apiOptions.as<A>()(...), apiOptions.as<B>()(...)]})`
* `queryClient.fetchQuery(apiOptions.as<T>()(...))`

A custom hook like `useApiQuery` can only be used inside React components. By building on `queryOptions` instead, we get one abstraction that works everywhere.

### [Basic usage](https://develop.sentry.dev/frontend/network-requests.md#basic-usage)

**tsx**

```tsx
import { useQuery } from "@tanstack/react-query";
import { apiOptions } from "sentry/utils/api/apiOptions";

function IssueDetail({ organization, issueId }) {
  const { data, isPending, isError } = useQuery(
    apiOptions.as<Group>()(
      "/organizations/$organizationIdOrSlug/issues/$issueId/",
      {
        path: { organizationIdOrSlug: organization.slug, issueId },
        staleTime: 30_000,
      },
    ),
  );

  // data is Group | undefined
}
```

The `.as<T>()` call provides the response type (until we can infer it from the endpoint itself).

The first argument is a URL pattern from a **generated list of known API endpoints**. This list is derived from the backend route definitions and lives in `knownSentryApiUrls.generated.ts` (with a manual companion for getsentry routes in `knownGetsentryApiUrls.ts`). TypeScript restricts the URL to one of these known patterns, so typos and invalid endpoints are caught at compile time.

URL patterns use `$param` placeholders (e.g. `$organizationIdOrSlug`, `$issueId`) which TypeScript extracts to enforce a matching `path` object. If the URL has parameters, you must supply all of them - [or pass `skipToken`](https://develop.sentry.dev/frontend/network-requests.md#conditional-fetching-with-skiptoken) to disable the query.

### [`staleTime` is required](https://develop.sentry.dev/frontend/network-requests.md#staletime-is-required)

`apiOptions` requires you to pass `staleTime`. This is intentional, as it forces you to think about how long your data should be considered fresh before TanStack Query marks it stale and refetches on the next trigger (mount, window focus, key change etc.).

* `staleTime: 0` — data is always stale; it changes often and I'm okay with excess refetches.
* `staleTime: 30_000` — "I only want to refetch at most every 30 seconds".
* `staleTime: Infinity` — data never goes stale. Use for data that doesn't change (or changes so rarely that you'll invalidate manually).
* `staleTime: 'static'` — data is fetched once and *never* refetched, even if the Query is invalidated manually.

Note that regardless of the value chosen for `staleTime`, values only stay in the cache for 5 minutes since last use. This can be modified with `gcTime`.

### [Conditional fetching with `skipToken`](https://develop.sentry.dev/frontend/network-requests.md#conditional-fetching-with-skiptoken)

To conditionally disable a query, pass `skipToken` as the `path` value:

**tsx**

```tsx
import { skipToken, useQuery } from "@tanstack/react-query";
import { apiOptions } from "sentry/utils/api/apiOptions";

function ProjectDetail({ organization, projectSlug }) {
  const { data } = useQuery(
    apiOptions.as<Project>()(
      "/organizations/$organizationIdOrSlug/projects/$projectIdOrSlug/",
      {
        path: projectSlug
          ? {
              organizationIdOrSlug: organization.slug,
              projectIdOrSlug: projectSlug,
            }
          : skipToken,
        staleTime: 30_000,
      },
    ),
  );
}
```

When `skipToken` is passed, the query is disabled and won't fire. This is similar to the `enabled` option, but `skipToken` is preferred as it supports type narrowing.

### [Creating abstractions](https://develop.sentry.dev/frontend/network-requests.md#creating-abstractions)

When an API call is used in multiple places, extract it into a reusable function that returns query options. Build these abstractions **over `apiOptions`**, not over `useQuery`:

**tsx**

```tsx
import { skipToken } from "@tanstack/react-query";
import { apiOptions } from "sentry/utils/api/apiOptions";

export function sentryAppApiOptions({
  appSlug,
}: {
  appSlug: string | null;
}) {
  return apiOptions.as<SentryApp>()("/sentry-apps/$sentryAppIdOrSlug/", {
    path: appSlug ? { sentryAppIdOrSlug: appSlug } : skipToken,
    staleTime: 0,
  });
}
```

Now any consumer can use it with `useQuery`, `prefetchQuery`, or anything else:

**tsx**

```tsx
// In a component
const { data } = useQuery(sentryAppApiOptions({ appSlug: "my-app" }));

// In a loader or prefetch
queryClient.prefetchQuery(sentryAppApiOptions({ appSlug: "my-app" }));
```

#### [Wrapping with `queryOptions` for shared config](https://develop.sentry.dev/frontend/network-requests.md#wrapping-with-queryoptions-for-shared-config)

If you need to add shared options like `retry: false` on top of `apiOptions`, wrap it in `queryOptions` from TanStack Query:

**tsx**

```tsx
import { queryOptions, skipToken } from "@tanstack/react-query";
import { apiOptions } from "sentry/utils/api/apiOptions";

export function projectTeamsApiOptions({ orgSlug, projectSlug, cursor }) {
  return queryOptions({
    ...apiOptions.as<Team[]>()(
      "/projects/$organizationIdOrSlug/$projectIdOrSlug/teams/",
      {
        path:
          orgSlug && projectSlug
            ? {
                organizationIdOrSlug: orgSlug,
                projectIdOrSlug: projectSlug,
              }
            : skipToken,
        query: { cursor },
        staleTime: 0,
      },
    ),
    retry: false,
  });
}
```

This pattern lets you share options like `retry`, `gcTime`, or `refetchInterval` without losing type safety.

#### [Overriding options at the call site](https://develop.sentry.dev/frontend/network-requests.md#overriding-options-at-the-call-site)

Because abstractions like `projectTeamsApiOptions` return a plain options object, you can spread it and override or add any TanStack Query option at the call site:

**tsx**

```tsx
const { data } = useQuery({
  ...projectTeamsApiOptions({ orgSlug, projectSlug }),
  select: (response) => response.json.filter((team) => team.isMember),
  enabled: hasPermission,
});
```

Note that `select` receives the raw `ApiResponse<T>` (see [How the cache stores data](https://develop.sentry.dev/frontend/network-requests.md#how-the-cache-stores-data) below), so you access the body via `response.json`.

### [How the cache stores data](https://develop.sentry.dev/frontend/network-requests.md#how-the-cache-stores-data)

Internally, API responses are stored in the query cache as `ApiResponse<T>`:

**tsx**

```tsx
type ApiResponse<T> = {
  headers: {
    Link?: string;
    "X-Hits"?: number;
    "X-Max-Hits"?: number;
  };
  json: T;
};
```

By default, `apiOptions` sets `select: selectJson`, which extracts only the `.json` body. This means when you use `useQuery`, the `data` you get back is just `T` - the headers are stripped away.

This distinction between what's **stored** and what's **selected** is important to understand because it affects several APIs.

#### [Getting response headers](https://develop.sentry.dev/frontend/network-requests.md#getting-response-headers)

To access response headers (e.g. `Link` for pagination, `X-Hits` for total counts), override `select` with `selectJsonWithHeaders`:

**tsx**

```tsx
import { useQuery } from "@tanstack/react-query";
import {
  apiOptions,
  selectJsonWithHeaders,
} from "sentry/utils/api/apiOptions";

const { data } = useQuery({
  ...apiOptions.as<Item[]>()(
    "/organizations/$organizationIdOrSlug/items/",
    {
      path: { organizationIdOrSlug: organization.slug },
      query: { cursor, per_page: 25 },
      staleTime: 0,
    },
  ),
  select: selectJsonWithHeaders,
});

// data is ApiResponse<Item[]> — an object with `json` and `headers`
const items = data?.json ?? [];
const pageLinks = data?.headers.Link;
const totalHits = data?.headers["X-Hits"]; // number | undefined
const maxHits = data?.headers["X-Max-Hits"]; // number | undefined
```

You can also provide your own `select` function. It receives the raw `ApiResponse<T>` and can return whatever shape you need:

**tsx**

```tsx
const { data } = useQuery({
  ...apiOptions.as<Item[]>()(
    "/organizations/$organizationIdOrSlug/items/",
    {
      path: { organizationIdOrSlug: organization.slug },
      staleTime: 30_000,
    },
  ),
  select: (response) => response.json.filter((item) => item.isActive),
});
```

#### [Type inference for `getQueryData` and `setQueryData`](https://develop.sentry.dev/frontend/network-requests.md#type-inference-for-getquerydata-and-setquerydata)

Because `apiOptions` builds on TanStack Query's `queryOptions`, the `queryKey` it produces carries type information. This means `queryClient.getQueryData` and `queryClient.setQueryData` can **infer the correct type** from the key:

**tsx**

```tsx
const opts = sentryAppApiOptions({ appSlug: "my-app" });

// TypeScript knows this is ApiResponse<SentryApp> | undefined
const cached = queryClient.getQueryData(opts.queryKey);

// TypeScript enforces the correct type for the updater
queryClient.setQueryData(opts.queryKey, (prev) => {
  // prev is ApiResponse<SentryApp> | undefined
  // ...
});
```

#### [APIs that operate on the raw cache structure](https://develop.sentry.dev/frontend/network-requests.md#apis-that-operate-on-the-raw-cache-structure)

Any TanStack Query API that gives you direct access to cached data — rather than going through `select` — will expose the raw `ApiResponse<T>` structure:

* **`queryClient.getQueryData(key)`** — returns `ApiResponse<T> | undefined`
* **`queryClient.setQueryData(key, updater)`** — the updater receives and must return `ApiResponse<T>`
* **`retry` function** — when using a function form, the `error` is from the raw fetch
* **`predicate` in `invalidateQueries` / `removeQueries`** — the `query.state.data` is `ApiResponse<T>`
* **`initialData`** — must be `ApiResponse<T>`, not just `T`
* **`placeholderData`** — receives `ApiResponse<T>` from the previous query

This is the standard TanStack Query behavior: `select` is a client-side transform that only applies when data flows out through hooks. Everything that touches the cache directly works with the stored shape.

### [Query invalidation](https://develop.sentry.dev/frontend/network-requests.md#query-invalidation)

Because `apiOptions` produces a `queryKey`, you can use it for cache invalidation too. Pass the `.queryKey` from your abstraction to `invalidateQueries`:

**tsx**

```tsx
const opts = sentryAppApiOptions({ appSlug: "my-app" });

// Invalidate this specific query
queryClient.invalidateQueries({ queryKey: opts.queryKey });
```

Since the return value of `apiOptions` matches the `QueryFilter` that needs to be passed `invalidateQueries`, you can also pass it directly:

**tsx**

```tsx
const opts = sentryAppApiOptions({ appSlug: "my-app" });
queryClient.invalidateQueries(opts);
```

### [Infinite queries](https://develop.sentry.dev/frontend/network-requests.md#infinite-queries)

For paginated endpoints that use cursor-based pagination, use `apiOptions.asInfinite`:

**tsx**

```tsx
import { useInfiniteQuery } from "@tanstack/react-query";
import { apiOptions } from "sentry/utils/api/apiOptions";

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(
  apiOptions.asInfinite<Item[]>()(
    "/organizations/$organizationIdOrSlug/items/",
    {
      path: { organizationIdOrSlug: organization.slug },
      staleTime: 0,
    },
  ),
);
```

`asInfinite` automatically handles `Link` header parsing for cursor-based pagination via `getNextPageParam` and `getPreviousPageParam`. Its query key uses `{infinite: true}` so regular and infinite queries for the same URL don't collide.

### [Migration from `useApiQuery`](https://develop.sentry.dev/frontend/network-requests.md#migration-from-useapiquery)

`useApiQuery` is deprecated. To migrate:

**tsx**

```tsx
// Before
const { data, isPending } = useApiQuery<Project[]>(
  [
    getApiUrl("/organizations/$organizationIdOrSlug/projects/", {
      path: { organizationIdOrSlug: organization.slug },
      query: { cursor: "1" },
    }),
  ],
  { staleTime: 0 },
);

// After
const { data, isPending } = useQuery(
  apiOptions.as<Project[]>()(
    "/organizations/$organizationIdOrSlug/projects/",
    {
      path: { organizationIdOrSlug: organization.slug },
      query: { cursor: "1" },
      staleTime: 0,
    },
  ),
);
```

Key differences:

* No separate `getApiUrl` call - `apiOptions` produces the query key internally.
* Path params are passed as a typed `path` object.
* You use `useQuery` from TanStack Query directly instead of a custom wrapper.

Similarly, `getApiQueryData` and `setApiQueryData` are deprecated. Use `queryClient.getQueryData` / `queryClient.setQueryData` directly with the query key from your options — the types are inferred automatically.

## [Mutations (POST/PUT/DELETE requests)](https://develop.sentry.dev/frontend/network-requests.md#mutations-postputdelete-requests)

Mutations, unlike queries, do not fire automatically. Instead of `data`, it returns a `mutate` function which accepts the dynamic variables necessary to complete the mutation. Also note that unlike `useApiQuery` where the query function was created automatically, you must supply your own mutation function.

### [Quick start](https://develop.sentry.dev/frontend/network-requests.md#quick-start)

Let's say you have a button that creates a project with `POST /organizations/<org>/projects/`. You will need to define the response type (`CreateProjectResponse` in this example) as well as the shape of the object you will pass to the mutation function (`CreateProjectVariables` in this example). You can then use the `mutate` function to trigger the mutation:

**tsx**

```tsx
import { fetchMutation } from "sentry/utils/queryClient";
import { useMutation } from "@tanstack/react-query";

type CreateProjectResponse = { id: string; name: string };
type CreateProjectVariables = { name: string; orgSlug: string };

function Component() {
  const { mutate } = useMutation({
    mutationFn: ({ name, orgSlug }: CreateProjectVariables) =>
      fetchMutation<CreateProjectResponse>({
        url: getApiUrl("/organizations/$organizationIdOrSlug/projects/", {
          path: { organizationIdOrSlug: organization.slug },
        }),
        method: "POST",
        data: { name },
      }),
    onSuccess: (response) => {
      addSuccessMessage(`Successfully created project ${response.name}`);
    },
  });

  return (
    <button
      onClick={() => mutate({ name: "My new project", orgSlug: "my-org" })}
    >
      Create project
    </button>
  );
}
```

### [Optimistic updates](https://develop.sentry.dev/frontend/network-requests.md#optimistic-updates)

In some situations, displaying a loading state for every action can be cumbersome and make the experience feel bloated and slow. For interactions like these, it may make sense to immediately update the cache rather than wait for a response.

While there is no loading state when optimistically updating, errors still need to be handled. Errors should reset the UI to the previous state and display a message to notify the user that their action didn't succeed.

For an example, let's say that you want to update the project's name:

**tsx**

```tsx
// useFetchProject.tsx
export const projectApiOptions = ({ orgSlug, projectSlug }) =>
  apiOptions.as<Project>()(
    "/projects/$organizationIdOrSlug/$projectIdOrSlug/",
    {
      path: {
        organizationIdOrSlug: orgSlug,
        projectIdOrSlug: projectSlug,
      },
      staleTime: Infinity,
    },
  );

// useUpdateProjectNameOptimistic.tsx
function useUpdateProjectNameOptimistic({ orgSlug }) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, name }) => {
      return fetchMutation({
        url: getApiUrl(
          "/organizations/$organizationIdOrSlug/projects/$projectIdOrSlug/",
          {
            path: { organizationIdOrSlug: orgSlug, projectIdOrSlug: id },
          },
        ),
        method: "PUT",
        data: { name },
      });
    },
    onMutate: async (variables) => {
      const projectQueryKey = projectApiOptions({
        orgSlug,
        projectSlug: variables.id,
      }).queryKey;
      // Cancel any ongoing queries so our cache changes aren't overridden
      await queryClient.cancelQueries({ queryKey: projectQueryKey });

      const previousProject = queryClient.getQueryData(projectQueryKey);

      // Update the cache with the new value
      queryClient.setQueryData(projectQueryKey, (oldData) => {
        return oldData
          ? { ...oldData, json: { ...oldData.json, name: variables.name } }
          : oldData;
      });

      // Return previous data that can be used in the case of an error
      // This will be accessible as `context` in the onError handler
      return { previousProject };
    },
    onError: (error, variables, context) => {
      addErrorMessage(t("Failed to update project name."));

      // Reset to the previous value which we set in the return value of onMutate
      if (context) {
        queryClient.setQueryData(
          projectApiOptions({ orgSlug, projectSlug: variables.id })
            .queryKey,
          context.previousProject,
        );
      }
    },
    onSettled: (_resp, _error, variables) => {
      // To be safe, trigger a refetch afterwards to ensure data is correct
      queryClient.invalidateQueries(
        projectApiOptions({ orgSlug, projectSlug: variables.id }),
      );
    },
  });
}
```

## [Testing components & hooks with network requests](https://develop.sentry.dev/frontend/network-requests.md#testing-components--hooks-with-network-requests)

We use `MockApiClient` to mock API responses. This is a globally-available class in tests that reimplements `Client` in the test environment. You can read up on the logic [here](https://github.com/getsentry/sentry/blob/master/static/app/__mocks__/api.tsx).

### [Test setup for components](https://develop.sentry.dev/frontend/network-requests.md#test-setup-for-components)

**tsx**

```tsx
import {render, screen} from 'sentry-test/reactTestingLibrary';

describe('useFetchSomeData', () => {
  it('should fetch', () => {
    const request = MockApiClient.addMockResponse(...);

    render(<MyComponentThatFetches />);

    expect(request).toHaveBeenCalled();
  });
});
```

### [Test setup for hooks](https://develop.sentry.dev/frontend/network-requests.md#test-setup-for-hooks)

**tsx**

```tsx
import {makeTestQueryClient} from 'sentry-test/queryClient';
import {reactHooks} from 'sentry-test/reactTestingLibrary';
import {QueryClientProvider} from 'sentry/utils/queryClient';

function wrapper({children}: {children?: ReactNode}) {
  return (
    <QueryClientProvider client={makeTestQueryClient()}>{children}</QueryClientProvider>
  );
}

describe('useFetchSomeData', () => {
  it('should fetch', () => {
    const request = MockApiClient.addMockResponse(...);

    const {result, waitFor} = reactHooks.renderHook(useFetchSomeData, {
      wrapper,
      initialProps: {organization},
    });

    expect(request).toHaveBeenCalled();
  });
});
```

### [Mocking successful responses](https://develop.sentry.dev/frontend/network-requests.md#mocking-successful-responses)

**tsx**

```tsx
// Mock fetching projects
MockApiClient.addMockResponse({
  url: "/projects/",
  body: [{ id: 1, name: "my project" }],
});

// Mock creating a project
MockApiClient.addMockResponse({
  url: "/projects/",
  method: "POST",
  body: { id: 1, name: "my project" },
});

// More complex matching logic:
// Matches POST /projects/?param=1 with {name: 'other'} in body
MockApiClient.addMockResponse({
  url: "/projects/",
  method: "POST",
  body: { id: 2, name: "other" },
  match: [
    MockApiClient.matchQuery({ param: "1" }),
    MockApiClient.matchData({ name: "other" }),
  ],
});
```

### [Mocking error responses](https://develop.sentry.dev/frontend/network-requests.md#mocking-error-responses)

**tsx**

```tsx
MockApiClient.addMockResponse({
  url: "/projects/",
  body: {
    detail: "Internal Error",
  },
  statusCode: 500,
});
```

### [Common pitfalls](https://develop.sentry.dev/frontend/network-requests.md#common-pitfalls)

#### [Waiting for your queries to respond](https://develop.sentry.dev/frontend/network-requests.md#waiting-for-your-queries-to-respond)

Always, always, always await your assertions when a network request is involved! Although the data is readily available (since we provide mocks), it is still an async process which takes multiple render cycles. So be sure to use [`findBy` queries](https://testing-library.com/docs/dom-testing-library/api-async#findby-queries) when necessary.

#### [Mocking mutations with refetches](https://develop.sentry.dev/frontend/network-requests.md#mocking-mutations-with-refetches)

When testing a component with a mutation, you often want to refetch data after the mutation has completed. Since the data has changed, you will need to update your mocks before the refetch occurs in order to create a test that matches reality. Otherwise you could have perfectly good logic which updates the cache, but the refetch will use the original mocked data and ruin the test. To prevent that from happening, see this example:

**tsx**

```tsx
it('adds a project to the list', function () {
  MockApiClient.addMockResponse({
    url: '/projects/',
    body: [],
  });
  const createProject = mockApiClient.addMockResponse({
    url: '/projects/',
    method: 'POST',
    body: {id: 1, name: 'My Project'},
  });

  render(<ProjectList />);
  expect(screen.getByText('My Project')).not.toBeInTheDocument();

  await userEvent.click(screen.getByRole('button', {name: 'Create Project'}));
  // Override the previous response with the new data
  MockApiClient.addMockResponse({
    url: '/projects/',
    body: [{id: 1, name: 'My Project'}],
  });
  await waitFor(() => expect(createProject).toHaveBeenCalledWith({name: 'My Project'}));
  await screen.findByText('My Project').toBeInTheDocument();
});
```
