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

·Matija Žiberna·
Fetching Non-Serializable Data in Next.js: Solve Server to Client Serialization with API Routes + TanStack Query

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:

  1. Server Component passes only journalId (string)
  2. Client Component uses useJournalData(journalId) to fetch full data
  3. API Route returns properly typed, complex data as JSON
  4. 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

0

Comments

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in