Docs
Dashboard

Data Fetching

Learn about data fetching patterns to retrieve data.

The dashboard uses React Server Components as the primary pattern for data fetching.

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.

// ~/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.

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 || '');
      })
    />
  );
}
 

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.