_private/qwestly-docs/Engineering/server-client-pages-pattern.md

Server-Side Data Fetching with Client-Side Interactions Pattern

Overview

This pattern separates server-side data fetching from client-side interactions by using a page.tsx file for server-side operations and a client.tsx file for client-side state management and user interactions. This approach leverages Next.js App Router's server components for optimal performance and SEO while maintaining rich client-side interactivity.

Pattern Structure

src/app/feature/
├── page.tsx      # Server component - data fetching
└── client.tsx    # Client component - interactions & state

Implementation Example

Server Component (page.tsx)

// No "use client" directive - this is a server component
import { getDataFromDatabase } from "@/services/data";
import FeatureClient from "./client";

export default async function FeaturePage() {
  // Server-side data fetching
  const initialData = await getDataFromDatabase();
  const processedData = await processData(initialData);

  // Pass data as props to client component
  return <FeatureClient initialData={processedData} />;
}

Client Component (client.tsx)

"use client";

import { useState } from "react";

interface FeatureClientProps {
  initialData: ProcessedData[];
}

export default function FeatureClient({ initialData }: FeatureClientProps) {
  // Client-side state management
  const [data, setData] = useState(initialData);
  const [isLoading, setIsLoading] = useState(false);

  // User interaction handlers
  const handleUserAction = async () => {
    setIsLoading(true);
    try {
      const response = await fetch("/api/feature/action", {
        method: "POST",
        body: JSON.stringify({ /* data */ }),
      });
      const result = await response.json();
      setData(updatedData);
    } catch (error) {
      console.error("Action failed:", error);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      {/* Interactive UI components */}
    </div>
  );
}

Key Principles

1. Server-Side Data Fetching

All initial data loading happens server-side in page.tsx:

  • Database queries
  • API calls to external services
  • Data processing and transformation
  • Authentication checks
  • SEO-critical data

Benefits:

  • Faster initial page loads
  • Better SEO (data available at render time)
  • Reduced client-side bundle size
  • Improved Core Web Vitals
  • Server-side caching opportunities

2. Client-Side State Management

Client-side operations in client.tsx include:

  • User interactions (clicks, form submissions)
  • Real-time updates
  • UI state management
  • Optimistic updates
  • Client-side filtering and sorting

3. The "use client" Directive

The "use client" directive at the top of client.tsx tells Next.js that this component should run on the client side. This enables:

  • React hooks (useState, useEffect, etc.)
  • Browser APIs
  • Event handlers
  • Client-side routing
  • Interactive UI components

Benefits

Performance

  • Faster Initial Load: Data is fetched server-side and available immediately
  • Reduced Bundle Size: Server components don't include client-side JavaScript
  • Better Caching: Server-side requests can be cached at the edge
  • Improved SEO: Search engines see complete content on first render

User Experience

  • Immediate Content: No loading states for initial data
  • Progressive Enhancement: Page works without JavaScript
  • Responsive Interactions: Client-side state updates are instant
  • Better Accessibility: Server-rendered content is more accessible

Development Experience

  • Clear Separation: Server vs client responsibilities are explicit
  • Type Safety: Props interface defines clear contracts
  • Testability: Server and client logic can be tested separately
  • Maintainability: Easier to reason about data flow

Anti-Patterns to Avoid

❌ Client-Side Data Fetching in useEffect

// DON'T DO THIS
"use client";

export default function BadComponent() {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // This creates unnecessary loading states and delays
    fetch("/api/data")
      .then(res => res.json())
      .then(data => {
        setData(data);
        setLoading(false);
      });
  }, []); // Empty dependency array

  if (loading) return <div>Loading...</div>;
  
  return <div>{/* render data */}</div>;
}

Problems with this approach:

  • Creates unnecessary loading states
  • Delays content visibility
  • Poor SEO (content not available on first render)
  • Additional network requests
  • Worse user experience

❌ Mixed Server/Client Responsibilities

// DON'T DO THIS
"use client";

export default function MixedComponent() {
  // Don't mix server-side data fetching with client-side logic
  const [data, setData] = useState([]);
  
  // Server-side logic in client component
  const serverData = await fetchServerData(); // This won't work!
  
  return <div>{/* render */}</div>;
}

Best Practices

1. Clear Data Flow

// page.tsx - Server component
export default async function UsersPage() {
  const users = await fetchUsers();
  const waitlistUsers = await fetchWaitlistUsers();
  
  return (
    <UsersClient 
      initialUsers={users}
      initialWaitlistUsers={waitlistUsers}
    />
  );
}

2. Explicit Props Interface

// client.tsx - Client component
interface UsersClientProps {
  initialUsers: User[];
  initialWaitlistUsers: WaitlistUser[];
}

export default function UsersClient({ 
  initialUsers, 
  initialWaitlistUsers 
}: UsersClientProps) {
  // Client-side state management
}

3. Optimistic Updates

const handleDeleteUser = async (userId: string) => {
  // Optimistic update
  setUsers(users.filter(u => u.id !== userId));
  
  try {
    await fetch(`/api/users/${userId}`, { method: 'DELETE' });
  } catch (error) {
    // Revert on error
    setUsers(originalUsers);
  }
};

4. Error Boundaries

// client.tsx
export default function UsersClient({ initialUsers }: UsersClientProps) {
  const [error, setError] = useState<string | null>(null);
  
  const handleAction = async () => {
    try {
      // Action logic
    } catch (error) {
      setError(error.message);
    }
  };
  
  if (error) return <ErrorDisplay error={error} />;
  
  return <div>{/* UI */}</div>;
}

When to Use This Pattern

✅ Use This Pattern For:

  • Pages with initial data requirements
  • Admin interfaces with data management
  • Forms with server-side validation
  • Dashboards with real-time updates
  • User profile pages

❌ Don't Use This Pattern For:

  • Static content pages
  • Simple contact forms
  • Landing pages without dynamic data
  • Pure client-side utilities

Migration from Client-Side Fetching

If you have existing components using useEffect for data fetching:

  1. Extract server-side logic to page.tsx
  2. Move client-side state to client.tsx
  3. Update props interface to pass initial data
  4. Remove useEffect data fetching
  5. Test thoroughly to ensure functionality
  • API Routes: Use for client-side mutations (POST, PUT, DELETE)
  • Server Actions: For form submissions and mutations
  • Streaming: For large datasets that need progressive loading
  • Parallel Data Fetching: Use Promise.all() for multiple server requests

Conclusion

This pattern provides the best of both worlds: server-side performance and SEO benefits with rich client-side interactivity. By clearly separating server and client responsibilities, you create maintainable, performant, and user-friendly applications that follow Next.js best practices.