You've learned that page.tsx files create routes, but there's much more to know about building pages in Next.js. In this lesson, we'll dive deep into page components—their structure, props, metadata, TypeScript types, and best practices. By the end, you'll be creating professional, production-ready pages with confidence.
The Anatomy of a page.tsx File
A complete page file typically contains several parts. Let's break down a real example:
import { Metadata } from 'next';
import Link from 'next/link';
// 1. Metadata Export (for SEO)
export const metadata: Metadata = {
title: 'Our Products',
description: 'Browse our amazing product catalog',
};
// 2. TypeScript Interface (optional but recommended)
interface Product {
id: number;
name: string;
price: number;
}
// 3. The Page Component (default export)
export default async function ProductsPage() {
// 4. Data Fetching (Server Component can be async!)
const res = await fetch('https://api.example.com/products');
const products: Product[] = await res.json();
// 5. JSX Return
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-6">Our Products</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{products.map((product) => (
<div key={product.id} className="border rounded-lg p-4">
<h2 className="text-xl font-semibold">{product.name}</h2>
<p className="text-gray-600">${product.price}</p>
<Link
href={`/products/${product.id}`}
className="text-blue-600 hover:underline"
>
View Details
</Link>
</div>
))}
</div>
</div>
);
}Let's understand each part:
1. Metadata Export
Sets the page title, description, and other SEO information. This is crucial for search engines and social media sharing.
2. TypeScript Interfaces
Define the shape of your data for type safety. This catches errors during development.
3. The Page Component
Must be the default export. This is what renders at the route. Can be async for Server Components!
4. Data Fetching
Server Components can fetch data directly with await. No useEffect or useState needed!
5. JSX Return
The actual UI that users see. Use Tailwind classes or your chosen styling method.
Server Component Pages (Default)
By default, every page.tsx is a Server Component. This means:
- It runs on the server during rendering
- It can be async and use await
- It can access backend resources directly
- It doesn't ship JavaScript to the browser (unless needed)
- It can't use React hooks like useState or useEffect
- It can't use browser APIs
// No "use client" = Server Component (default)
export default async function DashboardPage() {
// ✅ Can fetch data directly
const data = await fetch('https://api.example.com/stats').then(r => r.json());
// ✅ Can access environment variables
const apiKey = process.env.API_SECRET_KEY;
// ✅ Can access filesystem
// const file = await fs.readFile('data.json');
return (
<div>
<h1>Dashboard</h1>
<p>Total Users: {data.users}</p>
<p>Total Sales: ${data.sales}</p>
</div>
);
}When to Use Server Components (Most Pages)
- Fetching data from APIs or databases
- Accessing sensitive backend resources
- Static or mostly static content
- SEO-important pages
- When you don't need interactivity
Client Component Pages (When Needed)
If your page needs interactivity, add "use client" at the top:
"use client"; // Makes this a Client Component
import { useState } from 'react';
export default function CounterPage() {
// ✅ Can use React hooks
const [count, setCount] = useState(0);
// ✅ Can use browser APIs
const saveToLocalStorage = () => {
localStorage.setItem('count', count.toString());
};
// ✅ Can handle events
return (
<div className="p-8">
<h1 className="text-4xl font-bold mb-4">Counter</h1>
<p className="text-2xl mb-4">Count: {count}</p>
<div className="space-x-4">
<button
onClick={() => setCount(count + 1)}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Increment
</button>
<button
onClick={() => setCount(count - 1)}
className="px-4 py-2 bg-red-600 text-white rounded"
>
Decrement
</button>
<button
onClick={saveToLocalStorage}
className="px-4 py-2 bg-green-600 text-white rounded"
>
Save
</button>
</div>
</div>
);
}When to Use Client Components
- Forms with real-time validation
- Interactive widgets (counters, toggles, sliders)
- Using React hooks (useState, useEffect, etc.)
- Accessing browser APIs (localStorage, geolocation, etc.)
- Event handlers (onClick, onChange, etc.)
Server vs Client Components
| Feature | Server Component | Client Component |
|---|---|---|
| Runs on | Server (Node.js) | Browser |
| Can use async/await | ✓ Yes | ✓ Yes |
| Can fetch data directly | ✓ Yes | Via API calls |
| Access to databases | ✓ Yes | ✗ No |
| Can use environment variables | All variables | NEXT_PUBLIC_ only |
| Can use useState/useEffect | ✗ No | ✓ Yes |
| Can use event handlers | ✗ No | ✓ Yes |
| Can access browser APIs | ✗ No | ✓ Yes |
| Bundle size impact | No impact | Adds to bundle |
| SEO friendly | ✓ Yes | Depends |
💡 Best Practice: Server First
Start with Server Components by default. Only add "use client" when you specifically need interactivity. This keeps your bundle size small and improves performance.
Page Props: params and searchParams
Next.js automatically passes two props to your page components:
1. params - Route Parameters
Contains dynamic route segments from the URL:
// URL: /products/123
export default function ProductPage({
params,
}: {
params: { id: string };
}) {
// params.id = "123"
return (
<div>
<h1>Product {params.id}</h1>
</div>
);
}For nested dynamic routes:
// URL: /blog/tutorials/nextjs-routing
export default function BlogPostPage({
params,
}: {
params: { category: string; slug: string };
}) {
// params.category = "tutorials"
// params.slug = "nextjs-routing"
return (
<div>
<h1>Category: {params.category}</h1>
<h2>Post: {params.slug}</h2>
</div>
);
}2. searchParams - Query Parameters
Contains URL query parameters (the part after ?):
// URL: /search?q=nextjs&sort=recent
export default function SearchPage({
searchParams,
}: {
searchParams: { q?: string; sort?: string };
}) {
// searchParams.q = "nextjs"
// searchParams.sort = "recent"
const query = searchParams.q || '';
const sortBy = searchParams.sort || 'relevance';
return (
<div>
<h1>Search Results for: {query}</h1>
<p>Sorted by: {sortBy}</p>
</div>
);
}Important: searchParams is Server-Only
The searchParams prop is only available in Server Components. Client Components can't receive it directly. Use the useSearchParams hook in Client Components instead.
Combining Both Props
// URL: /shop/electronics?sort=price&order=asc
export default function CategoryPage({
params,
searchParams,
}: {
params: { category: string };
searchParams: { sort?: string; order?: string };
}) {
const category = params.category; // "electronics"
const sortBy = searchParams.sort; // "price"
const order = searchParams.order; // "asc"
return (
<div>
<h1>{category}</h1>
<p>Sorted by: {sortBy} ({order})</p>
</div>
);
}Adding Metadata for SEO
Metadata is crucial for SEO, social sharing, and browser behavior. Next.js provides two ways to add metadata:
1. Static Metadata (Simple Pages)
Export a metadata object directly:
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About Us',
description: 'Learn about our company and mission',
keywords: ['company', 'about', 'mission'],
authors: [{ name: 'Your Company' }],
openGraph: {
title: 'About Us',
description: 'Learn about our company',
images: ['/about-og.jpg'],
},
twitter: {
card: 'summary_large_image',
title: 'About Us',
description: 'Learn about our company',
images: ['/about-twitter.jpg'],
},
};
export default function AboutPage() {
return <div>About content</div>;
}2. Dynamic Metadata (Based on Data)
Export a generateMetadata function for dynamic content:
import { Metadata } from 'next';
// This runs before the page component
export async function generateMetadata({
params,
}: {
params: { id: string };
}): Promise<Metadata> {
// Fetch product data
const product = await fetch(`https://api.example.com/products/${params.id}`)
.then((res) => res.json());
// Return metadata based on product data
return {
title: product.name,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
images: [product.image],
},
};
}
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await fetch(`https://api.example.com/products/${params.id}`)
.then((res) => res.json());
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}Common Metadata Fields
title- Page title (shown in browser tab)description- Meta description for search engineskeywords- Keywords for SEOauthors- Author informationopenGraph- Open Graph data for social mediatwitter- Twitter Card datarobots- Control search engine crawlingalternates- Canonical URL and language alternates
Async Server Components
One of the most powerful features of Server Components is that they can be async:
// Notice the 'async' keyword
export default async function PostsPage() {
// Fetch data with await - no useEffect needed!
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json());
// You can even do multiple async operations
const categories = await fetch('https://api.example.com/categories')
.then(res => res.json());
const featuredPost = await fetch('https://api.example.com/posts/featured')
.then(res => res.json());
return (
<div>
<h1>Blog Posts</h1>
<section>
<h2>Featured</h2>
<article>{featuredPost.title}</article>
</section>
<section>
<h2>Categories</h2>
{categories.map((cat) => (
<span key={cat.id}>{cat.name}</span>
))}
</section>
<section>
<h2>All Posts</h2>
{posts.map((post) => (
<article key={post.id}>{post.title}</article>
))}
</section>
</div>
);
}⚡ Parallel Data Fetching
For better performance, fetch independent data in parallel:
// Sequential (slower)
const posts = await fetch('/api/posts').then(r => r.json());
const users = await fetch('/api/users').then(r => r.json());
// Parallel (faster!)
const [posts, users] = await Promise.all([
fetch('/api/posts').then(r => r.json()),
fetch('/api/users').then(r => r.json()),
]);Error Handling in Pages
For Server Components, handle errors with try-catch:
export default async function ProductsPage() {
try {
const res = await fetch('https://api.example.com/products');
if (!res.ok) {
throw new Error('Failed to fetch products');
}
const products = await res.json();
return (
<div>
{products.map((product) => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
} catch (error) {
return (
<div>
<h1>Error Loading Products</h1>
<p>Please try again later.</p>
</div>
);
}
}For better UX, create an error.tsx file in the same directory. We'll cover this in detail in the layouts lesson.
TypeScript Best Practices
Always type your page props properly:
Basic Page Props
// Page with no props
export default function HomePage() {
return <div>Home</div>;
}
// Alternative explicit typing
export default function HomePage(): JSX.Element {
return <div>Home</div>;
}Page with Params
interface PageProps {
params: {
id: string;
};
}
export default function UserPage({ params }: PageProps) {
return <div>User ID: {params.id}</div>;
}Page with Params and SearchParams
interface PageProps {
params: {
category: string;
};
searchParams: {
sort?: string;
filter?: string;
page?: string;
};
}
export default function CategoryPage({
params,
searchParams
}: PageProps) {
const page = searchParams.page ? parseInt(searchParams.page) : 1;
return (
<div>
<h1>Category: {params.category}</h1>
<p>Page: {page}</p>
</div>
);
}Async Page
interface Post {
id: number;
title: string;
content: string;
}
export default async function PostsPage(): Promise<JSX.Element> {
const posts: Post[] = await fetch('https://api.example.com/posts')
.then(res => res.json());
return (
<div>
{posts.map(post => (
<article key={post.id}>{post.title}</article>
))}
</div>
);
}Common Page Patterns
1. List Page (Index)
import Link from 'next/link';
interface Post {
id: string;
title: string;
excerpt: string;
date: string;
}
export default async function BlogPage() {
const posts: Post[] = await fetch('https://api.example.com/posts')
.then(res => res.json());
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="space-y-6">
{posts.map((post) => (
<article key={post.id} className="border-b pb-6">
<Link href={/blog/${post.id}`}>
<h2 className="text-2xl font-semibold mb-2 hover:text-blue-600">
{post.title}
</h2>
</Link>
<p className="text-gray-600 mb-2">{post.excerpt}</p>
<time className="text-sm text-gray-500">{post.date}</time>
</article>
))}
</div>
</div>
);
}2. Detail Page
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
interface Post {
id: string;
title: string;
content: string;
author: string;
date: string;
}
interface PageProps {
params: { id: string };
}
export async function generateMetadata({
params
}: PageProps): Promise<Metadata> {
const post: Post = await fetch(`https://api.example.com/posts/${params.id}`)
.then(res => res.json());
return {
title: post.title,
description: post.content.substring(0, 160),
};
}
export default async function BlogPostPage({ params }: PageProps) {
const post: Post = await fetch(`https://api.example.com/posts/${params.id}`)
.then(res => {
if (!res.ok) return null;
return res.json();
});
if (!post) {
notFound(); // Shows 404 page
}
return (
<article className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="text-gray-600 mb-8">
<span>By {post.author}</span>
<span className="mx-2">•</span>
<time>{post.date}</time>
</div>
<div className="prose max-w-none">
{post.content}
</div>
</article>
);
}3. Dashboard Page (Client Component)
"use client";
import { useState, useEffect } from 'react';
interface Stats {
users: number;
sales: number;
revenue: number;
}
export default function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/stats')
.then(res => res.json())
.then(data => {
setStats(data);
setLoading(false);
});
}, []);
if (loading) {
return <div>Loading dashboard...</div>;
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-blue-100 p-6 rounded-lg">
<h2 className="text-xl font-semibold mb-2">Total Users</h2>
<p className="text-3xl font-bold">{stats?.users}</p>
</div>
<div className="bg-green-100 p-6 rounded-lg">
<h2 className="text-xl font-semibold mb-2">Total Sales</h2>
<p className="text-3xl font-bold">{stats?.sales}</p>
</div>
<div className="bg-purple-100 p-6 rounded-lg">
<h2 className="text-xl font-semibold mb-2">Revenue</h2>
<p className="text-3xl font-bold">${stats?.revenue}</p>
</div>
</div>
</div>
);
}4. Search/Filter Page
import Link from 'next/link';
interface Product {
id: string;
name: string;
price: number;
category: string;
}
interface PageProps {
searchParams: {
q?: string;
category?: string;
minPrice?: string;
maxPrice?: string;
};
}
export default async function ProductsPage({ searchParams }: PageProps) {
// Build query string from searchParams
const params = new URLSearchParams();
if (searchParams.q) params.set('q', searchParams.q);
if (searchParams.category) params.set('category', searchParams.category);
if (searchParams.minPrice) params.set('minPrice', searchParams.minPrice);
if (searchParams.maxPrice) params.set('maxPrice', searchParams.maxPrice);
const products: Product[] = await fetch(
`https://api.example.com/products?${params}`
).then(res => res.json());
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Products</h1>
{/* Show active filters */}
{searchParams.q && (
<p className="mb-4">Searching for: {searchParams.q}</p>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{products.map((product) => (
<Link
key={product.id}
href={`/products/${product.id}`}
className="border rounded-lg p-4 hover:shadow-lg transition"
>
<h2 className="text-xl font-semibold">{product.name}</h2>
<p className="text-gray-600">${product.price}</p>
<span className="text-sm text-gray-500">{product.category}</span>
</Link>
))}
</div>
</div>
);
}Page Component Best Practices
1. Keep Pages Focused
Good: Pages orchestrate and compose
// Good: Page delegates to components
export default function ProductPage() {
return (
<div>
<ProductHeader />
<ProductDetails />
<ProductReviews />
<RelatedProducts />
</div>
);
}Avoid: Massive page components
// Bad: Everything in one component
export default function ProductPage() {
return (
<div>
{/* 500 lines of JSX */}
{/* All logic inline */}
{/* No reusable components */}
</div>
);
}2. Use Descriptive Names
- ✅
ProductPage,UserProfilePage,CheckoutPage - ❌
Page,Component,Index
3. Handle Loading and Error States
Even if you use loading.tsx and error.tsx, handle edge cases in your component:
export default async function PostsPage() {
const posts = await fetch('...').then(r => r.json());
// Handle empty state
if (posts.length === 0) {
return (
<div>
<h1>No posts yet</h1>
<p>Check back soon!</p>
</div>
);
}
return <div>{/* Render posts */}</div>;
}4. Optimize Images
Always use Next.js Image component:
import Image from 'next/image';
export default function ProductPage() {
return (
<Image
src="/product.jpg"
alt="Product"
width={800}
height={600}
priority // For above-fold images
/>
);
}5. Keep Sensitive Data on Server
Never expose API keys or secrets in Client Components. They'll be visible in the browser JavaScript bundle!
Practice: Build a Complete Page
Let's build a user profile page that demonstrates all concepts:
Complete User Profile Page
A full page with metadata, TypeScript types, and async data fetching
Output Preview
🎯 Challenge
Try creating these pages in your Next.js project:
- A team members listing page
- A services page with different service categories
- A blog post detail page that shows post content
Key Takeaways
- page.tsx creates routes - must export default component
- Server Components by default - can be async and fetch data directly
- Add "use client" for interactivity - when you need hooks or event handlers
- Pages receive params and searchParams - automatically passed as props
- Export metadata for SEO - static object or generateMetadata function
- Use TypeScript types - define props interfaces for type safety
- Keep pages focused - delegate to components, don't build monoliths
- Handle edge cases - empty states, errors, loading
What's Next?
You now know how to build powerful, type-safe pages in Next.js! But what about pages where the URL is dynamic—like blog posts, product pages, or user profiles? That's where dynamic routes come in.
In the next lesson, we'll learn how to create dynamic routes using the [slug] syntax, access route parameters, and build pages that work for any ID or identifier. This is essential for most real-world applications!
🚀 Keep Building!
The best way to master page creation is to build different types of pages. Try creating a blog, portfolio, or dashboard in your Next.js project. Experiment with both Server and Client Components to see the differences!