Skip to content

Next.js Server Components Deep Dive

React Server Components (RSC) in Next.js 13+ provide a new paradigm for building performant, server-first applications with PGRestify.

Understanding Server Components

The Server Component Model

Server Components run exclusively on the server and send their rendered output to the client as HTML. This enables:

  • Direct database access without API routes
  • Zero client-side JavaScript for static components
  • Automatic code splitting at the component level
  • Streaming rendering with Suspense boundaries

Basic Server Components

Simple Data Fetching

typescript
// app/components/UserList.tsx
import { client } from '../../lib/client'

export async function UserList() {
  const result = await client
    .from('users')
    .select('*')
    .execute()
  
  return (
    <ul>
      {result.data?.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

With Error Handling

typescript
// app/components/PostList.tsx
import { client } from '../../lib/client'
import { PostgRESTError } from '@webcoded/pgrestify'

export async function PostList() {
  try {
    const result = await client
      .from('posts')
      .select('*')
      .eq('published', true)
      .execute()
    
    if (result.error) {
      throw result.error
    }
    
    return (
      <div>
        {result.data?.map(post => (
          <article key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.excerpt}</p>
          </article>
        ))}
      </div>
    )
  } catch (error) {
    if (error instanceof PostgRESTError) {
      return <div>Database error: {error.message}</div>
    }
    return <div>Something went wrong</div>
  }
}

Streaming with Suspense

Progressive Loading

typescript
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { Analytics } from './Analytics'
import { RecentActivity } from './RecentActivity'
import { UserStats } from './UserStats'

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      
      {/* Load critical content first */}
      <Suspense fallback={<StatsPlaceholder />}>
        <UserStats />
      </Suspense>
      
      {/* Stream in non-critical content */}
      <Suspense fallback={<ActivityPlaceholder />}>
        <RecentActivity />
      </Suspense>
      
      {/* Load heavy analytics last */}
      <Suspense fallback={<AnalyticsPlaceholder />}>
        <Analytics />
      </Suspense>
    </div>
  )
}

Parallel Data Loading

typescript
// app/profile/[id]/page.tsx
import { notFound } from 'next/navigation'
import { client } from '../../../lib/client'

interface PageProps {
  params: Promise<{ id: string }>
}

export default async function ProfilePage({ params }: PageProps) {
  const { id } = await params
  
  // Parallel data fetching
  const [userResult, postsResult, followersResult] = await Promise.all([
    client.from('users').select('*').eq('id', id).single().execute(),
    client.from('posts').select('*').eq('user_id', id).execute(),
    client.from('followers').select('count').eq('user_id', id).execute()
  ])
  
  if (!userResult.data) {
    notFound()
  }
  
  return (
    <div>
      <h1>{userResult.data.name}</h1>
      <p>Posts: {postsResult.data?.length || 0}</p>
      <p>Followers: {followersResult.count || 0}</p>
    </div>
  )
}

Component Composition Patterns

Server and Client Boundary

typescript
// app/posts/PostCard.tsx (Server Component)
import { client } from '../../lib/client'
import { LikeButton } from './LikeButton'

export async function PostCard({ postId }: { postId: number }) {
  const result = await client
    .from('posts')
    .select('*, author:users(*)')
    .eq('id', postId)
    .single()
    .execute()
  
  const post = result.data
  
  return (
    <article>
      <h2>{post.title}</h2>
      <p>By {post.author.name}</p>
      <div>{post.content}</div>
      {/* Client Component for interactivity */}
      <LikeButton postId={postId} initialLikes={post.likes} />
    </article>
  )
}

// app/posts/LikeButton.tsx (Client Component)
'use client'

import { useState } from 'react'
import { likePost } from './actions'

export function LikeButton({ postId, initialLikes }: { 
  postId: number
  initialLikes: number 
}) {
  const [likes, setLikes] = useState(initialLikes)
  const [isLiking, setIsLiking] = useState(false)
  
  async function handleLike() {
    setIsLiking(true)
    const newLikes = await likePost(postId)
    setLikes(newLikes)
    setIsLiking(false)
  }
  
  return (
    <button onClick={handleLike} disabled={isLiking}>
      ❤️ {likes}
    </button>
  )
}

Advanced Patterns

Conditional Rendering

typescript
// app/admin/AdminPanel.tsx
import { client } from '../../lib/client'
import { getServerSession } from '../lib/auth'
import { redirect } from 'next/navigation'

export async function AdminPanel() {
  const session = await getServerSession()
  
  if (!session || session.user.role !== 'admin') {
    redirect('/unauthorized')
  }
  
  const result = await client
    .from('admin_metrics')
    .select('*')
    .execute()
  
  return (
    <div>
      <h1>Admin Dashboard</h1>
      {/* Admin-only content */}
    </div>
  )
}

Nested Server Components

typescript
// app/blog/BlogPost.tsx
import { client } from '../../lib/client'
import { CommentList } from './CommentList'

export async function BlogPost({ slug }: { slug: string }) {
  const result = await client
    .from('posts')
    .select('*')
    .eq('slug', slug)
    .single()
    .execute()
  
  const post = result.data
  
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
      {/* Nested server component */}
      <CommentList postId={post.id} />
    </article>
  )
}

// app/blog/CommentList.tsx
export async function CommentList({ postId }: { postId: number }) {
  const result = await client
    .from('comments')
    .select('*, author:users(name, avatar)')
    .eq('post_id', postId)
    .order('created_at', { ascending: false })
    .execute()
  
  return (
    <div>
      <h2>Comments</h2>
      {result.data?.map(comment => (
        <div key={comment.id}>
          <img src={comment.author.avatar} alt={comment.author.name} />
          <strong>{comment.author.name}</strong>
          <p>{comment.content}</p>
        </div>
      ))}
    </div>
  )
}

Performance Optimization

Request Deduplication

typescript
// lib/client.ts
import { createNextJSClient } from '@webcoded/pgrestify/nextjs'
import { cache } from 'react'

// Create a cached client for request deduplication
export const getCachedClient = cache(() => {
  return createNextJSClient({
    url: process.env.POSTGREST_URL!,
    options: {
      dedupeRequests: true
    }
  })
})

// Use in components
export async function Component() {
  const client = getCachedClient()
  // Multiple calls to the same query will be deduped
  const result = await client.from('users').select('*').execute()
}

Partial Prerendering

typescript
// app/product/[id]/page.tsx
export const dynamic = 'force-static'
export const revalidate = 3600 // 1 hour

export async function generateStaticParams() {
  const client = getCachedClient()
  const result = await client
    .from('products')
    .select('id')
    .execute()
  
  return result.data?.map(product => ({
    id: product.id.toString()
  })) || []
}

export default async function ProductPage({ 
  params 
}: { 
  params: Promise<{ id: string }> 
}) {
  const { id } = await params
  const client = getCachedClient()
  
  const result = await client
    .from('products')
    .select('*')
    .eq('id', id)
    .single()
    .execute()
  
  return <ProductDetails product={result.data} />
}

Error Boundaries

Component-Level Error Handling

typescript
// app/error.tsx
'use client'

import { useEffect } from 'react'
import { PostgRESTError } from '@webcoded/pgrestify'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    console.error('Server Component Error:', error)
  }, [error])
  
  if (error instanceof PostgRESTError) {
    return (
      <div>
        <h2>Database Error</h2>
        <p>We couldn't load the data. Please try again.</p>
        <button onClick={reset}>Retry</button>
      </div>
    )
  }
  
  return (
    <div>
      <h2>Something went wrong!</h2>
      <button onClick={reset}>Try again</button>
    </div>
  )
}

Not Found Handling

typescript
// app/not-found.tsx
import Link from 'next/link'

export default function NotFound() {
  return (
    <div>
      <h2>Not Found</h2>
      <p>Could not find the requested resource</p>
      <Link href="/">Return Home</Link>
    </div>
  )
}

Testing Server Components

Unit Testing

typescript
// __tests__/UserList.test.tsx
import { render } from '@testing-library/react'
import { UserList } from '../app/components/UserList'

// Mock the client
jest.mock('../lib/client', () => ({
  client: {
    from: jest.fn().mockReturnThis(),
    select: jest.fn().mockReturnThis(),
    execute: jest.fn().mockResolvedValue({
      data: [
        { id: 1, name: 'John' },
        { id: 2, name: 'Jane' }
      ]
    })
  }
}))

describe('UserList', () => {
  it('renders users', async () => {
    const component = await UserList()
    const { container } = render(component)
    
    expect(container.textContent).toContain('John')
    expect(container.textContent).toContain('Jane')
  })
})

Best Practices

1. Data Fetching Location

  • Fetch data as close to where it's used as possible
  • Use Server Components for data fetching by default
  • Only use Client Components when interactivity is needed

2. Component Boundaries

  • Keep Server Components pure (no event handlers, hooks)
  • Pass serializable props between Server and Client Components
  • Use composition to minimize Client Component bundles

3. Performance

  • Leverage streaming for progressive rendering
  • Use Suspense boundaries strategically
  • Implement proper caching strategies
  • Deduplicate requests within a single render

4. Error Handling

  • Implement error boundaries at appropriate levels
  • Provide meaningful error messages
  • Include retry mechanisms where appropriate
  • Log errors for monitoring

Common Pitfalls

Don't: Mix Server and Client Logic

typescript
// Bad: Trying to use hooks in Server Component
export async function ServerComponent() {
  const [state, setState] = useState() // Error!
  // ...
}

Do: Separate Concerns

typescript
// Good: Server Component for data
export async function ServerComponent() {
  const data = await fetchData()
  return <ClientComponent data={data} />
}

// Good: Client Component for interactivity
'use client'
export function ClientComponent({ data }) {
  const [state, setState] = useState(data)
  // ...
}

Next Steps

Released under the MIT License.