The portfolio that got me my first real software role was not beautiful. It was one page, a dark theme, four project cards, and a working contact form. No animations, no parallax, no glassmorphism. What it had was three things most portfolios lack: live demos you could click, a real backend handling the contact form, and correct SEO metadata so it appeared when recruiters searched my name.
I have reviewed hundreds of developer portfolios since — as a freelancer, as a founder hiring engineers, and as someone who gets forwarded portfolios by recruiters weekly. The pattern is consistent. The portfolios that get interviews are not the prettiest. They are the ones that answer five questions in under a minute: who is this person, what do they build, can I see it working, can I reach them, and is this a real person or a template?
This guide walks you through building exactly that portfolio, live, from an empty folder to a deployed site with a custom domain. I will use Next.js and Node.js because they are the most common stack in this audience, but the structure applies to any framework. The same steps took me a weekend the first time, and they will take you less.
Step 1: Decide the Structure Before You Write Code
Every effective portfolio has four sections, in this order. Recruiters spend seconds on a page; the order is the interface.
- Hero — one line about who you are and what you build. No "passionate developer" filler.
- Projects — three or four projects, each with a link to a working demo and the repository.
- About — a short, honest paragraph: your stack, your experience, and what you are looking for.
- Contact — a form that actually works, not a mailto link.
Skip the blog, the skills bars, and the timeline on the first version. Add them later if the page feels empty. The goal is a page a recruiter can scan top to bottom in thirty seconds and come away knowing what you do.
Step 2: Scaffold the Project
Start with the current default. I use Next.js App Router because it gives you server components, a built-in API layer for the contact form, and first-class metadata — all of which we need here.
npx create-next-app@latest my-portfolio
# ✔ TypeScript? … Yes
# ✔ ESLint? … Yes
# ✔ Tailwind CSS? … Yes
# ✔ App Router? … Yes
The App Router scaffold gives you app/page.tsx as the home page. That is the only page we need. Before writing any UI, clear the boilerplate and set up global metadata in app/layout.tsx — this is the SEO foundation that makes recruiters able to find you at all:
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Your Name — Software Engineer",
description:
"Software engineer building [stack]. Projects, live demos, and how to reach me.",
openGraph: {
title: "Your Name — Software Engineer",
description: "Live projects and contact.",
url: "https://yourdomain.com",
siteName: "Your Name",
type: "website",
},
};
Pitfall I see constantly: developers ship the portfolio and forget this file, then wonder why they do not rank for their own name. The metadata is the product, not an afterthought.
Step 3: Build the Hero and the Project Data
Keep the hero to one line and one call to action. The line should name your stack and your outcome, not your feelings:
"Software engineer. I build React and Node.js products used by 50k monthly users."
The projects section is where the portfolio lives or dies. Create a typed data file so the UI stays dumb and the content stays editable:
// lib/projects.ts
export type Project = {
title: string;
description: string;
stack: string[];
demoUrl: string;
repoUrl: string;
};
export const projects: Project[] = [
{
title: "Inventory Forecast API",
description:
"Demand forecasting endpoint for a logistics operator. Reduced monthly forecast error from 40% to 6%.",
stack: ["Python", "FastAPI", "PostgreSQL"],
demoUrl: "https://demo.yourdomain.com",
repoUrl: "https://github.com/you/inventory-forecast",
},
{
title: "Android Expense Tracker",
description:
"Offline-first expense tracker with CSV export. 10k downloads on Google Play.",
stack: ["Kotlin", "Room", "Jetpack Compose"],
demoUrl: "https://play.google.com/store/apps/details?id=com.you.expenses",
repoUrl: "https://github.com/you/expense-tracker",
},
];
Notice the two project archetypes working together: one backend project with a live demo, one Android app with a Play Store link. A mix of backend and mobile signals breadth, and the Android project gives you a storefront page you did not have to build.
The rule for every project card: the demo link must lead to something a stranger can use without cloning a repo. A "live demo" that 404s is worse than no demo at all — it signals you ship broken things.
The card component itself is deliberately boring — a title, one sentence of outcome, the stack chips, and two links. That is all the space you get to make the case:
import { projects } from "@/lib/projects";
export default function ProjectCard({ title, description, stack, demoUrl, repoUrl }: Project) {
return (
<div className="rounded-lg border border-neutral-800 p-6">
<h3 className="text-lg font-semibold">{title}</h3>
<p className="mt-2 text-sm text-neutral-400">{description}</p>
<div className="mt-3 flex flex-wrap gap-2">
{stack.map((tech) => (
<span key={tech} className="rounded bg-neutral-900 px-2 py-1 text-xs">
{tech}
</span>
))}
</div>
<div className="mt-4 flex gap-4 text-sm">
<a href={demoUrl} target="_blank" rel="noopener noreferrer">Live demo →</a>
<a href={repoUrl} target="_blank" rel="noopener noreferrer">Repository</a>
</div>
</div>
);
}
Keep the description to one sentence about the outcome — "Reduced forecast error from 40% to 6%" — not the implementation. Implementation goes in the README; the card sells the result.
Step 4: Write the Contact Form and Its Backend
This is the part that separates real portfolios from templates. A mailto: link opens the visitor's email client and silently fails on mobile. A working form, instead, sends the message to you and confirms it.
The front end is a controlled form:
"use client";
import { useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("sending");
const data = new FormData(e.currentTarget);
const res = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify({
name: data.get("name"),
email: data.get("email"),
message: data.get("message"),
}),
headers: { "Content-Type": "application/json" },
});
setStatus(res.ok ? "sent" : "error");
}
return (
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="you@example.com" required />
<textarea name="message" placeholder="What are you building?" required />
<button disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send"}
</button>
{status === "sent" && <p>Message sent — I will reply within 48 hours.</p>}
</form>
);
}
The backend is a Node.js route. Do not put your email credentials in client code. Read them from environment variables on the server and validate the input before sending:
// app/api/contact/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
const bodySchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
message: z.string().min(10).max(5000),
});
export async function POST(request: Request) {
const body = await request.json();
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
// Send via your mail provider using server-side env vars only.
await sendMail(parsed.data); // your SMTP/API call here
return NextResponse.json({ ok: true });
}
Pitfalls here, from real submissions I have seen: (1) sending the form to a public webhook with no validation, which spams you with garbage, and (2) shipping the API key to the client bundle, which leaks your credentials to anyone who opens DevTools. The validation schema above stops both. Add one more line of defense for production: a simple rate limit — one message per email per hour — because a portfolio form with no rate limiting is a mail-bomb waiting for a bot to find it.
Step 5: Add Structured Data for Search and AI Discovery
Recruiters increasingly use search, and answer engines increasingly read structured data. Add a Person JSON-LD block with the sameAs profiles — it costs five minutes and gives search engines an unambiguous map of who you are:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"name": "Your Name",
"url": "https://yourdomain.com",
"jobTitle": "Software Engineer",
"sameAs": [
"https://github.com/you",
"https://www.linkedin.com/in/you",
"https://play.google.com/store/apps/dev?id=you"
]
}
</script>
In Next.js, put this in the Page component with <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(personSchema) }} />. The same block feeds Google's knowledge panel and the answer engines that pull developer profiles.
The About Section That Sounds Like a Person
The About section is where most portfolios die of blandness. "I am a passionate software engineer who loves solving problems" is a sentence I have read, verbatim, in a hundred portfolios. It tells a recruiter nothing and, worse, makes you indistinguishable from a template.
Write the About section like a short version of the hero: what you build, for how long, and one concrete outcome. Then add a line that only a human would write — the stack you refuse to use, the kind of work you are looking for, or a single non-work sentence. An example that works:
"Software engineer, 7 years. I build React and Node.js products, and I have shipped ML pipelines into production. Currently looking for backend-heavy roles. I refuse to touch IE11 and I have opinions about keyboards."
The job line matters most of all. "Currently looking for X" is the one sentence that tells a recruiter whether to bother emailing you. Leave it out and you look like you might already be employed — a surprising number of portfolios fail to say what the person actually wants.
Step 6: Ship Analytics, Then Deploy
Add one analytics script before you publish — not for vanity metrics, but to answer the only question that matters: where did the visitors who emailed you come from? If all your conversions come from LinkedIn and none from the blog, you know where to spend time. I use privacy-friendly analytics, but any tool works. The point is to measure before you promote.
Then deploy. The free hosting options from my earlier comparison all handle a Next.js app; pick one, connect your git repo, and add your custom domain. Deploying a static, single-command build that is reproducible from git is itself a signal to hiring engineers — it shows you know how a project ships.
After deploying, run these checks before you share it anywhere:
- [ ]
pnpm buildpasses with zero errors - [ ] Metadata renders: view-source and confirm title, description, and OG tags
- [ ] Contact form sends a real email end-to-end
- [ ] Every demo link resolves to a working page
- [ ] The site passes Lighthouse mobile at 90+
- [ ] Your name in quotes returns the site in the first result
The Five Pitfalls That Kill Portfolios
- Demos that do not exist. The fastest way to lose a recruiter is a portfolio where every "live demo" is a dead link. Either host the demo or do not show the link.
- No contact path. I have seen portfolios with gorgeous pages and no way to reach the person. A working form or a clear email link is mandatory.
- Skill bars and buzzword walls. "Expert in React, Node, Python, Docker, AWS, Kubernetes, SQL, GraphQL" tells a recruiter nothing about what you have actually built. Replace the list with two projects that prove you can ship.
- The API key in the client bundle. As above — check your deployed bundle before sharing. It is a security incident waiting for a recruiter to find.
- Only screenshots. A video demo or an interactive playground beats a screenshot every time, because it proves the thing runs.
- No "what I want next" line. The About section that omits what you are looking for forces the recruiter to guess — and guessing is how you get filed under "not now."
The last pitfall deserves emphasis because it is the quiet killer: a portfolio is a living document, not a graduation photo. I still update mine with every meaningful project, and the ones that stagnate for a year are the ones that read as abandoned. A dated footer with the current year is a small signal that the page is maintained; a portfolio with a 2023 copyright is a signal that you have stopped shipping.
The Checklist Before You Call It Done
- [ ] Four sections: hero, projects, about, contact
- [ ] One-line hero that names stack and outcome
- [ ] Three to four projects, each with a working demo link and repo
- [ ] At least one backend project and one mobile/Android artifact
- [ ] Contact form validated server-side, credentials server-side only
- [ ]
PersonJSON-LD structured data present - [ ] Analytics installed before promotion
- [ ] Deployed to free hosting with your own custom domain
- [ ] The self-test list above passes
The portfolio that got me hired was built in a weekend with a template stack, no animations, and one thing nobody else in the pipeline had: every link worked. That is the entire bar. Build the four sections, wire the form, add the structured data, deploy it with your own domain, and make sure nothing is broken when a recruiter clicks. Everything else is decoration — and decoration does not get you hired, a working proof of who you are does.
*Gulshan Yad
Top comments (1)
Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support