Skip to main content

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.

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

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.

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

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

export default async function TasksPage({
  params,
}: {
  params: { wsId: string };
}) {
  const supabase = createClient();

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

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

Caching Strategy

// app/[locale]/(dashboard)/[wsId]/tasks/page.tsx

// Revalidate every hour
export const revalidate = 3600;

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

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

Loading States

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

Error Handling

// 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

// 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 = 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 = 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

'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

// 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: { wsId: string };
}) {
  const supabase = createClient();

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

  return <TaskListClient initialTasks={initialTasks || []} wsId={params.wsId} />;
}
// app/[locale]/(dashboard)/[wsId]/tasks/TaskListClient.tsx
'use client';

import { useState, useEffect } from 'react';
import { createClient } from '@tuturuuu/supabase/next/client';
import type { Database } from '@tuturuuu/types';

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

export function TaskListClient({
  initialTasks,
  wsId,
}: {
  initialTasks: Task[];
  wsId: string;
}) {
  const [tasks, setTasks] = useState(initialTasks);

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

    // Background refresh every 30 seconds
    const interval = setInterval(async () => {
      const { data } = await supabase
        .from('workspace_tasks')
        .select('*')
        .eq('ws_id', wsId);

      if (data) setTasks(data);
    }, 30000);

    return () => clearInterval(interval);
  }, [wsId]);

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

4. React 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 tRPC

'use client';

import { trpc } from '@/trpc/client';

export function TaskList({ wsId }: { wsId: string }) {
  const { data: tasks, isLoading, error } = trpc.tasks.list.useQuery(
    { wsId },
    {
      staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
      refetchOnWindowFocus: true,
      refetchInterval: 30000, // Refetch every 30 seconds
    }
  );

  if (isLoading) 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:
// ✅ 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 client';

import { trpc } from '@/trpc/client';

export function TaskCheckbox({ task }: { task: Task }) {
  const utils = trpc.useContext();

  const toggleComplete = trpc.tasks.update.useMutation({
    // Optimistically update before server responds
    onMutate: async (newData) => {
      await utils.tasks.get.cancel({ taskId: task.id });

      const previous = utils.tasks.get.getData({ taskId: task.id });

      utils.tasks.get.setData({ taskId: task.id }, (old) => ({
        ...old!,
        completed: !old!.completed,
      }));

      return { previous };
    },

    // Rollback on error
    onError: (err, newData, context) => {
      if (context?.previous) {
        utils.tasks.get.setData({ taskId: task.id }, context.previous);
      }
    },

    // Refetch after success or error
    onSettled: () => {
      utils.tasks.get.invalidate({ taskId: task.id });
      utils.tasks.list.invalidate();
    },
  });

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

Cache Invalidation

const utils = trpc.useContext();

// Invalidate specific query
utils.tasks.get.invalidate({ taskId: '123' });

// Invalidate all tasks.list queries
utils.tasks.list.invalidate();

// Invalidate all tasks queries
utils.tasks.invalidate();

// Reset to initial state
utils.tasks.list.reset({ wsId });

// Refetch immediately
utils.tasks.list.refetch({ wsId });

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

'use client';

import { useEffect, useState } from 'react';
import { createClient } from '@tuturuuu/supabase/next/client';
import type { Database } from '@tuturuuu/types';

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

export function RealtimeTaskList({ wsId }: { wsId: string }) {
  const [tasks, setTasks] = useState<Task[]>([]);
  const supabase = createClient();

  useEffect(() => {
    // Initial fetch
    supabase
      .from('workspace_tasks')
      .select('*')
      .eq('ws_id', wsId)
      .then(({ data }) => setTasks(data || []));

    // Subscribe to changes
    const channel = supabase
      .channel(`workspace-tasks-${wsId}`)
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'workspace_tasks',
          filter: `ws_id=eq.${wsId}`,
        },
        (payload) => {
          if (payload.eventType === 'INSERT') {
            setTasks((prev) => [...prev, payload.new as Task]);
          } else if (payload.eventType === 'UPDATE') {
            setTasks((prev) =>
              prev.map((task) =>
                task.id === payload.new.id ? (payload.new as Task) : task
              )
            );
          } else if (payload.eventType === 'DELETE') {
            setTasks((prev) => prev.filter((task) => task.id !== payload.old.id));
          }
        }
      )
      .subscribe();

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

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

React Query Guidelines

Query Configuration

const { data } = trpc.tasks.list.useQuery(
  { wsId },
  {
    // How long data is considered fresh
    staleTime: 5 * 60 * 1000, // 5 minutes

    // Cache time after component unmounts
    cacheTime: 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

// In parent component or on hover
const utils = trpc.useContext();

await utils.tasks.get.prefetch({ taskId: '123' });

Parallel Queries

'use client';

import { trpc } from '@/trpc/client';

export function Dashboard({ wsId }: { wsId: string }) {
  const tasks = trpc.tasks.list.useQuery({ wsId });
  const boards = trpc.boards.list.useQuery({ wsId });
  const members = trpc.workspace.members.useQuery({ wsId });

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

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

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

Dependent Queries

'use client';

import { trpc } from '@/trpc/client';

export function TaskDetails({ taskId }: { taskId: string }) {
  const { data: task } = trpc.tasks.get.useQuery({ taskId });

  const { data: assignees } = trpc.tasks.assignees.useQuery(
    { taskId },
    {
      enabled: !!task, // Only run after task is fetched
    }
  );

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

Decision Matrix

Use CaseStrategyWhy
Blog postsRSCSEO, rarely changes
Task list (initial load)RSCFast initial render
Create task formServer ActionMutation, redirect
Dashboard metricsReact QueryFrequent updates
Real-time chatRealtime SubscriptionCollaboration
Task toggleReact Query (optimistic)Interactive, instant feedback
User profile (view)RSCRarely changes
User profile (edit)Server ActionMutation with validation
Workspace membersRSC + hydrationSEO + background refresh
Live notificationsRealtime SubscriptionReal-time updates critical

Best Practices

✅ DO

  1. Start with RSC
    // Default to server components
    export default async function Page() { ... }
    
  2. Use specific query keys
    ['tasks', wsId, { listId, status: 'active' }]
    
  3. Set appropriate staleTime
    staleTime: 5 * 60 * 1000 // 5 minutes for user profiles
    
  4. Implement optimistic updates for better UX
    onMutate: async (newData) => { /* optimistic update */ }
    
  5. Invalidate narrowly
    utils.tasks.get.invalidate({ taskId }); // Not utils.tasks.invalidate()
    

❌ DON’T

  1. Don’t use client fetching for SEO content
    // ❌ Bad: Client-side fetch for blog
    useEffect(() => fetchBlogPosts(), []);
    
  2. Don’t skip staleTime
    // ❌ Bad: Refetches too often
    useQuery({ ... }) // defaults to 0ms staleTime
    
  3. Don’t invalidate globally
    // ❌ Bad: Refetches everything
    queryClient.invalidateQueries();
    
  4. Don’t use Realtime for infrequent updates
    // ❌ Bad: Waste of resources
    supabase.channel('user-profiles-changes')