Introduction
A portfolio website is the first thing recruiters and clients check before they read your resume. Next.js is the best framework for building one — it gives you fast page loads, built-in SEO support, and easy deployment, all without extra configuration.
In this guide, we'll build a complete portfolio site with:
- Project setup with the App Router
- A homepage with hero, about, and skills sections
- A dynamic projects page
- A contact form
- SEO metadata
- Deployment to Vercel
By the end, you'll have a production-ready portfolio you can customize and ship.
1. Project Setup
Create a new Next.js app with the App Router and Tailwind CSS:
npx create-next-app@latest my-portfolio --typescript --tailwind --app
cd my-portfolio
Your folder structure should look like this:
app/
layout.tsx
page.tsx
projects/
page.tsx
contact/
page.tsx
components/
Hero.tsx
About.tsx
Skills.tsx
ProjectCard.tsx
data/
projects.ts
2. Building the Homepage
Start with a hero section that introduces you:
// components/Hero.tsx
export default function Hero() {
return (
<section className="flex flex-col items-center justify-center min-h-[70vh] text-center px-4">
<h1 className="text-4xl md:text-6xl font-bold">
Hi, I'm Usman — Full-Stack Developer
</h1>
<p className="mt-4 text-lg text-gray-500 max-w-xl">
I build fast, scalable web applications with Next.js, Node.js, and Python.
</p>
<a
href="/projects"
className="mt-6 px-6 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition"
>
View My Work
</a>
</section>
);
}
Add an about section and a skills grid:
// components/Skills.tsx
const skills = [
"Next.js", "React", "TypeScript", "Node.js", "Python", "PostgreSQL",
];
export default function Skills() {
return (
<section className="py-16 px-4">
<h2 className="text-2xl font-bold mb-6 text-center">Skills</h2>
<div className="flex flex-wrap gap-3 justify-center max-w-2xl mx-auto">
{skills.map((skill) => (
<span
key={skill}
className="px-4 py-2 bg-gray-100 rounded-full text-sm font-medium"
>
{skill}
</span>
))}
</div>
</section>
);
}
Wire it all together on the homepage:
// app/page.tsx
import Hero from "@/components/Hero";
import About from "@/components/About";
import Skills from "@/components/Skills";
export default function HomePage() {
return (
<main>
<Hero />
<About />
<Skills />
</main>
);
}
3. Dynamic Projects Page
Store your projects as data instead of hardcoding markup — it's easier to update and reuse:
// data/projects.ts
export interface Project {
slug: string;
title: string;
description: string;
tech: string[];
link: string;
}
export const projects: Project[] = [
{
slug: "task-manager",
title: "Task Manager App",
description: "A full-stack task management app with auth and real-time updates.",
tech: ["Next.js", "Prisma", "PostgreSQL"],
link: "https://github.com/yourname/task-manager",
},
{
slug: "blog-platform",
title: "Blog Platform",
description: "A markdown-based blog platform with SEO optimization and dark mode.",
tech: ["Next.js", "MDX", "Tailwind"],
link: "https://github.com/yourname/blog-platform",
},
];
Render the projects with a reusable card component:
// components/ProjectCard.tsx
import { Project } from "@/data/projects";
export default function ProjectCard({ project }: { project: Project }) {
return (
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
className="block p-6 border rounded-xl hover:shadow-md transition"
>
<h3 className="text-xl font-semibold">{project.title}</h3>
<p className="mt-2 text-gray-500">{project.description}</p>
<div className="mt-4 flex gap-2 flex-wrap">
{project.tech.map((t) => (
<span key={t} className="text-xs px-2 py-1 bg-gray-100 rounded">
{t}
</span>
))}
</div>
</a>
);
}
// app/projects/page.tsx
import { projects } from "@/data/projects";
import ProjectCard from "@/components/ProjectCard";
export default function ProjectsPage() {
return (
<main className="max-w-4xl mx-auto px-4 py-16">
<h1 className="text-3xl font-bold mb-8">Projects</h1>
<div className="grid gap-6 md:grid-cols-2">
{projects.map((project) => (
<ProjectCard key={project.slug} project={project} />
))}
</div>
</main>
);
}
4. Contact Form
A simple contact form using a Server Action — no external API needed:
// app/contact/page.tsx
async function sendMessage(formData: FormData) {
"use server";
const name = formData.get("name");
const email = formData.get("email");
const message = formData.get("message");
// Send via email service (Resend, SendGrid, etc.)
console.log({ name, email, message });
}
export default function ContactPage() {
return (
<main className="max-w-md mx-auto px-4 py-16">
<h1 className="text-3xl font-bold mb-8">Get In Touch</h1>
<form action={sendMessage} className="flex flex-col gap-4">
<input
name="name"
placeholder="Your name"
required
className="border rounded-lg p-3"
/>
<input
name="email"
type="email"
placeholder="Your email"
required
className="border rounded-lg p-3"
/>
<textarea
name="message"
placeholder="Your message"
required
rows={5}
className="border rounded-lg p-3"
/>
<button
type="submit"
className="bg-black text-white rounded-lg py-3 hover:bg-gray-800 transition"
>
Send Message
</button>
</form>
</main>
);
}
5. SEO Metadata
Next.js makes per-page metadata simple with the metadata export:
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Usman Zafar — Full-Stack Developer",
description: "Portfolio of Usman Zafar, a full-stack developer specializing in Next.js, Node.js, and Python.",
openGraph: {
title: "Usman Zafar — Full-Stack Developer",
description: "Check out my projects and get in touch.",
url: "https://usmanzafar.dev",
siteName: "Usman Zafar Portfolio",
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
For individual pages, export a metadata object the same way — Next.js merges it with the root layout's metadata automatically.
6. Deploying to Vercel
Push your code to GitHub, then connect the repo on vercel.com. Vercel auto-detects Next.js and deploys with zero configuration:
git add .
git commit -m "Initial portfolio"
git push origin main
Once connected, every push to main triggers a new production deployment. Add a custom domain from the Vercel dashboard under Settings → Domains.
Putting It All Together
| Section | Purpose |
|---|---|
| Hero | First impression — name, role, call to action |
| About | Brief background and what you do |
| Skills | Quick scan of your tech stack |
| Projects | Proof of work with live links |
| Contact | Easy way for people to reach you |
A portfolio doesn't need to be fancy — it needs to load fast, look clean, and clearly show what you've built. Start with this structure, swap in your own projects and copy, and deploy it today. You can always iterate once it's live.