Reusable UI Patterns with Client/Server Components, Props, Slots, and State Handling
A clean UI is made up of modular, reusable components — think buttons, cards, layouts, and forms. In this module, you’ll learn how to properly build these components using TypeScript, the App Router, and the Client vs Server Component model in Next.js 14.
⚙️ Client vs Server Components — The Core Idea
Next.js 14 separates components into two types:
Component Type | Purpose | Usage |
---|---|---|
Server Component | Default (no "use client" ) |
Data fetching, backend logic |
Client Component | Marked with "use client" at top |
Hooks, interactivity, state |
This allows your app to reduce JS on the client, improving performance. You only use client components when necessary.
🔁 Example: A Static Hero
Server Component
// src/components/Hero.tsx (Server Component)
export default function Hero() {
return (
<section className="bg-blue-100 py-12 text-center">
<h1 className="text-4xl font-bold">Welcome to Our App</h1>
<p className="text-lg mt-2">Next.js 14 + TypeScript + Tailwind</p>
</section>
);
}
🧩 Creating Reusable Components
📦 Button.tsx
(Client Component with Props)
// src/components/ui/Button.tsx
"use client";
type Props = {
label: string;
onClick?: () => void;
variant?: "primary" | "secondary";
};
export default function Button({ label, onClick, variant = "primary" }: Props) {
const base = "px-4 py-2 rounded font-semibold";
const styles =
variant === "primary"
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-200 text-black hover:bg-gray-300";
return (
<button onClick={onClick} className={`${base} ${styles}`}>
{label}
</button>
);
}
🧪 Usage:
import Button from '@/components/ui/Button';
export default function ExamplePage() {
return (
<div className="p-8 space-y-4">
<Button label="Click Me" onClick={() => alert("Hello!")} />
<Button label="Secondary" variant="secondary" />
</div>
);
}
🃏 Card.tsx
with children
Slot
// src/components/ui/Card.tsx
type Props = {
title: string;
children: React.ReactNode;
};
export default function Card({ title, children }: Props) {
return (
<div className="p-4 rounded border shadow-sm bg-white dark:bg-gray-800">
<h3 className="text-xl font-bold mb-2">{title}</h3>
{children}
</div>
);
}
🧪 Usage:
import Card from "@/components/ui/Card";
<Card title="Card Title">
<p>This is card content.</p>
</Card>
🧨 Handling Loading & Error States
Next.js encourages UI-based boundaries over try-catch.
⚡ loading.tsx
(Auto-used while page loads)
// src/app/blog/loading.tsx
export default function Loading() {
return <p className="text-center">⏳ Loading blog posts...</p>;
}
❌ error.tsx
with Error Boundary
// src/app/blog/error.tsx
'use client';
export default function Error({ error }: { error: Error }) {
return <div className="text-red-500">Error loading blog: {error.message}</div>;
}
You can manually trigger this using
throw new Error('Failed')
inside a client component.
🔂 Dynamic Lists with Components
Here’s how you can dynamically render cards from an array of data:
🧪 BlogList.tsx
// src/components/BlogList.tsx
type Post = {
id: number;
title: string;
content: string;
};
const posts: Post[] = [
{ id: 1, title: "First Post", content: "This is the first blog post." },
{ id: 2, title: "Second Post", content: "This is the second blog post." },
];
import Card from "./ui/Card";
export default function BlogList() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{posts.map((post) => (
<Card key={post.id} title={post.title}>
<p>{post.content}</p>
</Card>
))}
</div>
);
}
Full Code Here:
/* Project Structure: src/ ├── app/ │ ├── blog/ │ │ ├── loading.tsx │ │ ├── error.tsx │ │ └── page.tsx (optional demo page) ├── components/ │ ├── BlogList.tsx │ ├── Hero.tsx │ └── ui/ │ ├── Button.tsx │ └── Card.tsx */ // src/components/Hero.tsx (Server Component) export default function Hero() { return ( <section className="bg-blue-100 py-12 text-center"> <h1 className="text-4xl font-bold">Welcome to Our App</h1> <p className="text-lg mt-2">Next.js 14 + TypeScript + Tailwind</p> </section> ); } // src/components/ui/Button.tsx (Client Component) "use client"; type ButtonProps = { label: string; onClick?: () => void; variant?: "primary" | "secondary"; }; export default function Button({ label, onClick, variant = "primary" }: ButtonProps) { const base = "px-4 py-2 rounded font-semibold"; const styles = variant === "primary" ? "bg-blue-600 text-white hover:bg-blue-700" : "bg-gray-200 text-black hover:bg-gray-300"; return ( <button onClick={onClick} className={`${base} ${styles}`}> {label} </button> ); } // src/components/ui/Card.tsx import React from "react"; type CardProps = { title: string; children: React.ReactNode; }; export default function Card({ title, children }: CardProps) { return ( <div className="p-4 rounded border shadow-sm bg-white dark:bg-gray-800"> <h3 className="text-xl font-bold mb-2">{title}</h3> {children} </div> ); } // src/components/BlogList.tsx import Card from "./ui/Card"; type Post = { id: number; title: string; content: string; }; const posts: Post[] = [ { id: 1, title: "First Post", content: "This is the first blog post." }, { id: 2, title: "Second Post", content: "This is the second blog post." }, ]; export default function BlogList() { return ( <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {posts.map((post) => ( <Card key={post.id} title={post.title}> <p>{post.content}</p> </Card> ))} </div> ); } // src/app/blog/loading.tsx export default function Loading() { return <p className="text-center">⏳ Loading blog posts...</p>; } // src/app/blog/error.tsx "use client"; export default function Error({ error }: { error: Error }) { return <div className="text-red-500">Error loading blog: {error.message}</div>; } // src/app/page.tsx (Example usage) import Hero from "../components/Hero"; import BlogList from "../components/BlogList"; import Button from "../components/ui/Button"; export default function HomePage() { return ( <main className="space-y-10 p-6"> <Hero /> <section className="text-center"> <Button label="Click Me" onClick={() => alert("Hello!")} /> <Button label="Secondary" variant="secondary" /> </section> <BlogList /> </main> ); }
🧠 Tips for Scalable Components
- Use folders:
components/ui/
,components/shared/
, etc. - Keep client components minimal — offload logic to server components if possible.
- Use props, default values, and
children
for flexibility. - Create small, testable components.
✅ Summary
You now know how to:
- Build Client and Server Components the right way
- Use props, children, and component slots
- Implement loading/error boundaries with App Router
- Create responsive and reusable UI components using Tailwind CSS
🔜 Next Up:
In Module 5, we’ll dive into State Management — from useState
to useContext
, and advanced setups using Zustand and Redux Toolkit.
Would you like this module as Markdown, HTML, or embedded in your blog structure?