Pages and Components: Make It Yours
Replace the demo home page with your own. Learn what a component is, how files import and export them, and build your first pieces by hand — a Hero and a Card — wired together and passed real data through props.
Time to make the template yours. Last lesson you found the file that draws the demo —
apps/web/src/app/page.tsx and its one <Demo /> line. This lesson you replace it. By
the end the home page shows your content, built from components you create.
This one time, you'll do it yourself — not by prompting the agent. From here on the
agent builds components for you constantly, but creating the first few files yourself is
the fastest way to actually get what it's doing. Copy-paste the snippets below into the
files — no need to type them out — and read the notes beside each. Once you've felt how
the pieces snap together, you'll read the agent's output instantly and direct it far
better. So: editor open, bun dev running, let's build.
What a component is
A component is a reusable piece of UI — think of it as a Lego brick. A button is a component. A card is a component. A whole page is just bricks snapped together.
Three things to hold onto:
- They nest. A page holds a hero, which holds a card, which holds a button — bricks inside bricks. You'll build exactly that nesting today.
- You write one, use it anywhere. Build a
Cardonce, drop it on five pages. - Each lives in its own file. One brick, one file — easy to find, easy to change.
That's the whole mental model. Everything below is just how you snap bricks together in code.
Files, exports, and imports
A component lives in a file and exports itself — "here's my brick, anyone can use it." Another file imports it — "I'd like to use that brick here." That's the entire wiring system.
You already saw both halves last lesson, in page.tsx:
import { Demo } from "@workspace/ui/components/demo";
// ↑ import the Demo brick from the shared UI package
const Page = () => (
<div className="flex min-h-svh items-center justify-center">
<Demo />
{/* ↑ snap it into the page */}
</div>
);
export default Page;
// ↑ export this page so Next.js can show itThe <Demo /> line is the brick we're about to swap out. (These are static imports —
written at the top of the file, the normal kind. You don't need any other kind for a
long while.)
What's that
<div>stuff? What a component returns looks like HTML but lives inside your code — it's called JSX. For now, read it as "the markup this brick draws."classNameattaches styling; ignore the specific classes until next lesson.
Build the Card
We'll start from the inside out — the smallest brick first. Make a little call-to-action card: a line of text and a button.
In VS Code, find apps/web/src/components in the left sidebar, right-click it →
New File → name it card.tsx. Paste this in:
import Link from "next/link";
export const Card = () => (
<div className="flex flex-col items-start gap-3 rounded-xl border p-6">
<p>Curious who's behind this?</p>
<Link className="rounded-md bg-foreground px-4 py-2 text-background" href="/about">
Learn more
</Link>
</div>
);What's all the
className="…"? That's Tailwind — it gives the card its border and padding and makes the link look like a button. Don't worry about reading it yet; styling is the whole next lesson. It's here only so your page looks like a real card instead of bare text. Focus on the structure, not the classes.
Why
Linkand not a plain<a>?Linkis Next.js's version of a link — that's theimport Link from "next/link"line up top. In the browser it still becomes an ordinary<a>, but for links to your own pages it switches between them instantly, with no full reload; for an email or outside link it just behaves like a normal link. So oneCardcan safely use it for both the/aboutlink here and themailto:button you'll add later.
Read what you just made:
export const Card = () => (...)defines the brick and exports it. The name is Capitalized — React requires that capital so it knowsCardis a component, not plain HTML. (The file is lowercase,card.tsx; that's just convention.)- It returns JSX: a line of text (
<p>) and a button that's really a link (<Link href=…>) — clicking it goes whereverhrefpoints. Here that's/about, a page we'll build soon.
Right now everything inside it is hardcoded — the words and the link are baked into the file. That's the simplest way to start. Hold that thought; we'll come back to it.
Build the Hero (it uses the Card)
Now the bigger brick. A hero is the intro block at the top of a page — your name and
title — and ours will hold the Card inside it. Create
apps/web/src/components/hero.tsx:
import { Card } from "@/components/card";
export const Hero = () => (
<div className="flex flex-col items-center gap-6 text-center">
<h1 className="font-bold text-4xl tracking-tight">Andrey Markin</h1>
<p className="text-lg text-muted-foreground">Building things on the internet with AI agents.</p>
<Card />
</div>
);Put your own name and subtitle in. The line that matters is the last one inside the
<div>: Hero imports the Card brick and uses it — <Card /> — exactly the way a
page will use <Hero />. Bricks inside bricks. (The @/components/card path uses the
@/ shortcut from last lesson: "this app's src/components.")
Like the Card, everything here is hardcoded for now.
Put it on the home page
Open apps/web/src/app/page.tsx and replace the whole thing with this — your Hero in
place of <Demo />:
import { Hero } from "@/components/hero";
const Page = () => (
<div className="flex min-h-svh flex-col items-center justify-center">
<Hero />
</div>
);
export default Page;Save, then open https://web.localhost. The wall of buttons is gone — your name, your subtitle, and a "Learn more" link are on screen. Plain and unstyled, exactly right for now.
See an error instead? Make sure
bun devis still running, then paste the error to your agent: "I wrote this and got this error — what's wrong?" Getting unstuck this way is a core skill, not cheating.
Reuse it on a second page — and hit a wall
Remember the rule from last lesson: folders in app/ become pages, the path becomes
the URL. So a file at app/about/page.tsx gives you /about — the page that "Learn
more" button is already pointing at. Let's build it and reuse the same Hero.
But there's a catch. On the About page you'd want the call to action to say something
different — "Schedule a meeting" that opens your email, not "Learn more" that goes
to /about. Yet every word in your Card and Hero is hardcoded. Drop <Hero />
on the About page and you'd get the exact same "Learn more" card. The bricks aren't
truly reusable yet — they only know how to say one thing.
This is the moment props exist for.
Props: make the brick configurable
Props are inputs to a component — settings you pass in when you use it. Instead of
baking the text into the Card, you let whoever uses it decide. Same brick, different
contents.
Turn the hardcoded Card into a configurable one. Update card.tsx — the - lines are
what you remove, the + lines are what you add:
import Link from "next/link";interface CardProps { text: string; buttonLabel: string; href: string;}export const Card = () => (export const Card = ({ text, buttonLabel, href }: CardProps) => ( <div className="flex flex-col items-start gap-3 rounded-xl border p-6"> <p>Curious who's behind this?</p> <Link className="rounded-md bg-foreground px-4 py-2 text-background" href="/about"> Learn more <p>{text}</p> <Link className="rounded-md bg-foreground px-4 py-2 text-background" href={href}> {buttonLabel} </Link> </div>);What changed:
CardPropslists the brick's inputs —text,buttonLabel,href, all text (string). This is TypeScript describing whatCardexpects, so you can't forget one or pass the wrong kind.- The hardcoded sentence became
{text}, and the hardcoded link became{href}and{buttonLabel}. Those curly braces mean "drop the value in here." The Card no longer decides what it says — its caller does.
Now do the same to the Hero, so it can be configured too — and so it can pass the
call-to-action values down into the Card:
import { Card } from "@/components/card";interface HeroProps { name: string; subtitle: string; ctaText: string; ctaLabel: string; ctaHref: string;}export const Hero = () => (export const Hero = ({ name, subtitle, ctaText, ctaLabel, ctaHref }: HeroProps) => ( <div className="flex flex-col items-center gap-6 text-center"> <h1 className="font-bold text-4xl tracking-tight">Andrey Markin</h1> <p className="text-lg text-muted-foreground">Building things on the internet with AI agents.</p> <h1 className="font-bold text-4xl tracking-tight">{name}</h1> <p className="text-lg text-muted-foreground">{subtitle}</p> <Card /> <Card text={ctaText} buttonLabel={ctaLabel} href={ctaHref} /> </div>);Look at that last + line: Hero takes the ctaText, ctaLabel, and ctaHref it
receives and hands them down to the Card. Hero is now a middle layer — data comes
in from above, and it forwards the relevant pieces to the brick below.
Now use it twice — differently
With both bricks configurable, the home page has to actually pass the values in. Update
apps/web/src/app/page.tsx:
import { Hero } from "@/components/hero";
const Page = () => (
<div className="flex min-h-svh flex-col items-center justify-center">
<Hero
name="Andrey Markin"
subtitle="Building things on the internet with AI agents."
ctaText="Curious who's behind this?"
ctaLabel="Learn more"
ctaHref="/about"
/>
</div>
);
export default Page;Same page as before — but now the words live in the props, not buried inside the
brick. And that is what unlocks the second page. Create
apps/web/src/app/about/page.tsx with the same Hero, different values:
import { Hero } from "@/components/hero";
const AboutPage = () => (
<div className="flex min-h-svh flex-col items-center justify-center">
<Hero
name="About me"
subtitle="A few words about what I build and why."
ctaText="Like what you see?"
ctaLabel="Schedule a meeting"
ctaHref="mailto:you@example.com"
/>
</div>
);
export default AboutPage;Open https://web.localhost and click "Learn more" — it takes
you to the About page, where the very same Hero and Card now say "Schedule a meeting"
and open an email. One definition of each brick, reused on two pages, saying different
things — because the words come in through props. That's the whole payoff: build the
brick once, pour in different data each time.
How the data flows
Step back and look at what you built. The values you set on each page travel down through three layers of bricks:
page.tsx you set the values here
└─ <Hero name … ctaText="Curious who's behind this?" ctaLabel="Learn more" ctaHref="/about" />
└─ <Card text="Curious who's behind this?" buttonLabel="Learn more" href="/about" />
└─ <Link href="/about">Learn more</Link> what finally rendersPage knows the content. Hero forwards the call-to-action parts to Card. Card
turns them into a real link. Each brick handles only its own job and passes the rest
along. This is the core pattern of every component-based app — and you just built it
by hand.
One place to configure: a profile file
Notice a smell: your name and subtitle are now typed into two files — the home page and the About page. Change your name and you'd have to remember to edit both. The fix is the same idea as a component, but for data: keep it in one place and pull it in wherever you need it.
Make a new folder apps/web/src/lib, and inside it a file profile.ts. (lib is just a
common name for "supporting code that isn't a component.") Put who you are in one
object — name the const after yourself:
export const andrey = {
name: "Andrey Markin",
subtitle: "Building things on the internet with AI agents.",
description:
"Andrey Markin builds web apps and bots with AI coding agents — and teaches others to do the same.",
email: "you@example.com",
};Use your own name for the const — this object is literally you, so andrey reads
truer than a generic person. It's a plain constant: no JSX, no component, just
data. It exports andrey exactly the way your components export themselves, so any
file can import it. (.ts not .tsx, because there's no markup in it.)
Now feed it into your pages instead of hardcoded text. The home page becomes:
import { Hero } from "@/components/hero";
import { andrey } from "@/lib/profile";
const Page = () => (
<div className="flex min-h-svh flex-col items-center justify-center">
<Hero
name={andrey.name}
subtitle={andrey.subtitle}
ctaText="Curious who's behind this?"
ctaLabel="Learn more"
ctaHref="/about"
/>
</div>
);
export default Page;And the About page pulls from the same object — including your email for the button:
import { Hero } from "@/components/hero";
import { andrey } from "@/lib/profile";
const AboutPage = () => (
<div className="flex min-h-svh flex-col items-center justify-center">
<Hero
name={andrey.name}
subtitle={andrey.subtitle}
ctaText="Like what you see?"
ctaLabel="Schedule a meeting"
ctaHref={`mailto:${andrey.email}`}
/>
</div>
);
export default AboutPage;andrey.name reads "the name key out of the andrey object." And `mailto:${andrey.email}` builds the link from your email — the backticks let you drop a value into
the middle of text with ${…}.
That's the win: your name, subtitle, and email now live in exactly one place. Edit
profile.ts once and both pages update. You just used import/export to share data the
same way you used it to share components — same tool, two jobs. As the site grows, this
one file becomes the dial you turn to reconfigure it.
Bonus: be found — page titles for search and shares
One more thing that profile object is perfect for: SEO — the title and description
search engines and chat apps show for your page. In the Next.js App Router, a page sets
these by exporting a metadata object. Add it to the top of your home page,
apps/web/src/app/page.tsx:
import type { Metadata } from "next";import { Hero } from "@/components/hero";import { andrey } from "@/lib/profile";export const metadata: Metadata = { title: andrey.name, description: andrey.description,};const Page = () => ( <div className="flex min-h-svh flex-col items-center justify-center"> <Hero name={andrey.name} subtitle={andrey.subtitle} ctaText="Curious who's behind this?" ctaLabel="Learn more" ctaHref="/about" /> </div>);export default Page;Save and look at your browser tab — it now reads your name instead of the default.
That title is what shows in the tab, in Google results, and on the search engine's
listing; description is the gray line of text underneath. Both come straight from the
same andrey object — write yourself once, reuse everywhere, even for search engines.
Give the About page its own metadata too, e.g.
title: "About — " + andrey.name. Each page can export its own. (Metadatais a type from Next.js that describes the allowed fields — your editor will autocomplete them.)
What's next
You built a Hero and a Card from scratch, nested one inside the other, passed data
down through both across two pages, then pulled your details into a single profile.ts
and even wired it into your page titles. That's components, props, import/export,
single-source config, and a first taste of SEO — all by hand. But it's bare. Next lesson:
Tailwind for styling, and you'll meet shadcn/ui's ready-made Card — a polished
version of the brick you just hand-built — and swap yours for it. Doing it by hand first
is exactly what makes that click.