#Next.JS app table design

1 messages · Page 1 of 1 (latest)

young cobaltBOT
#

Hi @zealous spoke. I'm kapa.ai, your friendly support bot. I will try to answer your question by searching through the knowledge base. :wave:

#

You’re already very close to the “canonical” App Router pattern. Based on the docs, here’s a design that matches Next.js best practices and avoids useEffect-style ad‑hoc fetching.


1. Where to query the database?

Query the DB in a Server Component or server utility, not in the client.

From the docs, Server Components are the place to fetch from databases/ORMs. [Fetching data]

Example pattern for your route:

app/tournaments/[tournamentId]/page.tsx (Server Component):

// app/tournaments/[tournamentId]/page.tsx
import TournamentStatsClient from './tournamentStatsClient'
import { getTournamentStats } from '@/lib/tournaments' // server-side DB helper

export default async function TournamentPage({
  params,
}: {
  params: { tournamentId: string }
}) {
  const { tournamentId } = params

  // 1. Fetch all data needed for the page on the server
  const {
    players,
    matches,
    initialFilters, // e.g. default time/distance ranges
  } = await getTournamentStats(tournamentId)

  // 2. Pass only the data the client needs, and only public fields
  return (
    <TournamentStatsClient
      players={players}
      matches={matches}
      initialFilters={initialFilters}
    />
  )
}

getTournamentStats would live in lib/ and use your Postgres client/ORM. This matches the “with an ORM or database” pattern. [Fetching data]

If the dataset is large and you want server‑side filtering on every change, you can instead expose a Route Handler (e.g. app/api/tournaments/[tournamentId]/stats/route.ts) and call it from the client, but that’s not covered explicitly in the provided docs, so I can’t give a detailed example.


2. Which components are Server vs Client?

#

From the docs, use Server Components for data fetching and static layout, and Client Components only where you need interactivity (state, event handlers, browser APIs). [Server vs Client]

In your case:

  • app/tournaments/[tournamentId]/page.tsxServer Component (already true)
  • app/tournaments/[tournamentId]/layout.tsxServer Component (layout, no interactivity)
  • app/tournaments/[tournamentId]/tournamentStatsClient.tsxClient Component (filters + table UI)
  • DropdownMenuCheckboxes, SliderRange, DataTableClient Components (interactive controls)

tournamentStatsClient.tsx should start with 'use client'. [use client]


3. Where should state live?

Per docs, state and event handlers belong in Client Components. [Server and Client]

A good structure:

// app/tournaments/[tournamentId]/tournamentStatsClient.tsx
'use client'

import { useMemo, useState } from 'react'
import { DropdownMenuCheckboxes } from './DropdownMenuCheckboxes'
import { SliderRange } from './SliderRange'
import { DataTable } from './DataTable'

type PlayerStat = {
  playerId: string
  // ...other fields used in the table
  matchId: string
  time: number
  distance: number
}

type Match = {
  id: string
  name: string
  // ...other fields needed for filters/labels
}

type Filters = {
  selectedMatchIds: string[]
  timeRange: [number, number]
  distanceRange: [number, number]
}
#

export default function TournamentStatsClient({
players,
matches,
initialFilters,
}: {
players: PlayerStat[]
matches: Match[]
initialFilters: Filters
}) {
// 1. Local UI state for filters
const [filters, setFilters] = useState<Filters>(initialFilters)

// 2. Derived filtered data (pure computation, no effects)
const filteredPlayers = useMemo(() => {
return players.filter((p) => {
const inMatch =
filters.selectedMatchIds.length === 0 ||
filters.selectedMatchIds.includes(p.matchId)

  const inTime =
    p.time >= filters.timeRange[0] && p.time <= filters.timeRange[1]

  const inDistance =
    p.distance >= filters.distanceRange[0] &&
    p.distance <= filters.distanceRange[1]

  return inMatch && inTime && inDistance
})

}, [players, filters])

return (
<div>
<DropdownMenuCheckboxes
matches={matches}
selectedMatchIds={filters.selectedMatchIds}
onChangeSelectedMatchIds={(selectedMatchIds) =>
setFilters((prev) => ({ ...prev, selectedMatchIds }))
}
/>

  <SliderRange
    label="Time"
    value={filters.timeRange}
    onChange={(timeRange) =>
      setFilters((prev) => ({ ...prev, timeRange }))
    }
  />

  <SliderRange
    label="Distance"
    value={filters.distanceRange}
    onChange={(distanceRange) =>
      setFilters((prev) => ({ ...prev, distanceRange }))
    }
  />

  <DataTable players={filteredPlayers} />
</div>

)
}


Notes:
#
  • State lives only in TournamentStatsClient (the “container” for your filters + table).
  • Child components (DropdownMenuCheckboxes, SliderRange, DataTable) are stateless or minimally stateful, receiving props and calling callbacks.
  • No useEffect is needed because:
    • Data is fetched on the server.
    • Filtering is synchronous and derived from state.

This matches the recommendation to keep data fetching on the server and use Client Components for interactivity. [No async client]


4. Passing data and avoiding over‑exposure

The docs warn against passing full DB rows directly to Client Components; instead, sanitize and pass only what’s needed. [Data security]

So in getTournamentStats, shape your data like:

// lib/tournaments.ts (server-only)
export async function getTournamentStats(tournamentId: string) {
  // query DB here
  // const rows = await db...

  return {
    players: rows.map((r) => ({
      playerId: r.player_id,
      matchId: r.match_id,
      time: r.time_seconds,
      distance: r.distance_meters,
      // only fields needed for the table
    })),
    matches: matchesRows.map((m) => ({
      id: m.id,
      name: m.name,
    })),
    initialFilters: {
      selectedMatchIds: [],
      timeRange: [0, 100], // or computed from data
      distanceRange: [0, 10000],
    },
  }
}

Then pass this sanitized data to TournamentStatsClient.


5. Scalability and performance

From the production checklist and data‑fetching docs: [Production checklist; Fetching data]

#
  • Server-side fetching keeps secrets and heavy work on the server and reduces JS bundle size.
  • Keep 'use client' as low in the tree as possible (only on tournamentStatsClient.tsx and the interactive subcomponents), so the rest of the route stays server‑rendered and cached. [use client]
  • If the tournament stats don’t change often, you can add caching/ISR at the data layer (e.g. via fetch options or unstable_cache), but the provided docs don’t show a concrete DB + cache example.

Summary

  • DB queries: in a server utility (lib/tournaments.ts) called from [tournamentId]/page.tsx (Server Component).
  • Data flow: page.tsx → sanitized props → TournamentStatsClient (Client Component) → stateless filter controls + table.
  • State: only in TournamentStatsClient (selected matches, time range, distance range).
  • No useEffect: all filtering is synchronous, based on local state and server‑fetched data.

If you share a rough shape of your DB rows (for players/matches), I can help refine the exact prop types and filtering logic within this pattern.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon: