Most portfolio and marketing pages hardcode their sections in a single, ever-growing component. It works — until you want to reorder things, hide one section for an A/B test, or reuse a block elsewhere. Every change means editing JSX.
There's a cleaner way: treat the page as data. This post walks through the pattern that drives the homepage of this exact site — src/config/sections.ts, src/features/home/registry.tsx & src/app/page.tsx.
The core idea
Instead of writing sections in order, describe them in a config object and render from it. Three pieces:
- A settings map — which sections are on, in what order & their metadata (nav label, scroll anchor, optional standalone route).
- A registry — mapping each section id to its component.
- A thin page that reads the settings, sorts & renders — and never changes when content does.
The settings map
export interface SectionSetting {
enabled: boolean;
order: number;
label: string;
anchor: string;
route?: string;
}
export const sectionSettings: Record<SectionId, SectionSetting> = {
hero: { enabled: true, order: 10, label: "Intro", anchor: "hero" },
"featured-projects": {
enabled: true,
order: 40,
label: "Projects",
anchor: "projects",
},
certifications: {
enabled: false,
order: 100,
label: "Certifications",
anchor: "certifications",
route: "/certifications",
},
// …
};
export const getOrderedSectionIds = (): SectionId[] =>
(Object.keys(sectionSettings) as SectionId[])
.filter((id) => sectionSettings[id].enabled)
.sort((a, b) => sectionSettings[a].order - sectionSettings[b].order);Because the keys are a typed union (SectionId), TypeScript guarantees you can't reference a section that doesn't exist — adding a new section starts with extending that union, so the compiler forces every config site to catch up.
The label and anchor aren't decoration — they're consumed elsewhere: anchor becomes the section's DOM id (scroll-mt-24 navigation target & the target of in-page search-palette jumps) & label feeds the site's nav config.
The registry
The registry maps ids to components. Marking it Partial lets configuration run ahead of implementation — a section can be enabled before its component exists & the page just skips it instead of throwing.
type SectionComponent = ComponentType | (() => Promise<ReactNode>);
export const sectionRegistry: Partial<Record<SectionId, SectionComponent>> = {
hero: Hero,
"featured-projects": FeaturedProjects,
// …
};The component type deliberately allows an async function component, not just a plain ComponentType. Several sections here fetch their own data server-side (GitHub activity, latest blog posts) — the registry doesn't care whether a section is sync or async, it just renders whatever comes back.
Rendering the page
const HomePage = () => {
const sectionIds = getOrderedSectionIds();
return (
<main id="main">
{sectionIds.map((id) => {
const Section = sectionRegistry[id];
return Section ? (
<Suspense key={id} fallback={null}>
<Section />
</Suspense>
) : null;
})}
</main>
);
};Each section streams inside its own Suspense boundary. A slow data-fetching section (say, one hitting the GitHub API) doesn't block the sections above or below it from painting — the page composes independently-streaming slices, not one monolithic render.
That's the whole page. It never changes when content does.
The standalone-route escape hatch
Not every section wants to live only on the homepage. Certifications, for example, has its own dedicated page at /certifications — but it's currently toggled off from the homepage feed (enabled: false). That's what the optional route field on SectionSetting is for.
Two guard functions read the same config to keep the homepage, the nav & the standalone page all in sync:
export const isSectionEnabled = (id: SectionId): boolean =>
sectionSettings[id].enabled;
const disabledSectionRoutes = new Set(
(Object.keys(sectionSettings) as SectionId[])
.map((id) => sectionSettings[id])
.filter((s) => !s.enabled && s.route)
.map((s) => s.route as string),
);
export const isRouteEnabled = (href: string): boolean =>
!disabledSectionRoutes.has(href);isRouteEnabled filters the nav config — so a disabled section's standalone route quietly disappears from the header and footer instead of leaving a dead link. isSectionEnabled gates the page itself:
const CertificationsPage = () => {
if (!isSectionEnabled("certifications")) {
notFound();
}
// …
};One boolean in one config file turns a whole feature — homepage tile, nav link & standalone page — on or off. There's nowhere else to remember to check.
Why this scales
This is the Open/Closed Principle in practice: the page is open to extension (add a section to config + registry) but closed to modification (you never edit the page itself). It also keeps layout decisions in one reviewable place, which is a gift when you come back six months later.
The same pattern generalizes to dashboards, settings screens & any surface where the arrangement and availability of blocks is itself a product decision.