Skip to main content

Frontend Architecture

The folksbase frontend (apps/web) is built with Next.js 15 using the App Router. The guiding principle is simple: default to React Server Components (RSC). Only add 'use client' when you genuinely need it.

Why RSC-First?

React Server Components render on the server and send HTML to the browser — no JavaScript bundle for those components. This matters because:
  1. Less JavaScript shipped to the client. Pages that just display data (contact lists, dashboard stats, settings) don’t need client-side React at all.
  2. Direct data access. Server Components can fetch data directly — no API calls from the browser, no loading spinners for initial content.
  3. Better performance on slow connections. The browser receives rendered HTML instead of a JavaScript bundle that needs to execute before anything appears.

When to Use What

Use RSC (the default) when:

  • Fetching and displaying data
  • Rendering static or server-rendered content
  • The component doesn’t need interactivity

Use 'use client' when:

  • You need event handlers (onClick, onChange, etc.)
  • You need browser APIs (localStorage, window, etc.)
  • You need SWR hooks for client-side data fetching
  • You need state that changes without navigation (real-time progress bars, optimistic updates)

When you need both

Split into two components: a Server Component wrapper that fetches data, and a Client Component leaf that handles interaction. Never add 'use client' to a data-fetching component.
// ✅ Server Component fetches, Client Component interacts
// contacts/page.tsx (Server Component — no directive needed)
export default async function ContactsPage() {
  const data = await fetchContacts();
  return <ContactsPageContent initialData={data} />;
}

// contacts-page-content.tsx (Client Component)
'use client'
export function ContactsPageContent({ initialData }) {
  const { data } = useContacts({ initialData });
  return <ContactsTable contacts={data} />;
}

Component Structure

Components are organized by domain, not by type:
components/
├── ui/           # Atoms — buttons, badges, inputs, dialogs
│                 # No business logic, no data fetching
├── contacts/     # Contact list, table, drawer, search
├── imports/      # Upload drop zone, progress tracker, column mapper
├── exports/      # Export dialog, progress, download
├── tags/         # Tag input, tag filter, tag management
├── settings/     # Workspace settings, API key management
├── dashboard/    # Stat cards, growth chart, recent activity
└── layout/       # App shell: sidebar, header, command palette
The ui/ directory contains reusable atoms — components with no business logic and no data fetching. Everything else is organized by the feature it belongs to. Compound components are used for complex UI. For example, the contacts table uses a compound pattern where the table, rows, cells, and actions are composed together. See contacts/contacts-table.tsx for the pattern.

Data Fetching Patterns

folksbase uses two data fetching strategies depending on the context:

RSC Pages — Direct Fetch

Server Component pages fetch data directly. No hooks, no loading states for the initial render:
// app/(dashboard)/contacts/page.tsx
export default async function ContactsPage() {
  const contacts = await apiServer.contacts.list();
  const tags = await apiServer.tags.list();
  return <ContactsPageContent initialContacts={contacts} initialTags={tags} />;
}

Client Components — SWR Hooks

Client components that need real-time updates use SWR hooks from hooks/use-*.ts:
// hooks/use-contacts.ts
export function useContacts(params) {
  return useSWR(['/contacts', { cursor, search, tag }], fetcher);
}
SWR keys follow a consistent pattern: ['/endpoint', { ...params }]. This makes cache invalidation predictable.

Dashboard Pattern — Best of Both

The dashboard combines both approaches for instant render with live updates:
  1. The RSC page prefetches all data (stats, contacts, imports, exports, tags)
  2. Passes it as initialData to the client component
  3. The client component wraps children in <SWRConfig value={{ fallback }}>
  4. SWR uses the prefetched data immediately, then revalidates in the background
This means the dashboard loads instantly (server-rendered HTML) and stays fresh (SWR revalidation).

Why These Choices?

Why Next.js 15 with App Router?

The App Router’s file-based routing, nested layouts, and native RSC support make it the natural choice for a React app that prioritizes performance. The alternative (Pages Router) doesn’t support Server Components.

Why SWR over React Query?

SWR is simpler and lighter. folksbase doesn’t need React Query’s advanced features (infinite queries, mutations with rollback). SWR’s useSWR hook with mutate for cache invalidation covers all the use cases here.

Why Radix Primitives?

Building accessible UI components from scratch is hard and error-prone. Radix provides unstyled, accessible primitives (dialogs, dropdowns, selects) that handle focus management, keyboard navigation, and ARIA attributes correctly. folksbase styles them with Tailwind.

Performance Patterns

CSV review virtualization

The CSV import review step needs to render files with 500K+ rows in the browser. Rather than parsing the entire file into memory, the frontend uses an index-and-on-demand approach:
  • csv-parser.ts builds a lightweight byte-offset index of row positions (~4 MB for 500K rows vs ~200 MB for fully parsed arrays)
  • getRow() parses a single row on demand using the offset — only ~30 rows exist in memory at any time
  • @tanstack/react-virtual renders a fixed window of DOM elements regardless of file size
This pattern keeps memory usage proportional to the raw file size rather than exploding with per-row object overhead. See the CSV Import review step for the full breakdown.

Retry logic for mutations

fetchApi() in apps/web/src/lib/api.ts retries non-GET requests once with a 1-second delay on transient server failures (502, 503, 504) and network TypeErrors. Client errors (4xx) are never retried. GET requests rely on SWR’s built-in revalidation instead.

Storybook

UI components are documented with Storybook 8. Stories live alongside their components as *.stories.tsx files.
  • Config lives in apps/web/.storybook/
  • Next.js dependencies (next/image, next/navigation) are mocked
  • Auto-deploys to Netlify when component or storybook config files change on main

What’s Next?