Skip to content

React to Smithy API

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

Before using this generator, ensure your React application has:

  1. A main.tsx file that renders your application
  2. A working Smithy TypeScript API backend (generated using the ts#api generator with --framework=smithy)
  3. Cognito Auth added via the ts#website#auth generator if connecting an API which uses Cognito or IAM auth
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>,
);

Run this generator@aws/nx-plugin:connection

pnpm nx g @aws/nx-plugin:connection
Build your command5

Required

Required

Generator Options5 options
sourceProjectRequiredstring

The source project

targetProjectRequiredstring

The target project to connect to

sourceComponentstring

The source component to connect from (component name, path relative to source project root, or generator id). Use '.' to explicitly select the project as the source.

targetComponentstring

The target component to connect to (component name, path relative to target project root, or generator id). Use '.' to explicitly select the project as the target.

preferInstallDependenciesbooleanDefault: true

Whether to prefer installing dependencies after the generator runs. Set to false to defer installing when batching multiple generators (an install still runs if needed so subsequent generators can compute the Nx project graph); install once at the end.

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
      • DirectoryRuntimeConfig/ Runtime configuration component for local development
    • 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 a file to your Smithy model:

  • Directorymodel
    • Directorysrc
      • extensions.smithy Defines traits which can be used to customise the generated client

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

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

  • Directorysrc
    • Directorygenerated
      • Directory<ApiName>
        • types.gen.ts Generated types from the Smithy model structures
        • 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

The generated type-safe client can be used to call your Smithy API 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.

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

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.isPending) return <div>Loading...</div>;
if (item.isError) return <div>Error: {JSON.stringify(item.error.error)}</div>;
return <div>Item: {item.data.name}</div>;
}

The error is a discriminated union of { status, error } objects rather than an Error, so there is no top-level message to render. Narrow on status to read a specific response body — see Error Handling below.

Click here for an example using the vanilla client directly.

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: {JSON.stringify(createItem.error.error)}
</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.

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.isPending) {
return <LoadingSpinner />;
}
if (items.isError) {
return <ErrorMessage message={JSON.stringify(items.error.error)} />;
}
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.

The integration includes built-in error handling with typed error responses. An <OperationName>Error type is generated which is a union of one <OperationName><StatusCode>Error member per error status the operation models. Each member has a status and an error property, so switching on status narrows error to that status’s payload.

The example below assumes CreateItem models the error structures defined further down this page — a 422 InvalidRequestError, a 403 UnauthorizedError and a 500 InternalServerError. Only the statuses your model declares appear in the union, so a case for a status you haven’t modelled is a compile 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 422:
// error.error is typed as InvalidRequestErrorResponseContent
return (
<div>
<h2>Invalid input:</h2>
<p>{createItem.error.error.message}</p>
</div>
);
case 403:
// error.error is typed as UnauthorizedErrorResponseContent
return (
<div>
<h2>Not authorized:</h2>
<p>{createItem.error.error.reason}</p>
</div>
);
case 500:
// error.error is typed as InternalServerErrorResponseContent
return (
<div>
<h2>Server error:</h2>
<p>{createItem.error.error.message}</p>
</div>
);
}
}
return <button onClick={handleClick}>Create Item</button>;
}
Click here for an example using the vanilla client directly.

A selection of Smithy traits are added to your target Smithy model project in extensions.smithy which you can use to customise the generated client.

By default, operations in your Smithy API 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 the @query and @mutation Smithy traits which are added to your model project in extensions.smithy.

Apply the @query trait to your Smithy operation to force it to be treated as a query:

@http(method: "POST", uri: "/search-items")
@query
operation SearchItems {
input: SearchItemsInput
output: SearchItemsOutput
}

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

const items = useQuery(api.searchItems.queryOptions({ term: 'widget' }));

Apply the @mutation trait to your Smithy operation to force it to be treated as a mutation:

@http(method: "GET", uri: "/start-processing")
@mutation
operation StartProcessing {
input: StartProcessingInput
output: StartProcessingOutput
}

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

const startProcessing = useMutation(api.startProcessing.mutationOptions());
startProcessing.mutate({ id: 'some-id' });

By default, the generated hooks assume cursor-based pagination with a parameter named cursor. You can customize this behavior using the @cursor trait which is added to your model project in extensions.smithy.

Apply the @cursor trait with inputToken to change the name of the input parameter used for the pagination token:

@http(method: "GET", uri: "/paged-items")
@cursor(inputToken: "nextToken")
operation ListPagedItems {
input := {
@httpQuery("nextToken")
nextToken: String
@httpQuery("limit")
limit: Integer
}
output := {
@required
items: ItemList
nextToken: String
}
}

infiniteQueryOptions then pages on nextToken:

const items = useInfiniteQuery({
...api.listPagedItems.infiniteQueryOptions(
{ limit: 10 },
{ getNextPageParam: (lastPage) => lastPage.nextToken || undefined },
),
});

If you would not like to generate infiniteQueryOptions for an operation which has an input parameter named cursor, you can disable cursor-based pagination:

@http(method: "GET", uri: "/unpaged-items")
@cursor(enabled: false)
operation ListUnpagedItems {
input := {
// Input parameter named 'cursor' will cause this operation to be treated as a paginated operation by default
@httpQuery("cursor")
cursor: String
}
output := {
@required
items: ItemList
}
}

api.listUnpagedItems then only provides queryOptions, queryKey and queryFilter.

The generated hooks and client methods are automatically organized based on the @tags trait in your Smithy operations. Operations with the same tags are grouped together, which helps keep your API calls organized and provides better code completion in your IDE.

Within a group each operation keeps its own camelCased name, so an operation named ListItems tagged "items" is reached at api.items.listItems — not api.items.list.

For example, with this Smithy model:

service MyService {
operations: [ListItems, CreateItem, ListUsers, CreateUser]
}
@tags(["items"])
@http(method: "GET", uri: "/items")
@readonly
operation ListItems {
input := {}
output := {
@required
items: ItemList
}
}
@tags(["items"])
@http(method: "POST", uri: "/items")
operation CreateItem {
input := {
@required
name: String
}
output := {
@required
id: String
}
}
@tags(["users"])
@http(method: "GET", uri: "/users")
@readonly
operation ListUsers {
input := {}
output := {
@required
users: UserList
}
}
@tags(["users"])
@http(method: "POST", uri: "/users")
operation CreateUser {
input := {
@required
name: String
}
output := {
@required
id: String
}
}

The generated hooks will be grouped by 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.listItems.queryOptions());
const createItem = useMutation(api.items.createItem.mutationOptions());
// Users operations are grouped under api.users
const users = useQuery(api.users.listUsers.queryOptions());
// Usage example
const handleCreateItem = () => {
createItem.mutate({ name: 'New Item' });
};
return (
<div>
<h2>Items</h2>
<ul>
{items.data?.items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
<button onClick={handleCreateItem}>Add Item</button>
<h2>Users</h2>
<ul>
{users.data?.users.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.

You can customize error responses in your Smithy API by defining custom error structures in your Smithy model. The generated client will automatically handle these custom error types.

Define your error structures in your Smithy model:

@error("client")
@httpError(422)
structure InvalidRequestError {
@required
message: String
fieldErrors: FieldErrorList
}
@error("client")
@httpError(403)
structure UnauthorizedError {
@required
reason: String
}
@error("server")
@httpError(500)
structure InternalServerError {
@required
message: String
traceId: String
}
list FieldErrorList {
member: FieldError
}
structure FieldError {
@required
field: String
@required
message: String
}

Specify which errors your operations can return:

operation CreateItem {
input: CreateItemInput
output: CreateItemOutput
errors: [
InvalidRequestError
UnauthorizedError
InternalServerError
]
}
operation GetItem {
input: GetItemInput
output: GetItemOutput
errors: [
ItemNotFoundError
InternalServerError
]
}
@error("client")
@httpError(404)
structure ItemNotFoundError {
@required
message: String
}

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();
const getItem = useQuery(api.getItem.queryOptions({ itemId: '123' }));
// Mutation with typed error handling
const createItem = useMutation({
...api.createItem.mutationOptions(),
onError: (error) => {
// Error is typed based on the errors in your Smithy model
switch (error.status) {
case 422:
// error.error is typed as InvalidRequestErrorResponseContent
console.error('Validation error:', error.error.message);
console.error('Field errors:', error.error.fieldErrors);
break;
case 403:
// error.error is typed as UnauthorizedErrorResponseContent
console.error('Unauthorized:', error.error.reason);
break;
}
}
});
// Component rendering with error handling
if (getItem.isError) {
switch (getItem.error.status) {
case 404:
// error.error is typed as ItemNotFoundErrorResponseContent
return <NotFoundMessage message={getItem.error.error.message} />;
case 500:
// error.error is typed as InternalServerErrorResponseContent
return <ErrorMessage message={getItem.error.error.message} />;
}
}
return (
<div>
{/* Component content */}
</div>
);
}
Click here for an example using the client directly.

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 UnauthorizedErrorResponseContent
return <ErrorMessage message={err.error.reason} />;
case 500:
// err.error is typed as InternalServerErrorResponseContent
return (
<ErrorMessage
message={err.error.message}
/>
);
default:
return <ErrorMessage message="An unknown error occurred" />;
}
}
return (
<ul>
{items.data?.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
Click here for an example using the vanilla client directly.

Implement optimistic updates for a better user experience:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { ListItemsResponseContent } from '../generated/my-api/types.gen';
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<ListItemsResponseContent>(
api.listItems.queryKey({}),
);
// Optimistically update to the new value
queryClient.setQueryData<ListItemsResponseContent>(
api.listItems.queryKey({}),
(old) =>
old && {
...old,
items: old.items.filter((item) => item.id !== itemId),
},
);
// Return a context object with the snapshot
return { previousItems };
},
onError: (err, _input, 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?.items.map((item) => (
<li key={item.id}>
{item.name}
<button
onClick={() => deleteMutation.mutate({ itemId: item.id })}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</button>
</li>
))}
</ul>
);
}
Click here for an example using the vanilla client directly.

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 422:
// error.error is typed as InvalidRequestErrorResponseContent
return (
<FormError
message="Invalid input"
errors={error.error.fieldErrors}
/>
);
case 403:
// error.error is typed as UnauthorizedErrorResponseContent
return <AuthError reason={error.error.reason} />;
default:
// error.error is typed as InternalServerErrorResponseContent for 500, 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 Smithy API’s OpenAPI schema, ensuring that any changes to your API are reflected in your frontend code after a build.

If your Smithy API uses Custom authentication (Lambda Authorizer), you will need to edit the generated client provider to add the authorization headers your authorizer expects. Look for the fetch configuration in the generated <ApiName>Provider.tsx and add your token or API key to the request headers.