From 74991a4e9c4dc7e1ad6f3df01419cba155032708 Mon Sep 17 00:00:00 2001 From: Duy Luong <33130695+lmduy2612@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:30:26 +0700 Subject: [PATCH 1/2] feat(ssr): server-render landing rows and first page of builders (fixes #410) --- app/builders/page.tsx | 61 ++++++++++++++- app/page.tsx | 61 ++++++++++++++- components/discover/builders-view.tsx | 54 ++++--------- components/discover/use-builders.ts | 97 ++++++++++++++++++++++-- components/landing/featured-projects.tsx | 11 +-- components/landing/index.ts | 2 + components/landing/landing-queries.ts | 64 ++++++++++++++++ components/landing/stats-strip.tsx | 31 +------- components/landing/top-builders.tsx | 15 ++-- 9 files changed, 300 insertions(+), 96 deletions(-) create mode 100644 components/landing/landing-queries.ts diff --git a/app/builders/page.tsx b/app/builders/page.tsx index b6a77d50..eb8e9e46 100644 --- a/app/builders/page.tsx +++ b/app/builders/page.tsx @@ -1,9 +1,21 @@ import { Suspense } from 'react'; +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from '@tanstack/react-query'; import { BuildersView, BuildersViewFallback, } from '@/components/discover/builders-view'; +import { + BUILDERS_REVALIDATE_SECONDS, + buildersKeys, + fetchBuilderFilters, + fetchBuilders, + parseBuildersQueryParams, +} from '@/components/discover/use-builders'; import { SiteFooter } from '@/components/layout/site-footer'; import { SiteHeader } from '@/components/layout/site-header'; import { JsonLd } from '@/components/seo/json-ld'; @@ -33,16 +45,57 @@ const buildersBreadcrumbs = breadcrumbSchema([ { name: 'Builders', path: '/builders' }, ]); -export default function BuildersPage() { +interface BuildersPageProps { + searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; +} + +export default async function BuildersPage(props: BuildersPageProps) { + const resolvedSearchParams = props.searchParams + ? await props.searchParams + : {}; + const queryParams = parseBuildersQueryParams(resolvedSearchParams); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, + }, + }, + }); + + await Promise.allSettled([ + queryClient.prefetchQuery({ + queryKey: buildersKeys.list(queryParams), + queryFn: () => + fetchBuilders(queryParams, { + next: { revalidate: BUILDERS_REVALIDATE_SECONDS }, + }), + }), + queryClient.prefetchQuery({ + queryKey: buildersKeys.filters, + queryFn: () => + fetchBuilderFilters({ + next: { revalidate: BUILDERS_REVALIDATE_SECONDS }, + }), + }), + ]); + return ( <> - }> - - + + query.state.status === 'success' || query.state.status === 'error', + })} + > + }> + + + ); diff --git a/app/page.tsx b/app/page.tsx index d157b9b3..43a3501b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,9 +1,21 @@ +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from '@tanstack/react-query'; import Link from 'next/link'; import { FeaturedProjects, + featuredProjectsQueryKey, + fetchFeaturedProjects, + fetchTopBuilders, + fetchTotal, + LANDING_REVALIDATE_SECONDS, + STATS, StatsStrip, TopBuilders, + topBuildersQueryKey, WantToBuild, } from '@/components/landing'; import { SiteFooter } from '@/components/layout/site-footer'; @@ -29,7 +41,41 @@ export const metadata: Metadata = { }, }; -export default function Home() { +export default async function Home() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, + }, + }, + }); + + await Promise.allSettled([ + ...STATS.map((stat) => + queryClient.prefetchQuery({ + queryKey: stat.queryKey, + queryFn: () => + fetchTotal(stat.path, { + next: { revalidate: LANDING_REVALIDATE_SECONDS }, + }), + }) + ), + queryClient.prefetchQuery({ + queryKey: topBuildersQueryKey, + queryFn: () => + fetchTopBuilders({ + next: { revalidate: LANDING_REVALIDATE_SECONDS }, + }), + }), + queryClient.prefetchQuery({ + queryKey: featuredProjectsQueryKey, + queryFn: () => + fetchFeaturedProjects({ + next: { revalidate: LANDING_REVALIDATE_SECONDS }, + }), + }), + ]); + return ( <> @@ -59,9 +105,16 @@ export default function Home() { - - - + + query.state.status === 'success' || query.state.status === 'error', + })} + > + + + + diff --git a/components/discover/builders-view.tsx b/components/discover/builders-view.tsx index c09084d8..72931a27 100644 --- a/components/discover/builders-view.tsx +++ b/components/discover/builders-view.tsx @@ -20,44 +20,26 @@ import { } from './filter-rail'; import { FilterSheet } from './filter-sheet'; import { + BUILDERS_PAGE_SIZE, + BUILDERS_SEARCH_PARAM, DEFAULT_BUILDER_SORT, - isBuilderSort, + parseBuildersQueryParams, + readBuilderFilters, + readBuilderPage, + readBuilderSort, useBuilderFilters, useBuilders, type BuilderSort, type BuildersQueryParams, } from './use-builders'; -const PAGE_SIZE = 12; +const PAGE_SIZE = BUILDERS_PAGE_SIZE; const SEARCH_DEBOUNCE_MS = 300; -const SEARCH_PARAM = 'q'; +const SEARCH_PARAM = BUILDERS_SEARCH_PARAM; -/** - * The query string is the source of truth for everything that narrows the - * list, so a filtered view can be linked, bookmarked, and walked back through - * with the browser buttons. Defaults are left out to keep the URL readable. - */ -function readFilters(params: URLSearchParams): FilterValue { - const country = params.get('country'); - const status = params.get('status'); - return { - // Repeated `skills` params, matching what the endpoint accepts. Joining on - // a comma would break any facet whose own label contains one. - skills: params.getAll('skills'), - country: country ? [country] : [], - status: status ? [status] : [], - }; -} - -function readPage(params: URLSearchParams): number { - const raw = Number(params.get('page')); - return Number.isInteger(raw) && raw > 0 ? raw : 1; -} - -function readSort(params: URLSearchParams): BuilderSort { - const raw = params.get('sort'); - return raw && isBuilderSort(raw) ? raw : DEFAULT_BUILDER_SORT; -} +const readFilters = readBuilderFilters; +const readPage = readBuilderPage; +const readSort = readBuilderSort; function buildQuery( search: string, @@ -174,18 +156,8 @@ export function BuildersView() { }; const params = useMemo( - () => ({ - page, - limit: PAGE_SIZE, - search: search || undefined, - // `GET /users/directory` takes a single value for these two, so their - // rail sections are single-select radios. Only `skills` is repeatable. - skills: filters.skills?.length ? filters.skills : undefined, - country: filters.country?.[0], - status: filters.status?.[0], - sort, - }), - [page, search, filters, sort] + () => parseBuildersQueryParams(searchParams), + [searchParams] ); const { data, isError, isPending } = useBuilders(params); diff --git a/components/discover/use-builders.ts b/components/discover/use-builders.ts index 777cd6a7..ce41b9b6 100644 --- a/components/discover/use-builders.ts +++ b/components/discover/use-builders.ts @@ -95,14 +95,100 @@ function toQueryString(params: BuildersQueryParams): string { return query ? `?${query}` : ''; } +export const BUILDERS_REVALIDATE_SECONDS = 60; +export const BUILDERS_PAGE_SIZE = 12; +export const BUILDERS_SEARCH_PARAM = 'q'; + +export type BuilderFilterValue = Record & { + skills: string[]; + country: string[]; + status: string[]; +}; + +export function readBuilderFilters(params: URLSearchParams): BuilderFilterValue { + const country = params.get('country'); + const status = params.get('status'); + return { + skills: params.getAll('skills'), + country: country ? [country] : [], + status: status ? [status] : [], + }; +} + +export function readBuilderPage(params: URLSearchParams): number { + const raw = Number(params.get('page')); + return Number.isInteger(raw) && raw > 0 ? raw : 1; +} + +export function readBuilderSort(params: URLSearchParams): BuilderSort { + const raw = params.get('sort'); + return raw && isBuilderSort(raw) ? raw : DEFAULT_BUILDER_SORT; +} + +export function toUrlSearchParams( + raw: Record +): URLSearchParams { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(raw)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) { + params.append(key, item); + } + } else { + params.set(key, value); + } + } + return params; +} + +export function parseBuildersQueryParams( + searchParams: + | URLSearchParams + | Record +): BuildersQueryParams { + const params = + searchParams instanceof URLSearchParams + ? searchParams + : toUrlSearchParams(searchParams); + + const search = params.get(BUILDERS_SEARCH_PARAM) ?? ''; + const filters = readBuilderFilters(params); + const page = readBuilderPage(params); + const sort = readBuilderSort(params); + + return { + page, + limit: BUILDERS_PAGE_SIZE, + search: search || undefined, + skills: filters.skills?.length ? filters.skills : undefined, + country: filters.country?.[0], + status: filters.status?.[0], + sort, + }; +} + +export function fetchBuilders( + params: BuildersQueryParams = {}, + init?: RequestInit +): Promise { + return apiFetch( + `/users/directory${toQueryString(params)}`, + init + ); +} + +export function fetchBuilderFilters( + init?: RequestInit +): Promise { + return apiFetch('/users/filters', init); +} + /** Paginated `/users/directory` list, filtered by the params above. */ export function useBuilders(params: BuildersQueryParams = {}) { return useQuery({ queryKey: buildersKeys.list(params), - queryFn: () => - apiFetch( - `/users/directory${toQueryString(params)}` - ), + queryFn: () => fetchBuilders(params), }); } @@ -110,6 +196,7 @@ export function useBuilders(params: BuildersQueryParams = {}) { export function useBuilderFilters() { return useQuery({ queryKey: buildersKeys.filters, - queryFn: () => apiFetch('/users/filters'), + queryFn: () => fetchBuilderFilters(), }); } + diff --git a/components/landing/featured-projects.tsx b/components/landing/featured-projects.tsx index 51cbc141..6bc20082 100644 --- a/components/landing/featured-projects.tsx +++ b/components/landing/featured-projects.tsx @@ -8,18 +8,19 @@ import { OpportunityCard } from '@/components/cards/opportunity-card'; import { ArrowRightIcon } from '@/components/icons'; import { Section, SectionHeading } from '@/components/marketing/section'; import { Button } from '@/components/ui/button'; -import { apiFetch } from '@/lib/api/client'; -import type { Schemas } from '@/lib/api/types'; import { toProjectCard } from '@/components/discover/to-project-card'; +import { + featuredProjectsQueryKey, + fetchFeaturedProjects, +} from './landing-queries'; const FEATURED_PROJECTS_SKELETON_COUNT = 3; function useFeaturedProjects() { return useQuery({ - queryKey: ['projects', 'featured'], - queryFn: () => - apiFetch('/projects/featured'), + queryKey: featuredProjectsQueryKey, + queryFn: () => fetchFeaturedProjects(), }); } diff --git a/components/landing/index.ts b/components/landing/index.ts index a19c3790..466a318e 100644 --- a/components/landing/index.ts +++ b/components/landing/index.ts @@ -2,3 +2,5 @@ export { StatsStrip } from './stats-strip'; export { TopBuilders } from './top-builders'; export { FeaturedProjects } from './featured-projects'; export { WantToBuild } from './want-to-build'; +export * from './landing-queries'; + diff --git a/components/landing/landing-queries.ts b/components/landing/landing-queries.ts new file mode 100644 index 00000000..a76f1615 --- /dev/null +++ b/components/landing/landing-queries.ts @@ -0,0 +1,64 @@ +import { apiFetch } from '@/lib/api/client'; +import type { Schemas } from '@/lib/api/types'; +import type { TopBuilder } from './to-builder-card'; + +/** + * Cache lifetime in seconds for the landing page data. + * Keeps API load low while revalidating periodically. + */ +export const LANDING_REVALIDATE_SECONDS = 180; + +export const STATS = [ + { + label: 'Builders', + path: '/users/directory?limit=1', + queryKey: ['ecosystem-stats', 'builders'] as const, + }, + { + label: 'Projects', + path: '/projects?limit=1', + queryKey: ['ecosystem-stats', 'projects'] as const, + }, + { + label: 'Teams', + path: '/organizations?limit=1', + queryKey: ['ecosystem-stats', 'teams'] as const, + }, +] as const; + +export type CountedList = + | Schemas['BuildersDirectoryResponseDto'] + | Schemas['PublicProjectListDto'] + | Schemas['PublicOrganizationListDto']; + +export async function fetchTotal( + path: string, + init?: RequestInit +): Promise { + const response = await apiFetch(path, init); + return response.pagination.total; +} + +export const TOP_BUILDERS_LIMIT = 8; +export const topBuildersQueryKey = [ + 'users', + 'top-builders', + TOP_BUILDERS_LIMIT, +] as const; + +export async function fetchTopBuilders( + init?: RequestInit +): Promise { + return apiFetch( + `/users/top-builders?limit=${TOP_BUILDERS_LIMIT}`, + init + ); +} + +export const featuredProjectsQueryKey = ['projects', 'featured'] as const; + +export async function fetchFeaturedProjects( + init?: RequestInit +): Promise { + return apiFetch('/projects/featured', init); +} diff --git a/components/landing/stats-strip.tsx b/components/landing/stats-strip.tsx index b3df3339..d48ed954 100644 --- a/components/landing/stats-strip.tsx +++ b/components/landing/stats-strip.tsx @@ -6,26 +6,8 @@ import { Section } from '@/components/marketing/section'; import { CountUp } from '@/components/ui/count-up'; import { Skeleton } from '@/components/ui/skeleton'; import { StatsBar, type StatItem } from '@/components/ui/stats-bar'; -import { apiFetch } from '@/lib/api/client'; -import type { Schemas } from '@/lib/api/types'; -const STATS = [ - { - label: 'Builders', - path: '/users/directory?limit=1', - queryKey: ['ecosystem-stats', 'builders'], - }, - { - label: 'Projects', - path: '/projects?limit=1', - queryKey: ['ecosystem-stats', 'projects'], - }, - { - label: 'Teams', - path: '/organizations?limit=1', - queryKey: ['ecosystem-stats', 'teams'], - }, -] as const; +import { STATS, fetchTotal } from './landing-queries'; function NumberSkeleton({ mobile = false }: { mobile?: boolean }) { return ( @@ -36,17 +18,6 @@ function NumberSkeleton({ mobile = false }: { mobile?: boolean }) { ); } -/** Every list the strip counts documents the same `pagination` block. */ -type CountedList = - | Schemas['BuildersDirectoryResponseDto'] - | Schemas['PublicProjectListDto'] - | Schemas['PublicOrganizationListDto']; - -async function fetchTotal(path: string) { - const response = await apiFetch(path); - return response.pagination.total; -} - function useEcosystemStats() { return useQueries({ queries: STATS.map(stat => ({ diff --git a/components/landing/top-builders.tsx b/components/landing/top-builders.tsx index 32d391fb..e4853b32 100644 --- a/components/landing/top-builders.tsx +++ b/components/landing/top-builders.tsx @@ -8,17 +8,18 @@ import { BuilderCardSkeleton } from '@/components/cards/builder-card-skeleton'; import { ArrowRightIcon } from '@/components/icons'; import { Section, SectionHeading } from '@/components/marketing/section'; import { Button } from '@/components/ui/button'; -import { apiFetch } from '@/lib/api/client'; -import { type TopBuilder, toBuilderCard } from './to-builder-card'; - -const TOP_BUILDERS_LIMIT = 8; +import { + TOP_BUILDERS_LIMIT, + topBuildersQueryKey, + fetchTopBuilders, +} from './landing-queries'; +import { toBuilderCard } from './to-builder-card'; function useTopBuilders() { return useQuery({ - queryKey: ['users', 'top-builders', TOP_BUILDERS_LIMIT], - queryFn: () => - apiFetch(`/users/top-builders?limit=${TOP_BUILDERS_LIMIT}`), + queryKey: topBuildersQueryKey, + queryFn: () => fetchTopBuilders(), }); } From bdd01c01c6e5f6f950fefa4fbc37322b388a31de Mon Sep 17 00:00:00 2001 From: Duy Luong <33130695+lmduy2612@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:55:24 +0700 Subject: [PATCH 2/2] refactor(ssr): address review feedback on timeouts and dehydration - Add AbortSignal.timeout(5000) to server-side prefetches in page.tsx and builders/page.tsx - Remove custom shouldDehydrateQuery to only dehydrate successful queries by default - Replace helper aliases with direct imports in builders-view.tsx --- app/builders/page.tsx | 9 +++------ app/page.tsx | 10 ++++------ components/discover/builders-view.tsx | 24 +++++++++--------------- 3 files changed, 16 insertions(+), 27 deletions(-) diff --git a/app/builders/page.tsx b/app/builders/page.tsx index eb8e9e46..4cd59e00 100644 --- a/app/builders/page.tsx +++ b/app/builders/page.tsx @@ -69,6 +69,7 @@ export default async function BuildersPage(props: BuildersPageProps) { queryFn: () => fetchBuilders(queryParams, { next: { revalidate: BUILDERS_REVALIDATE_SECONDS }, + signal: AbortSignal.timeout(5000), }), }), queryClient.prefetchQuery({ @@ -76,6 +77,7 @@ export default async function BuildersPage(props: BuildersPageProps) { queryFn: () => fetchBuilderFilters({ next: { revalidate: BUILDERS_REVALIDATE_SECONDS }, + signal: AbortSignal.timeout(5000), }), }), ]); @@ -86,12 +88,7 @@ export default async function BuildersPage(props: BuildersPageProps) { - - query.state.status === 'success' || query.state.status === 'error', - })} - > + }> diff --git a/app/page.tsx b/app/page.tsx index 43a3501b..d6e63524 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -57,6 +57,7 @@ export default async function Home() { queryFn: () => fetchTotal(stat.path, { next: { revalidate: LANDING_REVALIDATE_SECONDS }, + signal: AbortSignal.timeout(5000), }), }) ), @@ -65,6 +66,7 @@ export default async function Home() { queryFn: () => fetchTopBuilders({ next: { revalidate: LANDING_REVALIDATE_SECONDS }, + signal: AbortSignal.timeout(5000), }), }), queryClient.prefetchQuery({ @@ -72,6 +74,7 @@ export default async function Home() { queryFn: () => fetchFeaturedProjects({ next: { revalidate: LANDING_REVALIDATE_SECONDS }, + signal: AbortSignal.timeout(5000), }), }), ]); @@ -105,12 +108,7 @@ export default async function Home() { - - query.state.status === 'success' || query.state.status === 'error', - })} - > + diff --git a/components/discover/builders-view.tsx b/components/discover/builders-view.tsx index 72931a27..2f1a8364 100644 --- a/components/discover/builders-view.tsx +++ b/components/discover/builders-view.tsx @@ -33,13 +33,7 @@ import { type BuildersQueryParams, } from './use-builders'; -const PAGE_SIZE = BUILDERS_PAGE_SIZE; const SEARCH_DEBOUNCE_MS = 300; -const SEARCH_PARAM = BUILDERS_SEARCH_PARAM; - -const readFilters = readBuilderFilters; -const readPage = readBuilderPage; -const readSort = readBuilderSort; function buildQuery( search: string, @@ -48,7 +42,7 @@ function buildQuery( sort: BuilderSort ): string { const next = new URLSearchParams(); - if (search) next.set(SEARCH_PARAM, search); + if (search) next.set(BUILDERS_SEARCH_PARAM, search); for (const skill of filters.skills ?? []) next.append('skills', skill); if (filters.country?.[0]) next.set('country', filters.country[0]); if (filters.status?.[0]) next.set('status', filters.status[0]); @@ -83,10 +77,10 @@ export function BuildersView() { const router = useRouter(); const searchParams = useSearchParams(); - const search = searchParams.get(SEARCH_PARAM) ?? ''; - const filters = useMemo(() => readFilters(searchParams), [searchParams]); - const page = readPage(searchParams); - const sort = readSort(searchParams); + const search = searchParams.get(BUILDERS_SEARCH_PARAM) ?? ''; + const filters = useMemo(() => readBuilderFilters(searchParams), [searchParams]); + const page = readBuilderPage(searchParams); + const sort = readBuilderSort(searchParams); const [searchInput, setSearchInput] = useState(search); const [railOpen, setRailOpen] = useState(true); @@ -135,8 +129,8 @@ export function BuildersView() { // reverting the sort. Everything except the term is carried through // exactly as it stands at fire time. const next = new URLSearchParams(window.location.search); - if (trimmed) next.set(SEARCH_PARAM, trimmed); - else next.delete(SEARCH_PARAM); + if (trimmed) next.set(BUILDERS_SEARCH_PARAM, trimmed); + else next.delete(BUILDERS_SEARCH_PARAM); // Narrowing the list invalidates the page number. next.delete('page'); const query = next.toString(); @@ -259,7 +253,7 @@ export function BuildersView() { isError={isError} isNarrowed={hasActiveFilters(filters) || search.length > 0} page={page} - pageSize={PAGE_SIZE} + pageSize={BUILDERS_PAGE_SIZE} onPageChange={next => navigate(search, filters, next, 'push')} /> @@ -299,7 +293,7 @@ export function BuildersViewFallback() { subtitle='Meet the builders making an impact across the Boundless ecosystem.' />
- {Array.from({ length: PAGE_SIZE }, (_, index) => ( + {Array.from({ length: BUILDERS_PAGE_SIZE }, (_, index) => ( ))}