React: Pass a Component as a Prop, the Way This Codebase Does It 35 Times
Every icon on this site is JSX assigned to a data field, not a name looked up inside a component. 35 instances across 5 data files, one helper function pattern.
Pass a component as a prop by writing the JSX where you'd normally write a string, then rendering {prop} on the receiving end — no children, no render-prop function, no extra wrapper. This codebase does exactly that 35 times: every section's icon is a pre-built <svg> handed to a molecule as an ordinary object field, never re-derived inside it.
The short answer
"Passing a component as a prop" means the value of a prop is JSX (or a ReactNode) rather than a string or number — <FeatureCard icon={<svg>...</svg>} /> instead of <FeatureCard iconName="check" />. The receiving component doesn't know or care what produced the element; it just renders {icon} wherever the icon belongs. It's the same mechanism as passing children, just under a name you choose yourself.
Where this codebase actually does it
CLAUDE.md states the rule for src/data: "Files are .tsx because items embed icon/image JSX." Every section's content array is .tsx, not .ts, for exactly this reason — the data can't be plain JSON because some of its fields are React elements.
| File | icon: field type | JSX literals |
|---|---|---|
src/data/features.tsx | ReactNode | 14 |
src/data/heroTech.tsx | ReactNode | 7 |
src/data/footer.tsx | ReactNode | 6 |
src/data/techStack.tsx | ReactNode | 5 |
src/data/plugins.tsx | ReactNode | 3 (via a helper, below) |
| Total | 35 |
FeatureCard, the molecule that renders one of those 14 features.tsx entries, never imports an icon library and never switches on a name string:
// src/components/molecules/FeatureCard.tsx
import type { FeatureItem } from "@/data/features";
export default function FeatureCard({ icon, title, description }: FeatureItem) {
return (
<div className="rounded-3xl border border-stroke-secondary bg-gray-50 p-1 duration-200 hover:border-primary-200 hover:bg-primary-25 md:p-2">
<div className="h-full rounded-2xl border border-[#F2F4F7] bg-white p-4 md:p-6">
<div className="mb-7.5 text-primary">{icon}</div>
<h3 className="mb-4 text-xl font-semibold text-title-color md:text-2xl lg:text-xl xl:text-2xl">
{title}
</h3>
<p className="text-base !leading-normal text-text-color-secondary">
{description}
</p>
</div>
</div>
);
}
{icon} is the entire integration. Whatever icon holds — an inline <svg>, an <img>, eventually even another component instance — renders exactly where it's placed, with zero branching in the molecule. The type signature backs this up: FeatureItem.icon is declared as ReactNode, React's own "anything renderable" type, not string or an enum of known icon names.
FeaturePill — the compact pill variant used elsewhere on the home page — repeats the identical shape from a different data file:
// src/components/molecules/FeaturePill.tsx
import type { FeaturePillItem } from "@/data/features";
export default function FeaturePill({ icon, title }: FeaturePillItem) {
return (
<div className="flex items-center gap-4 rounded-3xl border border-stroke-secondary bg-white px-4 py-3 duration-200 hover:border-primary-200 md:px-7.5 md:py-6">
<div className="flex items-center gap-4">
<div className="text-primary">{icon}</div>
<h3 className="text-lg font-semibold text-text-color md:text-xl lg:text-lg xl:text-xl">
{title}
</h3>
</div>
</div>
);
}
Two different molecules, two different data files, the same one-line contract: receive a ReactNode, render it, style its container. Neither file needs to know an icon exists as a concept — it just holds a slot.
The function-that-returns-JSX variant
src/data/plugins.tsx does the same thing through one extra layer — a small function that produces the element instead of writing it inline three times:
// src/data/plugins.tsx
const placeholderLogo = (alt: string): ReactNode => (
<img
alt={alt}
decoding="async"
height="44"
loading="lazy"
src="/images/placeholder-logo.svg"
style={{ color: "transparent" }}
width="44"
/>
);
export const pluginCards: PluginItem[] = [
{ icon: placeholderLogo("Apex Charts"), title: "Apex Charts", description: "..." },
{ icon: placeholderLogo("Jsvectormap"), title: "Jsvectormap", description: "..." },
{ icon: placeholderLogo("Prettier"), title: "Prettier", description: "..." },
];
placeholderLogo isn't a component — it's a plain function typed to return ReactNode, called three times while the module loads, not three times per render. Each call produces one already-built <img> element that gets frozen into the pluginCards array alongside its title and description. PluginCard, the molecule that consumes it, is just as unaware of placeholderLogo's existence as FeatureCard is of raw JSX literals — it still only sees icon: ReactNode and renders {icon}.
That distinction matters the moment a prop needs to react to something at render time rather than be computed once at import time. A function called during module evaluation can't read a component's state or props — if the eventual design needs the icon's color to change with a hover state that isn't purely CSS, the element has to move into the render path (a real component, or a render-prop function evaluated inside PluginCard) instead of staying a value computed once at the top of the file. Nothing here needs that yet, which is exactly why the simpler shape — a value, not a function-as-prop — is the right one.
Why not a prop-drilled string instead
The alternative most tutorials reach for is an icon name plus a lookup table inside the component:
// what this codebase does NOT do
const ICONS = { check: <CheckIcon />, star: <StarIcon /> };
function FeatureCard({ iconName, title, description }: { iconName: keyof typeof ICONS; ... }) {
return <div>{ICONS[iconName]}...</div>;
}
That shape forces every consumer of FeatureCard to agree on a shared icon vocabulary, and it forces the molecule to import every icon any caller might ever request — even the ones only techStack.tsx uses. Passing the element itself instead means each data file owns exactly the icons it needs, FeatureCard never imports an icon library at all, and a brand-new one-off SVG for a single feature costs nothing beyond writing it where it's used.
Comparison: value vs. render prop vs. children
| Pattern | Looks like | Re-evaluated on parent re-render? | Used in this codebase |
|---|---|---|---|
| Element as a value (this repo) | icon: <Svg /> in a data object | No — built once, held as a reference | 35 times, src/data/*.tsx |
| Function-returning-element | icon: placeholderLogo("x") | No — called once at module load | 3 times, plugins.tsx |
| Render prop | render={(state) => <Svg color={state.hue} />} | Yes — called by the parent every render | 0 times |
children | <Card>{content}</Card> | Depends on where content is created | Used for page-level composition (Container, templates), not icons |
The house rule from CLAUDE.md explains the boundary directly: "Molecules never import runtime values from data files (type-only imports OK) — organisms compute and pass props." An icon is exactly that kind of runtime value, so it's computed once, at the data layer, and passed down — never reconstructed inside the molecule that displays it.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| TypeScript build fails on a lowercase SVG tag inside a data file | <clippath>/<lineargradient> etc. compile in next dev but fail next build's type-check | Use JSX casing — clipPath, linearGradient — per the convention CLAUDE.md documents for every inline SVG in this repo |
| An icon renders once and never updates | The element was built by a plain function at import time (like placeholderLogo), so it holds no reference to component state | Move the icon into the component's own render path (a real child component or a render prop) if it ever needs to react to props |
| A new icon "leaks" into an unrelated bundle | An icon library was imported inside a shared molecule instead of passed in as a prop | Import the icon only in the data file that needs it; keep the molecule generic over ReactNode |
| Passing a component type instead of an element throws or renders nothing | icon={Svg} (the function reference) was passed where icon={<Svg />} (an already-invoked element) was expected | Invoke it — <Svg /> — before assigning it to the prop; a ReactNode prop wants an element, not a component reference |
| Prettier keeps re-sorting classNames inside a JSX-as-prop icon differently than expected | prettier-plugin-tailwindcss sorts class order inside any JSX it can see, including icons buried in data files | This is expected — per CLAUDE.md, class order is irrelevant as long as every class survives |
Frequently asked questions
Is passing a component as a prop the same as children?
Mechanically yes — both are ReactNode values rendered with {}. The difference is naming and cardinality: children is React's implicit single slot for "whatever's nested inside the tag," while a named prop like icon lets a component accept several independent renderable slots (icon, plus a separate title, description) at once.
Why not just pass an icon name string and look it up inside the component? That works, but it makes the component own a fixed vocabulary of icons it must import, whether or not a given page uses them. Passing the element directly means the data file — the only place that actually needs a specific icon — is the only place that imports it.
Does this pattern cost anything at runtime?
No more than any other JSX. A ReactNode held in a data array is just a lightweight React element description; nothing renders until the tree that includes it actually mounts.
When should a function-returning-JSX (like placeholderLogo) become a real component instead?
The moment it needs access to component state, context, or per-render props. A function called once during module load — like placeholderLogo here — can't react to anything; if the design later needs the icon's appearance to depend on hover, theme, or another prop, it has to move into the actual render path.
Templates in this post
ASoc Sentinel, ASoc Signal and ASoc Sterling are Next.js + Tailwind landing page templates built on the same FeatureCard/FeaturePill components audited above — each one's feature grid is just a new src/data/*.tsx array of icon-and-copy pairs, no molecule changes required.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
