Skip to content

React to FastAPI

The api-connection generator provides a way to quickly integrate your React website with your FastAPI backend. It sets up all necessary configuration for connecting to your FastAPI backends in a type-safe manner, including client and TanStack Query hooks generation, AWS IAM authentication support and proper error handling.

Prerequisites

Before using this generator, ensure your React application has:

  1. A main.tsx file that renders your application
  2. A working FastAPI backend (generated using the FastAPI generator)
Example of required main.tsx structure
import { StrictMode } from 'react';
import * as ReactDOM from 'react-dom/client';
import App from './app/app';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement,
);
root.render(
<StrictMode>
<App />
</StrictMode>,
);

Usage

Run the Generator

  1. Install the Nx Console VSCode Plugin if you haven't already
  2. Open the Nx Console in VSCode
  3. Click Generate (UI) in the "Common Nx Commands" section
  4. Search for @aws/nx-plugin - api-connection
  5. Fill in the required parameters
    • Click Generate

    Options

    Parameter Type Default Description
    sourceProject required string - The source project which will call the API
    targetProject required string - The target project containing your API
    auth string IAM Authentication strategy (choose from IAM or None)

    Generator Output

    The generator will make changes to the following files in your FastAPI project:

    • Directoryscripts
      • generate_open_api.py Add a script which generates an OpenAPI specification for your API
    • project.json A new target is added to the build which invokes the above generate script

    The generator will make changes to the following files in your React application:

    • Directorysrc
      • Directorycomponents
        • <ApiName>Provider.tsx Provider for your API client
        • QueryClientProvider.tsx TanStack React Query client provider
      • Directoryhooks
        • use<ApiName>.tsx Add a hook for calling your API with state managed by TanStack Query
        • use<ApiName>Client.tsx Add a hook for instantiating the vanilla API client which can call your API.
        • useSigV4.tsx Add a hook for signing HTTP requests with SigV4 (if you selected IAM authentication)
    • project.json A new target is added to the build which generates a type-safe client
    • .gitignore The generated client files are ignored by default

    The generator will also add Runtime Config to your website infrastructure if not present already, which ensures that the API URL for your FastAPI is available in the website and automatically configured by the use<ApiName>.tsx hook.

    Code Generation

    At build time, a type-safe client is generated from your FastAPI’s OpenAPI specification. This will add three new files to your React application:

    • Directorysrc
      • Directorygenerated
        • Directory<ApiName>
          • types.gen.ts Generated types from the pydantic models defined in your FastAPI
          • client.gen.ts Type-safe client for calling your API
          • options-proxy.gen.ts Provides methods to create TanStack Query hooks options for interacting with your API using TanStack Query

    Using the Generated Code

    The generated type-safe client can be used to call your FastAPI from your React application. It’s recommended to make use of the client via the TanStack Query hooks, but you can use the vanilla client if you prefer.

    Using the API Hook

    The generator provides a use<ApiName> hook which you can use to call your API with TanStack Query.

    Queries

    You can use the queryOptions method to retrieve the options required for calling your API using TanStack Query’s useQuery hook:

    import { useQuery } from '@tanstack/react-query';
    import { useState, useEffect } from 'react';
    import { useMyApi } from './hooks/useMyApi';
    function MyComponent() {
    const api = useMyApi();
    const item = useQuery(api.getItem.queryOptions({ itemId: 'some-id' }));
    if (item.isLoading) return <div>Loading...</div>;
    if (item.isError) return <div>Error: {item.error.message}</div>;
    return <div>Item: {item.data.name}</div>;
    }
    Click here for an example using the vanilla client directly.

    Mutations

    The generated hooks include support for mutations using TanStack Query’s useMutation hook. This provides a clean way to handle create, update, and delete operations with loading states, error handling, and optimistic updates.

    import { useMutation } from '@tanstack/react-query';
    import { useMyApi } from './hooks/useMyApi';
    function CreateItemForm() {
    const api = useMyApi();
    // Create a mutation using the generated mutation options
    const createItem = useMutation(api.createItem.mutationOptions());
    const handleSubmit = (e) => {
    e.preventDefault();
    createItem.mutate({ name: 'New Item', description: 'A new item' });
    };
    return (
    <form onSubmit={handleSubmit}>
    {/* Form fields */}
    <button
    type="submit"
    disabled={createItem.isPending}
    >
    {createItem.isPending ? 'Creating...' : 'Create Item'}
    </button>
    {createItem.isSuccess && (
    <div className="success">
    Item created with ID: {createItem.data.id}
    </div>
    )}
    {createItem.isError && (
    <div className="error">
    Error: {createItem.error.message}
    </div>
    )}
    </form>
    );
    }

    You can also add callbacks for different mutation states:

    const createItem = useMutation({
    ...api.createItem.mutationOptions(),
    onSuccess: (data) => {
    // This will run when the mutation succeeds
    console.log('Item created:', data);
    // You can navigate to the new item
    navigate(`/items/${data.id}`);
    },
    onError: (error) => {
    // This will run when the mutation fails
    console.error('Failed to create item:', error);
    },
    onSettled: () => {
    // This will run when the mutation completes (success or error)
    // Good place to invalidate queries that might be affected
    queryClient.invalidateQueries({ queryKey: api.listItems.queryKey() });
    }
    });
    Click here for an example using the client directly.

    Pagination with Infinite Queries

    For endpoints that accept a cursor parameter as input, the generated hooks provide support for infinite queries using TanStack Query’s useInfiniteQuery hook. This makes it easy to implement “load more” or infinite scrolling functionality.

    import { useInfiniteQuery } from '@tanstack/react-query';
    import { useMyApi } from './hooks/useMyApi';
    function ItemList() {
    const api = useMyApi();
    const items = useInfiniteQuery({
    ...api.listItems.infiniteQueryOptions({
    limit: 10, // Number of items per page
    }, {
    // Make sure you define a getNextPageParam function to return
    // the parameter that should be passed as the 'cursor' for the
    // next page
    getNextPageParam: (lastPage) =>
    lastPage.nextCursor || undefined
    }),
    });
    if (items.isLoading) {
    return <LoadingSpinner />;
    }
    if (items.isError) {
    return <ErrorMessage message={items.error.message} />;
    }
    return (
    <div>
    {/* Flatten the pages array to render all items */}
    <ul>
    {items.data.pages.flatMap(page =>
    page.items.map(item => (
    <li key={item.id}>{item.name}</li>
    ))
    )}
    </ul>
    <button
    onClick={() => items.fetchNextPage()}
    disabled={!items.hasNextPage || items.isFetchingNextPage}
    >
    {items.isFetchingNextPage
    ? 'Loading more...'
    : items.hasNextPage
    ? 'Load More'
    : 'No more items'}
    </button>
    </div>
    );
    }

    The generated hooks automatically handle cursor-based pagination if your API supports it. The nextCursor value is extracted from the response and used to fetch the next page.

    Click here for an example using the client directly.

    Error Handling

    The integration includes built-in error handling with typed error responses. An <operation-name>Error type is generated which encapsulates the possible error responses defined in the OpenAPI specification. Each error has a status and error property, and by checking the value of status you can narrow to a specific type of error.

    import { useMutation } from '@tanstack/react-query';
    function MyComponent() {
    const api = useMyApi();
    const createItem = useMutation(api.createItem.mutationOptions());
    const handleClick = () => {
    createItem.mutate({ name: 'New Item' });
    };
    if (createItem.error) {
    switch (createItem.error.status) {
    case 400:
    // error.error is typed as CreateItem400Response
    return (
    <div>
    <h2>Invalid input:</h2>
    <p>{createItem.error.error.message}</p>
    <ul>
    {createItem.error.error.validationErrors.map((err) => (
    <li key={err.field}>{err.message}</li>
    ))}
    </ul>
    </div>
    );
    case 403:
    // error.error is typed as CreateItem403Response
    return (
    <div>
    <h2>Not authorized:</h2>
    <p>{createItem.error.error.reason}</p>
    </div>
    );
    case 500:
    case 502:
    // error.error is typed as CreateItem5XXResponse
    return (
    <div>
    <h2>Server error:</h2>
    <p>{createItem.error.error.message}</p>
    <p>Trace ID: {createItem.error.error.traceId}</p>
    </div>
    );
    }
    }
    return <button onClick={handleClick}>Create Item</button>;
    }
    Click here for an example using the vanilla client directly.

    Consuming a Stream

    If you have configured your FastAPI to stream responses, your useQuery hook will automatically update its data as new chunks of the stream arrive.

    For example:

    function MyStreamingComponent() {
    const api = useMyApi();
    const stream = useQuery(api.myStream.queryOptions());
    return (
    <ul>
    {(stream.data ?? []).map((chunk) => (
    <li>
    {chunk.timestamp.toISOString()}: {chunk.message}
    </li>
    ))}
    </ul>
    );
    }

    You can use the isLoading and fetchStatus properties to determine the current state of the stream if necessary. A stream follows this lifecycle:

    1. The HTTP request to start streaming is sent

      • isLoading is true
      • fetchStatus is 'fetching'
      • data is undefined
    2. The first chunk of the stream is received

      • isLoading becomes false
      • fetchStatus remains 'fetching'
      • data becomes an array containing the first chunk
    3. Subsequent chunks are received

      • isLoading remains false
      • fetchStatus remains 'fetching'
      • data is updated with each subsequent chunk as soon as it is received
    4. The stream completes

      • isLoading remains false
      • fetchStatus becomes 'idle'
      • data is an array of all received chunks
    Click here for an example using the vanilla client directly.

    Customising the Generated Code

    Queries and Mutations

    By default, operations in your FastAPI which use the HTTP methods PUT, POST, PATCH and DELETE are considered mutations, and all others are considered queries.

    You can change this behaviour using x-query and x-mutation.

    x-query

    @app.post(
    "/items",
    openapi_extra={
    "x-query": True
    }
    )
    def list_items():
    # ...

    The generated hook will provide queryOptions even though it uses the POST HTTP method:

    const items = useQuery(api.listItems.queryOptions());

    x-mutation

    @app.get(
    "/start-processing",
    openapi_extra={
    "x-mutation": True
    }
    )
    def start_processing():
    # ...

    The generated hook will provide mutationOptions even though it uses the GET HTTP method:

    // Generated hook will include the custom options
    const startProcessing = useMutation(api.startProcessing.mutationOptions());

    Custom Pagination Cursor

    By default, the generated hooks assume cursor-based pagination with a parameter named cursor. You can customize this behavior using the x-cursor extension:

    @app.get(
    "/items",
    openapi_extra={
    # Specify a different parameter name for the cursor
    "x-cursor": "page_token"
    }
    )
    def list_items(page_token: str = None, limit: int = 10):
    # ...
    return {
    "items": items,
    "page_token": next_page_token # The response must include the cursor with the same name
    }

    If you would not like to generate infiniteQueryOptions for an operation, you can set x-cursor to False:

    @app.get(
    "/items",
    openapi_extra={
    # Disable cursor-based pagination for this endpoint
    "x-cursor": False
    }
    )
    def list_items(page: int = 1, limit: int = 10):
    # ...
    return {
    "items": items,
    "total": total_count,
    "page": page,
    "pages": total_pages
    }

    Grouping Operations

    The generated hooks and client methods are automatically organized based on the OpenAPI tags in your FastAPI endpoints. This helps keep your API calls organized and makes it easier to find related operations.

    For example:

    items.py
    @app.get(
    "/items",
    tags=["items"],
    )
    def list():
    # ...
    @app.post(
    "/items",
    tags=["items"],
    )
    def create(item: Item):
    # ...
    users.py
    @app.get(
    "/users",
    tags=["users"],
    )
    def list():
    # ...

    The generated hooks will be grouped by these tags:

    import { useQuery, useMutation } from '@tanstack/react-query';
    import { useMyApi } from './hooks/useMyApi';
    function ItemsAndUsers() {
    const api = useMyApi();
    // Items operations are grouped under api.items
    const items = useQuery(api.items.list.queryOptions());
    const createItem = useMutation(api.items.create.mutationOptions());
    // Users operations are grouped under api.users
    const users = useQuery(api.users.list.queryOptions());
    // Usage example
    const handleCreateItem = () => {
    createItem.mutate({ name: 'New Item' });
    };
    return (
    <div>
    <h2>Items</h2>
    <ul>
    {items.data?.map(item => (
    <li key={item.id}>{item.name}</li>
    ))}
    </ul>
    <button onClick={handleCreateItem}>Add Item</button>
    <h2>Users</h2>
    <ul>
    {users.data?.map(user => (
    <li key={user.id}>{user.name}</li>
    ))}
    </ul>
    </div>
    );
    }

    This grouping makes it easier to organize your API calls and provides better code completion in your IDE.

    Click here for an example using the client directly.

    Errors

    You can customize error responses in your FastAPI by defining custom exception classes, exception handlers, and specifying response models for different error status codes. The generated client will automatically handle these custom error types.

    Defining Custom Error Models

    First, define your error models using Pydantic:

    models.py
    from pydantic import BaseModel
    class ErrorDetails(BaseModel):
    message: str
    class ValidationError(BaseModel):
    message: str
    field_errors: list[str]

    Creating Custom Exceptions

    Then create custom exception classes for different error scenarios:

    exceptions.py
    class NotFoundException(Exception):
    def __init__(self, message: str):
    self.message = message
    class ValidationException(Exception):
    def __init__(self, details: ValidationError):
    self.details = details

    Adding Exception Handlers

    Register exception handlers to convert your exceptions to HTTP responses:

    main.py
    from fastapi import Request
    from fastapi.responses import JSONResponse
    @app.exception_handler(NotFoundException)
    async def not_found_handler(request: Request, exc: NotFoundException):
    return JSONResponse(
    status_code=404,
    content=exc.message,
    )
    @app.exception_handler(ValidationException)
    async def validation_error_handler(request: Request, exc: ValidationException):
    return JSONResponse(
    status_code=400,
    content=exc.details.model_dump(),
    )

    Specifying Response Models

    Finally, specify the response models for different error status codes in your endpoint definitions:

    main.py
    @app.get(
    "/items/{item_id}",
    responses={
    404: {"model": str}
    500: {"model": ErrorDetails}
    }
    )
    def get_item(item_id: str) -> Item:
    item = find_item(item_id)
    if not item:
    raise NotFoundException(message=f"Item with ID {item_id} not found")
    return item
    @app.post(
    "/items",
    responses={
    400: {"model": ValidationError},
    403: {"model": str}
    }
    )
    def create_item(item: Item) -> Item:
    if not is_valid(item):
    raise ValidationException(
    ValidationError(
    message="Invalid item data",
    field_errors=["name is required"]
    )
    )
    return save_item(item)

    Using Custom Error Types in React

    The generated client will automatically handle these custom error types, allowing you to type-check and handle different error responses:

    import { useMutation, useQuery } from '@tanstack/react-query';
    function ItemComponent() {
    const api = useMyApi();
    // Query with typed error handling
    const getItem = useQuery({
    ...api.getItem.queryOptions({ itemId: '123' }),
    onError: (error) => {
    // Error is typed based on the responses in your FastAPI
    switch (error.status) {
    case 404:
    // error.error is a string as specified in the responses
    console.error('Not found:', error.error);
    break;
    case 500:
    // error.error is typed as ErrorDetails
    console.error('Server error:', error.error.message);
    break;
    }
    }
    });
    // Mutation with typed error handling
    const createItem = useMutation({
    ...api.createItem.mutationOptions(),
    onError: (error) => {
    switch (error.status) {
    case 400:
    // error.error is typed as ValidationError
    console.error('Validation error:', error.error.message);
    console.error('Field errors:', error.error.field_errors);
    break;
    case 403:
    // error.error is a string as specified in the responses
    console.error('Forbidden:', error.error);
    break;
    }
    }
    });
    // Component rendering with error handling
    if (getItem.isError) {
    if (getItem.error.status === 404) {
    return <NotFoundMessage message={getItem.error.error} />;
    } else {
    return <ErrorMessage message={getItem.error.error.message} />;
    }
    }
    return (
    <div>
    {/* Component content */}
    </div>
    );
    }
    Click here for an example using the client directly.

    Best Practices

    Handle Loading States

    Always handle loading and error states for a better user experience:

    import { useQuery } from '@tanstack/react-query';
    function ItemList() {
    const api = useMyApi();
    const items = useQuery(api.listItems.queryOptions());
    if (items.isLoading) {
    return <LoadingSpinner />;
    }
    if (items.isError) {
    const err = items.error;
    switch (err.status) {
    case 403:
    // err.error is typed as ListItems403Response
    return <ErrorMessage message={err.error.reason} />;
    case 500:
    case 502:
    // err.error is typed as ListItems5XXResponse
    return (
    <ErrorMessage
    message={err.error.message}
    details={`Trace ID: ${err.error.traceId}`}
    />
    );
    default:
    return <ErrorMessage message="An unknown error occurred" />;
    }
    }
    return (
    <ul>
    {items.data.map((item) => (
    <li key={item.id}>{item.name}</li>
    ))}
    </ul>
    );
    }
    Click here for an example using the vanilla client directly.

    Optimistic Updates

    Implement optimistic updates for a better user experience:

    import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
    function ItemList() {
    const api = useMyApi();
    const queryClient = useQueryClient();
    // Query to fetch items
    const itemsQuery = useQuery(api.listItems.queryOptions());
    // Mutation for deleting items with optimistic updates
    const deleteMutation = useMutation({
    ...api.deleteItem.mutationOptions(),
    onMutate: async (itemId) => {
    // Cancel any outgoing refetches
    await queryClient.cancelQueries({ queryKey: api.listItems.queryKey() });
    // Snapshot the previous value
    const previousItems = queryClient.getQueryData(api.listItems.queryKey());
    // Optimistically update to the new value
    queryClient.setQueryData(
    api.listItems.queryKey(),
    (old) => old.filter((item) => item.id !== itemId)
    );
    // Return a context object with the snapshot
    return { previousItems };
    },
    onError: (err, itemId, context) => {
    // If the mutation fails, use the context returned from onMutate to roll back
    queryClient.setQueryData(api.listItems.queryKey(), context.previousItems);
    console.error('Failed to delete item:', err);
    },
    onSettled: () => {
    // Always refetch after error or success to ensure data is in sync with server
    queryClient.invalidateQueries({ queryKey: api.listItems.queryKey() });
    },
    });
    if (itemsQuery.isLoading) {
    return <LoadingSpinner />;
    }
    if (itemsQuery.isError) {
    return <ErrorMessage message="Failed to load items" />;
    }
    return (
    <ul>
    {itemsQuery.data.map((item) => (
    <li key={item.id}>
    {item.name}
    <button
    onClick={() => deleteMutation.mutate(item.id)}
    disabled={deleteMutation.isPending}
    >
    {deleteMutation.isPending ? 'Deleting...' : 'Delete'}
    </button>
    </li>
    ))}
    </ul>
    );
    }
    Click here for an example using the vanilla client directly.

    Type Safety

    The integration provides complete end-to-end type safety. Your IDE will provide full autocompletion and type checking for all your API calls:

    import { useMutation } from '@tanstack/react-query';
    function ItemForm() {
    const api = useMyApi();
    // Type-safe mutation for creating items
    const createItem = useMutation({
    ...api.createItem.mutationOptions(),
    // ✅ Type error if onSuccess callback doesn't handle the correct response type
    onSuccess: (data) => {
    // data is fully typed based on your API's response schema
    console.log(`Item created with ID: ${data.id}`);
    },
    });
    const handleSubmit = (data: CreateItemInput) => {
    // ✅ Type error if input doesn't match schema
    createItem.mutate(data);
    };
    // Error UI can use type narrowing to handle different error types
    if (createItem.error) {
    const error = createItem.error;
    switch (error.status) {
    case 400:
    // error.error is typed as CreateItem400Response
    return (
    <FormError
    message="Invalid input"
    errors={error.error.validationErrors}
    />
    );
    case 403:
    // error.error is typed as CreateItem403Response
    return <AuthError reason={error.error.reason} />;
    default:
    // error.error is typed as CreateItem5XXResponse for 500, 502, etc.
    return <ServerError message={error.error.message} />;
    }
    }
    return (
    <form onSubmit={(e) => {
    e.preventDefault();
    handleSubmit({ name: 'New Item' });
    }}>
    {/* Form fields */}
    <button
    type="submit"
    disabled={createItem.isPending}
    >
    {createItem.isPending ? 'Creating...' : 'Create Item'}
    </button>
    </form>
    );
    }
    Click here for an example using the vanilla client directly.

    The types are automatically generated from your FastAPI’s OpenAPI schema, ensuring that any changes to your API are reflected in your frontend code after a build.