Use when building a new page from scratch โ library listing pages, detail pages, or browse pages...
This skill defines how to build new pages in the homeflix frontend, covering both library listing pages and detail pages.
| Type | Example | Structure |
|---|---|---|
| Library listing | /library/movies, /library/shows |
Featured + Filter + Grid |
| Detail page | /media/movies/[id], /media/shows/[id] |
Header + Stats + Tabs |
| Browse page | /browse |
Featured + Browse rows + Search |
app/(protected)/library/{media}/
โโโ page.tsx
โโโ _components/
โโโ featured-{media}.tsx # Hero section with random featured item
โโโ {media}-grid.tsx # Integrates filters, tabs, and grid rendering
The grid component owns the filter hook (useMediaFilters) and passes filter state to query options. Filters are integrated into the grid โ no separate filter component. Card and list item rendering uses shared components from components/media/items/.
Define in api/entities/{media}/:
// api/entities/{media}/{media}-item.ts
export interface MediaItem {
id: string | number;
title: string;
year?: number;
type: '{media}'; // Discriminator
status: MediaStatus;
posterUrl?: string;
backdropUrl?: string;
quality?: string;
rating?: number;
runtime?: number;
genres?: string[];
// ... media-specific fields
}
Create in api/functions/{media}/library.ts:
export async function fetchMediaItems(props: MediaItemsRequest): Promise<MediaItemsResponse> {
const client = createApiClient();
const { data, error } = await client.GET('/api/endpoint');
if (error) throw new Error('Failed to fetch');
const items = data.map(mapToMediaItem);
return {
stats: {
all: items.length,
// ... status counts for tabs
},
items: items
.filter(filterByStatus(props.status ?? 'all'))
.filter(filterBySearch(props.search ?? ''))
.filter(filterByGenres(props.genres ?? []))
.sort(sortItems(props.sortField ?? 'title', props.sortDirection ?? 'asc')),
};
}
Create in options/queries/{media}/library.ts:
export function mediaQueryOptions(props: MediaQueryProps) {
return queryOptions({
queryKey: ['{media}', props],
queryFn: async () => await fetchMediaItems(props),
staleTime: 2 * 60 * 1000,
});
}
export function featuredMediaQuery() {
return queryOptions({
queryKey: ['{media}', 'featured'],
queryFn: async () => await fetchFeaturedMedia(),
staleTime: 2 * 60 * 1000,
});
}
Create in hooks/filters/use-{media}-filters.ts:
import { parseAsString, parseAsStringLiteral, parseAsArrayOf, parseAsInteger, useQueryStates } from 'nuqs';
export const sortFields = ['added', 'title', 'year', 'rating'] as const;
export const sortDirections = ['asc', 'desc'] as const;
export const tabValues = ['all', 'downloaded', 'missing', 'wanted'] as const;
export const viewModes = ['grid', 'list'] as const;
const filterParsers = {
q: parseAsString.withDefault(''),
sort: parseAsStringLiteral(sortFields).withDefault('title'),
dir: parseAsStringLiteral(sortDirections).withDefault('asc'),
tab: parseAsStringLiteral(tabValues).withDefault('all'),
view: parseAsStringLiteral(viewModes).withDefault('grid'),
genres: parseAsArrayOf(parseAsString).withDefault([]),
yearMin: parseAsInteger,
yearMax: parseAsInteger,
ratingMin: parseAsInteger,
};
export function useMediaFilters() {
const [filters, setFilters] = useQueryStates(filterParsers, {
history: 'replace',
shallow: true,
});
// ... setter helpers, clearFilters, activeFilterCount
return { filters, setFilters, /* helpers */ };
}
// page.tsx โ Server component, pure composition
export default function MediaPage() {
return (
<Suspense>
<FeaturedMedia />
<MediaGrid />
</Suspense>
);
}
Each component manages its own query. The grid owns the filter hook and passes filter state to query options. URL state is managed via nuqs (shareable, bookmarkable).
The grid renders status tabs with counts from stats, and uses <MediaGrid> from components/media/ for the actual grid rendering. Card/list item rendering uses <MediaCard> and <MediaItem> from components/media/items/.
app/(protected)/media/{media}/[id]/
โโโ page.tsx
โโโ _components/
โโโ {media}-header/
โ โโโ index.tsx
โ โโโ library-status-badge.tsx
โโโ {media}-stats.tsx
โโโ {media}-tabs/
โโโ index.tsx
โโโ overview-tab/
โ โโโ index.tsx
โ โโโ section-header.tsx
โ โโโ overview-section.tsx
โ โโโ cast-section.tsx
โ โโโ gallery-section.tsx
โ โโโ recommendations-section.tsx
โ โโโ similar-section.tsx
โ โโโ ... more sections
โโโ files-tab.tsx
โโโ history-tab.tsx
โโโ manage-tab.tsx
// page.tsx โ Async server component
type PageProps = { params: Promise<{ id: string }> };
export default async function Page({ params }: PageProps) {
const { id } = await params;
const parsedId = parseInt(id, 10);
if (isNaN(parsedId) || parsedId <= 0) notFound();
return (
<>
<MediaHeader id={parsedId} />
<MediaStats id={parsedId} />
<MediaTabs id={parsedId} />
</>
);
}
The header is the hero section with backdrop image, poster, title, metadata, and library status:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Backdrop Image (original, aspect-[2.4/1]) โ
โ โโโโโโโโโโโโ โ
โ โ โ โ gradient overlays (LโR, BโT) โ
โ โ Poster โ Title โ
โ โ (w500) โ Year ยท Runtime ยท Rating ยท Genre โ
โ โ 2:3 โ [Library Badge] [Trailer Btn] โ
โ โโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Uses its own query (e.g., tmdbMovieQueryOptions(id)) with <Query> wrapper.
Grid of 4 stat cards showing key metrics. May combine multiple queries with <Queries>:
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCard label="Rating" value="8.5" icon={Star} />
<StatCard label="Status" value="Downloaded" icon={CheckCircle2} />
{/* ... */}
</div>
The tab container queries library status to determine which tabs to show:
function MediaTabs({ id }: Props) {
const libraryQuery = useQuery(libraryLookupOptions(id));
return (
<Query
result={libraryQuery}
callbacks={{
loading: TabsLoading,
error: (error) => <TabsError error={error} />,
success: (data) => (
<MediaTabsContent id={id} inLibrary={data.inLibrary} />
),
}}
/>
);
}
function MediaTabsContent({ id, inLibrary }: Props) {
return inLibrary ? (
<Tabs defaultValue="overview">
<TabsList className="mb-6 grid w-full grid-cols-4 bg-muted/20">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
<TabsTrigger value="manage">Manage</TabsTrigger>
</TabsList>
<TabsContent value="overview"><OverviewTab id={id} /></TabsContent>
<TabsContent value="files"><FilesTab id={id} /></TabsContent>
<TabsContent value="history"><HistoryTab id={id} /></TabsContent>
<TabsContent value="manage"><ManageTab id={id} /></TabsContent>
</Tabs>
) : (
<OverviewTab id={id} />
);
}
The overview tab composes independent sections:
function OverviewTabContent({ data }: Props) {
return (
<div className="flex flex-col space-y-8">
<OverviewSection overview={data.overview} /> {/* Data-passed */}
<GallerySection id={data.id} /> {/* Own query */}
<CastSection id={data.id} /> {/* Own query */}
<CrewSection id={data.id} /> {/* Own query (shared cache with cast) */}
<DetailsSection data={data} id={data.id} /> {/* Hybrid */}
<ProductionSection data={data} /> {/* Data-passed */}
<ExternalLinksSection data={data} /> {/* Data-passed */}
</div>
);
}
| Pattern | When to use | Example |
|---|---|---|
| Data-passed | Simple display, no extra fetch needed | OverviewSection, ProductionSection |
| Independent query | Needs separate API data | CastSection, GallerySection |
| Hybrid | Receives some data, child fetches more | DetailsSection (has KeywordsSection inside) |
Every section that fetches data follows this structure:
function Section({ id }: Props) {
const query = useQuery(sectionQueryOptions(id));
return (
<Query result={query} callbacks={{
loading: SectionLoading,
error: () => null, // Silent failure
success: (data) => <SectionContent data={data} />,
}} />
);
}
components/media/| Component | Purpose |
|---|---|
MediaGrid |
Grid/list with optional virtualization |
MediaCard |
Poster card with status badge + slots (components/media/items/) |
MediaItem |
Row-based list item with slots (components/media/items/) |
FeaturedMedia |
Hero featured item for browse pages (components/media/browse/) |
MediaBrowse |
Browse grid with category rows (components/media/browse/) |
MediaRow |
Horizontal scrolling category row (components/media/browse/) |
GridEmpty |
Empty state with icon + message |
GridSkeleton |
Loading skeleton for grid/list |
components/query/| Component | Purpose |
|---|---|
Query |
Single query state handler |
Queries |
Multiple query state handler |
components/ui/All shadcn/ui components. Key ones for pages:
Tabs, TabsList, TabsTrigger, TabsContent โ Tab systemBadge โ Status, quality, count badgesButton โ ActionsSkeleton โ Loading statesCarousel โ Horizontal scrolling (cast, gallery)Dialog โ Lightbox, modalsTooltip โ Hover infoAspectRatio โ Consistent image ratiosPopover โ Filter popoversMultiple components on the same page can use the same query without duplicate requests. TanStack Query deduplicates by query key:
MovieHeader โ tmdbMovieQueryOptions(123) โ fetches
MovieStats โ tmdbMovieQueryOptions(123) โ uses cache
OverviewTab โ tmdbMovieQueryOptions(123) โ uses cache
CastSection โ tmdbCreditsQueryOptions(123) โ fetches
CrewSection โ tmdbCreditsQueryOptions(123) โ uses cache
Design your query options to maximize cache sharing across components on the same page.
api/entities/api/functions/library/api/utils/options/queries/hooks/filters/page.tsx โ Server component, pure compositionapi/entities/options/queries/page.tsx โ Async server component, ID validation, three-section composition