#Next.JS app table design
1 messages · Page 1 of 1 (latest)
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.tsx→ Server Component (already true)app/tournaments/[tournamentId]/layout.tsx→ Server Component (layout, no interactivity)app/tournaments/[tournamentId]/tournamentStatsClient.tsx→ Client Component (filters + table UI)DropdownMenuCheckboxes,SliderRange,DataTable→ Client 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
useEffectis 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 ontournamentStatsClient.tsxand 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
fetchoptions orunstable_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.