Skip to content

Building a Conversation Log Browser with Next.js and shadcn/ui

7 min read

How to create a modern, responsive UI for browsing AI conversation logs stored in MongoDB


In the first two articles of this series, we built a robust pipeline for capturing Claude Code conversation logs: Article 1 covered the overall architecture, and Article 2 detailed the sync service that watches JSONL files and persists them to MongoDB. Now we have thousands of conversation entries sitting in a database. But data without visibility is just noise.

In this article, we’ll build the user interface layer that transforms raw conversation data into an explorable, searchable, and visually informative experience. We’re creating a conversation log browser using Next.js, TypeScript, and shadcn/ui that connects directly to our MongoDB backend.

By the end, you’ll understand how to structure a Next.js App Router application for data-heavy dashboards, implement efficient cursor-based pagination, and leverage shadcn/ui for rapid, beautiful component development.


The Tech Stack

Before diving into code, let’s understand what we’re working with:

TechnologyVersionPurpose
Next.js16.1App Router, API routes, React Server Components
React19.2UI framework with latest features
TypeScript5.xType safety across the stack
Tailwind CSS4.xUtility-first styling
shadcn/ui3.6Pre-built, customizable components
TanStack Query5.xServer state management
Recharts2.xData visualization
MongoDB7.xDatabase driver
Zod4.xRuntime validation

This stack gives us the best of both worlds: the developer experience of modern React with the performance of server-side rendering and direct database access.


Project Architecture

Understanding the project structure is crucial before we explore individual components. Here’s how the UI layer is organized:

ui/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── api/ # API route handlers
│ │ │ ├── conversations/
│ │ │ │ ├── route.ts # Paginated list endpoint
│ │ │ │ └── export/
│ │ │ │ └── route.ts # JSON export endpoint
│ │ │ ├── projects/
│ │ │ │ └── route.ts # Distinct projects endpoint
│ │ │ ├── sessions/
│ │ │ │ └── route.ts # Sessions per project
│ │ │ └── stats/
│ │ │ └── route.ts # Aggregated chart data
│ │ ├── globals.css # Tailwind + theme variables
│ │ ├── layout.tsx # Root layout with providers
│ │ ├── page.tsx # Main dashboard page
│ │ └── providers.tsx # React Query provider
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── chart.tsx
│ │ │ ├── table.tsx
│ │ │ └── ...
│ │ ├── ConversationDetail.tsx
│ │ ├── ConversationList.tsx
│ │ ├── FilterPanel.tsx
│ │ └── SessionChart.tsx
│ ├── hooks/
│ │ ├── useConversations.ts # Infinite query hook
│ │ └── useStats.ts # Stats query hook
│ └── lib/
│ ├── mongodb.ts # Database connection
│ ├── types.ts # TypeScript interfaces
│ ├── utils.ts # Utility functions
│ └── dateUtils.ts # Date range helpers
├── components.json # shadcn/ui configuration
├── next.config.ts # Next.js configuration
├── package.json
└── tsconfig.json

The architecture follows a clear separation of concerns:

  • API Routes handle all database interactions, keeping MongoDB logic server-side
  • Custom Hooks abstract TanStack Query complexity from components
  • Components focus purely on presentation and user interaction
  • Lib contains shared utilities and type definitions

Here’s how data flows through the application:

┌─────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ page.tsx │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌───────────────┐ │ │
│ │ │ FilterPanel │ │SessionChart │ │ConversationList│ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └───────┬───────┘ │ │
│ │ │ │ │ │ │
│ │ └───────────────┼────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────────▼──────────┐ │ │
│ │ │ Custom Hooks │ │ │
│ │ │ useConversations │ │ │
│ │ │ useStats │ │ │
│ │ └──────────┬──────────┘ │ │
│ └─────────────────────────┼───────────────────────────┘ │
│ │ fetch() │
└────────────────────────────┼────────────────────────────────┘
┌────────────────────────────▼────────────────────────────────┐
│ Next.js Server │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ API Routes │ │
│ │ /api/projects /api/sessions /api/conversations │ │
│ │ /api/stats /api/conversations/export │ │
│ └──────────────────────────┬──────────────────────────┘ │
│ │ │
│ ┌──────────────────────────▼──────────────────────────┐ │
│ │ lib/mongodb.ts │ │
│ │ (Connection Pool Singleton) │ │
│ └──────────────────────────┬──────────────────────────┘ │
└─────────────────────────────┼───────────────────────────────┘
┌─────────────────────────────▼───────────────────────────────┐
│ MongoDB │
│ conversations collection │
└─────────────────────────────────────────────────────────────┘

shadcn/ui Integration

One of my favorite aspects of this project is how shadcn/ui accelerates development. Unlike traditional component libraries where you install a package, shadcn/ui copies component source code directly into your project. You own the code, which means complete customization freedom.

Configuration

The components.json file defines how shadcn/ui integrates with your project:

{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

We’re using the “new-york” style variant, which provides a slightly more refined aesthetic compared to the default style. The rsc: true setting indicates React Server Components compatibility, and the aliases map to our project’s directory structure.

Components in Use

The UI leverages these shadcn/ui components:

ComponentUsage
CardContainer for filter panel, conversation list, charts
ButtonActions, sort toggles, load more
TableConversation list display
SelectProject/session dropdowns
CalendarDate range pickers
PopoverCalendar trigger containers
ScrollAreaScrollable conversation list
BadgeMessage type indicators
SeparatorVisual content dividers
InputSearch field

The cn() Utility Pattern

Every shadcn/ui component uses the cn() utility for class name merging:

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

This small utility is surprisingly powerful. It combines clsx for conditional class handling with tailwind-merge for intelligent Tailwind class deduplication. When you pass className="p-4" to a component that already has className="p-2", twMerge ensures only p-4 applies.

Theming with CSS Variables

The theme is defined in globals.css using the modern oklch color space:

:root {
--background: oklch(0.9789 0.0082 121.6272);
--foreground: oklch(0 0 0);
--primary: oklch(0.5106 0.2301 276.9656);
--primary-foreground: oklch(1.0000 0 0);
--muted: oklch(0.9551 0 0);
--muted-foreground: oklch(0.3211 0 0);
/* ... more variables */
--chart-1: oklch(0.5106 0.2301 276.9656);
--chart-2: oklch(0.7038 0.1230 182.5025);
--radius: 1rem;
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(1.0000 0 0);
/* ... dark mode overrides */
}

Components reference these variables, making theme changes trivial. Want a different primary color? Change one line. Need dark mode? The .dark class handles it automatically.


Data Layer Architecture

The UI communicates with MongoDB through Next.js API routes. This keeps database credentials server-side and provides a clean separation between frontend and backend concerns.

MongoDB Connection Singleton

Database connections are expensive to create. We use a singleton pattern to reuse connections across requests:

src/lib/mongodb.ts
import { MongoClient, Db } from 'mongodb';
const uri = process.env.MONGO_URI || 'mongodb://localhost:27017';
const dbName = process.env.MONGO_DB || 'claude_logs';
let cachedClient: MongoClient | null = null;
let cachedDb: Db | null = null;
export async function getDatabase(): Promise<Db> {
if (cachedDb) {
return cachedDb;
}
if (!cachedClient) {
cachedClient = await MongoClient.connect(uri, {
maxPoolSize: 10,
});
}
cachedDb = cachedClient.db(dbName);
return cachedDb;
}
export async function getConversationsCollection() {
const db = await getDatabase();
return db.collection('conversations');
}

The maxPoolSize: 10 setting limits concurrent connections, preventing connection exhaustion under load. In serverless environments, this caching pattern is essential for performance.

API Routes Structure

Each API route follows a consistent pattern: validate input with Zod, query MongoDB, and return JSON:

src/app/api/conversations/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { ObjectId, Filter, Document } from 'mongodb';
import { z } from 'zod';
import { getConversationsCollection } from '@/lib/mongodb';
const querySchema = z.object({
projectId: z.string().min(1),
sessionId: z.string().optional(),
search: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
cursor: z.string().optional(),
limit: z.coerce.number().min(1).max(100).default(50),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
export async function GET(request: NextRequest) {
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(searchParams);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid parameters', details: parsed.error.issues },
{ status: 400 }
);
}
const { projectId, sessionId, search, startDate, endDate, cursor, limit, sortOrder } = parsed.data;
try {
const collection = await getConversationsCollection();
const query: Filter<Document> = { projectId };
// Build query filters...
if (sessionId) query.sessionId = sessionId;
if (search) query.$text = { $search: search };
// Date range filtering
if (startDate || endDate) {
query.timestamp = { $ne: null, $exists: true };
if (startDate) query.timestamp.$gte = startDate;
if (endDate) query.timestamp.$lte = endDate + 'T23:59:59.999Z';
}
// Cursor-based pagination (details below)
// ...
const docs = await collection
.find(query)
.sort({ timestamp: sortOrder === 'desc' ? -1 : 1, _id: sortOrder === 'desc' ? -1 : 1 })
.limit(limit + 1)
.toArray();
const hasMore = docs.length > limit;
const data = hasMore ? docs.slice(0, -1) : docs;
return NextResponse.json({
data,
pagination: { nextCursor, hasMore, total },
});
} catch (error) {
console.error('Failed to fetch conversations:', error);
return NextResponse.json({ error: 'Failed to fetch conversations' }, { status: 500 });
}
}

Cursor-Based Pagination

Instead of offset pagination (skip N records), we use cursor-based pagination for better performance with large datasets. The cursor encodes the last seen record’s position:

// Cursor format: base64(timestamp|_id)
if (cursor) {
const decoded = Buffer.from(cursor, 'base64').toString();
const [cursorTimestamp, cursorId] = decoded.split('|');
const cursorOid = new ObjectId(cursorId);
if (sortOrder === 'desc') {
query.$or = [
{ timestamp: { $lt: cursorTimestamp } },
{ timestamp: cursorTimestamp, _id: { $lt: cursorOid } },
];
} else {
query.$or = [
{ timestamp: { $gt: cursorTimestamp } },
{ timestamp: cursorTimestamp, _id: { $gt: cursorOid } },
];
}
}
// Generate next cursor from last document
const nextCursor = hasMore && data.length > 0
? Buffer.from(`${data[data.length - 1].timestamp}|${data[data.length - 1]._id.toString()}`).toString('base64')
: null;

This approach is more efficient because MongoDB can use indexes to jump directly to the cursor position rather than counting through skipped records.

Stats Aggregation Pipeline

The chart data comes from a MongoDB aggregation pipeline that groups messages by time period:

src/app/api/stats/route.ts
const pipeline = [
{ $match: matchStage },
{
$group: {
_id: {
period: { $dateToString: { format: dateFormat, date: { $toDate: '$timestamp' } } },
type: '$type',
},
count: { $sum: 1 },
},
},
{ $sort: { '_id.period': -1 } },
{ $limit: periodCount * 10 },
];
const results = await collection.aggregate(pipeline).toArray();

The dateFormat changes based on granularity (hourly, daily, weekly), enabling flexible time-series visualization.


Component Patterns

Let’s examine the key UI components and the patterns they employ.

FilterPanel: URL-Based State Management

The FilterPanel demonstrates using URL search parameters as state. This provides shareable, bookmarkable filter states:

src/components/FilterPanel.tsx
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
export function FilterPanel({ onExport, chartFilterActive, onResetChartFilter }) {
const router = useRouter();
const searchParams = useSearchParams();
const [projects, setProjects] = useState<string[]>([]);
const projectId = searchParams.get('projectId') || '';
const sessionId = searchParams.get('sessionId') || '';
// Update URL when filter changes
const updateFilter = (key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
// Clear dependent filters
if (key === 'projectId') {
params.delete('sessionId');
}
router.push(`?${params.toString()}`);
};
return (
<Card className="mb-6">
<CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
value={projectId}
onValueChange={(value) => updateFilter('projectId', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent>
{projects.map((p) => (
<SelectItem key={p} value={p}>
{getProjectDisplayName(p)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* More filters... */}
</div>
</CardContent>
</Card>
);
}

ConversationList: Infinite Scroll

The conversation list uses TanStack Query’s useInfiniteQuery for seamless infinite scrolling:

src/hooks/useConversations.ts
'use client';
import { useInfiniteQuery } from '@tanstack/react-query';
export function useConversations(filters: Omit<FilterParams, 'cursor' | 'limit'>) {
return useInfiniteQuery({
queryKey: ['conversations', filters],
queryFn: ({ pageParam }) =>
fetchConversations({ ...filters, cursor: pageParam, limit: 50 }),
getNextPageParam: (lastPage) => lastPage.pagination.nextCursor,
initialPageParam: undefined,
enabled: !!filters.projectId,
});
}

The component consumes this hook and renders with proper loading states:

src/components/ConversationList.tsx
export function ConversationList({
conversations,
hasMore,
total,
onLoadMore,
isLoading,
sortOrder,
onSortChange,
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<div className="flex gap-6">
<div className={cn('flex-1', selectedConversation && 'max-w-[60%]')}>
<Card>
<CardHeader>
<CardTitle>
Showing {conversations.length} of {total} conversations
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="h-[600px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>
<Button variant="ghost" onClick={toggleSort}>
Timestamp
{sortOrder === 'desc' ? <ArrowDown /> : <ArrowUp />}
</Button>
</TableHead>
<TableHead>Preview</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{conversations.map((conv) => (
<TableRow
key={conv._id.toString()}
onClick={() => setSelectedId(conv._id.toString())}
className={cn('cursor-pointer', selectedId === conv._id.toString() && 'bg-muted')}
>
<TableCell><Badge>{conv.type}</Badge></TableCell>
<TableCell>{formatTimestamp(conv.timestamp)}</TableCell>
<TableCell className="truncate">{conv.message?.slice(0, 60)}...</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
{hasMore && (
<div className="p-4 border-t text-center">
<Button variant="outline" onClick={onLoadMore} disabled={isLoading}>
{isLoading ? <Loader2 className="animate-spin" /> : 'Load More'}
</Button>
</div>
)}
</CardContent>
</Card>
</div>
{selectedConversation && (
<ConversationDetail
conversation={selectedConversation}
onClose={() => setSelectedId(null)}
/>
)}
</div>
);
}

SessionChart: Interactive Visualization

The chart component uses Recharts wrapped with shadcn’s ChartContainer for consistent styling:

src/components/SessionChart.tsx
'use client';
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { ChartContainer, ChartConfig, ChartTooltip, ChartLegend } from '@/components/ui/chart';
const chartConfig: ChartConfig = {
user: {
label: 'User',
color: 'hsl(221, 83%, 53%)',
},
assistant: {
label: 'Assistant',
color: 'hsl(142, 71%, 45%)',
},
};
export function SessionChart({ projectId, sessionId, onBarClick }) {
const [granularity, setGranularity] = useState<TimeGranularity>('day');
const { data: statsData, isLoading } = useStats({
projectId,
sessionId,
granularity,
periodCount: 14,
});
const handleBarClick = (data) => {
if (onBarClick && data.payload?.periodStart) {
onBarClick(data.payload.periodStart, granularity);
}
};
return (
<Card className="mb-6">
<CardHeader>
<CardTitle>Message Activity</CardTitle>
<div className="flex gap-2">
<Select value={granularity} onValueChange={setGranularity}>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hour">Hourly</SelectItem>
<SelectItem value="day">Daily</SelectItem>
<SelectItem value="week">Weekly</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[250px] w-full">
<BarChart data={statsData?.data}>
<CartesianGrid vertical={false} />
<XAxis dataKey="period" tickLine={false} />
<YAxis tickLine={false} />
<ChartTooltip />
<ChartLegend />
<Bar
dataKey="user"
stackId="messages"
fill="var(--color-user)"
onClick={handleBarClick}
style={{ cursor: 'pointer' }}
/>
<Bar
dataKey="assistant"
stackId="messages"
fill="var(--color-assistant)"
radius={[4, 4, 0, 0]}
onClick={handleBarClick}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}

State Management Strategy

This application uses a deliberately simple state management approach that avoids the complexity of global state libraries.

URL as Source of Truth

All filter state lives in the URL. This provides several benefits:

  • Shareable: Copy the URL to share exact filter state
  • Bookmarkable: Save specific views for later
  • Back button works: Browser history navigation just works
  • No hydration issues: State is consistent between server and client
// Reading state from URL
const projectId = searchParams.get('projectId') || '';
const sortOrder = searchParams.get('sortOrder') as SortOrder || 'desc';
// Writing state to URL
const handleSortChange = (newSortOrder: SortOrder) => {
const params = new URLSearchParams(searchParams.toString());
params.set('sortOrder', newSortOrder);
router.push(`?${params.toString()}`);
};

TanStack Query for Server State

Server data is managed entirely by TanStack Query. The query key includes all filter parameters, so changing any filter automatically triggers a refetch:

return useInfiniteQuery({
queryKey: ['conversations', filters], // filters object becomes part of cache key
queryFn: ({ pageParam }) => fetchConversations({ ...filters, cursor: pageParam }),
staleTime: 60 * 1000, // Cache for 1 minute
refetchOnWindowFocus: false,
});

Local State for UI

Component-specific UI state (like which row is selected) stays local:

const [selectedId, setSelectedId] = useState<string | null>(null);

This three-tier approach keeps the codebase simple and predictable.


Interactive Features

Chart-to-Filter Interaction

Clicking a bar in the chart filters the conversation list to that time period. This creates a natural drill-down workflow:

page.tsx
const handleChartBarClick = (periodStart: string, granularity: TimeGranularity) => {
const { startDate, endDate } = periodToDateRange(periodStart, granularity);
const params = new URLSearchParams(searchParams.toString());
params.set('startDate', startDate);
params.set('endDate', endDate);
params.set('chartFilter', 'true'); // Visual indicator
router.push(`?${params.toString()}`);
};

The periodToDateRange helper handles the conversion from chart periods to filter dates:

lib/dateUtils.ts
export function periodToDateRange(periodStart: string, granularity: TimeGranularity): DateRange {
switch (granularity) {
case 'hour':
const date = parseISO(periodStart);
const dayStr = format(date, 'yyyy-MM-dd');
return { startDate: dayStr, endDate: dayStr };
case 'day':
return { startDate: periodStart, endDate: periodStart };
case 'week':
return parseISOWeekToDateRange(periodStart);
}
}

The search input uses a debounce pattern to avoid excessive API calls:

const [search, setSearch] = useState(searchParams.get('search') || '');
useEffect(() => {
const timer = setTimeout(() => {
updateFilter('search', search);
}, 300);
return () => clearTimeout(timer);
}, [search]);

Export Functionality

Users can export filtered conversations as JSON. The export endpoint respects all active filters:

const handleExport = () => {
const params = new URLSearchParams();
params.set('projectId', projectId);
if (sessionId) params.set('sessionId', sessionId);
if (search) params.set('search', search);
if (startDate) params.set('startDate', startDate);
if (endDate) params.set('endDate', endDate);
window.open(`/api/conversations/export?${params.toString()}`, '_blank');
};

The server sets proper headers for file download:

return new NextResponse(JSON.stringify(docs, null, 2), {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="${filename}"`,
},
});

Development and Deployment

Local Development

Getting the UI running locally is straightforward:

Terminal window
cd ui
npm install # First time only
npm run dev # Starts on http://localhost:3000

The UI expects MongoDB to be running and the sync service to have populated the conversations collection. Environment variables are loaded from the parent directory’s .env file through next.config.ts:

next.config.ts
import { config } from "dotenv";
import path from "path";
config({ path: path.resolve(__dirname, "../.env") });
const nextConfig: NextConfig = {};
export default nextConfig;

Production Build

For production deployment:

Terminal window
npm run build # Creates optimized production build
npm start # Runs production server

The production build benefits from:

  • Static page generation where possible
  • Optimized JavaScript bundles
  • Automatic code splitting
  • Server components for reduced client JavaScript

Conclusion

We’ve built a feature-rich conversation log browser that transforms raw MongoDB data into an explorable interface. The key architectural decisions that make this work:

  1. URL-based state keeps the application predictable and shareable
  2. API routes isolate database logic from the frontend
  3. Cursor pagination scales efficiently with large datasets
  4. shadcn/ui accelerates development with customizable, accessible components
  5. TanStack Query handles caching, refetching, and infinite scroll seamlessly

The UI now provides real visibility into Claude Code interactions: browse by project and session, search message content, visualize activity over time, and export data for further analysis.

In the next article, we’ll build the analytics layer that takes this data even further with dbt transformations and Metabase dashboards for deeper insights.


Try it yourself: Clone the repository, start MongoDB and the sync service (covered in Article 2), then run npm run dev in the UI directory. Your Claude Code conversations become instantly explorable.

Questions or improvements? Open an issue or PR on the repository. I’d love to hear how you’re using this for your own observability needs.


Suggested Tags: nextjs, typescript, mongodb, shadcn-ui, react


Written by

Farshad Akbari

Software engineer writing about Java, Kotlin TypeScript, Python, data systems and AI

Keyboard Shortcuts

Navigation

  • Open search ⌘K
  • Next article j
  • Previous article k

Actions

  • Toggle dark mode d
  • Toggle table of contents t
  • Show this help ?
  • Close modal Esc

Shortcuts are disabled when typing in inputs or textareas.