Skip to main content

Server Components Guide

This guide covers using @pressw/chat-nextjs with Next.js Server Components for server-side rendering (SSR) and static site generation (SSG).

Overview

Server Components allow you to render thread data on the server, providing benefits like:

  • Faster Initial Page Load: Data is pre-rendered on the server
  • Better SEO: Search engines can crawl the rendered content
  • Reduced Client-Side JavaScript: Less code shipped to the browser
  • Direct Database Access: No HTTP overhead for data fetching

ThreadServerClient

The ThreadServerClient enables direct database access in Server Components without making HTTP requests.

Basic Usage

// app/threads/page.tsx
import { createThreadServerClient } from '@pressw/chat-nextjs/server';
import { adapter } from '@/lib/adapter';
import { getCurrentUser } from '@/lib/auth-server';

export default async function ThreadsPage() {
// Get user context from server-side authentication
const userContext = await getCurrentUser();

// Create server client
const client = createThreadServerClient({
adapter,
userContext,
});

// Fetch threads directly from database
const threadsResponse = await client.listThreads({
limit: 20,
orderBy: 'updatedAt',
orderDirection: 'desc',
});

return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">
Threads ({threadsResponse.total})
</h1>

<div className="grid gap-4">
{threadsResponse.threads.map((thread) => (
<div key={thread.id} className="p-4 border rounded-lg">
<h2 className="text-lg font-semibold">
{thread.title || 'Untitled Thread'}
</h2>
<p className="text-sm text-gray-600">
Updated: {thread.updatedAt.toLocaleDateString()}
</p>
</div>
))}
</div>

{threadsResponse.hasMore && (
<div className="mt-4">
<LoadMoreButton offset={threadsResponse.threads.length} />
</div>
)}
</div>
);
}

Server-Side Authentication

Server Components require a different authentication approach since they don't have access to NextRequest objects.

// lib/auth-server.ts
import { cookies } from 'next/headers';
import { verify } from 'jsonwebtoken';
import type { UserContext } from '@pressw/chat-core';

export async function getCurrentUser(): Promise<UserContext> {
const cookieStore = cookies();
const sessionCookie = cookieStore.get('session')?.value;

if (!sessionCookie) {
throw new Error('No session found');
}

try {
const payload = verify(sessionCookie, process.env.JWT_SECRET!) as any;

return {
userId: payload.userId,
organizationId: payload.organizationId,
tenantId: payload.tenantId,
};
} catch (error) {
throw new Error('Invalid session');
}
}

NextAuth.js Server-Side

// lib/auth-server.ts
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/config';
import type { UserContext } from '@pressw/chat-core';

export async function getCurrentUser(): Promise<UserContext> {
const session = await getServerSession(authOptions);

if (!session?.user?.id) {
throw new Error('Authentication required');
}

return {
userId: session.user.id,
organizationId: session.user.organizationId,
tenantId: session.user.tenantId,
};
}

Headers-Based Authentication

// lib/auth-server.ts
import { headers } from 'next/headers';
import { verify } from 'jsonwebtoken';
import type { UserContext } from '@pressw/chat-core';

export async function getCurrentUserFromHeaders(): Promise<UserContext> {
const headersList = headers();
const authorization = headersList.get('authorization');

if (!authorization?.startsWith('Bearer ')) {
throw new Error('Missing authorization header');
}

const token = authorization.slice(7);

try {
const payload = verify(token, process.env.JWT_SECRET!) as any;

return {
userId: payload.sub || payload.userId,
organizationId: payload.organizationId,
tenantId: payload.tenantId,
};
} catch (error) {
throw new Error('Invalid token');
}
}

Static Site Generation (SSG)

Generate static pages with thread data at build time.

Static Thread List

// app/threads/static/page.tsx
import { createThreadServerClient } from '@pressw/chat-nextjs/server';
import { adapter } from '@/lib/adapter';

// Static generation with default user context
const DEFAULT_USER_CONTEXT = {
userId: 'default-user',
organizationId: 'public-org',
};

export default async function StaticThreadsPage() {
const client = createThreadServerClient({
adapter,
userContext: DEFAULT_USER_CONTEXT,
});

const threadsResponse = await client.listThreads({
limit: 50,
orderBy: 'updatedAt',
orderDirection: 'desc',
});

return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Public Threads</h1>

<div className="grid gap-4">
{threadsResponse.threads.map((thread) => (
<div key={thread.id} className="p-4 border rounded-lg">
<h2 className="text-lg font-semibold">
{thread.title || 'Untitled Thread'}
</h2>
<p className="text-sm text-gray-600">
Updated: {thread.updatedAt.toLocaleDateString()}
</p>
</div>
))}
</div>
</div>
);
}

// Force static generation
export const dynamic = 'force-static';

Dynamic Static Paths

Generate static pages for individual threads:

// app/threads/[id]/page.tsx
import { createThreadServerClient } from '@pressw/chat-nextjs/server';
import { adapter } from '@/lib/adapter';
import { notFound } from 'next/navigation';

interface ThreadPageProps {
params: {
id: string;
};
}

export default async function ThreadPage({ params }: ThreadPageProps) {
const userContext = await getCurrentUser();

const client = createThreadServerClient({
adapter,
userContext,
});

const thread = await client.getThread(params.id);

if (!thread) {
notFound();
}

return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">
{thread.title || 'Untitled Thread'}
</h1>

<div className="bg-gray-50 p-4 rounded-lg">
<p><strong>ID:</strong> {thread.id}</p>
<p><strong>Created:</strong> {thread.createdAt.toLocaleDateString()}</p>
<p><strong>Updated:</strong> {thread.updatedAt.toLocaleDateString()}</p>

{thread.metadata && (
<div className="mt-4">
<strong>Metadata:</strong>
<pre className="mt-2 bg-white p-2 rounded border text-sm">
{JSON.stringify(thread.metadata, null, 2)}
</pre>
</div>
)}
</div>
</div>
);
}

// Generate static params for known threads
export async function generateStaticParams() {
const client = createThreadServerClient({
adapter,
userContext: DEFAULT_USER_CONTEXT,
});

const threadsResponse = await client.listThreads({
limit: 100, // Generate for first 100 threads
orderBy: 'updatedAt',
orderDirection: 'desc',
});

return threadsResponse.threads.map((thread) => ({
id: thread.id,
}));
}

Incremental Static Regeneration (ISR)

Combine static generation with periodic updates.

// app/threads/isr/page.tsx
import { createThreadServerClient } from '@pressw/chat-nextjs/server';
import { adapter } from '@/lib/adapter';

export default async function ISRThreadsPage() {
const userContext = await getCurrentUser();

const client = createThreadServerClient({
adapter,
userContext,
});

const threadsResponse = await client.listThreads({
limit: 20,
orderBy: 'updatedAt',
orderDirection: 'desc',
});

return (
<div className="container mx-auto p-4">
<div className="mb-4 flex justify-between items-center">
<h1 className="text-2xl font-bold">Latest Threads</h1>
<p className="text-sm text-gray-600">
Last updated: {new Date().toLocaleString()}
</p>
</div>

<div className="grid gap-4">
{threadsResponse.threads.map((thread) => (
<div key={thread.id} className="p-4 border rounded-lg">
<h2 className="text-lg font-semibold">
{thread.title || 'Untitled Thread'}
</h2>
<p className="text-sm text-gray-600">
Updated: {thread.updatedAt.toLocaleDateString()}
</p>
</div>
))}
</div>
</div>
);
}

// Revalidate every hour
export const revalidate = 3600;

Combining Server and Client Components

Use Server Components for initial data loading and Client Components for interactivity.

Server Component with Client Hydration

// app/threads/hybrid/page.tsx (Server Component)
import { createThreadServerClient } from '@pressw/chat-nextjs/server';
import { adapter } from '@/lib/adapter';
import { getCurrentUser } from '@/lib/auth-server';
import { ThreadListClient } from '@/components/ThreadListClient';

export default async function HybridThreadsPage() {
const userContext = await getCurrentUser();

const client = createThreadServerClient({
adapter,
userContext,
});

// Get initial data on server
const initialData = await client.listThreads({
limit: 20,
orderBy: 'updatedAt',
orderDirection: 'desc',
});

return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Threads</h1>

{/* Client component with server-side initial data */}
<ThreadListClient
initialData={initialData}
userContext={userContext}
/>
</div>
);
}
// components/ThreadListClient.tsx (Client Component)
'use client';

import { useState } from 'react';
import { useThreads, useCreateThread } from '@pressw/chat-core/react';
import type { ThreadsResponse, UserContext } from '@pressw/chat-core';

interface ThreadListClientProps {
initialData: ThreadsResponse;
userContext: UserContext;
}

export function ThreadListClient({ initialData, userContext }: ThreadListClientProps) {
const [search, setSearch] = useState('');

const {
data: threadsResponse = initialData,
refetch,
isLoading,
} = useThreads({
apiConfig: {
baseUrl: '/api/chat',
},
listOptions: {
limit: 20,
search: search || undefined,
},
// Use server data as initial data
initialData,
});

const createThreadMutation = useCreateThread({
apiConfig: {
baseUrl: '/api/chat',
},
onSuccess: () => {
refetch();
},
});

const handleCreateThread = () => {
const title = prompt('Enter thread title:');
if (title) {
createThreadMutation.mutate({ title });
}
};

return (
<div>
<div className="mb-4 flex gap-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search threads..."
className="flex-1 px-3 py-2 border rounded"
/>
<button
onClick={handleCreateThread}
disabled={createThreadMutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
{createThreadMutation.isPending ? 'Creating...' : 'Create Thread'}
</button>
</div>

{isLoading ? (
<div>Loading...</div>
) : (
<div className="grid gap-4">
{threadsResponse.threads.map((thread) => (
<div key={thread.id} className="p-4 border rounded-lg">
<h2 className="text-lg font-semibold">
{thread.title || 'Untitled Thread'}
</h2>
<p className="text-sm text-gray-600">
Updated: {thread.updatedAt.toLocaleDateString()}
</p>
</div>
))}
</div>
)}
</div>
);
}

Error Handling in Server Components

Handle authentication and database errors gracefully.

Custom Error Pages

// app/threads/error.tsx
'use client';

import { useEffect } from 'react';

export default function ThreadsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('Threads page error:', error);
}, [error]);

return (
<div className="container mx-auto p-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
<h2 className="text-lg font-semibold text-red-800 mb-2">
Something went wrong!
</h2>
<p className="text-red-600 mb-4">
{error.message === 'Authentication required'
? 'Please sign in to view threads.'
: 'Unable to load threads. Please try again.'
}
</p>
<button
onClick={reset}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Try again
</button>
</div>
</div>
);
}

Graceful Error Handling

// app/threads/safe/page.tsx
import { createThreadServerClient } from '@pressw/chat-nextjs/server';
import { adapter } from '@/lib/adapter';
import { getCurrentUser } from '@/lib/auth-server';
import { redirect } from 'next/navigation';

export default async function SafeThreadsPage() {
let userContext;
let threadsResponse;

try {
userContext = await getCurrentUser();
} catch (error) {
// Redirect to login if authentication fails
redirect('/login?return=/threads/safe');
}

try {
const client = createThreadServerClient({
adapter,
userContext,
});

threadsResponse = await client.listThreads({
limit: 20,
orderBy: 'updatedAt',
orderDirection: 'desc',
});
} catch (error) {
console.error('Failed to load threads:', error);

return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Threads</h1>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-yellow-800">
Unable to load threads at this time. Please try again later.
</p>
</div>
</div>
);
}

return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">
Threads ({threadsResponse.total})
</h1>

<div className="grid gap-4">
{threadsResponse.threads.map((thread) => (
<div key={thread.id} className="p-4 border rounded-lg">
<h2 className="text-lg font-semibold">
{thread.title || 'Untitled Thread'}
</h2>
<p className="text-sm text-gray-600">
Updated: {thread.updatedAt.toLocaleDateString()}
</p>
</div>
))}
</div>
</div>
);
}

Performance Optimization

Data Streaming

Stream data for faster perceived performance:

// app/threads/stream/page.tsx
import { Suspense } from 'react';
import { ThreadsListSkeleton } from '@/components/ThreadsListSkeleton';
import { ThreadsList } from '@/components/ThreadsList';

export default function StreamingThreadsPage() {
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Threads</h1>

<Suspense fallback={<ThreadsListSkeleton />}>
<ThreadsList />
</Suspense>
</div>
);
}

// components/ThreadsList.tsx
import { createThreadServerClient } from '@pressw/chat-nextjs/server';
import { adapter } from '@/lib/adapter';
import { getCurrentUser } from '@/lib/auth-server';

export async function ThreadsList() {
const userContext = await getCurrentUser();

const client = createThreadServerClient({
adapter,
userContext,
});

const threadsResponse = await client.listThreads({
limit: 20,
orderBy: 'updatedAt',
orderDirection: 'desc',
});

return (
<div className="grid gap-4">
{threadsResponse.threads.map((thread) => (
<div key={thread.id} className="p-4 border rounded-lg">
<h2 className="text-lg font-semibold">
{thread.title || 'Untitled Thread'}
</h2>
<p className="text-sm text-gray-600">
Updated: {thread.updatedAt.toLocaleDateString()}
</p>
</div>
))}
</div>
);
}

Parallel Data Fetching

Fetch multiple data sources in parallel:

// app/dashboard/page.tsx
import { createThreadServerClient } from '@pressw/chat-nextjs/server';
import { adapter } from '@/lib/adapter';
import { getCurrentUser } from '@/lib/auth-server';

export default async function DashboardPage() {
const userContext = await getCurrentUser();

const client = createThreadServerClient({
adapter,
userContext,
});

// Fetch data in parallel
const [recentThreads, userStats] = await Promise.all([
client.listThreads({
limit: 5,
orderBy: 'updatedAt',
orderDirection: 'desc',
}),
getUserStats(userContext.userId), // Your custom function
]);

return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>

<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h2 className="text-lg font-semibold mb-3">Recent Threads</h2>
<div className="space-y-2">
{recentThreads.threads.map((thread) => (
<div key={thread.id} className="p-3 bg-gray-50 rounded">
<h3 className="font-medium">{thread.title || 'Untitled'}</h3>
<p className="text-sm text-gray-600">
{thread.updatedAt.toLocaleDateString()}
</p>
</div>
))}
</div>
</div>

<div>
<h2 className="text-lg font-semibold mb-3">Statistics</h2>
<div className="bg-blue-50 p-4 rounded">
<p>Total Threads: {userStats.totalThreads}</p>
<p>This Month: {userStats.thisMonth}</p>
</div>
</div>
</div>
</div>
);
}

Caching Strategies

React Cache

Use React's cache function for deduplication:

// lib/cache.ts
import { cache } from 'react';
import { createThreadServerClient } from '@pressw/chat-nextjs/server';
import { adapter } from '@/lib/adapter';
import type { UserContext } from '@pressw/chat-core';

export const getThreadsForUser = cache(async (userContext: UserContext) => {
const client = createThreadServerClient({
adapter,
userContext,
});

return client.listThreads({
limit: 20,
orderBy: 'updatedAt',
orderDirection: 'desc',
});
});

export const getThreadById = cache(async (userContext: UserContext, threadId: string) => {
const client = createThreadServerClient({
adapter,
userContext,
});

return client.getThread(threadId);
});

Unstable_cache

For longer-term caching:

// lib/cache.ts
import { unstable_cache } from 'next/cache';
import { createThreadServerClient } from '@pressw/chat-nextjs/server';

export const getCachedThreads = unstable_cache(
async (userId: string) => {
const client = createThreadServerClient({
adapter,
userContext: { userId },
});

return client.listThreads({
limit: 50,
orderBy: 'updatedAt',
orderDirection: 'desc',
});
},
['user-threads'],
{
revalidate: 60, // 1 minute
tags: ['threads'],
},
);

// Revalidate cache when threads change
export async function revalidateThreadsCache() {
revalidateTag('threads');
}

Best Practices

1. Keep Server Components Simple

Focus on data fetching and rendering, delegate complex logic to utilities:

// Good: Simple, focused Server Component
export default async function ThreadsPage() {
const threadsData = await getThreadsData(); // Utility function
return <ThreadsDisplay data={threadsData} />;
}

// Avoid: Complex logic in Server Component
export default async function ThreadsPage() {
// Lots of complex logic here...
}

2. Handle Loading States

Provide loading indicators for better UX:

// app/threads/loading.tsx
export default function ThreadsLoading() {
return (
<div className="container mx-auto p-4">
<div className="animate-pulse">
<div className="h-8 bg-gray-300 rounded w-1/3 mb-4"></div>
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-20 bg-gray-200 rounded"></div>
))}
</div>
</div>
</div>
);
}

3. Implement Proper Error Boundaries

Use error boundaries to handle Server Component errors:

// app/threads/error.tsx
'use client';

export default function ThreadsError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
if (error.message.includes('Authentication')) {
return <AuthenticationError />;
}

if (error.message.includes('Database')) {
return <DatabaseError onRetry={reset} />;
}

return <GenericError onRetry={reset} />;
}

Next Steps