Fetching Non-Serializable Data in Next.js: Solve Server to Client Serialization with API Routes + TanStack Query
A practical solution for handling Dates, Decimals, and complex props between Server and Client Components

I was building a construction management app with Next.js when I hit a frustrating serialization wall. My Server Components were trying to pass complex journal entries with Date objects, Prisma Decimal fields, and nested relationships to Client Components, but everything was breaking during the JSON serialization process.
After hours of debugging and research, I discovered a pattern that not only solves the serialization problem but also provides better performance and developer experience. This guide shows you exactly how to implement API Routes + TanStack Query to handle non-serializable data between Server and Client Components.
The Serialization Problem
When working with Next.js Server Components that need to pass complex data to Client Components, we encounter fundamental serialization limitations. Data passed as props from Server Components to Client Components must be JSON-serializable, which excludes:
Date
objects (become strings)Decimal
types from Prisma- Complex nested objects with methods
- Functions
undefined
values in objects
This becomes particularly problematic when dealing with rich domain models that contain relationships, calculated fields, and complex data structures.
Our Solution: API Route Handlers + TanStack Query
Instead of fighting the serialization system, we implemented a pattern that fetches data client-side using API routes and TanStack Query. This approach keeps Server Components lean while providing intelligent caching and better error handling.
Architecture Overview
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Server │ │ API Route │ │ Client │
│ Component │ │ Handler │ │ Component │
│ │ │ │ │ │
│ - Basic data │ │ - Authentication│ │ - TanStack │
│ - User auth │ │ - Authorization │ │ Query hook │
│ - Projects[] │ │ - Prisma query │ │ - Loading UI │
│ │ │ - JSON response │ │ - Error UI │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ ▲ │
│ │ │
└─ journalId ────────────────────── fetch() ──────┘
The Server Component passes only the ID, the API Route handles authentication and data fetching, and the Client Component uses TanStack Query for intelligent caching and state management.
Implementation Steps
Step 1: Create the API Route Handler
First, we need an API route that can fetch complex data with proper authentication and authorization.
File: src/app/api/v1/journals/[journalId]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { fetchJournalEntryWithDetails } from '@/lib/data';
import { JournalEntryWithDetails } from '@/types/construction';
import getUserFromSession from '@/utils/getUserFromSession';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ journalId: string }> }
): Promise<NextResponse<JournalEntryWithDetails | { error: string }>> {
try {
const userId = await getUserFromSession();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { journalId } = await params;
if (!journalId) {
return NextResponse.json({ error: 'Journal ID is required' }, { status: 400 });
}
const journalData = await fetchJournalEntryWithDetails(journalId);
// Basic authorization check - user should only access their own journal entries
if (journalData.user.id !== userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json(journalData);
} catch (error) {
console.error('Error fetching journal entry:', error);
return NextResponse.json(
{ error: 'Failed to fetch journal entry' },
{ status: 500 }
);
}
}
This API route handles authentication, authorization, and returns fully typed Prisma data with complex relationships. The key insight is that API routes can return any JSON-serializable data, including complex nested objects that would cause issues when passed as props.
Step 2: Create the TanStack Query Hook
Next, we create a custom hook that handles the data fetching with intelligent caching and error management.
File: src/hooks/useJournalData.ts
'use client';
import { useQuery } from '@tanstack/react-query';
import { JournalEntryWithDetails } from '@/types/construction';
export function useJournalData(journalId: string) {
return useQuery({
queryKey: ['journal', journalId],
queryFn: async (): Promise<JournalEntryWithDetails> => {
const response = await fetch(`/api/v1/journals/${journalId}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch journal entry');
}
return response.json();
},
enabled: !!journalId, // Only run query if journalId exists
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
});
}
This hook provides intelligent caching, background refetching, and built-in loading and error states. The enabled
option ensures the query only runs when we have a valid journalId, and the cache settings optimize performance.
Step 3: Update the Client Component
Now we modify our Client Component to use the hook while maintaining backward compatibility.
File: src/components/journals/journal-form.tsx
function JournalFormDetailed({
action,
journalId,
journalData, // Backward compatibility
projects,
cta = "Shrani dnevnik",
redirectUrl = "/journals",
onSuccess,
readOnly = false
}: {
action: (prevState: JournalActionState, formData: FormData) => Promise<JournalActionState>,
journalId?: string,
journalData?: Journal & { project?: Project },
projects: ProjectForSelect[],
cta?: string,
redirectUrl?: string,
onSuccess?: (data?: JournalActionState['data']) => void,
readOnly?: boolean
}) {
// Use TanStack Query to fetch journal data if journalId is provided
const { data: fetchedJournalData, isLoading, error } = useJournalData(journalId || '');
// Use fetched data or passed journalData (for backward compatibility)
const effectiveJournalData = fetchedJournalData || journalData;
// Initialize form with default values (always called, no conditionals)
const form = useForm<z.infer<typeof journalFormSchema>>({
resolver: zodResolver(journalFormSchema),
defaultValues: {
journalDate: effectiveJournalData?.journalDate
? new Date(effectiveJournalData.journalDate).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0],
projectId: effectiveJournalData?.projectId ?? '',
// ... other fields
},
});
// Show loading/error states when using journalId
if (journalId && isLoading) {
return <div>Loading...</div>;
}
if (journalId && error) {
return <div>Error loading journal: {error.message}</div>;
}
// Rest of component...
}
The component now handles both scenarios: when journalId
is provided (uses the hook) and when journalData
is passed directly (backward compatibility). This ensures a smooth migration path for existing code.
Step 4: Simplify the Server Component
Finally, we update the Server Component to pass only serializable data.
File: src/app/(manager)/journals/[journalId]/page.tsx
async function JournalDetailPage({ params }: { params: Promise<{ journalId: string }> }) {
const { journalId } = await params;
try {
const userId = await getUserFromSession();
if (!userId) {
throw new Error('User not authenticated');
}
const [recordings, projects] = await Promise.all([
fetchRecordingsByJournalId(journalId),
projectRepository.findBasicProjectsForUser(userId) // Only basic data (id, name)
]);
return (
<ContentLayout title="Dnevnik">
{/* Breadcrumbs, other static content */}
<JournalFormDetailed
action={updateJournal}
journalId={journalId} // Pass ID instead of data
projects={projects} // Only serializable data
readOnly={true}
/>
</ContentLayout>
);
} catch (error) {
return <ErrorView error={error} />;
}
}
The Server Component now only fetches and passes serializable data. Complex data fetching is handled client-side, which improves server-side rendering performance and eliminates serialization issues.
Setup Requirements
Before implementing this pattern, you need to set up TanStack Query in your Next.js application.
TanStack Query Provider
File: src/components/providers/query-provider.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState, type ReactNode } from 'react';
export function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
retry: 1,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Root Layout Integration
File: src/app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="sl">
<body>
<QueryProvider>
{children}
</QueryProvider>
</body>
</html>
</ClerkProvider>
);
}
Real-World Example: Construction Journal Entries
Our construction management app's journal entries contain exactly the type of complex data that causes serialization issues:
- Date fields:
journalDate
,workDate
,workStartTime
,workEndTime
- Decimal fields: Various calculation fields from Prisma
- Complex relationships:
user
,project
,photos[]
,documents[]
,weatherRecord
- JSON fields:
weatherGrid
,workersGrid
,machineryGrid
Previously, passing this as props caused serialization issues. Now:
- Server Component passes only
journalId
(string) - Client Component uses
useJournalData(journalId)
to fetch full data - API Route returns properly typed, complex data as JSON
- TanStack Query handles caching, loading, and error states
Benefits and Trade-offs
Benefits
Serialization Safety: No more Date object serialization issues or Decimal field problems. Complex nested objects with relationships work perfectly.
Performance: Server-side rendering is faster with less data to serialize, while client-side intelligent caching reduces subsequent requests.
Developer Experience: Full TypeScript support throughout the chain, centralized error states, and clear separation of concerns.
Scalability: Easy to add real-time updates, optimistic updates, and offline support.
Trade-offs
Additional Network Request: The client needs to make an extra API call, but this is offset by intelligent caching.
Loading States: You need to handle loading and error states in the UI, but TanStack Query makes this straightforward.
Complexity: More moving parts (API route + hook + component), but the separation of concerns actually makes the codebase more maintainable.
When to Use This Pattern
Use this pattern for complex data with Date objects, Decimal fields, or deep relationships that change frequently and benefit from caching. It's particularly valuable for forms that need optimistic updates or real-time features.
Avoid this pattern for simple, static data that rarely changes or small objects that serialize easily without issues.
Conclusion
This API Routes + TanStack Query pattern solves the fundamental serialization problem in Next.js while providing better performance, developer experience, and scalability. It's particularly valuable for complex domain models with rich relationships and non-serializable types.
The trade-off of an additional network request is offset by intelligent caching, better error handling, and the flexibility to add advanced features like real-time updates and optimistic mutations.
By implementing this pattern, you'll have a robust solution for handling complex data between Server and Client Components that scales with your application's needs.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija