Skip to main content
Contafy uses a hybrid data fetching strategy that combines Server Components for initial data loads with TanStack Query for client-side interactivity.

Data fetching strategy

Server Components for initial data

Server Components fetch data directly on the server before rendering: Benefits:
  • No client-side loading states on initial render
  • Better SEO (content in HTML)
  • Reduced client bundle size
  • Direct access to backend API
Example from app/dashboard/components/DashboardContent.tsx:50-57:
export async function DashboardContent({ profileId, mes, año }) {
  // Fetch all data in parallel on the server
  const [metrics, invoices, expenses, profiles, trendData, currentUser] = await Promise.all([
    getMetrics(profileId, mes, año),
    getInvoices({ profileId, mes, año, limit: 3 }),
    getExpenses({ profileId, mes, año, limit: 3 }),
    getProfiles(),
    getTrendData(profileId, año, 'año-actual', mes),
    getCurrentUser(),
  ]);

  return (
    <>
      <MetricsCards metrics={metrics} />
      <RecentInvoicesTable invoices={invoices.data} />
      <RecentExpensesTable expenses={expenses.data} />
    </>
  );
}
Key points:
  • Use Promise.all() to fetch data in parallel
  • No loading states needed (data ready before render)
  • Pass data as props to child components
  • Great for SEO and initial page load

TanStack Query for client-side data

Client Components use TanStack Query for interactive data: Benefits:
  • Automatic caching and revalidation
  • Background refetching
  • Optimistic updates
  • Request deduplication
  • Built-in loading/error states
Example from app/dashboard/expenses/components/ExpensesListContent.tsx:
'use client';

import { useQuery } from '@tanstack/react-query';

export function ExpensesListContent() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['expenses', profileId, mes, año],
    queryFn: () => getExpenses({ profileId, mes, año }),
  });

  if (isLoading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;

  return <ExpensesTable expenses={data.data} />;
}
Key points:
  • Must be in a Client Component ('use client')
  • Provides loading and error states
  • Automatically caches results
  • Revalidates on window focus (configurable)

API client architecture

Contafy has two API clients: one for Server Components and one for Client Components.

Server API client

Location: lib/api/server-client.ts Used in: Server Components only Implementation:
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';

export async function serverApiClient<T>(endpoint: string, options?) {
  const cookieStore = await cookies();
  const accessToken = cookieStore.get('accessToken')?.value;
  
  const response = await fetch(`${API_URL}${endpoint}`, {
    headers: {
      'Content-Type': 'application/json',
      ...(accessToken && { Authorization: `Bearer ${accessToken}` }),
    },
  });
  
  const data = await response.json();
  
  // Redirect to login on 401 (cannot refresh tokens in Server Components)
  if (response.status === 401) {
    redirect('/auth/login');
  }
  
  if (!response.ok) {
    throw new ServerApiError(data.error || 'API Error', response.status, data);
  }
  
  return data as T;
}
Key characteristics:
  • Reads cookies directly via next/headers
  • Cannot refresh tokens (can’t modify cookies)
  • Redirects to login on 401
  • Throws errors for bad responses
Example usage:
// lib/api/auth.server.ts
export async function getCurrentUser() {
  return serverApiClient<GetCurrentUserResponse>('/api/auth/me', {
    redirectOnAuthError: true,
  });
}

Client API client

Location: lib/api/client.ts Used in: Client Components only Implementation:
export async function apiClient<T>(endpoint: string, options?) {
  // Use /backend proxy to avoid CORS issues
  const apiUrl = typeof window !== 'undefined' 
    ? '/backend'  // Browser: use Next.js proxy
    : process.env.NEXT_PUBLIC_API_URL;  // Server: direct URL
  
  // Get access token via API route
  if (options?.requireAuth) {
    const accessToken = await getAccessToken();
    if (accessToken) {
      headers.Authorization = `Bearer ${accessToken}`;
    }
  }
  
  let response = await fetch(`${apiUrl}${endpoint}`, {
    ...options,
    credentials: 'include',
    headers,
  });
  
  let data = await response.json();
  
  // Automatic token refresh on 401
  if (response.status === 401 && !options?.skipAuthRetry) {
    const newAccessToken = await refreshAccessToken();
    
    if (newAccessToken) {
      // Retry request with new token
      response = await fetch(`${apiUrl}${endpoint}`, {
        ...options,
        headers: {
          ...headers,
          Authorization: `Bearer ${newAccessToken}`,
        },
      });
      
      data = await response.json();
    }
  }
  
  if (!response.ok) {
    throw new ApiError(data.error || 'API Error', response.status, data);
  }
  
  return data as T;
}
Key characteristics:
  • Uses /backend proxy to avoid CORS
  • Automatically refreshes tokens on 401
  • Retries failed requests with new token
  • Redirects to login if refresh fails
Example usage:
// lib/api/invoices.client.ts
export async function getInvoices(params: GetInvoicesParams) {
  const queryString = buildQueryString(params);
  return apiClient<GetInvoicesResponse>(`/api/invoices${queryString}`, {
    requireAuth: true,
  });
}

Token refresh mechanism

Contafy implements automatic token refresh to keep users logged in.

How it works

  1. User makes authenticated request
  2. Backend responds with 401 (token expired)
  3. Client automatically calls /api/auth/refresh
  4. Backend validates refresh token, returns new access token
  5. Client retries original request with new token
  6. User stays logged in without interruption

Implementation details

Refresh function (lib/api/client.ts:49-95):
let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null;

async function refreshAccessToken(): Promise<string | null> {
  // Prevent multiple simultaneous refresh requests
  if (isRefreshing && refreshPromise) {
    return refreshPromise;
  }
  
  isRefreshing = true;
  refreshPromise = (async () => {
    try {
      const response = await fetch('/api/auth/refresh', {
        method: 'POST',
        credentials: 'include',
      });
      
      const data = await response.json();
      
      if (!response.ok) {
        // Refresh token expired, redirect to login
        if (response.status === 401 || data.redirect) {
          window.location.href = '/auth/login';
          return null;
        }
        return null;
      }
      
      return data.accessToken || null;
    } finally {
      isRefreshing = false;
      refreshPromise = null;
    }
  })();
  
  return refreshPromise;
}
Key features:
  • Prevents duplicate refresh requests with flag
  • Shares single refresh promise across requests
  • Redirects to login if refresh fails
  • Transparent to calling code

Token storage

Tokens are stored in httpOnly cookies for security:
  • Access token: Short-lived (15 minutes)
  • Refresh token: Long-lived (7 days)
Security benefits:
  • Cannot be accessed by JavaScript (XSS protection)
  • Automatically sent with requests
  • Secure flag in production (HTTPS only)

API proxy configuration

To avoid CORS issues, Contafy proxies API requests through Next.js. Configuration (next.config.ts:5-12):
const nextConfig: NextConfig = {
  async rewrites() {
    return [
      {
        source: '/backend/:path*',
        destination: `${process.env.NEXT_PUBLIC_API_URL}/:path*`,
      },
    ];
  },
};
How it works:
  1. Client requests /backend/api/invoices
  2. Next.js rewrites to http://localhost:3001/api/invoices
  3. Backend processes request
  4. Next.js forwards response to client
Benefits:
  • No CORS headers needed
  • Simplified client code
  • Works in all environments

TanStack Query configuration

Setup (components/providers/ReactQueryProvider.tsx:11-24):
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,  // Don't refetch when window focused
      retry: 1,                      // Retry failed requests once
    },
  },
});

Query keys

Query keys uniquely identify cached data:
// Simple key
queryKey: ['profiles']

// Key with parameters
queryKey: ['invoices', profileId, mes, año]

// Key with multiple parameters
queryKey: ['expenses', { profileId, mes, año, search }]
Best practices:
  • Include all parameters that affect the data
  • Use consistent ordering
  • Use arrays for hierarchical keys

Query functions

Query functions fetch the actual data:
queryFn: () => getInvoices({ profileId, mes, año })
Requirements:
  • Must return a Promise
  • Should use API client functions
  • Can access query key parameters

Cache invalidation

Invalidate queries when data changes:
import { useQueryClient } from '@tanstack/react-query';

const queryClient = useQueryClient();

// Invalidate specific query
await queryClient.invalidateQueries({ queryKey: ['invoices', profileId] });

// Invalidate all invoice queries
await queryClient.invalidateQueries({ queryKey: ['invoices'] });

// Invalidate and refetch immediately
await queryClient.invalidateQueries({ 
  queryKey: ['expenses'], 
  refetchType: 'active' 
});

Mutations

Mutations modify server data:
import { useMutation, useQueryClient } from '@tanstack/react-query';

const queryClient = useQueryClient();

const deleteMutation = useMutation({
  mutationFn: (id: string) => deleteExpense(id),
  onSuccess: () => {
    // Invalidate and refetch expenses
    queryClient.invalidateQueries({ queryKey: ['expenses'] });
    toast.success('Gasto eliminado correctamente');
  },
  onError: (error) => {
    toast.error('Error al eliminar gasto');
  },
});

// Use mutation
deleteMutation.mutate(expenseId);

Data flow examples

Server Component data flow

  1. User navigates to /dashboard
  2. Server Component renders
  3. Server fetches data via serverApiClient
  4. Server renders HTML with data
  5. Client receives fully rendered page
  6. No loading state on client

Client Component data flow

  1. User clicks “Ver más” button
  2. Client Component renders
  3. Shows loading spinner
  4. Client fetches data via apiClient + TanStack Query
  5. Data cached by TanStack Query
  6. Component re-renders with data
  7. Cache reused on subsequent renders

Error handling

API errors

Both API clients throw ApiError/ServerApiError:
try {
  const data = await getInvoices();
} catch (error) {
  if (error instanceof ApiError) {
    console.error('API error:', error.status, error.message);
  }
}

TanStack Query error handling

const { data, error, isError } = useQuery({
  queryKey: ['invoices'],
  queryFn: getInvoices,
});

if (isError) {
  return <ErrorMessage error={error} />;
}

Performance optimizations

Parallel data fetching

Fetch multiple resources in parallel:
const [data1, data2, data3] = await Promise.all([
  getMetrics(),
  getInvoices(),
  getExpenses(),
]);

Request deduplication

TanStack Query automatically deduplicates identical requests:
// Both components use same query key
// Only one request sent, result shared
function Component1() {
  useQuery({ queryKey: ['invoices'], queryFn: getInvoices });
}

function Component2() {
  useQuery({ queryKey: ['invoices'], queryFn: getInvoices });
}

Prefetching

Prefetch data before it’s needed:
const queryClient = useQueryClient();

// Prefetch on hover
const handleMouseEnter = () => {
  queryClient.prefetchQuery({
    queryKey: ['invoice', invoiceId],
    queryFn: () => getInvoice(invoiceId),
  });
};
See Architecture for how this fits into the overall system.