_private/qwestly-docs/Engineering/server-client-pages-pattern.md
Table of Contents
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:
- Extract server-side logic to
page.tsx - Move client-side state to
client.tsx - Update props interface to pass initial data
- Remove useEffect data fetching
- Test thoroughly to ensure functionality
Related Patterns
- 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.