⚡ TECH BLOG
Home
Blog
Tags
About
⚡

Powered by Next.js 15 & Modern Web Tech ⚡

Back to Home

React Server Components: A Complete Introduction

July 15, 2022
reactserver-componentsnextjsweb-development
React Server Components: A Complete Introduction

React Server Components: A Complete Introduction

React Server Components represent a fundamental shift in how we build React applications. They allow you to render components on the server, sending only the result to the client, dramatically improving performance and user experience.

What Are Server Components?

Server Components are a new type of React component that render on the server and send their output to the client. Unlike Client Components, they:

  • Run only on the server - JavaScript is never sent to the browser
  • Have zero bundle size impact - No client-side JavaScript
  • Can directly access backend resources - Database queries, file system, etc.
  • Render once - No interactivity or state on the client

Server vs Client Components

FeatureServer ComponentsClient Components
Render LocationServerClient
Bundle SizeZeroIncluded
Data FetchingDirectVia API
InteractivityNoneFull
State/hooksNoneFull support
Browser APIsNoneFull access

Basic Server Component

// This is a Server Component (default in app directory)
// File: app/users/page.tsx

async function UsersList() {
  // Direct database access - runs on server only
  const users = await db.users.findMany()
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name} - {user.email}
        </li>
      ))}
    </ul>
  )
}

export default UsersList

Client Components

Components that need interactivity must be marked with 'use client':

// File: components/counter.tsx
'use client'

import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>
        Increment
      </button>
    </div>
  )
}

Using Client Components in Server Components

// File: app/page.tsx (Server Component)
import { Counter } from '@/components/counter'

export default function Page() {
  return (
    <div>
      <h1>Welcome</h1>
      {/* Client Component rendered within Server Component */}
      <Counter />
    </div>
  )
}

Data Fetching Patterns

Parallel Data Fetching

// File: app/dashboard/page.tsx

async function getUser(id: string) {
  const res = await fetch(`/api/users/${id}`)
  return res.json()
}

async function getUserPosts(id: string) {
  const res = await fetch(`/api/users/${id}/posts`)
  return res.json()
}

export default async function Dashboard({ params }: { params: { id: string } }) {
  // Parallel fetching - both requests start at the same time
  const [user, posts] = await Promise.all([
    getUser(params.id),
    getUserPosts(params.id)
  ])

  return (
    <div>
      <h1>{user.name}</h1>
      <PostsList posts={posts} />
    </div>
  )
}

Sequential Data Fetching

// File: app/profile/page.tsx

export default async function Profile({ params }: { params: { id: string } }) {
  // Sequential - second request waits for first
  const user = await getUser(params.id)
  const posts = await getUserPosts(params.id)

  return (
    <div>
      <h1>{user.name}</h1>
      <PostsList posts={posts} />
    </div>
  )
}

Streaming with Suspense

// File: app/page.tsx
import { Suspense } from 'react'

async function SlowComponent() {
  await new Promise(resolve => setTimeout(resolve, 2000))
  return <div>Loaded after 2 seconds!</div>
}

async function FastComponent() {
  return <div>Loaded immediately!</div>
}

export default function Page() {
  return (
    <div>
      <Suspense fallback={<div>Loading fast component...</div>}>
        <FastComponent />
      </Suspense>
      
      <Suspense fallback={<div>Loading slow component...</div>}>
        <SlowComponent />
      </Suspense>
    </div>
  )
}

Loading States

// File: app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
      <div className="h-4 bg-gray-200 rounded w-2/3 mb-2"></div>
      <div className="h-4 bg-gray-200 rounded w-1/2"></div>
    </div>
  )
}

Data Fetching with Cache Control

// File: app/products/page.tsx

async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    // Cache for 1 hour
    next: { revalidate: 3600 }
  })
  return res.json()
}

// Disable caching
async function getRealTimeData() {
  const res = await fetch('https://api.example.com/realtime', {
    cache: 'no-store'
  })
  return res.json()
}

export default async function ProductsPage() {
  const products = await getProducts()
  
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

Server Actions

Server Actions allow you to define server-side functions that can be called from client components:

// File: app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function createUser(formData: FormData) {
  const name = formData.get('name') as string
  const email = formData.get('email') as string
  
  await db.users.create({
    data: { name, email }
  })
  
  revalidatePath('/users')
}

export async function deleteUser(id: string) {
  await db.users.delete({ where: { id } })
  revalidatePath('/users')
}

Using Server Actions

// File: app/users/page.tsx
import { createUser } from './actions'

export default function NewUserPage() {
  return (
    <form action={createUser}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <button type="submit">Create User</button>
    </form>
  )
}

Server Actions with useActionState

// File: components/user-form.tsx
'use client'

import { useActionState } from 'react'
import { createUser } from '@/app/actions'

export function UserForm() {
  const [state, formAction, isPending] = useActionState(createUser, null)

  return (
    <form action={formAction}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create User'}
      </button>
      {state?.error && <p className="text-red-500">{state.error}</p>}
    </form>
  )
}

Sharing Data Between Components

Using React Context (Server-Safe Pattern)

// File: context/theme.tsx
'use client'

import { createContext, useContext } from 'react'

const ThemeContext = createContext<'light' | 'dark'>('light')

export function ThemeProvider({ 
  children, 
  theme 
}: { 
  children: React.ReactNode
  theme: 'light' | 'dark' 
}) {
  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  )
}

export function useTheme() {
  return useContext(ThemeContext)
}

Server-side Data Sharing

// File: lib/cache.ts
import { cache } from 'react'

export const getUser = cache(async (id: string) => {
  const user = await db.users.findUnique({ where: { id } })
  return user
})

// File: app/profile/page.tsx
import { getUser } from '@/lib/cache'
import { UserProfile } from '@/components/user-profile'
import { UserStats } from '@/components/user-stats'

export default async function Profile({ params }: { params: { id: string } }) {
  // getUser is cached per request - called once
  return (
    <div>
      <UserProfile user={await getUser(params.id)} />
      <UserStats user={await getUser(params.id)} /> {/* Uses cached result */}
    </div>
  )
}

Error Handling

Error Boundary

// File: app/users/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

Not Found

// File: app/users/not-found.tsx
import Link from 'next/link'

export default function NotFound() {
  return (
    <div>
      <h2>User Not Found</h2>
      <p>Could not find the requested user.</p>
      <Link href="/users">Back to Users</Link>
    </div>
  )
}

// Usage in page
import { notFound } from 'next/navigation'

export default async function UserPage({ params }: { params: { id: string } }) {
  const user = await getUser(params.id)
  
  if (!user) {
    notFound()
  }
  
  return <div>{user.name}</div>
}

Best Practices

1. Keep Server Components Lean

// Good - Server Component handles data, Client handles interactivity
// File: app/todos/page.tsx
async function TodosPage() {
  const todos = await getTodos() // Server-side fetch
  
  return <TodoList todos={todos} /> // Client component for interactivity
}

// File: components/todo-list.tsx
'use client'

export function TodoList({ todos }: { todos: Todo[] }) {
  const [filter, setFilter] = useState('')
  
  const filtered = todos.filter(t => t.title.includes(filter))
  
  return (
    <div>
      <input 
        value={filter} 
        onChange={e => setFilter(e.target.value)} 
      />
      <ul>
        {filtered.map(todo => (
          <TodoItem key={todo.id} todo={todo} />
        ))}
      </ul>
    </div>
  )
}

2. Use Server Actions for Mutations

// Good - Server Action handles the mutation
async function updateTodo(id: string, completed: boolean) {
  'use server'
  await db.todos.update({
    where: { id },
    data: { completed }
  })
  revalidatePath('/todos')
}

// Client component just triggers it
'use client'
function TodoItem({ todo }: { todo: Todo }) {
  return (
    <input
      type="checkbox"
      checked={todo.completed}
      onChange={() => updateTodo(todo.id, !todo.completed)}
    />
  )
}

3. Minimize Client Boundaries

// Bad - Entire form is client component
'use client'
export function ContactForm() { /* ... */ }

// Good - Only interactive parts are client
export function ContactForm() {
  return (
    <form action={submitContact}>
      <input name="email" />
      <SubmitButton /> {/* Only this is client */}
    </form>
  )
}

'use client'
function SubmitButton() {
  const { pending } = useFormStatus()
  return <button disabled={pending}>Submit</button>
}

4. Use Composition Over Prop Drilling

// Good - Pass components, not data
async function Layout({ children }: { children: React.ReactNode }) {
  const user = await getCurrentUser()
  
  return (
    <div>
      <Header user={user} />
      {children}
    </div>
  )
}

// Or use slots/children pattern
async function DashboardLayout({
  sidebar,
  children,
}: {
  sidebar: React.ReactNode
  children: React.ReactNode
}) {
  return (
    <div className="flex">
      <aside>{sidebar}</aside>
      <main>{children}</main>
    </div>
  )
}

Performance Considerations

Streaming Progressive Enhancement

// File: app/page.tsx
import { Suspense } from 'react'

async function Recommendations() {
  const items = await getRecommendations() // Slow API
  return <div>{items.length} recommendations</div>
}

export default function Page() {
  return (
    <div>
      <h1>Products</h1>
      <ProductList />
      
      <Suspense fallback={<RecommendationSkeleton />}>
        <Recommendations />
      </Suspense>
    </div>
  )
}

Route Groups for Organization

app/
├── (marketing)/
│   ├── about/
│   │   └── page.tsx
│   └── contact/
│       └── page.tsx
├── (shop)/
│   ├── products/
│   │   └── page.tsx
│   └── cart/
│       └── page.tsx
└── layout.tsx

Conclusion

React Server Components represent a significant evolution in React's architecture. By moving data fetching and rendering to the server, we get better performance, simpler caching, and a more intuitive mental model for building applications.

Key Takeaways

  • Server Components run on the server and have zero bundle size
  • Use 'use client' for components requiring interactivity
  • Fetch data directly in Server Components
  • Use Server Actions for mutations
  • Leverage Suspense for streaming and loading states

Start experimenting with Server Components in your Next.js applications today!

Share:

💬 Comments