---
status: "published"
title: "Data Fetching with Server Components"
label: "Server Components"
description: "Load data in Next.js Server Components with Supabase. Covers streaming, Suspense boundaries, parallel data loading, caching, and error handling patterns."
order: 3
---
Server Components fetch data on the server during rendering, streaming HTML directly to the browser without adding to your JavaScript bundle. They're the default for all data loading in MakerKit because they're secure (queries never reach the client), SEO-friendly (content renders for search engines), and fast (no client-side fetching waterfalls). Tested with Next.js 16 and React 19.
{% callout title="When to use Server Components" %}
**Use Server Components** (the default) for page loads, SEO content, and data that doesn't need real-time updates. Only switch to Client Components with React Query when you need optimistic updates, real-time subscriptions, or client-side filtering.
{% /callout %}
## Why Server Components for Data Fetching
Server Components provide significant advantages for data loading:
- **No client bundle impact** - Database queries don't increase JavaScript bundle size
- **Direct database access** - Query Supabase directly without API round-trips
- **Streaming** - Users see content progressively as data loads
- **SEO-friendly** - Content is rendered on the server for search engines
- **Secure by default** - Queries never reach the browser
## Basic Data Fetching
Every component in Next.js is a Server Component by default. Add the `async` keyword to fetch data directly:
```tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export default async function TasksPage() {
const supabase = getSupabaseServerClient();
const { data: tasks, error } = await supabase
.from('tasks')
.select('id, title, completed, created_at')
.order('created_at', { ascending: false });
if (error) {
throw new Error('Failed to load tasks');
}
return (
{tasks.map((task) => (
{task.title}
))}
);
}
```
## Streaming with Suspense
Suspense boundaries let you show loading states while data streams in. This prevents the entire page from waiting for slow queries.
### Page-Level Loading States
Create a `loading.tsx` file next to your page to show a loading UI while the page data loads:
```tsx
// app/tasks/loading.tsx
export default function Loading() {
return (
);
}
```
### Component-Level Suspense
For granular control, wrap individual components in Suspense boundaries:
```tsx
import { Suspense } from 'react';
export default function DashboardPage() {
return (
{/* Stats load first */}
}>
{/* Tasks can load independently */}
}>
{/* Activity loads last */}
}>
);
}
// Each component fetches its own data
async function DashboardStats() {
const supabase = getSupabaseServerClient();
const { data } = await supabase.rpc('get_dashboard_stats');
return ;
}
async function RecentTasks() {
const supabase = getSupabaseServerClient();
const { data } = await supabase.from('tasks').select('*').limit(5);
return ;
}
```
## Parallel Data Loading
Load multiple data sources simultaneously to minimize waterfall requests. Use `Promise.all` to fetch in parallel:
```tsx
import { getSupabaseServerClient } from '@kit/supabase/server-client';
export default async function AccountDashboard({
params,
}: {
params: Promise<{ account: string }>;
}) {
const { account } = await params;
const supabase = getSupabaseServerClient();
// All queries run in parallel
const [tasksResult, membersResult, statsResult] = await Promise.all([
supabase
.from('tasks')
.select('*')
.eq('account_slug', account)
.limit(10),
supabase
.from('account_members')
.select('*, user:users(name, avatar_url)')
.eq('account_slug', account),
supabase.rpc('get_account_stats', { account_slug: account }),
]);
return (
);
}
```
### Avoiding Waterfalls
A waterfall occurs when queries depend on each other sequentially:
```tsx
// BAD: Waterfall - each query waits for the previous
async function SlowDashboard() {
const supabase = getSupabaseServerClient();
const { data: account } = await supabase
.from('accounts')
.select('*')
.single();
// This waits for account to load first
const { data: tasks } = await supabase
.from('tasks')
.select('*')
.eq('account_id', account.id);
// This waits for tasks to load
const { data: members } = await supabase
.from('members')
.select('*')
.eq('account_id', account.id);
}
// GOOD: Parallel loading when data is independent
async function FastDashboard({ accountId }: { accountId: string }) {
const supabase = getSupabaseServerClient();
const [tasks, members] = await Promise.all([
supabase.from('tasks').select('*').eq('account_id', accountId),
supabase.from('members').select('*').eq('account_id', accountId),
]);
}
```
## Caching Strategies
### Request Deduplication
Next.js automatically deduplicates identical fetch requests within a single render. If multiple components need the same data, wrap your data fetching in React's `cache()`:
```tsx
import { cache } from 'react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
// This query runs once per request, even if called multiple times
export const getAccount = cache(async (slug: string) => {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('accounts')
.select('*')
.eq('slug', slug)
.single();
if (error) throw error;
return data;
});
// Both components can call getAccount('acme') - only one query runs
async function AccountHeader({ slug }: { slug: string }) {
const account = await getAccount(slug);
return
{account.name}
;
}
async function AccountSidebar({ slug }: { slug: string }) {
const account = await getAccount(slug);
return ;
}
```
### Using `unstable_cache` for Persistent Caching
For data that doesn't change often, use Next.js's `unstable_cache` to cache across requests:
```tsx
import { unstable_cache } from 'next/cache';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
const getCachedPricingPlans = unstable_cache(
async () => {
const supabase = getSupabaseServerClient();
const { data } = await supabase
.from('pricing_plans')
.select('*')
.eq('active', true);
return data;
},
['pricing-plans'], // Cache key
{
revalidate: 3600, // Revalidate every hour
tags: ['pricing'], // Tag for manual revalidation
}
);
export default async function PricingPage() {
const plans = await getCachedPricingPlans();
return ;
}
```
To invalidate the cache after updates:
```tsx
'use server';
import { revalidateTag } from 'next/cache';
export async function updatePricingPlan(data: PlanUpdate) {
const supabase = getSupabaseServerClient();
await supabase.from('pricing_plans').update(data).eq('id', data.id);
// Invalidate the pricing cache
revalidateTag('pricing');
}
```
## Error Handling
### Error Boundaries
Create an `error.tsx` file to catch errors in your route segment:
```tsx
// app/tasks/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
Something went wrong
{error.message}
);
}
```
### Graceful Degradation
For non-critical data, handle errors gracefully instead of throwing:
```tsx
async function OptionalWidget() {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('widgets')
.select('*')
.limit(5);
// Don't crash the page if this fails
if (error || !data?.length) {
return null; // or return a fallback UI
}
return ;
}
```
## Real-World Example: Team Dashboard
Here's a complete example combining multiple patterns:
```tsx
// app/home/[account]/page.tsx
import { Suspense } from 'react';
import { cache } from 'react';
import { notFound } from 'next/navigation';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
// Cached account loader - reusable across components
const getAccount = cache(async (slug: string) => {
const supabase = getSupabaseServerClient();
const { data } = await supabase
.from('accounts')
.select('*')
.eq('slug', slug)
.single();
return data;
});
export default async function TeamDashboard({
params,
}: {
params: Promise<{ account: string }>;
}) {
const { account: slug } = await params;
const account = await getAccount(slug);
if (!account) {
notFound();
}
return (
{account.name}
{/* Stats stream in first */}
}>
{/* Tasks load independently */}
}>
);
}
```
## When to Use Client Components Instead
Server Components are great for initial page loads, but some scenarios need client components:
- **Real-time updates** - Use React Query with Supabase subscriptions
- **User interactions** - Sorting, filtering, pagination with instant feedback
- **Forms** - Complex forms with validation and state management
- **Optimistic updates** - Update UI before server confirms
For these cases, load initial data in Server Components and pass to client components:
```tsx
// Server Component - loads initial data
export default async function TasksPage() {
const tasks = await loadTasks();
return ;
}
// Client Component - handles interactivity
'use client';
function TasksManager({ initialTasks }) {
const [tasks, setTasks] = useState(initialTasks);
// ... sorting, filtering, real-time updates
}
```
## Next Steps
- [Server Actions](server-actions) - Mutate data from Server Components
- [React Query](react-query) - Client-side data management
- [Route Handlers](route-handlers) - Build API endpoints