--- 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 ( ); } ``` ## 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 */} }>
{/* Activity feed can load last */} }>
); } async function AccountStats({ accountId }: { accountId: string }) { const supabase = getSupabaseServerClient(); const { data } = await supabase.rpc('get_account_stats', { p_account_id: accountId, }); return (
); } async function RecentTasks({ accountId }: { accountId: string }) { const supabase = getSupabaseServerClient(); const { data: tasks } = await supabase .from('tasks') .select('id, title, completed, assignee:users(name)') .eq('account_id', accountId) .order('created_at', { ascending: false }) .limit(5); return (

Recent Tasks

    {tasks?.map((task) => (
  • {task.title} {task.assignee && ( - {task.assignee.name} )}
  • ))}
); } async function ActivityFeed({ accountId }: { accountId: string }) { const supabase = getSupabaseServerClient(); const { data: activities } = await supabase .from('activity_log') .select('*, user:users(name)') .eq('account_id', accountId) .order('created_at', { ascending: false }) .limit(10); return (

Recent Activity

    {activities?.map((activity) => (
  • {activity.user.name}{' '} {activity.action}
  • ))}
); } ``` ## 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