> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tuturuuu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Data Fetching Strategies

> When and how to fetch data using RSC, Server Actions, TanStack Query, and Internal API helpers

The Tuturuuu platform uses a layered approach to data fetching, choosing the right strategy based on use case, caching requirements, and user experience needs.

<Note>
  The client-side fetch boundary is **TanStack Query (React Query v5) hooks backed
  by `@tuturuuu/internal-api` helpers**, which call the REST `/api/v1/...`
  endpoints. tRPC in `apps/web/src/trpc` is an **intentional stub** during the
  TanStack + Rust migration (it exposes only a `healthCheck` procedure), so this
  page does not use `trpc.*` routers for product data. See
  [tRPC Implementation](/platform/architecture/trpc) for the scaffold details.
</Note>

For the dedicated TanStack Start frontend plus Rust backend migration, use
`platform/architecture/tanstack-rust-migration`. TanStack loaders and server
functions are allowed as a BFF layer, while product data ownership moves behind
Rust endpoints and `packages/internal-api` facades.

## Heavy Dashboards

For queue-heavy dashboards that run expensive row + summary RPCs (for example posts review):

* Keep the route page server-only for auth, permissions, and canonical URL normalization.
* Move the actual table + summary fetch into a client shell with TanStack Query.
* Fetch through `@tuturuuu/internal-api` helpers, not ad-hoc client fetch calls.
* Avoid `router.refresh()` for routine list refreshes; prefer query refetch/invalidation.

## Server And Service Orchestration

When a server-side data flow coordinates multiple async resources, expected
failures, retry/scheduling policy, or replaceable dependencies, use
`@tuturuuu/utils/effect` inside the server/shared helper and keep the route or
client boundary plain. Effect complements this page's data-fetching strategy; it
does not replace React state, TanStack Query, `packages/internal-api`, Zod, or
generated database types.

Use Effect for server/service orchestration:

```ts theme={null}
import {
  Effect,
  fromDataError,
  runEffectAsResult,
} from '@tuturuuu/utils/effect';

const program = fromDataError(
  () =>
    supabase
      .from('workspace_tasks')
      .select('id, name')
      .eq('ws_id', wsId),
  {
    code: 'TASKS_READ_FAILED',
    message: 'Task list read failed.',
    context: { wsId },
  }
);

const result = await runEffectAsResult(Effect.map(program, (tasks) => ({ tasks })));
```

Client components should still call TanStack Query hooks and internal API
helpers. Server Components, Server Actions, API routes, cron jobs, and shared
packages may use Effect behind their public boundary when it improves typed
failure handling or orchestration clarity.

## Builder Migration Pattern (Education Module Groups)

For highly interactive editors (for example the course builder with module groups + drag/drop):

* Keep the route page as a thin server-side auth/permission gate.
* Move list data loading into a client shell backed by React Query.
* Use `@tuturuuu/internal-api` helpers as the client fetch boundary (`listWorkspaceCourseModuleGroups`, `listWorkspaceCourseModules`, and reorder helpers).
* Avoid `router.refresh()` for routine drag/drop updates; mutate via API and invalidate the corresponding query keys.
* When a drag/drop move changes both membership and order, await the membership mutation before firing any follow-up reorder mutations, and invalidate only the affected query keys instead of every list in the builder.
* Keep create/update payload contracts explicit (`module_group_id` is required for new course modules).

## Strategy Preference Order

Choose the earliest applicable strategy:

1. **Pure Server Component (RSC)** - Read-only, cacheable, SEO-critical data
2. **Server Action** - Mutations returning updated state to RSC
3. **RSC + Client Hydration** - When background refresh needed
4. **React Query (Client-Side)** - Interactive, rapidly changing state
5. **Realtime Subscriptions** - Live updates materially improve UX

## 1. Pure Server Component (RSC)

### When to Use

✅ **Best For:**

* Initial page load data
* SEO-critical content
* Rarely changing data
* Read-only operations
* Database queries that don't need real-time updates

❌ **Not For:**

* Interactive forms
* Frequent updates
* Client-side state
* Real-time data

### Implementation

```typescript theme={null}
// app/[locale]/(dashboard)/[wsId]/tasks/page.tsx
import { createClient } from '@tuturuuu/supabase/next/server';

export default async function TasksPage({
  params,
}: {
  params: Promise<{ wsId: string }>;
}) {
  const { wsId } = await params;
  // createClient() from @tuturuuu/supabase/next/server is async — always await it.
  const supabase = await createClient();

  // Direct database query in Server Component
  const { data: tasks } = await supabase
    .from('workspace_tasks')
    .select('*')
    .eq('ws_id', wsId)
    .order('created_at', { ascending: false })
    .limit(20);

  return (
    <div>
      <h1>Tasks</h1>
      <TaskList tasks={tasks || []} />
    </div>
  );
}
```

### Caching Strategy

```typescript theme={null}
// app/[locale]/(dashboard)/[wsId]/tasks/page.tsx

// Revalidate every hour
export const revalidate = 3600;

// OR generate static params
export async function generateStaticParams() {
  const supabase = await createClient();
  const { data: workspaces } = await supabase.from('workspaces').select('id');

  return workspaces?.map((ws) => ({ wsId: ws.id })) || [];
}
```

### Loading States

```typescript theme={null}
// app/[locale]/(dashboard)/[wsId]/tasks/loading.tsx
export default function Loading() {
  return <TasksSkeleton />;
}
```

### Error Handling

```typescript theme={null}
// app/[locale]/(dashboard)/[wsId]/tasks/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <button onClick={reset}>Try again</button>
    </div>
  );
}
```

## 2. Server Actions

### When to Use

✅ **Best For:**

* Form submissions
* Mutations with server-side validation
* Operations requiring auth checks
* Redirects after mutations
* Progressive enhancement

❌ **Not For:**

* Read operations (use RSC instead)
* Complex client-side state management
* Real-time updates

### Implementation

```typescript theme={null}
// app/[locale]/(dashboard)/[wsId]/tasks/actions.ts
'use server';

import { createClient } from '@tuturuuu/supabase/next/server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';

const createTaskSchema = z.object({
  name: z.string().min(1).max(255),
  description: z.string().optional(),
  listId: z.string(),
});

export async function createTask(formData: FormData) {
  const supabase = await createClient();

  // Check authentication
  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (!user) {
    throw new Error('Unauthorized');
  }

  // Validate input
  const parsed = createTaskSchema.safeParse({
    name: formData.get('name'),
    description: formData.get('description'),
    listId: formData.get('listId'),
  });

  if (!parsed.success) {
    return { error: 'Invalid input' };
  }

  // Perform mutation
  const { data, error } = await supabase
    .from('workspace_tasks')
    .insert({
      name: parsed.data.name,
      description: parsed.data.description,
      list_id: parsed.data.listId,
      created_by: user.id,
    })
    .select()
    .single();

  if (error) {
    return { error: error.message };
  }

  // Revalidate cache
  revalidatePath(`/[locale]/(dashboard)/[wsId]/tasks`);

  return { success: true, task: data };
}

export async function deleteTask(taskId: string, wsId: string) {
  const supabase = await createClient();

  const { error } = await supabase
    .from('workspace_tasks')
    .delete()
    .eq('id', taskId);

  if (error) {
    return { error: error.message };
  }

  revalidatePath(`/[locale]/(dashboard)/${wsId}/tasks`);
  redirect(`/${wsId}/tasks`);
}
```

### Client Usage

```tsx theme={null}
'use client';

import { createTask } from './actions';
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Creating...' : 'Create Task'}
    </button>
  );
}

export function CreateTaskForm({ listId }: { listId: string }) {
  async function handleSubmit(formData: FormData) {
    const result = await createTask(formData);
    if (result.error) {
      console.error(result.error);
    }
  }

  return (
    <form action={handleSubmit}>
      <input type="hidden" name="listId" value={listId} />
      <input name="name" placeholder="Task name" required />
      <textarea name="description" placeholder="Description" />
      <SubmitButton />
    </form>
  );
}
```

## 3. RSC + Client Hydration

### When to Use

✅ **Best For:**

* Initial server render with client updates
* Data that changes frequently
* Combining SEO with interactivity
* Background refresh patterns

### Implementation

The server renders initial data and passes it as `initialData` to a TanStack
Query hook. Background refresh is handled by Query's `refetchInterval` — **not**
a `useEffect` + `setInterval` loop. AGENTS.md forbids `useEffect` for data
fetching.

```typescript theme={null}
// app/[locale]/(dashboard)/[wsId]/tasks/page.tsx
import { createClient } from '@tuturuuu/supabase/next/server';
import { TaskListClient } from './TaskListClient';

export default async function TasksPage({
  params,
}: {
  params: Promise<{ wsId: string }>;
}) {
  const { wsId } = await params;
  // Async server client — await it.
  const supabase = await createClient();

  const { data: initialTasks } = await supabase
    .from('workspace_tasks')
    .select('*')
    .eq('ws_id', wsId);

  return <TaskListClient initialTasks={initialTasks || []} wsId={wsId} />;
}
```

```tsx theme={null}
// app/[locale]/(dashboard)/[wsId]/tasks/TaskListClient.tsx
'use client';

import { useQuery } from '@tanstack/react-query';
import { listWorkspaceTasks } from '@tuturuuu/internal-api/tasks';
import type { Database } from '@tuturuuu/types';

type Task = Database['public']['Tables']['workspace_tasks']['Row'];

export function TaskListClient({
  initialTasks,
  wsId,
}: {
  initialTasks: Task[];
  wsId: string;
}) {
  // Server-rendered rows seed the cache via initialData; Query owns refreshes.
  const { data: tasks = initialTasks } = useQuery({
    queryKey: ['workspaces', wsId, 'tasks'],
    queryFn: () => listWorkspaceTasks(wsId),
    initialData: initialTasks,
    staleTime: 30 * 1000,
    // Background refresh without a manual setInterval.
    refetchInterval: 30_000,
  });

  return (
    <div>
      {tasks.map((task) => (
        <div key={task.id}>{task.name}</div>
      ))}
    </div>
  );
}
```

The client hook fetches through an `@tuturuuu/internal-api` helper (which calls
the REST `/api/v1` endpoint) rather than re-running Supabase queries from the
browser. This keeps authorization on the server and gives Query a single,
cacheable fetch boundary.

## 4. TanStack Query (Client-Side)

### When to Use

✅ **Best For:**

* Interactive dashboards
* Rapidly changing data
* Complex client-side state
* Optimistic updates
* Automatic background refetching

❌ **Not For:**

* SEO-critical content
* Initial page load (prefer RSC)

### Implementation with Internal API helpers

Client queries call `@tuturuuu/internal-api` helpers, which hit the REST
`/api/v1/...` endpoints where authorization lives. This is the actual fetch
boundary in `apps/web` — there are no `trpc.tasks.*` / `trpc.boards.*` routers
(tRPC is a [stub](/platform/architecture/trpc)).

```tsx theme={null}
'use client';

import { useQuery } from '@tanstack/react-query';
import { listWorkspaceTasks } from '@tuturuuu/internal-api/tasks';

export function TaskList({ wsId }: { wsId: string }) {
  const {
    data: tasks,
    isPending,
    error,
  } = useQuery({
    queryKey: ['workspaces', wsId, 'tasks'],
    queryFn: () => listWorkspaceTasks(wsId),
    staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
    refetchOnWindowFocus: true,
    refetchInterval: 30_000, // Refetch every 30 seconds
  });

  if (isPending) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {tasks?.map((task) => (
        <TaskCard key={task.id} task={task} />
      ))}
    </div>
  );
}
```

### Query Key Conventions

Use stable array query keys:

```typescript theme={null}
// ✅ Good: Specific, cacheable
['tasks', wsId, { listId, completed: false }]

// ❌ Bad: Too generic
['tasks']
```

### Thin Server Gate + Client Query Shell

For dense dashboard surfaces that need URL-driven filtering, pagination, and frequent background refreshes, prefer a **thin server gate** plus a **client query shell**:

1. Keep the route page server-side only for auth, permission checks, redirects, and metadata.
2. Move interactive data loading into client hooks backed by TanStack Query.
3. Back those hooks with workspace-scoped Internal API helpers instead of ad hoc `fetch('/api/...')` calls.
4. Store queryable UI state such as `page`, `pageSize`, `q`, `sortBy`, and `path` in `nuqs`.
5. Invalidate query keys after mutations instead of relying on `router.refresh()`.

This pattern is now the default for file-explorer style dashboard pages such as Drive, where the user expects immediate client-side updates while the server still owns authorization.

### Optimistic Updates

Use `useMutation` with the `queryClient` cache helpers. Drive the mutation
through an `@tuturuuu/internal-api` helper so the write goes through the same
authorized REST boundary as reads.

```tsx theme={null}
'use client';

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateWorkspaceTask } from '@tuturuuu/internal-api/tasks';

export function TaskCheckbox({ task, wsId }: { task: Task; wsId: string }) {
  const queryClient = useQueryClient();
  const taskKey = ['workspaces', wsId, 'tasks', task.id];
  const listKey = ['workspaces', wsId, 'tasks'];

  const toggleComplete = useMutation({
    mutationFn: (input: { completed: boolean }) =>
      updateWorkspaceTask(wsId, task.id, input),

    // Optimistically update before the server responds
    onMutate: async (input) => {
      await queryClient.cancelQueries({ queryKey: taskKey });

      const previous = queryClient.getQueryData<Task>(taskKey);

      queryClient.setQueryData<Task>(taskKey, (old) =>
        old ? { ...old, completed: input.completed } : old
      );

      return { previous };
    },

    // Rollback on error
    onError: (_err, _input, context) => {
      if (context?.previous) {
        queryClient.setQueryData(taskKey, context.previous);
      }
    },

    // Refetch after success or error
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: taskKey });
      queryClient.invalidateQueries({ queryKey: listKey });
    },
  });

  return (
    <input
      type="checkbox"
      checked={task.completed}
      onChange={() => toggleComplete.mutate({ completed: !task.completed })}
    />
  );
}
```

### Cache Invalidation

```typescript theme={null}
const queryClient = useQueryClient();

// Invalidate a specific task
queryClient.invalidateQueries({ queryKey: ['workspaces', wsId, 'tasks', '123'] });

// Invalidate the workspace task list
queryClient.invalidateQueries({ queryKey: ['workspaces', wsId, 'tasks'] });

// Reset a query to its initial state
queryClient.resetQueries({ queryKey: ['workspaces', wsId, 'tasks'] });

// Refetch immediately
queryClient.refetchQueries({ queryKey: ['workspaces', wsId, 'tasks'] });
```

<Note>
  Query keys are prefix-matched by default, so invalidating
  `['workspaces', wsId, 'tasks']` also invalidates every more specific key under
  it (e.g. `['workspaces', wsId, 'tasks', taskId]`). Keep keys ordered
  broad → narrow so partial invalidation stays predictable.
</Note>

## 5. Realtime Subscriptions

### When to Use

✅ **Best For:**

* Collaborative features
* Chat applications
* Live dashboards
* Multiplayer features
* Notification systems

❌ **Not For:**

* Infrequent updates
* Static content
* SEO-critical data

### Implementation

The **initial fetch is a TanStack Query hook** (through an `@tuturuuu/internal-api`
helper), never a `useEffect` fetch. A `useEffect` is used only for the
subscription **lifecycle** (a genuine side effect), and the realtime payloads
mutate the **query cache** so the live data and the cached data stay in sync.

```tsx theme={null}
'use client';

import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { createClient } from '@tuturuuu/supabase/next/client';
import { listWorkspaceTasks } from '@tuturuuu/internal-api/tasks';
import type { Database } from '@tuturuuu/types';

type Task = Database['public']['Tables']['workspace_tasks']['Row'];

export function RealtimeTaskList({ wsId }: { wsId: string }) {
  const queryClient = useQueryClient();
  const queryKey = ['workspaces', wsId, 'tasks'];

  // Initial + cached data comes from Query, not a useEffect fetch.
  const { data: tasks = [] } = useQuery({
    queryKey,
    queryFn: () => listWorkspaceTasks(wsId),
  });

  useEffect(() => {
    const supabase = createClient();

    // Subscription lifecycle is a side effect — payloads update the cache.
    const channel = supabase
      .channel(`workspace-tasks-${wsId}`)
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'workspace_tasks',
          filter: `ws_id=eq.${wsId}`,
        },
        (payload) => {
          queryClient.setQueryData<Task[]>(queryKey, (prev = []) => {
            if (payload.eventType === 'INSERT') {
              return [...prev, payload.new as Task];
            }
            if (payload.eventType === 'UPDATE') {
              return prev.map((task) =>
                task.id === payload.new.id ? (payload.new as Task) : task
              );
            }
            if (payload.eventType === 'DELETE') {
              return prev.filter((task) => task.id !== payload.old.id);
            }
            return prev;
          });
        }
      )
      .subscribe();

    return () => {
      channel.unsubscribe();
    };
  }, [wsId, queryClient]);

  return (
    <div>
      {tasks.map((task) => (
        <div key={task.id}>{task.name}</div>
      ))}
    </div>
  );
}
```

## TanStack Query Guidelines

The shared `QueryClient` lives in `apps/web/src/trpc/query.ts` (`makeQueryClient`)
and ships a default `staleTime` of 30s plus rate-limit-aware retry handling.
Override per-query options only when a surface needs different freshness.

### Query Configuration

```typescript theme={null}
const { data } = useQuery({
  queryKey: ['workspaces', wsId, 'tasks'],
  queryFn: () => listWorkspaceTasks(wsId),

  // How long data is considered fresh
  staleTime: 5 * 60 * 1000, // 5 minutes

  // Garbage-collection time after the query has no observers.
  // (React Query v5 renamed `cacheTime` to `gcTime`.)
  gcTime: 10 * 60 * 1000, // 10 minutes

  // Refetch on window focus
  refetchOnWindowFocus: true,

  // Refetch on network reconnect
  refetchOnReconnect: true,

  // Background refetch interval
  refetchInterval: false, // or number in ms

  // Retry failed requests
  retry: 3,

  // Only run if condition is true
  enabled: !!wsId,
});
```

### Prefetching

```typescript theme={null}
// In a parent component or on hover
const queryClient = useQueryClient();

await queryClient.prefetchQuery({
  queryKey: ['workspaces', wsId, 'tasks', taskId],
  queryFn: () => getWorkspaceTask(wsId, taskId),
});
```

### Parallel Queries

```typescript theme={null}
'use client';

import { useQuery } from '@tanstack/react-query';
import { listWorkspaceTasks, listWorkspaceTaskBoards } from '@tuturuuu/internal-api/tasks';
import { getWorkspaceMembers } from '@tuturuuu/internal-api/workspaces';

export function Dashboard({ wsId }: { wsId: string }) {
  const tasks = useQuery({
    queryKey: ['workspaces', wsId, 'tasks'],
    queryFn: () => listWorkspaceTasks(wsId),
  });
  const boards = useQuery({
    queryKey: ['workspaces', wsId, 'task-boards'],
    queryFn: () => listWorkspaceTaskBoards(wsId),
  });
  const members = useQuery({
    queryKey: ['workspaces', wsId, 'members'],
    queryFn: () => getWorkspaceMembers(wsId),
  });

  // All three queries run in parallel
  const isPending = tasks.isPending || boards.isPending || members.isPending;

  if (isPending) return <div>Loading...</div>;

  return <div>{/* Render dashboard */}</div>;
}
```

For many dynamic queries, prefer `useQueries` over hand-listing hooks.

### Dependent Queries

```typescript theme={null}
'use client';

import { useQuery } from '@tanstack/react-query';
import { getWorkspaceTask, listWorkspaceTaskAssignees } from '@tuturuuu/internal-api/tasks';

export function TaskDetails({ wsId, taskId }: { wsId: string; taskId: string }) {
  const { data: task } = useQuery({
    queryKey: ['workspaces', wsId, 'tasks', taskId],
    queryFn: () => getWorkspaceTask(wsId, taskId),
  });

  const { data: assignees } = useQuery({
    queryKey: ['workspaces', wsId, 'tasks', taskId, 'assignees'],
    queryFn: () => listWorkspaceTaskAssignees(wsId, taskId),
    enabled: !!task, // Only run after the task is fetched
  });

  return <div>{/* Render task details */}</div>;
}
```

<Note>
  The exact helper names above (`getWorkspaceTask`, `listWorkspaceTaskAssignees`,
  `getWorkspaceMembers`) are illustrative — browse `packages/internal-api/src`
  for the real exports before wiring a hook. `listWorkspaceTasks` and
  `listWorkspaceTaskBoards` exist today.
</Note>

## Decision Matrix

| Use Case                 | Strategy                    | Why                           |
| ------------------------ | --------------------------- | ----------------------------- |
| Blog posts               | RSC                         | SEO, rarely changes           |
| Task list (initial load) | RSC                         | Fast initial render           |
| Create task form         | Server Action               | Mutation, redirect            |
| Dashboard metrics        | TanStack Query              | Frequent updates              |
| Real-time chat           | Realtime Subscription       | Collaboration                 |
| Task toggle              | TanStack Query (optimistic) | Interactive, instant feedback |
| User profile (view)      | RSC                         | Rarely changes                |
| User profile (edit)      | Server Action               | Mutation with validation      |
| Workspace members        | RSC + hydration             | SEO + background refresh      |
| Live notifications       | Realtime Subscription       | Real-time updates critical    |

## Best Practices

### ✅ DO

1. **Start with RSC**
   ```typescript theme={null}
   // Default to server components
   export default async function Page() { ... }
   ```

2. **Use specific query keys**
   ```typescript theme={null}
   ['tasks', wsId, { listId, status: 'active' }]
   ```

3. **Set appropriate staleTime**
   ```typescript theme={null}
   staleTime: 5 * 60 * 1000 // 5 minutes for user profiles
   ```

4. **Implement optimistic updates for better UX**
   ```typescript theme={null}
   onMutate: async (newData) => { /* optimistic update */ }
   ```

5. **Invalidate narrowly**
   ```typescript theme={null}
   // Target the specific task, not the whole workspace tasks tree
   queryClient.invalidateQueries({
     queryKey: ['workspaces', wsId, 'tasks', taskId],
   });
   ```

6. **Fetch through Internal API helpers**
   ```typescript theme={null}
   // Route client reads/writes through @tuturuuu/internal-api, not raw
   // fetch('/api/...') or trpc.* routers.
   queryFn: () => listWorkspaceTasks(wsId);
   ```

### ❌ DON'T

1. **Don't use client fetching for SEO content**
   ```typescript theme={null}
   // ❌ Bad: Client-side fetch for blog
   useEffect(() => fetchBlogPosts(), []);
   ```

2. **Don't skip staleTime**
   ```typescript theme={null}
   // ❌ Bad: Refetches too often
   useQuery({ ... }) // defaults to 0ms staleTime
   ```

3. **Don't invalidate globally**
   ```typescript theme={null}
   // ❌ Bad: Refetches everything
   queryClient.invalidateQueries();
   ```

4. **Don't use Realtime for infrequent updates**
   ```typescript theme={null}
   // ❌ Bad: Waste of resources
   supabase.channel('user-profiles-changes')
   ```

## Related Documentation

* [tRPC Implementation](/platform/architecture/trpc) — the minimal `healthCheck`
  stub kept alive during the migration (not a product fetch path)
* [TanStack + Rust Migration](/platform/architecture/tanstack-rust-migration)
* [Supabase Client](/reference/packages/supabase)
* [Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components)
* [TanStack Query](https://tanstack.com/query/latest)
