Data Fetching
Learn about data fetching patterns to retrieve data.
The dashboard uses React Server Components as the primary pattern for data fetching.
Grab yourself a coffee or tea before diving in It's not easy if you are not familiar with React Server Components and the App Router yet. Usually new developers have an easier time than seasoned ones, because it's difficult to unlearn previous patterns. Once you understand this page, you really mastered the important parts of the App Router.
React Server Components
React Server Components are the recommended way to fetch data:
import { getItems } from '~/data/items/get-items';
export default async function ItemsPage() {
const items = await getItems();
return (
<div>
<p>Items</p>
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
Parallel data fetching
When no data dependencies exist, we can fetch multiple data at the same time:
import { getItems } from '~/data/items/get-items';
import { getOtherItems } from '~/data/items/get-other-items';
export default async function ItemsPage() {
const [items, otherItems] = await Promise.all([
getItems(),
getOtherItems(),
]);
return (
<div>
<p>Items</p>
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
<p>Other Items</p>
<ul>
{otherItems.map((otherItem) => (
<li key={otherItem.id}>{otherItem.name}</li>
))}
</ul>
</div>
);
}
Sequential data fetching
When data dependencies exist, we have to use the waterfall pattern:
import { getItem } from '~/data/items/get-item';
import { getItemActivities } from '~/data/items/get-item-activities';
export default async function ItemDetailsPage() {
const item = await getItem();
const activities = await getItemActivities({ id: item.id });
return (
<div>
<p>Item Details</p>
<p>{item.name}</p>
<ul>
{activities.map((activity) => (
<li key={activity.id}>{activity.description}</li>
))}
</ul>
</div>
);
}
Suspense & error boundary
To provide an immediate response while delivering the result later, we can leverage Suspense
. The fallback
property allows us to display a loading state during data fetching.
For error handling, we need to create an ErrorBoundary
component, which can accept custom components to display error messages gracefully.
import { Suspense } from 'react';
import { DefaultError } from '@workspace/ui/components/default-error';
export default function ItemsPage() {
return (
<ErrorBoundary component={(props) => <DefaultError {...props} />}>
<Suspense fallback={<ItemsListSkeleton />}>
<Items />
</Suspense>
</ErrorBoundary>
);
}
async function Items() {
const items = await getItems();
return (
<div>
<p>Items</p>
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
This approach is fine for a single loading state. However in some pages it becomes very repetitive. Instead of repeating it over and over again, we can instead use parallel routes if the page can be divided into multiple individual parts.
Parallel routes
Parallel routes enable the simultaneous rendering of multiple pages or pagelets within the same layout. They come with built-in suspense and error boundaries, simplifying the process. Here's how we implement this in the developers page:
app/organizations/[slug]/settings/organization/developers/
├── @apiKeys/
│ └── error.tsx # API Keys error state
│ └── layout.tsx # API Keys static content
│ └── loading.tsx # API Keys loading state
│ ├── page.tsx # API Keys dynamic content
├── @webhooks/
│ └── error.tsx # Webhooks error state
│ └── layout.tsx # Webhooks static content
│ └── loading.tsx # Webhooks loading state
│ ├── page.tsx # Webhooks dynamic content
├── layout.tsx # Developers Page/Layout (static content)
// app/organizations/[slug]/settings/organization/developers/layout.tsx
export type DevelopersLayoutProps = {
apiKeys: React.ReactNode;
webhooks: React.ReactNode;
};
export default function DevelopersLayout({
apiKeys,
webhooks
}: DevelopersLayoutProps): React.JSX.Element {
return (
<Page>
<PageHeader>
<PagePrimaryBar>
<PageTitle>Developers</PageTitle>
</PagePrimaryBar>
</PageHeader>
<PageBody>
<AnnotatedLayout>
{apiKeys}
<Separator />
{webhooks}
</AnnotatedLayout>
</PageBody>
</Page>
);
}
Parallel routes (in this case, pagelets) fetch, load, and handle errors independently. This means that if one pagelet encounters an error, the rest of the page continues to function as expected. For the errored pagelet, we display a default error with a retry option.
Data fetching functions
They are just plain async functions to fetch data and live in the data
folder.
// ~/data/items/get-items.ts
import { prisma } from '@workspace/database/client';
export async function getItems() {
return await prisma.item.findMany({
select: {
id: true,
name: true,
},
orderBy: { name: 'asc' },
});
}
Explicit types with DTOs
It's a good practice to define a data transfer object (DTO) so you can consistently use it across client components.
Another advantage of using DTOs is IDE speed. Inferring types from an ORM operation result is really slow and if you do that often enough, your LSP/Typescript server slows down.
// ~/types/dtos/item-dto.ts
export type ItemDto {
id: string;
name: string;
}
// ~/data/items/get-items.ts
import { prisma } from '@workspace/database/client';
import type { ItemDto } from '~/types/dtos/item-dto';
export async function getItems(): Promise<ItemDto[]> {
return await prisma.item.findMany({
select: {
id: true,
name: true,
},
orderBy: { name: 'asc' },
});
}
Input validation
To make the function more robust, validate inputs using a schema validation library like Zod.
// ~/schemas/items/get-items-schema.ts
import { z } from 'zod';
export const getItemsSchema = z.object({
searchQuery: z.string().optional(),
});
export type GetItemsSchema = z.infer<typeof getItemsSchema>;
// ~/data/items/get-items.ts
import { ValidationError } from '`/lib/exceptions';
import { getItemsSchema, type GetItemsSchema } from '~/schemas/items/get-items-schema';
import type { ItemDto } from '~/types/dtos/item-dto';
export async function getItems(input: GetItemsSchema): Promise<ItemDto[]> {
const result = getItemsSchema.safeParse(input);
if (!result.success) {
throw new ValidationError(JSON.stringify(result.error.flatten()));
}
const parsedInput = result.data;
return await prisma.item.findMany({
where: {
name: parsedInput.searchQuery ? { contains: parsedInput.searchQuery, mode: 'insensitive' } : undefined,
},
select: {
id: true,
name: true,
},
orderBy: { name: 'asc' },
});
}
Authentication context
If your application requires the user to be authenticated or user-specific data, include authentication context.
import { getAuthContext } from '@workspace/auth/context';
import type { ItemDto } from '~/types/dtos/item-dto';
export async function getItems(): Promise<ItemDto[]> {
const ctx = await getAuthContext();
return await prisma.item.findMany({
where: {
userId: ctx.session.user.id,
},
select: {
id: true,
name: true,
},
orderBy: { name: 'asc' },
});
}
Authentication organization context
If your application requires active organization data, include the following context.
import { getAuthOrganizationContext } from '@workspace/auth/context';
import type { ItemDto } from '~/types/dtos/item-dto';
export async function getItems(): Promise<ItemDto[]> {
const ctx = await getAuthOrganizationContext();
return await prisma.item.findMany({
where: {
userId: ctx.session.user.id,
organizationId: ctx.organization.id
},
select: {
id: true,
name: true,
},
orderBy: { name: 'asc' },
});
}
Server-side caching
To improve performance we can cache the results by using unstable_cache
. The default cache handler stores the cache entries on the disk. So it's really effective for any I/O operation, but less effective for in-memory computed values. The method differentiates between KeyParts
and Tags
.
- KeyParts build up the cache key
- Tags identify and group cache entries for revalidation
// `~/data/get-items.ts`**
import { unstable_cache } from 'next/cache';
import { getAuthOrganizationContext } from '@workspace/auth/context';
import { ValidationError } from '`/lib/exceptions';
import { getItemsSchema, type GetItemsSchema } from '~/schemas/items/get-items-schema';
import type { ItemDto } from '~/types/dtos/item-dto';
export async function getItems(input: GetItemsSchema): Promise<ItemDto[]> {
const ctx = await getAuthOrganizationContext();
const result = getItemsSchema.safeParse(input);
if (!result.success) {
throw new ValidationError(JSON.stringify(result.error.flatten()));
}
const parsedInput = result.data;
return unstable_cache(
async () => {
return await prisma.item.findMany({
where: {
organizationId: ctx.organizationId,
name: parsedInput.searchQuery ? { contains: parsedInput.searchQuery, mode: 'insensitive' } : undefined,
},
select: {
id: true,
name: true,
},
orderBy: { name: 'asc' },
});
},
Caching.createOrganizationKeyParts(
OrganizationCacheKey.Items,
ctx.organization.id,
parsedInput.searchQuery
),
{
tags: [
Caching.createOrganizationTag(
OrganizationCacheKey.Items,
ctx.organization.id
)
]
}
)();
}
State management
As for the state, we try to use the URL, specifically searchParams
(e.g. ?searchQuery=hello
) as much as possible, since this state is also available for server components.
Search param state management in the starter kit is implemented with the Nuqs library.
Server component
We create a searchParamsCache
(this one can also be shared with client components if you move it to another file) and parse the incoming params. When a searchParam changes, the server component will re-render.
import { createSearchParamsCache, parseAsString } from 'nuqs/server';
import { getItems } from '~/data/items/get-items';
const searchParamsCache = createSearchParamsCache({
searchQuery: parseAsString.withDefault(''),
});
export default async function ItemsPage({
searchParams
}: NextPageProps) {
const { searchQuery } = await searchParamsCache.parse(searchParams);
const items = await getItems({ searchQuery });
return (
<MyComponent items={items} />
);
}
Client component
Nuqs also provides client component hooks to change the searchParam with optionally providing a transition.
'use client';
import * as React from 'react';
import { useQueryState } from 'nuqs';
import { InputSearch } from '@workspace/ui/components/input-search';
function ClientComponent() {
const transition = React.useTransition();
const [searchQuery, setSearchQuery] = useQueryState(
'searchQuery',
searchParams.searchQuery.withOptions({
startTransition: transition.startTransition,
shallow: false
})
);
return (
<InputSearch
placeholder="Search items..."
value={searchQuery}
onChange((e) => {
setSearchQuery(e.target.value || '');
})
/>
);
}
A transition is always necessary if you don't want to block the UI. In many cases you do actually want to block the UI, for example on form submissions.
Route handlers
In rare cases, such as when you need to return binary data, you can fall back to route handlers. In the starter kit, we use GET route handlers to serve image data stored in the database.