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:
- Pure Server Component (RSC) - Read-only, cacheable, SEO-critical data
- Server Action - Mutations returning updated state to RSC
- RSC + Client Hydration - When background refresh needed
- React Query (Client-Side) - Interactive, rapidly changing state
- 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:
- Keep the route page server-side only for auth, permission checks, redirects, and metadata.
- Move interactive data loading into client hooks backed by TanStack Query.
- Back those hooks with workspace-scoped Internal API helpers instead of ad hoc
fetch('/api/...') calls.
- Store queryable UI state such as
page, pageSize, q, sortBy, and path in nuqs.
- 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 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 | React Query | Frequent updates |
| Real-time chat | Realtime Subscription | Collaboration |
| Task toggle | React 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
-
Start with RSC
// Default to server components
export default async function Page() { ... }
-
Use specific query keys
['tasks', wsId, { listId, status: 'active' }]
-
Set appropriate staleTime
staleTime: 5 * 60 * 1000 // 5 minutes for user profiles
-
Implement optimistic updates for better UX
onMutate: async (newData) => { /* optimistic update */ }
-
Invalidate narrowly
utils.tasks.get.invalidate({ taskId }); // Not utils.tasks.invalidate()
❌ DON’T
-
Don’t use client fetching for SEO content
// ❌ Bad: Client-side fetch for blog
useEffect(() => fetchBlogPosts(), []);
-
Don’t skip staleTime
// ❌ Bad: Refetches too often
useQuery({ ... }) // defaults to 0ms staleTime
-
Don’t invalidate globally
// ❌ Bad: Refetches everything
queryClient.invalidateQueries();
-
Don’t use Realtime for infrequent updates
// ❌ Bad: Waste of resources
supabase.channel('user-profiles-changes')