Persona-Inspired Developer Portfolio
Build a Next.js portfolio with Persona UI aesthetics, animations, and SEO.
Introduction
30 Second Summary
Every developer needs a portfolio, but most look identical. The ones that get remembered borrow design principles from places recruiters don't expect.
In this project, you will build a developer portfolio inspired by the bold visual languages of Persona 5 Royal and Persona 3 Reload. You will deploy it as a fully SEO-optimized web application that ranks on Google and shows rich previews when shared on social media.
What You'll Build
A production-grade Next.js portfolio with server-rendered pages, animated page transitions, and a data-driven content architecture that makes adding new projects a single-file edit.
By the end of this project, you'll have:
- A live portfolio deployed on Vercel with server-rendered pages, Open Graph metadata, JSON-LD structured data, and a dynamic sitemap for search engine visibility.
- A data-driven content layer where adding a new project requires editing only a single TypeScript data file with zero component changes.
- A custom Persona-inspired design system with angular clip-path shapes, page transitions, and scroll-triggered animations using the Motion library.
- Secret Mission: Add a hidden Confidant easter egg, a themed loading screen, and a custom animated cursor that reacts to interactive elements.
Are there any prerequisites?
You need basic familiarity with HTML, CSS, and JavaScript. No prior React or Next.js experience is required. This project teaches those from scratch.
Before We Start
Before you can build your Persona-inspired portfolio, you need a JavaScript runtime on your machine.
In this step, you'll install Node.js using a version manager called nvm. You'll also set up VS Code with extensions that make Next.js development faster and more enjoyable.
In this step, get ready to:
- Install Node.js v22 LTS using nvm.
- Verify the installation with version check commands.
- Configure VS Code with recommended extensions for Next.js development.
Install Node.js v22 LTS using nvm
Node.js is the JavaScript runtime that powers Next.js. Instead of downloading it directly, you'll use nvm (Node Version Manager) to install and manage Node.js versions. This lets you switch versions easily between projects.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Install nvm by running this command:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
What does this command do?
This downloads the nvm install script from GitHub and runs it. The script clones the nvm repository to ~/.nvm and adds loading lines to your shell profile (~/.bashrc, ~/.zshrc, or ~/.profile).
- Close and reopen your terminal so the shell profile changes take effect.
- Install Node.js v22 LTS by running this command:
nvm install 22
What does this command do?
This tells nvm to download and install the latest Node.js version 22 release. It also installs npm (Node Package Manager) alongside it, which you'll use to install project dependencies.
- Verify Node.js is installed by running:
node --version
Check your output and pick the tab that matches what you see.
✔️ I see v22 or higher
Node.js is ready to go. You should see something like v22.22.1 in the output.
- Confirm npm is also available by running:
npm --version
You should see a version number like 10.9.4 or similar. Both tools are ready.
ⓧ I see an older version
You have Node.js installed but it's an older version. You need version 22 or higher for this project.
- Install and switch to version 22 by running:
nvm install 22
nvm use 22
- Run node --version again. You should now see v22 or higher.
ⓧ Command not found
Your terminal cannot find the node command. This usually means nvm did not load properly.
- Make sure you closed and reopened your terminal after installing nvm.
- If you still see the error, manually load nvm by running:
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
- Then install Node.js by running:
nvm install 22
- Run node --version again. You should now see v22 or higher.
Still not working?
If nvm commands are not recognized, check that the install script added the loading lines to your shell profile. Open ~/.bashrc or ~/.zshrc and look for the NVM_DIR lines near the bottom.
help me troubleshoot nvm not being recognized in my terminal after running the install script
Configure VS Code with recommended extensions
Three VS Code extensions will make your Next.js development significantly smoother. They provide real-time error checking, automatic code formatting, and intelligent CSS class suggestions.
- Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows) and typing Visual Studio Code.
- Open the Extensions panel by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
- Search for ESLint and click Install on the one published by dbaeumer.
- Search for Prettier - Code formatter and click Install on the one published by esbenp.
- Search for Tailwind CSS IntelliSense and click Install on the one published by bradlc.
What do these extensions do?
ESLint highlights code errors and bad patterns in real time as you type. It catches bugs before you run the code.
Prettier automatically formats your code on save so it stays consistent and readable without manual effort.
Tailwind CSS IntelliSense provides autocomplete suggestions for Tailwind CSS class names. It also shows you what CSS each class generates on hover.
- Confirm all three extensions show Enabled in the Extensions panel.
Extension not showing up?
Make sure you're searching by the exact name. If you see multiple results with similar names, check the publisher name matches: dbaeumer for ESLint, esbenp for Prettier, and bradlc for Tailwind CSS IntelliSense.
help me find and install the correct ESLint, Prettier, and Tailwind CSS IntelliSense extensions in VS Code
Your machine is ready to build. Next up, you'll scaffold the Next.js project and see your first page in the browser.
Scaffold the Next.js Project
Your machine is ready with Node.js and VS Code. Now you need an actual project to build in.
Next.js provides a scaffold command called create-next-app that generates the entire folder structure, installs dependencies, and configures TypeScript and Tailwind CSS in a single step. In this step, you'll create the project, configure it for the Persona portfolio, and verify everything runs cleanly.
In this step, get ready to:
- Scaffold the Next.js project on your Desktop.
- Configure TypeScript, Tailwind CSS, and App Router through the setup prompts.
- Verify the dev server runs and clear out default template content.
Create the project with create-next-app
The create-next-app CLI generates a ready-to-run Next.js project with all the tooling pre-configured. You'll run it from your Desktop so the project folder is easy to find.
- Switch back to VS Code and open the integrated terminal by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold the Next.js project by running this command:
npx create-next-app@latest persona-portfolio
What does this command do?
- npx runs a package from the npm registry without installing it globally.
- create-next-app@latest fetches the newest version of the Next.js scaffolding tool.
- persona-portfolio is the folder name for your project. The CLI creates this folder on your Desktop.
What will happen next?
After you run this command, the CLI will ask you a series of setup questions in the terminal. You won't see an installer window. Instead, the questions appear as text prompts right in the terminal, and you answer by pressing arrow keys and Enter.
Walk through the create-next-app prompts
The CLI starts by asking whether you want the recommended defaults or custom settings. You need to customize because the project uses a src/ directory structure that isn't part of the defaults.
- When you see Would you like to use the recommended Next.js defaults?, use the arrow keys to select No, customize settings - Choose your own preferences and press Enter.
- When you see Would you like to use TypeScript?, select Yes.
- When you see Which linter would you like to use?, select ESLint.
- When you see Would you like to use React Compiler?, select No.
- When you see Would you like to use Tailwind CSS?, select Yes.
- When you see Would you like your code inside a `src/` directory?, select Yes.
- When you see Would you like to use App Router? (recommended), select Yes.
- When you see Would you like to customize the import alias (`@/*` by default)?, select No. The default @/* alias is exactly what the project uses.
- When you see Would you like to include AGENTS.md to guide coding agents to write up-to-date Next.js code?, select Yes.
After the final prompt, the CLI installs all dependencies automatically. This may take a minute.
Why customize instead of using defaults?
The recommended defaults skip the src/ directory option. This project uses a src/ directory to keep application code (components, data files, utilities) separate from configuration files at the project root. It keeps things organized as the portfolio grows.
Seeing an error about npm or permissions?
If you see a permissions error, try running the command with sudo on macOS/Linux, or open your terminal as Administrator on Windows. If npx is not found, confirm Node.js is installed by running node --version.
help me troubleshoot a create-next-app installation error
Verify the dev server and clear the default template
Before building anything custom, you'll confirm the scaffold works by starting the development server. Then you'll strip out the default boilerplate so you have a clean slate for the Persona design.
- Move into your new project folder by running:
cd persona-portfolio
- Start the development server by running:
npm run dev
- Open your browser and navigate to http://localhost:3000.
You should see the default Next.js welcome page with the Next.js logo and some links. This confirms the scaffold is working.
- Return to your terminal and press Ctrl+C to stop the dev server.
Now you'll clear out the default template content. The scaffolded page.tsx and globals.css contain demo content you don't need.
- Open the persona-portfolio folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the persona-portfolio folder, and click Open.
- In the Explorer sidebar on the left, expand src then app.
- Click on page.tsx to open it.
- Select all the content with Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
- Replace it with the following minimal placeholder:
// Minimal placeholder for the landing page
export default function Home() {
return (
<main>
<h1>Persona Portfolio</h1>
</main>
)
}
What does this code do?
This is the simplest possible Next.js page component. It exports a function called Home that returns a heading inside a <main> tag. You'll replace this with the full animated landing page in a later step.
- Now open globals.css in the same src/app folder.
- Select all and replace the entire file with:
@import "tailwindcss";
Why remove all the default styles?
The default globals.css contains demo styles for the Next.js welcome page. You only need the single @import "tailwindcss" line, which activates Tailwind CSS v4 across the project. You'll add the full Persona theme in the next step.
- Save both files with Cmd+S (macOS) or Ctrl+S (Windows).
- Open the integrated terminal again with Ctrl+` and start the dev server:
npm run dev
- Refresh your browser at http://localhost:3000. You should see a plain white page with just the text "Persona Portfolio" in default browser font.
Still seeing the old Next.js template?
Make sure you saved both page.tsx and globals.css. Check that you're editing the files inside src/app/ and not a different folder. If the page still hasn't changed, try stopping the server with Ctrl+C and restarting with npm run dev.
help me fix my Next.js dev server not showing updated content
Now confirm your package.json has the correct dependencies. Open package.json from the root of your project (not inside src/) and check the dependencies and devDependencies sections.
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files, then leave the dev server running.
ⓧ I'd like to double check the full code
Your package.json should include these dependencies (version numbers may have a slightly higher patch):
{
"dependencies": {
"next": "^16.2.6",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",
"tailwindcss": "^4.3.0",
"typescript": "^5.8.0",
"@types/node": "^22.0.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0"
}
}
Your src/app/page.tsx should look like this:
// Minimal placeholder for the landing page
export default function Home() {
return (
<main>
<h1>Persona Portfolio</h1>
</main>
)
}
Your src/app/globals.css should contain only:
@import "tailwindcss";
Your project scaffold is clean and running. Next up, you'll build the Persona design system with custom colors, angular shapes, and typography that bring the P5R and P3R aesthetics to life.
Build the Persona Design System
Your Next.js project is scaffolded and the dev server is confirmed running. Now it's time to define the visual DNA of your portfolio.
Every game in the Persona series has an instantly recognizable visual language. In this step, you'll translate those design principles into a Tailwind CSS theme that powers every component you build from here forward.
In this step, get ready to:
- Configure a Tailwind CSS v4 theme with P5R and P3R color palettes.
- Learn the angular clip-path shape patterns used throughout the portfolio.
- Set up the Arsenal heading font for a geometric, Persona-inspired look.
Configure the Tailwind CSS v4 theme
Tailwind CSS v4 uses a CSS-first approach to configuration. You define your design tokens (colors, fonts, spacing) directly in CSS using the @theme directive.
- Open app/globals.css in VS Code.
- Replace the entire file contents with this Persona design system theme:
@import "tailwindcss";
@theme {
/* P5R colors: bold red, stark black/white contrast */
--color-persona-red: #FF0000;
--color-persona-black: #0a0a0a;
--color-persona-white: #fafafa;
/* P3R colors: ethereal blue, teal, deep midnight */
--color-persona-blue: #1a237e;
--color-persona-teal: #00bcd4;
--color-persona-midnight: #0d1b2a;
--color-persona-dark: #111111;
/* Typography: Arsenal for headings, system font for body */
--font-heading: "Arsenal", system-ui, sans-serif;
--font-body: system-ui, -apple-system, sans-serif;
}
body {
margin: 0;
overflow-x: hidden;
}
What does this code do?
The @import "tailwindcss" line loads the Tailwind CSS framework. Everything inside the @theme block defines custom design tokens that Tailwind automatically turns into utility classes.
- Each --color-persona-* variable creates matching utility classes like bg-persona-red, text-persona-white, and border-persona-teal.
- The --font-heading and --font-body variables create font-heading and font-body utility classes you can apply to any element.
- The body styles remove the default margin and prevent horizontal scrollbar from appearing when angular shapes extend past the viewport edge.
- Save app/globals.css.
- Check your terminal. The dev server should still be running without errors.
- Open your browser's DevTools (right-click the page and select Inspect). In the Styles panel, you should see your custom properties like --color-persona-red: #FF0000 listed on the root element.
Not seeing your custom properties in DevTools?
- Make sure the file starts with @import "tailwindcss"; on the very first line. The @theme block only works when Tailwind is imported above it.
- Confirm you saved the file. Unsaved changes won't trigger a rebuild.
- If your dev server crashed, restart it by running npm run dev again in your terminal.
Help me troubleshoot my Tailwind CSS v4 @theme block not loading custom properties.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
@import "tailwindcss";
@theme {
--color-persona-red: #FF0000;
--color-persona-black: #0a0a0a;
--color-persona-white: #fafafa;
--color-persona-blue: #1a237e;
--color-persona-teal: #00bcd4;
--color-persona-midnight: #0d1b2a;
--color-persona-dark: #111111;
--font-heading: "Arsenal", system-ui, sans-serif;
--font-body: system-ui, -apple-system, sans-serif;
}
body {
margin: 0;
overflow-x: hidden;
}
Understand the angular clip-path shapes
Persona 5 Royal's UI is defined by its aggressive angular shapes. Parallelograms, diagonal cuts, and triangular indicators appear on every menu, button, and transition screen.
In CSS, you achieve these shapes using clip-path: polygon(). This property defines a visible region by listing coordinate points that form a polygon.
What is clip-path: polygon()?
The clip-path property clips an element to a shape you define. The polygon() function takes a series of x/y coordinate pairs that trace the outline of the visible area.
Here are the three patterns you'll use throughout this portfolio:
- Diagonal cut (background shapes): polygon(0 0, 60% 0, 40% 100%, 0 100%). Creates a trapezoid that slants from wide at the top-left to narrow at the bottom-right.
- Parallelogram (buttons and cards): polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%). Both vertical edges are angled inward, creating the signature P5R button shape.
- Triangle indicator (navigation arrows): polygon(0 0, 100% 50%, 0 100%). A right-pointing triangle, perfect for directional cues.
clip-path disables border-radius
Once you apply clip-path to an element, border-radius and overflow: hidden stop working on that element. If you need a shadow on a clipped element, use filter: drop-shadow() on a parent wrapper instead.
You'll apply these patterns as inline styles on components in upcoming steps. No additional CSS is needed right now.
Set up the Arsenal heading font
Arsenal is a geometric sans-serif font from Google Fonts. Its clean angles and sharp terminals match P5R's typography style closely.
Your @theme block already references Arsenal in the --font-heading variable. The system-ui fallback ensures text renders immediately while the font loads.
How will Arsenal actually load?
Next.js provides a built-in next/font/google module that downloads Google Fonts at build time and self-hosts them with your app. No browser requests are sent to Google at runtime.
You'll wire this up in Step 5 when you build the root layout. For now, the --font-heading variable is defined and ready. The system-ui fallback means your pages will render correctly until the font loads.
Why Arsenal over other fonts?
Persona 5's English version uses Optima Nova Black for its UI text. Arsenal shares a similar geometric quality with clean angles, but is freely available through Google Fonts.
The combination of Arsenal for headings and system-ui for body text creates clear visual hierarchy. Headings feel bold and game-inspired while body text stays fast and readable.
Your design system is locked in. Next up, you'll create the data layer that powers all of your portfolio's content.
Create the Data Layer
Most portfolios hard-code project titles, descriptions, and links directly into their components. That works until you want to add a new project. Suddenly you're digging through JSX trying to remember which <div> holds what.
In this step, you'll build a data layer that separates your content from your presentation. Every piece of portfolio content lives in typed TypeScript files inside a single src/data/ folder. Adding a new project later means editing one file with zero component changes.
In this step, get ready to:
- Create a data folder with typed TypeScript files for projects, education, skills, and site metadata.
- Define interfaces that enforce the shape of your content.
- Populate each file with placeholder content you'll swap for your real details.
Create the data folder and define your project types
The src/data/ folder is where all your portfolio content will live. Each file exports a TypeScript interface (the shape of your data) and an array of entries that match that shape.
- Create the data folder by running this command in your terminal:
mkdir src/data
- Confirm the folder exists by running:
ls src
You should see data listed alongside your existing src contents (like app).
Your first data file defines the shape of a portfolio project. Each project needs a URL-friendly slug, descriptions for both card views and detail pages, a tech stack, and links to the source code.
- Create a new file called projects.ts inside src/data/ and add the following code:
export interface Project {
slug: string
title: string
shortDescription: string
fullDescription: string
challenge: string
solution: string
techStack: string[]
githubUrl: string
deployUrl?: string
imageUrl: string
featured: boolean
}
export const projects: Project[] = [
{
slug: "project-one",
title: "Your First Project",
shortDescription: "A brief, compelling one-liner about this project.",
fullDescription: "A detailed explanation of what this project does and why it exists.",
challenge: "Describe the problem you were solving.",
solution: "Describe your approach and what you learned.",
techStack: ["React", "TypeScript", "Tailwind CSS"],
githubUrl: "https://github.com/yourusername/project-one",
deployUrl: "https://project-one.vercel.app",
imageUrl: "/images/projects/project-one.png",
featured: true,
},
]
What does this code do?
The Project interface acts like a contract. TypeScript will warn you if any project entry is missing a required field or has the wrong type.
The deployUrl? field uses the ? to mark it as optional. Not every project has a live deployment, so this field can be left out.
The slug field is a URL-friendly identifier. Later, Next.js will use it to generate individual pages at /projects/project-one.
- Save projects.ts.
Replace the placeholder content
The entry above is a template. Duplicate it 5-6 more times in the array and replace each placeholder with your real project details. Give each project a unique slug (lowercase, hyphens instead of spaces).
Seeing red underlines in VS Code?
Make sure the file is saved inside src/data/ and not src/app/. Check that every string value is wrapped in quotes and every array item ends with a comma.
help me fix TypeScript errors in my projects.ts data file
Define your education and skills data
Your portfolio needs more than just projects. Education entries and skills each get their own typed file following the same pattern.
- Create a new file called education.ts inside src/data/ and add the following code:
export interface Education {
year: string
institution: string
degree: string
description?: string
}
export const education: Education[] = [
{
year: "2020-2024",
institution: "Your University",
degree: "B.S. Computer Science",
description: "Relevant coursework or achievements.",
},
]
What does this code do?
The Education interface is simpler than Project. Each entry needs a year range, institution name, and degree. The description is optional for entries where you don't have extra detail to add.
- Save education.ts.
Next up is the skills file. This one uses a numeric level field that you'll later use to drive animated progress bars on the page.
- Create a new file called skills.ts inside src/data/ and add the following code:
export interface Skill {
name: string
category: string
level: number
}
export const skills: Skill[] = [
{
name: "React",
category: "Frontend",
level: 90,
},
{
name: "TypeScript",
category: "Frontend",
level: 85,
},
{
name: "Next.js",
category: "Frontend",
level: 80,
},
{
name: "Tailwind CSS",
category: "Frontend",
level: 90,
},
{
name: "Node.js",
category: "Backend",
level: 75,
},
{
name: "Git",
category: "Tools",
level: 85,
},
]
What does this code do?
Each skill has a level from 0 to 100 representing your proficiency. Later, this number will control the width of an animated progress bar.
The category field groups skills visually on the page. You can use categories like "Frontend", "Backend", "Tools", or whatever fits your experience.
- Save skills.ts.
- Confirm both files exist by running:
ls src/data
You should see education.ts, projects.ts, and skills.ts listed.
Files not showing up?
Make sure you created the files inside src/data/ and not in the project root or inside src/app/. Check the VS Code Explorer sidebar to verify the folder structure is src > data > education.ts.
help me find my data files in the project
Create the site configuration
The last data file holds your global site metadata. Your name, portfolio URL, social links, and SEO defaults all live here. Every page in your portfolio will import from this file when it needs your name or a link.
- Create a new file called site.ts inside src/data/ and add the following code:
export const siteConfig = {
name: "Your Name",
title: "Your Name | Developer Portfolio",
description: "Frontend developer specializing in React, TypeScript, and animated web experiences.",
url: "https://yourname.vercel.app",
ogImage: "/og-default.png",
links: {
github: "https://github.com/yourusername",
linkedin: "https://linkedin.com/in/yourusername",
email: "your@email.com",
},
}
What does this code do?
The siteConfig object is a central reference for everything that identifies you and your site. When you build your layout, metadata, and footer later, they all import from this one object.
The ogImage field points to a default Open Graph image. This is the preview image that appears when someone shares your portfolio link on LinkedIn or Twitter.
- Save site.ts.
- Run ls src/data one more time to confirm all four files are present.
You should see education.ts, projects.ts, skills.ts, and site.ts.
Why is this architecture powerful?
Every component you build from here on will import its content from these four files. When you finish a new project six months from now, you add one object to projects.ts. Next.js automatically generates a new page for it at build time. No component edits needed.
TypeScript complaining about missing fields?
If you see errors about missing properties, check that every required field in the interface has a matching key in your data object. Optional fields (marked with ?) can be left out, but required ones cannot.
help me fix my data file errors
✔️ Awesome, I've got everything!
Great. Make sure all four files are saved in VS Code before moving on.
ⓧ I'd like to double check the full code
Here are all four data files for comparison.
export interface Project {
slug: string
title: string
shortDescription: string
fullDescription: string
challenge: string
solution: string
techStack: string[]
githubUrl: string
deployUrl?: string
imageUrl: string
featured: boolean
}
export const projects: Project[] = [
{
slug: "project-one",
title: "Your First Project",
shortDescription: "A brief, compelling one-liner about this project.",
fullDescription: "A detailed explanation of what this project does and why it exists.",
challenge: "Describe the problem you were solving.",
solution: "Describe your approach and what you learned.",
techStack: ["React", "TypeScript", "Tailwind CSS"],
githubUrl: "https://github.com/yourusername/project-one",
deployUrl: "https://project-one.vercel.app",
imageUrl: "/images/projects/project-one.png",
featured: true,
},
]
export interface Education {
year: string
institution: string
degree: string
description?: string
}
export const education: Education[] = [
{
year: "2020-2024",
institution: "Your University",
degree: "B.S. Computer Science",
description: "Relevant coursework or achievements.",
},
]
export interface Skill {
name: string
category: string
level: number
}
export const skills: Skill[] = [
{
name: "React",
category: "Frontend",
level: 90,
},
{
name: "TypeScript",
category: "Frontend",
level: 85,
},
{
name: "Next.js",
category: "Frontend",
level: 80,
},
{
name: "Tailwind CSS",
category: "Frontend",
level: 90,
},
{
name: "Node.js",
category: "Backend",
level: 75,
},
{
name: "Git",
category: "Tools",
level: 85,
},
]
export const siteConfig = {
name: "Your Name",
title: "Your Name | Developer Portfolio",
description: "Frontend developer specializing in React, TypeScript, and animated web experiences.",
url: "https://yourname.vercel.app",
ogImage: "/og-default.png",
links: {
github: "https://github.com/yourusername",
linkedin: "https://linkedin.com/in/yourusername",
email: "your@email.com",
},
}
Your data layer is locked in. Every page you build from here will pull content from these files. Next up, you'll create the root layout and a navigation bar that ties the whole site together.
Build the Root Layout and Navigation
Your data layer is ready. Now you need a skeleton that wraps every page of your portfolio and tells search engines what your site is about.
The root layout in Next.js is the outermost shell of your application. It defines the <html> and <body> tags, injects global metadata for SEO, and renders shared components like navigation and footer on every page.
In this step, get ready to:
- Create the root layout with SEO metadata and JSON-LD structured data.
- Build a P5R-inspired navigation bar with animated active indicators.
- Add a responsive mobile menu and site-wide footer.
Create the root layout with global metadata
The root layout is where you define metadata that Google and social platforms read when they encounter your site. This includes your page title, description, Open Graph tags for LinkedIn previews, and JSON-LD structured data that helps Google understand you're a person with a professional identity.
- In VS Code, create a new file at app/layout.tsx inside your persona-portfolio project folder.
- Add the imports and metadata configuration by pasting this code:
import type { Metadata } from "next"
import { siteConfig } from "@/data/site"
import Navbar from "@/components/Navbar"
import Footer from "@/components/Footer"
import "./globals.css"
// Global metadata for SEO and social sharing
export const metadata: Metadata = {
metadataBase: new URL(siteConfig.url),
title: { default: siteConfig.title, template: `%s | ${siteConfig.name}` },
description: siteConfig.description,
openGraph: {
title: siteConfig.title,
description: siteConfig.description,
url: siteConfig.url,
siteName: siteConfig.name,
locale: "en_US",
type: "website",
images: [{ url: siteConfig.ogImage, width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
title: siteConfig.title,
description: siteConfig.description,
images: [siteConfig.ogImage],
},
robots: { index: true, follow: true },
}
What does this metadata do?
The metadataBase sets the base URL so relative paths in Open Graph images resolve correctly.
The title.template pattern means child pages can set just their own title (e.g. "Projects") and Next.js appends "| Your Name" automatically.
The openGraph and twitter objects control what appears in rich preview cards when someone shares your portfolio on LinkedIn or Twitter.
- Below the metadata export in the same file, add the RootLayout component:
// The root component that wraps every page
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-persona-black text-persona-white font-body antialiased">
<Navbar />
<main>{children}</main>
<Footer />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ "@context": "https://schema.org", "@type": "Person", name: siteConfig.name, url: siteConfig.url, sameAs: [siteConfig.links.github, siteConfig.links.linkedin], jobTitle: "Frontend Developer" }) }} />
</body>
</html>
)
}
What is JSON-LD structured data?
JSON-LD is a format that tells search engines exactly what type of entity your page represents. Here, you're telling Google "this site belongs to a Person who is a Frontend Developer" with links to their GitHub and LinkedIn profiles.
This helps Google display rich results (like a knowledge panel) when someone searches your name.
- Save app/layout.tsx.
Seeing import errors?
Your editor will show errors for the Navbar and Footer imports because those components don't exist yet. This is expected. You'll create them in the next two substeps.
Build the P5R-inspired navigation bar
The navbar is the first thing visitors interact with. You'll style it with Persona 5 Royal's signature angular shapes using CSS clip-path and add a smooth animated indicator that slides between links using Motion's layoutId feature.
- Install the Motion animation library by running this command in your terminal:
npm install motion
What is Motion?
Motion (formerly Framer Motion) is a production-ready animation library for React. It provides components like motion.div that you can animate declaratively with props. The layoutId feature lets you create shared layout animations where an element appears to smoothly move between positions.
- Create a new folder at src/components/ if it doesn't already exist.
- Create a new file at src/components/Navbar.tsx and paste this code:
"use client"
import { useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { motion, AnimatePresence } from "motion/react"
// Navigation links for all pages
const navLinks = [
{ href: "/", label: "Home" },
{ href: "/about", label: "About" },
{ href: "/projects", label: "Projects" },
{ href: "/resume", label: "Resume" },
]
export default function Navbar() {
const pathname = usePathname()
const [mobileOpen, setMobileOpen] = useState(false)
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-persona-black/90 backdrop-blur-sm border-b border-persona-red/20">
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
{/* Site logo */}
<Link
href="/"
className="font-heading text-xl font-bold text-persona-red uppercase tracking-wider"
>
Portfolio
</Link>
{/* Desktop navigation with angular clip-path links */}
<div className="hidden md:flex items-center gap-1">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="relative px-4 py-2 font-heading text-sm uppercase tracking-wider text-persona-white/70 hover:text-persona-white transition-colors"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
>
{link.label}
{/* Animated active indicator using layoutId */}
{pathname === link.href && (
<motion.span
layoutId="activeNav"
className="absolute inset-0 bg-persona-red/20 -z-10"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</Link>
))}
</div>
{/* Mobile hamburger button */}
<button
className="md:hidden flex flex-col gap-1.5 p-2"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label="Toggle menu"
>
<motion.span
animate={mobileOpen ? { rotate: 45, y: 6 } : { rotate: 0, y: 0 }}
className="block w-6 h-0.5 bg-persona-white"
/>
<motion.span
animate={mobileOpen ? { opacity: 0 } : { opacity: 1 }}
className="block w-6 h-0.5 bg-persona-white"
/>
<motion.span
animate={mobileOpen ? { rotate: -45, y: -6 } : { rotate: 0, y: 0 }}
className="block w-6 h-0.5 bg-persona-white"
/>
</button>
</div>
{/* Mobile menu panel with exit animation */}
<AnimatePresence>
{mobileOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="md:hidden bg-persona-black/95 border-t border-persona-red/20"
>
<div className="px-6 py-4 flex flex-col gap-2">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className="font-heading text-lg uppercase tracking-wider text-persona-white/70 hover:text-persona-red transition-colors py-2"
>
{link.label}
</Link>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</nav>
)
}
What's happening in this component?
- The "use client" directive is required because this component uses React hooks and browser APIs (useState, usePathname).
- The clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" creates the parallelogram shape that's signature to Persona 5 Royal's UI.
- The motion.span with layoutId="activeNav" creates a shared element that smoothly animates between nav links as the active page changes.
- The three motion.span elements in the hamburger button animate from three lines into an X shape when toggled.
- Save src/components/Navbar.tsx.
Seeing a module not found error?
Make sure you ran npm install motion before creating this file. Check that motion appears in your package.json dependencies.
If the import path shows an error, verify you're importing from motion/react (not framer-motion).
Help me fix my Motion import error.
Create the footer and verify the layout
The footer provides social links and closes out every page. It's also where the Secret Mission easter egg will live later.
- Create a new file at src/components/Footer.tsx and paste this code:
import Link from "next/link"
import { siteConfig } from "@/data/site"
export default function Footer() {
return (
<footer className="border-t border-persona-red/20 bg-persona-black py-8">
<div className="max-w-6xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-4">
{/* Social links */}
<div className="flex items-center gap-4">
<a
href={siteConfig.links.github}
target="_blank"
rel="noopener noreferrer"
className="text-persona-white/60 hover:text-persona-red transition-colors"
>
GitHub
</a>
<a
href={siteConfig.links.linkedin}
target="_blank"
rel="noopener noreferrer"
className="text-persona-white/60 hover:text-persona-red transition-colors"
>
LinkedIn
</a>
<a
href={`mailto:${siteConfig.links.email}`}
className="text-persona-white/60 hover:text-persona-red transition-colors"
>
Email
</a>
</div>
{/* Built with note */}
<p className="text-persona-white/40 text-sm">
Built with Next.js, Tailwind CSS, and Motion.
</p>
</div>
</footer>
)
}
What does this component do?
The Footer pulls social links from siteConfig so updating your links in one place updates them everywhere. The red hover color and border match the P5R design language from your theme.
- Save src/components/Footer.tsx.
- Return to your terminal and run the dev server:
npm run dev
- Open http://localhost:3000 in your browser.
- You should see your fixed navigation bar at the top with angular-styled links (Home, About, Projects, Resume) and the footer at the bottom with your social links.
Page not loading?
If you see a compilation error, check that your file paths are correct: src/components/Navbar.tsx and src/components/Footer.tsx. The layout imports them from @/components/Navbar and @/components/Footer.
If the page loads but looks broken, make sure app/globals.css still contains your @theme block from the previous step.
Help me debug why my layout isn't rendering correctly.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files.
ⓧ I'd like to double check the full code
Here's the complete app/layout.tsx:
import type { Metadata } from "next"
import { siteConfig } from "@/data/site"
import Navbar from "@/components/Navbar"
import Footer from "@/components/Footer"
import "./globals.css"
export const metadata: Metadata = {
metadataBase: new URL(siteConfig.url),
title: { default: siteConfig.title, template: `%s | ${siteConfig.name}` },
description: siteConfig.description,
openGraph: {
title: siteConfig.title,
description: siteConfig.description,
url: siteConfig.url,
siteName: siteConfig.name,
locale: "en_US",
type: "website",
images: [{ url: siteConfig.ogImage, width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
title: siteConfig.title,
description: siteConfig.description,
images: [siteConfig.ogImage],
},
robots: { index: true, follow: true },
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-persona-black text-persona-white font-body antialiased">
<Navbar />
<main>{children}</main>
<Footer />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ "@context": "https://schema.org", "@type": "Person", name: siteConfig.name, url: siteConfig.url, sameAs: [siteConfig.links.github, siteConfig.links.linkedin], jobTitle: "Frontend Developer" }) }} />
</body>
</html>
)
}
Here's the complete src/components/Navbar.tsx:
"use client"
import { useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { motion, AnimatePresence } from "motion/react"
const navLinks = [
{ href: "/", label: "Home" },
{ href: "/about", label: "About" },
{ href: "/projects", label: "Projects" },
{ href: "/resume", label: "Resume" },
]
export default function Navbar() {
const pathname = usePathname()
const [mobileOpen, setMobileOpen] = useState(false)
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-persona-black/90 backdrop-blur-sm border-b border-persona-red/20">
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link
href="/"
className="font-heading text-xl font-bold text-persona-red uppercase tracking-wider"
>
Portfolio
</Link>
<div className="hidden md:flex items-center gap-1">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="relative px-4 py-2 font-heading text-sm uppercase tracking-wider text-persona-white/70 hover:text-persona-white transition-colors"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
>
{link.label}
{pathname === link.href && (
<motion.span
layoutId="activeNav"
className="absolute inset-0 bg-persona-red/20 -z-10"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</Link>
))}
</div>
<button
className="md:hidden flex flex-col gap-1.5 p-2"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label="Toggle menu"
>
<motion.span
animate={mobileOpen ? { rotate: 45, y: 6 } : { rotate: 0, y: 0 }}
className="block w-6 h-0.5 bg-persona-white"
/>
<motion.span
animate={mobileOpen ? { opacity: 0 } : { opacity: 1 }}
className="block w-6 h-0.5 bg-persona-white"
/>
<motion.span
animate={mobileOpen ? { rotate: -45, y: -6 } : { rotate: 0, y: 0 }}
className="block w-6 h-0.5 bg-persona-white"
/>
</button>
</div>
<AnimatePresence>
{mobileOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="md:hidden bg-persona-black/95 border-t border-persona-red/20"
>
<div className="px-6 py-4 flex flex-col gap-2">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className="font-heading text-lg uppercase tracking-wider text-persona-white/70 hover:text-persona-red transition-colors py-2"
>
{link.label}
</Link>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</nav>
)
}
Here's the complete src/components/Footer.tsx:
import Link from "next/link"
import { siteConfig } from "@/data/site"
export default function Footer() {
return (
<footer className="border-t border-persona-red/20 bg-persona-black py-8">
<div className="max-w-6xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4">
<a
href={siteConfig.links.github}
target="_blank"
rel="noopener noreferrer"
className="text-persona-white/60 hover:text-persona-red transition-colors"
>
GitHub
</a>
<a
href={siteConfig.links.linkedin}
target="_blank"
rel="noopener noreferrer"
className="text-persona-white/60 hover:text-persona-red transition-colors"
>
LinkedIn
</a>
<a
href={`mailto:${siteConfig.links.email}`}
className="text-persona-white/60 hover:text-persona-red transition-colors"
>
Email
</a>
</div>
<p className="text-persona-white/40 text-sm">
Built with Next.js, Tailwind CSS, and Motion.
</p>
</div>
</footer>
)
}
Your portfolio has its skeleton in place. Every page now inherits SEO metadata, a navigation bar with smooth Persona-style animations, and a consistent footer. Next up, you'll build the dramatic animated landing page that greets visitors.
Create the Animated Landing Page
Your navigation and layout are wired up, meaning every page you build from now on gets the Persona-styled shell for free.
The landing page is the first thing a recruiter sees. It needs to grab attention in under three seconds. In this step, you will build a dramatic P5R-style entrance with staggered text animations, angular background shapes, and scroll-triggered content that proves your animation chops before a recruiter even clicks a link.
In this step, get ready to:
- Build a hero section with staggered text reveal and animated call-to-action buttons.
- Add diagonal background shapes using CSS clip-path for the signature Persona angular feel.
- Implement a scroll-triggered "what I do" section below the fold using Motion's whileInView prop.
Build the hero entrance with staggered text and CTA buttons
The hero is a full-screen section that reveals your name, title, and a brief description in a staggered sequence. Each element fades in slightly after the previous one, creating a cinematic entrance that mirrors Persona 5's menu reveals.
- Create a new file at app/page.tsx by right-clicking the app folder in VS Code's Explorer sidebar and selecting New File.
- Paste the following code into app/page.tsx to set up the hero section with staggered animations:
"use client"
import { motion } from "motion/react"
import Link from "next/link"
import { siteConfig } from "@/data/site"
export default function HomePage() {
return (
<div className="relative">
{/* Hero Section */}
<section className="min-h-screen flex items-center justify-center relative overflow-hidden">
{/* Hero content with staggered reveal */}
<div className="relative z-10 text-center px-4">
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="text-persona-red font-heading text-lg md:text-xl uppercase tracking-widest mb-4"
>
Frontend Developer
</motion.p>
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
className="font-heading text-5xl md:text-7xl lg:text-8xl font-bold mb-6"
>
{siteConfig.name}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
className="text-persona-white/70 text-lg md:text-xl max-w-2xl mx-auto mb-10"
>
{siteConfig.description}
</motion.p>
{/* CTA Buttons with angular clip-path */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.8 }}
className="flex flex-col sm:flex-row gap-4 justify-center"
>
<Link
href="/projects"
className="px-8 py-3 bg-persona-red text-persona-white font-heading uppercase tracking-wider hover:bg-red-700 transition-colors"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
>
View My Work
</Link>
<Link
href="/about"
className="px-8 py-3 border border-persona-white/30 text-persona-white font-heading uppercase tracking-wider hover:border-persona-red hover:text-persona-red transition-colors"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
>
About Me
</Link>
</motion.div>
</div>
</section>
</div>
)
}
What does this code do?
The "use client" directive tells Next.js this is a Client Component, which is required for any file that uses Motion animations.
Each motion.p and motion.h1 element starts invisible and offset (initial) then animates to full opacity and its natural position (animate). The delay on each element creates the staggered reveal. Your name appears 0.4 seconds in, your description at 0.6 seconds, and the buttons at 0.8 seconds.
The CTA buttons use clip-path: polygon() to create parallelogram shapes instead of boring rectangles. This is the signature Persona 5 angular style.
- Save app/page.tsx.
- Switch to your browser and check localhost:3000. You should see your name animating in large bold text, with "Frontend Developer" above it and two angular buttons below.
Page is blank or shows an error?
- Make sure you saved the file as page.tsx (not page.ts) inside the app folder. Next.js requires the .tsx extension for files that return JSX.
- Check that "use client" is the very first line, before any imports.
- If you see a module-not-found error for @/data/site, confirm your src/data/site.ts file exists from the previous step.
Help me debug my landing page
Add diagonal background shapes with clip-path
The hero text works, but the background is plain black. Persona 5's UI uses bold angular shapes cutting across the screen to create depth and energy. You will add absolute-positioned divs with clip-path polygons that slice diagonal shapes behind the hero content.
- In app/page.tsx, find the comment that says {/* Hero content with staggered reveal */}.
- Add the following diagonal shape divs directly above that comment (still inside the <section> element):
{/* Diagonal background shapes */}
<div
className="absolute inset-0 bg-persona-red/10"
style={{ clipPath: "polygon(0 0, 50% 0, 30% 100%, 0 100%)" }}
/>
<div
className="absolute inset-0 bg-persona-red/5"
style={{ clipPath: "polygon(60% 0, 100% 0, 100% 100%, 40% 100%)" }}
/>
<div
className="absolute top-0 right-0 w-1/3 h-full bg-persona-dark"
style={{ clipPath: "polygon(20% 0, 100% 0, 100% 100%, 0% 100%)" }}
/>
How do these shapes work?
Each div covers the full section (absolute inset-0) but only a diagonal slice is visible thanks to clip-path: polygon(). The polygon coordinates define corner points that form the visible area.
The first shape is a red-tinted diagonal on the left. The second is a faint red diagonal on the right. The third is a dark overlay creating depth. Together they produce the layered angular feel that P5R uses across its entire UI.
- Save app/page.tsx and check your browser.
- You should now see subtle red-tinted diagonal shapes cutting across the background behind your hero text, with a darker angular overlay on the right side.
Shapes not visible?
- Confirm the three <div> elements are inside the <section> but above the hero content div. They need absolute positioning and the content div needs relative z-10 to sit above them.
- If the shapes cover your text, check that the hero content div has className="relative z-10" so it renders above the background shapes.
Help me fix my clip-path background shapes
Add the scroll-triggered "what I do" section
Below the fold, you want content that reveals itself as the recruiter scrolls. Motion's whileInView prop triggers animations when an element enters the viewport. Combined with viewport={{ once: true }}, each card animates in once and stays visible.
- In app/page.tsx, find the closing </section> tag of the hero section.
- Add the following "What I Do" section directly below that closing tag, but still inside the outer <div className="relative">:
{/* What I Do Section - scroll triggered */}
<section className="py-24 px-4 max-w-5xl mx-auto">
<motion.h2
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
className="font-heading text-3xl md:text-4xl font-bold text-center mb-16"
>
What I Do
</motion.h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[
{ title: "Frontend Development", description: "Building responsive, performant web applications with React and TypeScript." },
{ title: "UI Animation", description: "Creating fluid, purposeful motion that guides users through interfaces." },
{ title: "Design Systems", description: "Crafting reusable component libraries that scale across products." },
].map((item, index) => (
<motion.div
key={item.title}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.15 }}
viewport={{ once: true }}
className="p-6 border border-persona-white/10 hover:border-persona-red/50 transition-colors"
style={{ clipPath: "polygon(0 0, 100% 0, 100% calc(100% - 16px), calc(100% - 16px) 100%, 0 100%)" }}
>
<h3 className="font-heading text-xl font-bold text-persona-red mb-3">
{item.title}
</h3>
<p className="text-persona-white/70 text-sm leading-relaxed">
{item.description}
</p>
</motion.div>
))}
</div>
</section>
How does whileInView work?
The whileInView prop defines the animation target for when an element scrolls into the viewport. Paired with initial, it creates a fade-and-slide-up effect as you scroll down.
Setting viewport={{ once: true }} means the animation fires only the first time the element enters view. Without it, elements would animate every time you scroll past them. The staggered delay: index * 0.15 makes each card appear slightly after the previous one.
- Save app/page.tsx and return to your browser.
- Scroll down past the hero section. You should see a "What I Do" heading fade in, followed by three angular cards that appear in sequence with staggered timing.
Cards not animating on scroll?
- Make sure the hero section takes up the full viewport height (min-h-screen class). If both sections are visible without scrolling, the "What I Do" section is already in view on load and the animation fires immediately.
- Check that viewport={{ once: true }} uses double curly braces. The outer pair is JSX expression syntax, the inner pair is the object.
Help me fix my scroll-triggered animations
✔️ Awesome, I've got everything!
Great. Double check you have saved app/page.tsx and that both the hero and the "What I Do" section are animating correctly.
ⓧ I'd like to double check the full code
Here is the complete app/page.tsx file:
"use client"
import { motion } from "motion/react"
import Link from "next/link"
import { siteConfig } from "@/data/site"
export default function HomePage() {
return (
<div className="relative">
{/* Hero Section */}
<section className="min-h-screen flex items-center justify-center relative overflow-hidden">
{/* Diagonal background shapes */}
<div
className="absolute inset-0 bg-persona-red/10"
style={{ clipPath: "polygon(0 0, 50% 0, 30% 100%, 0 100%)" }}
/>
<div
className="absolute inset-0 bg-persona-red/5"
style={{ clipPath: "polygon(60% 0, 100% 0, 100% 100%, 40% 100%)" }}
/>
<div
className="absolute top-0 right-0 w-1/3 h-full bg-persona-dark"
style={{ clipPath: "polygon(20% 0, 100% 0, 100% 100%, 0% 100%)" }}
/>
{/* Hero content with staggered reveal */}
<div className="relative z-10 text-center px-4">
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="text-persona-red font-heading text-lg md:text-xl uppercase tracking-widest mb-4"
>
Frontend Developer
</motion.p>
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
className="font-heading text-5xl md:text-7xl lg:text-8xl font-bold mb-6"
>
{siteConfig.name}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
className="text-persona-white/70 text-lg md:text-xl max-w-2xl mx-auto mb-10"
>
{siteConfig.description}
</motion.p>
{/* CTA Buttons with angular clip-path */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.8 }}
className="flex flex-col sm:flex-row gap-4 justify-center"
>
<Link
href="/projects"
className="px-8 py-3 bg-persona-red text-persona-white font-heading uppercase tracking-wider hover:bg-red-700 transition-colors"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
>
View My Work
</Link>
<Link
href="/about"
className="px-8 py-3 border border-persona-white/30 text-persona-white font-heading uppercase tracking-wider hover:border-persona-red hover:text-persona-red transition-colors"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
>
About Me
</Link>
</motion.div>
</div>
</section>
{/* What I Do Section - scroll triggered */}
<section className="py-24 px-4 max-w-5xl mx-auto">
<motion.h2
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
className="font-heading text-3xl md:text-4xl font-bold text-center mb-16"
>
What I Do
</motion.h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[
{ title: "Frontend Development", description: "Building responsive, performant web applications with React and TypeScript." },
{ title: "UI Animation", description: "Creating fluid, purposeful motion that guides users through interfaces." },
{ title: "Design Systems", description: "Crafting reusable component libraries that scale across products." },
].map((item, index) => (
<motion.div
key={item.title}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.15 }}
viewport={{ once: true }}
className="p-6 border border-persona-white/10 hover:border-persona-red/50 transition-colors"
style={{ clipPath: "polygon(0 0, 100% 0, 100% calc(100% - 16px), calc(100% - 16px) 100%, 0 100%)" }}
>
<h3 className="font-heading text-xl font-bold text-persona-red mb-3">
{item.title}
</h3>
<p className="text-persona-white/70 text-sm leading-relaxed">
{item.description}
</p>
</motion.div>
))}
</div>
</section>
</div>
)
}
Your landing page is alive with motion. Next, you will build the About Me and Education section using the ethereal P3R blue palette to create a contrast between the aggressive red energy of the homepage and the reflective tone of your personal story.
Build the About Me and Projects Pages
Your landing page is live and channeling Phantom Thief energy. Now it's time to give visitors somewhere to go when they want to learn more about you and explore your work.
In this step, you'll build two new pages. The About page uses P3R's ethereal blue palette to create a reflective, bio-focused space with an animated education timeline. The Projects page shifts back to P5R's aggressive red and angular shapes to showcase your work in a grid that demands attention.
In this step, get ready to:
- Create an About page with P3R blue gradients, an animated education timeline, and scroll-triggered skill tags.
- Build a Projects grid page with angular cards, hover animations, and SEO metadata.
Create the About page with bio and animated timeline
The About page is where visitors shift from the high-energy landing page into a calmer space. P3R's blue gradients set a reflective tone, while Motion's staggerChildren variant staggers each education entry into view one after another.
- Create a new file at app/about/page.tsx and paste the following code:
"use client"
import { motion } from "motion/react"
import { education } from "@/data/education"
// Animation variants for staggered timeline entries
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.2,
},
},
}
const itemVariants = {
hidden: { opacity: 0, x: -30 },
visible: { opacity: 1, x: 0, transition: { duration: 0.5 } },
}
// Skills data displayed as animated tags
const skills = [
"React", "TypeScript", "Next.js", "Tailwind CSS",
"Node.js", "Python", "Git", "Docker",
"PostgreSQL", "REST APIs", "GraphQL", "CI/CD",
]
export default function AboutPage() {
return (
<section className="min-h-screen py-24 px-6">
{/* Split layout: Bio on the left, Education timeline on the right */}
<div className="max-w-6xl mx-auto grid md:grid-cols-2 gap-12">
{/* Bio side with P3R blue gradient background */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="relative p-8 rounded-lg"
style={{
background: "linear-gradient(135deg, var(--color-persona-midnight), var(--color-persona-blue))",
}}
>
<h1 className="font-heading text-4xl font-bold text-persona-white mb-6">
About Me
</h1>
<p className="text-persona-white/80 leading-relaxed mb-4">
I'm a frontend developer who cares deeply about user experience,
performance, and clean code. I build animated, accessible web
applications that feel as good to use as they look.
</p>
<p className="text-persona-white/80 leading-relaxed">
When I'm not coding, you'll find me exploring game UI design,
contributing to open source, and learning new frameworks.
</p>
{/* Decorative P3R triangular accent */}
<div
className="absolute top-0 right-0 w-24 h-24 bg-persona-teal/20"
style={{ clipPath: "polygon(100% 0, 0 0, 100% 100%)" }}
/>
</motion.div>
{/* Education timeline with staggered entrance */}
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
<h2 className="font-heading text-3xl font-bold text-persona-white mb-8">
Education
</h2>
{education.map((item, index) => (
<motion.div
key={index}
variants={itemVariants}
className="relative pl-8 pb-8 border-l-2 border-persona-teal/50 last:pb-0"
>
{/* Timeline dot */}
<div className="absolute left-[-9px] top-0 w-4 h-4 rounded-full bg-persona-teal" />
<span className="text-persona-teal text-sm font-mono">
{item.year}
</span>
<h3 className="font-heading text-xl text-persona-white mt-1">
{item.degree}
</h3>
<p className="text-persona-white/60">{item.institution}</p>
{item.description && (
<p className="text-persona-white/50 text-sm mt-1">
{item.description}
</p>
)}
</motion.div>
))}
</motion.div>
</div>
{/* Skills sub-section with scroll-triggered reveal */}
<div className="max-w-6xl mx-auto mt-20">
<h2 className="font-heading text-3xl font-bold text-persona-white mb-8">
Skills
</h2>
<motion.div
className="flex flex-wrap gap-3"
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
variants={containerVariants}
>
{skills.map((skill, index) => (
<motion.span
key={index}
variants={itemVariants}
className="px-4 py-2 bg-persona-dark border border-persona-teal/30 text-persona-teal font-mono text-sm hover:border-persona-teal hover:bg-persona-teal/10 transition-colors"
style={{ clipPath: "polygon(6% 0%, 100% 0%, 94% 100%, 0% 100%)" }}
>
{skill}
</motion.span>
))}
</motion.div>
</div>
</section>
)
}
What does this code do?
The page is split into three visual zones, each using a different animation technique.
- The bio section uses P3R's midnight-to-blue gradient as a background. It fades in and slides up on page load with Motion's initial and animate props.
- The education timeline uses containerVariants with staggerChildren: 0.2 so each entry appears 200ms after the previous one. Each itemVariants slides in from the left.
- The skills section uses whileInView so the tags only animate when the learner scrolls down to them. The viewport: { once: true } setting ensures the animation plays only once.
- Save app/about/page.tsx.
- In your browser, navigate to http://localhost:3000/about. You should see a blue gradient bio panel on the left, a timeline with your education entries staggering in from the left on the right, and skill tags appearing as you scroll down.
Page showing a 404?
- Make sure the file is at app/about/page.tsx (not app/about/index.tsxor inside src/app). The App Router uses page.tsx as the file convention for route pages.
- Confirm your dev server is still running. If it crashed, restart it with npm run dev.
Still stuck? Why isn't my /about route rendering in Next.js App Router?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
"use client"
import { motion } from "motion/react"
import { education } from "@/data/education"
// Animation variants for staggered timeline entries
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.2,
},
},
}
const itemVariants = {
hidden: { opacity: 0, x: -30 },
visible: { opacity: 1, x: 0, transition: { duration: 0.5 } },
}
// Skills data displayed as animated tags
const skills = [
"React", "TypeScript", "Next.js", "Tailwind CSS",
"Node.js", "Python", "Git", "Docker",
"PostgreSQL", "REST APIs", "GraphQL", "CI/CD",
]
export default function AboutPage() {
return (
<section className="min-h-screen py-24 px-6">
{/* Split layout: Bio on the left, Education timeline on the right */}
<div className="max-w-6xl mx-auto grid md:grid-cols-2 gap-12">
{/* Bio side with P3R blue gradient background */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="relative p-8 rounded-lg"
style={{
background: "linear-gradient(135deg, var(--color-persona-midnight), var(--color-persona-blue))",
}}
>
<h1 className="font-heading text-4xl font-bold text-persona-white mb-6">
About Me
</h1>
<p className="text-persona-white/80 leading-relaxed mb-4">
I'm a frontend developer who cares deeply about user experience,
performance, and clean code. I build animated, accessible web
applications that feel as good to use as they look.
</p>
<p className="text-persona-white/80 leading-relaxed">
When I'm not coding, you'll find me exploring game UI design,
contributing to open source, and learning new frameworks.
</p>
{/* Decorative P3R triangular accent */}
<div
className="absolute top-0 right-0 w-24 h-24 bg-persona-teal/20"
style={{ clipPath: "polygon(100% 0, 0 0, 100% 100%)" }}
/>
</motion.div>
{/* Education timeline with staggered entrance */}
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
>
<h2 className="font-heading text-3xl font-bold text-persona-white mb-8">
Education
</h2>
{education.map((item, index) => (
<motion.div
key={index}
variants={itemVariants}
className="relative pl-8 pb-8 border-l-2 border-persona-teal/50 last:pb-0"
>
{/* Timeline dot */}
<div className="absolute left-[-9px] top-0 w-4 h-4 rounded-full bg-persona-teal" />
<span className="text-persona-teal text-sm font-mono">
{item.year}
</span>
<h3 className="font-heading text-xl text-persona-white mt-1">
{item.degree}
</h3>
<p className="text-persona-white/60">{item.institution}</p>
{item.description && (
<p className="text-persona-white/50 text-sm mt-1">
{item.description}
</p>
)}
</motion.div>
))}
</motion.div>
</div>
{/* Skills sub-section with scroll-triggered reveal */}
<div className="max-w-6xl mx-auto mt-20">
<h2 className="font-heading text-3xl font-bold text-persona-white mb-8">
Skills
</h2>
<motion.div
className="flex flex-wrap gap-3"
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
variants={containerVariants}
>
{skills.map((skill, index) => (
<motion.span
key={index}
variants={itemVariants}
className="px-4 py-2 bg-persona-dark border border-persona-teal/30 text-persona-teal font-mono text-sm hover:border-persona-teal hover:bg-persona-teal/10 transition-colors"
style={{ clipPath: "polygon(6% 0%, 100% 0%, 94% 100%, 0% 100%)" }}
>
{skill}
</motion.span>
))}
</motion.div>
</div>
</section>
)
}
Build the Projects grid page with SEO metadata
The Projects page needs to do two things at once. It exports Metadata for SEO (so Google indexes the page with the right title and description), and it renders animated cards with Motion. Since metadata exports only work in Server Components and Motion requires a Client Component, you'll split the page into two files.
- Create a new file at app/projects/page.tsx and paste the following server component code:
import type { Metadata } from "next"
import { siteConfig } from "@/data/site"
import { projects } from "@/data/projects"
import ProjectsGridClient from "./ProjectsGridClient"
// SEO metadata so Google indexes this page with a clear title and description
export const metadata: Metadata = {
title: "Projects",
description: `Explore ${siteConfig.name}'s portfolio of web development projects featuring React, TypeScript, and modern frontend technologies.`,
openGraph: {
title: `Projects | ${siteConfig.name}`,
description: `Explore ${siteConfig.name}'s portfolio of web development projects.`,
url: `${siteConfig.url}/projects`,
},
}
export default function ProjectsPage() {
return (
<section className="min-h-screen py-24 px-6">
<div className="max-w-6xl mx-auto">
<h1 className="font-heading text-5xl font-bold text-persona-white mb-4">
Projects
</h1>
<p className="text-persona-white/60 mb-12 max-w-2xl">
A collection of projects I've built. Each one taught me something new
and pushed my skills forward.
</p>
{/* Pass projects data down to the animated client component */}
<ProjectsGridClient projects={projects} />
</div>
</section>
)
}
Why split into two files?
Next.js App Router enforces a rule: generateMetadata (or a static metadata export) only works in Server Components. Motion's whileHover and initial props require a Client Component with the "use client" directive.
The pattern is: keep page.tsx as a server file for SEO and data, then render a separate client component for animations. This is the same pattern you'll use later for individual project detail pages.
- Now create the animated grid component at app/projects/ProjectsGridClient.tsx and paste this code:
"use client"
import Link from "next/link"
import { motion } from "motion/react"
import { Project } from "@/data/projects"
interface Props {
projects: Project[]
}
export default function ProjectsGridClient({ projects }: Props) {
return (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project, index) => (
<motion.div
key={project.slug}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: index * 0.1 }}
whileHover={{ y: -8, scale: 1.02 }}
className="relative bg-persona-dark border border-persona-red/20 p-6 cursor-pointer group"
style={{ clipPath: "polygon(0 0, 100% 0, 100% calc(100% - 16px), calc(100% - 16px) 100%, 0 100%)" }}
>
<Link href={`/projects/${project.slug}`} className="block">
{/* Project title with red hover color shift */}
<h2 className="font-heading text-xl font-bold text-persona-white mb-2 group-hover:text-persona-red transition-colors">
{project.title}
</h2>
{/* Short description clamped to 2 lines */}
<p className="text-persona-white/60 text-sm mb-4 line-clamp-2">
{project.shortDescription}
</p>
{/* Tech stack tags */}
<div className="flex flex-wrap gap-2">
{project.techStack.map((tech) => (
<span
key={tech}
className="px-2 py-1 text-xs font-mono bg-persona-red/10 text-persona-red/80 border border-persona-red/20"
>
{tech}
</span>
))}
</div>
</Link>
{/* Angular corner accent that lights up on hover */}
<div className="absolute bottom-0 right-0 w-4 h-4 bg-persona-red/40 group-hover:bg-persona-red transition-colors" />
</motion.div>
))}
</div>
)
}
What does this code do?
- Each card gets an angular shape using clip-path: polygon() that cuts a notch from the bottom-right corner. This is the signature Persona 5 Royal angular feel.
- Motion's whileHover: { y: -8, scale: 1.02 } lifts each card up and slightly scales it when the cursor enters. Combined with delay: index * 0.1, cards stagger into view on page load.
- Each card links to /projects/${project.slug} using the slug from your data layer. You'll build those detail pages in the next step.
- Save both app/projects/page.tsx and app/projects/ProjectsGridClient.tsx.
- In your browser, navigate to http://localhost:3000/projects. You should see a heading that says "Projects", a description paragraph, and a grid of cards. Each card shows the project title, a short description, and tech stack tags in red-tinted boxes. Hovering a card lifts it up smoothly.
Cards not appearing or import errors?
- If you see a module-not-found error for ProjectsGridClient, confirm the file is saved at app/projects/ProjectsGridClient.tsx (same folder as page.tsx). The import uses a relative path ./ProjectsGridClient.
- If the grid is empty, check that your src/data/projects.ts file has at least one project in the array. The grid renders one card per project object.
- If hover animations don't work, make sure the file starts with "use client". Without it, Motion's interactive props are ignored.
Need help? My projects page is showing an error or no cards are rendering.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
import type { Metadata } from "next"
import { siteConfig } from "@/data/site"
import { projects } from "@/data/projects"
import ProjectsGridClient from "./ProjectsGridClient"
// SEO metadata so Google indexes this page with a clear title and description
export const metadata: Metadata = {
title: "Projects",
description: `Explore ${siteConfig.name}'s portfolio of web development projects featuring React, TypeScript, and modern frontend technologies.`,
openGraph: {
title: `Projects | ${siteConfig.name}`,
description: `Explore ${siteConfig.name}'s portfolio of web development projects.`,
url: `${siteConfig.url}/projects`,
},
}
export default function ProjectsPage() {
return (
<section className="min-h-screen py-24 px-6">
<div className="max-w-6xl mx-auto">
<h1 className="font-heading text-5xl font-bold text-persona-white mb-4">
Projects
</h1>
<p className="text-persona-white/60 mb-12 max-w-2xl">
A collection of projects I've built. Each one taught me something new
and pushed my skills forward.
</p>
{/* Pass projects data down to the animated client component */}
<ProjectsGridClient projects={projects} />
</div>
</section>
)
}
"use client"
import Link from "next/link"
import { motion } from "motion/react"
import { Project } from "@/data/projects"
interface Props {
projects: Project[]
}
export default function ProjectsGridClient({ projects }: Props) {
return (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project, index) => (
<motion.div
key={project.slug}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: index * 0.1 }}
whileHover={{ y: -8, scale: 1.02 }}
className="relative bg-persona-dark border border-persona-red/20 p-6 cursor-pointer group"
style={{ clipPath: "polygon(0 0, 100% 0, 100% calc(100% - 16px), calc(100% - 16px) 100%, 0 100%)" }}
>
<Link href={`/projects/${project.slug}`} className="block">
{/* Project title with red hover color shift */}
<h2 className="font-heading text-xl font-bold text-persona-white mb-2 group-hover:text-persona-red transition-colors">
{project.title}
</h2>
{/* Short description clamped to 2 lines */}
<p className="text-persona-white/60 text-sm mb-4 line-clamp-2">
{project.shortDescription}
</p>
{/* Tech stack tags */}
<div className="flex flex-wrap gap-2">
{project.techStack.map((tech) => (
<span
key={tech}
className="px-2 py-1 text-xs font-mono bg-persona-red/10 text-persona-red/80 border border-persona-red/20"
>
{tech}
</span>
))}
</div>
</Link>
{/* Angular corner accent that lights up on hover */}
<div className="absolute bottom-0 right-0 w-4 h-4 bg-persona-red/40 group-hover:bg-persona-red transition-colors" />
</motion.div>
))}
</div>
)
}
Both your content pages are live. Visitors can now explore who you are and browse your work. Next up, you'll build individual project detail pages so each project gets its own URL, rich metadata, and a full-page showcase.
Build Individual Project Detail Pages
Your projects grid gives visitors an overview, but recruiters want the full story. They want to know what problem you solved, how you solved it, and what technologies you used.
In this step, you will create dedicated detail pages for each project using Next.js dynamic routes. Each page gets its own URL, its own SEO metadata, and structured data so Google and LinkedIn can display rich previews when someone shares a link.
In this step, get ready to:
- Create a dynamic route with pre-rendered pages for each project.
- Build an animated detail page component with hero, tech stack, and challenge/solution sections.
- Create a custom Persona-themed 404 page for invalid project slugs.
Create the dynamic route server page
Dynamic routes in the App Router use folder names wrapped in square brackets. A folder called [slug] tells Next.js to match any value in that URL segment and pass it to your page as a parameter.
- In the VS Code Explorer sidebar, right-click the app/projects folder.
- Create a new folder called [slug] (including the square brackets).
- Inside app/projects/[slug]/, create a new file called page.tsx.
This file is the server component that handles data lookup, SEO metadata, and structured data. It delegates the visual rendering to a client component you will build next.
- Add the imports, Props interface, and generateStaticParams function by pasting the following code into app/projects/[slug]/page.tsx:
import type { Metadata } from "next"
import { notFound } from "next/navigation"
import { projects } from "@/data/projects"
import { siteConfig } from "@/data/site"
import ProjectDetailClient from "./ProjectDetailClient"
// Define the type for route params (Promise in Next.js 16)
interface Props {
params: Promise<{ slug: string }>
}
// Tell Next.js which slugs to pre-render at build time
export async function generateStaticParams() {
return projects.map((project) => ({
slug: project.slug,
}))
}
What does generateStaticParams do?
At build time, Next.js calls generateStaticParams to get a list of all valid slug values. It then pre-renders an HTML page for each one.
This means your project pages are ready as static HTML before any visitor arrives. Google can crawl them instantly, and page loads are near-instant because there is no server-side computation at request time.
- Below generateStaticParams, add the generateMetadata function for per-project SEO:
// Generate unique SEO metadata for each project page
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const project = projects.find((p) => p.slug === slug)
if (!project) return { title: "Project Not Found" }
return {
title: project.title,
description: project.shortDescription,
openGraph: {
title: project.title,
description: project.shortDescription,
url: `${siteConfig.url}/projects/${project.slug}`,
type: "article",
images: [{ url: project.imageUrl, width: 1200, height: 630 }],
},
}
}
Why does each project need its own metadata?
When someone shares a link to /projects/project-one on LinkedIn, the platform reads the Open Graph tags to build a rich preview card. Without per-page metadata, every shared link would show the same generic site title and description.
The generateMetadata function runs at build time for each slug, producing unique <meta> tags in the HTML head of every project page.
- Below generateMetadata, add the page component that renders the project detail with JSON-LD structured data:
// The page component: looks up the project, embeds structured data, renders the client UI
export default async function ProjectPage({ params }: Props) {
const { slug } = await params
const project = projects.find((p) => p.slug === slug)
// If no matching project, trigger the 404 page
if (!project) notFound()
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "CreativeWork",
name: project.title,
description: project.fullDescription,
url: project.deployUrl || `${siteConfig.url}/projects/${project.slug}`,
author: { "@type": "Person", name: siteConfig.name },
keywords: project.techStack.join(", "),
}),
}}
/>
<ProjectDetailClient project={project} />
</>
)
}
What is JSON-LD and why add it here?
JSON-LD is a structured data format that search engines read to understand what a page represents. The CreativeWork schema tells Google this page describes a creative project, who authored it, and what technologies it uses.
This can enable rich search results (like project cards in Google) and helps search engines connect your projects to your professional identity.
- Save app/projects/[slug]/page.tsx.
Seeing a red squiggly under ProjectDetailClient?
That is expected. TypeScript cannot find the import because you have not created the ProjectDetailClient.tsx file yet. You will build it in the next substep, and the error will disappear.
Help me troubleshoot this import error
Build the ProjectDetailClient component
Server components cannot use browser APIs or animation libraries. The pattern in the App Router is to handle data and SEO in a server component, then pass the data to a client component for interactive rendering.
- Inside app/projects/[slug]/, create a new file called ProjectDetailClient.tsx.
- Paste the following code into ProjectDetailClient.tsx:
"use client"
import { motion } from "motion/react"
import Link from "next/link"
import { Project } from "@/data/projects"
export default function ProjectDetailClient({ project }: { project: Project }) {
return (
<div className="min-h-screen pt-24 pb-16 px-6 max-w-4xl mx-auto">
{/* Hero section: title and short description */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h1 className="font-heading text-5xl font-bold text-persona-white mb-4">
{project.title}
</h1>
<p className="text-xl text-persona-white/70 mb-8">
{project.shortDescription}
</p>
</motion.div>
{/* Tech stack tags with angular clip-path */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="flex flex-wrap gap-2 mb-12"
>
{project.techStack.map((tech) => (
<span
key={tech}
className="px-3 py-1 bg-persona-red/10 text-persona-red text-sm font-heading"
style={{ clipPath: "polygon(4% 0%, 100% 0%, 96% 100%, 0% 100%)" }}
>
{tech}
</span>
))}
</motion.div>
{/* Full project description */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="mb-12"
>
<h2 className="font-heading text-2xl text-persona-red mb-4">Overview</h2>
<p className="text-persona-white/80 leading-relaxed">
{project.fullDescription}
</p>
</motion.section>
{/* Challenge and solution narrative */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mb-12 grid md:grid-cols-2 gap-8"
>
<div>
<h2 className="font-heading text-2xl text-persona-red mb-4">Challenge</h2>
<p className="text-persona-white/80 leading-relaxed">
{project.challenge}
</p>
</div>
<div>
<h2 className="font-heading text-2xl text-persona-teal mb-4">Solution</h2>
<p className="text-persona-white/80 leading-relaxed">
{project.solution}
</p>
</div>
</motion.section>
{/* GitHub and optional deploy links */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="flex gap-4"
>
<Link
href={project.githubUrl}
target="_blank"
className="px-6 py-3 bg-persona-red text-persona-white font-heading uppercase tracking-wider hover:bg-red-700 transition-colors"
style={{ clipPath: "polygon(5% 0%, 100% 0%, 95% 100%, 0% 100%)" }}
>
View on GitHub
</Link>
{project.deployUrl && (
<Link
href={project.deployUrl}
target="_blank"
className="px-6 py-3 border border-persona-teal text-persona-teal font-heading uppercase tracking-wider hover:bg-persona-teal/10 transition-colors"
style={{ clipPath: "polygon(5% 0%, 100% 0%, 95% 100%, 0% 100%)" }}
>
Live Demo
</Link>
)}
</motion.div>
</div>
)
}
What does this component do?
This client component receives the full project object from the server page and renders it with staggered Motion animations. Each section fades in with a slight upward slide, delayed incrementally so the content reveals from top to bottom.
The P5R angular clip-path shapes appear on the tech stack tags and link buttons. The Challenge section uses persona-red while the Solution uses persona-teal (P3R blue) to create visual contrast between problem and resolution.
- Save ProjectDetailClient.tsx.
- Return to your browser and navigate to http://localhost:3000/projects/project-one.
You should see the project detail page with a large title, short description, tech stack tags in angular shapes, an Overview section, the Challenge/Solution grid, and GitHub/Live Demo buttons at the bottom.
Seeing a blank page or error?
Check that the folder name is exactly [slug] with square brackets. Without the brackets, Next.js treats it as a static route instead of a dynamic one.
Also confirm the URL uses a slug that exists in your src/data/projects.ts array. If you are using the default data, the slug is project-one.
Help me debug my dynamic route
Create the custom 404 page
When someone visits a slug that does not match any project in your data array, the notFound() call in your page component triggers Next.js to render a 404 page. By default this is a plain white page with generic text.
You will replace it with a Persona-themed "Shadow World" error page that keeps visitors immersed in your design.
- In the VS Code Explorer sidebar, right-click the app/ folder and create a new file called not-found.tsx.
- Paste the following code into app/not-found.tsx:
"use client"
import Link from "next/link"
import { motion } from "motion/react"
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden">
{/* Angular P5R shape in the background */}
<div
className="absolute inset-0 bg-persona-red/5"
style={{ clipPath: "polygon(0 0, 60% 0, 40% 100%, 0 100%)" }}
/>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-center relative z-10"
>
<h1 className="font-heading text-8xl font-bold text-persona-red mb-4">404</h1>
<p className="font-heading text-2xl text-persona-white/70 mb-8">
You've entered the Shadow World.
</p>
<Link
href="/"
className="px-8 py-3 bg-persona-red text-persona-white font-heading uppercase tracking-wider hover:bg-red-700 transition-colors inline-block"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
>
Return to Reality
</Link>
</motion.div>
</div>
)
}
What does this page do?
This global 404 page renders whenever notFound() is called or a visitor navigates to a URL that does not match any route. The diagonal clip-path background and bold red typography keep the error page consistent with your P5R design language.
The motion.div fades the content in with a slight upward slide, so even the error page feels polished.
- Save app/not-found.tsx.
- In your browser, navigate to http://localhost:3000/projects/fake-slug (a slug that does not exist in your data).
You should see a bold red "404" heading, the text "You've entered the Shadow World.", and a parallelogram-shaped "Return to Reality" button that links back to your home page.
Still seeing the default Next.js 404?
Make sure not-found.tsx is inside the app/ folder directly, not nested inside app/projects/. The file must be at the root of the App Router directory to act as the global 404 handler.
Help me fix my custom 404 page
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: page.tsx, ProjectDetailClient.tsx, and not-found.tsx.
ⓧ I'd like to double check the full code
Here is the complete code for each file created in this step.
import type { Metadata } from "next"
import { notFound } from "next/navigation"
import { projects } from "@/data/projects"
import { siteConfig } from "@/data/site"
import ProjectDetailClient from "./ProjectDetailClient"
interface Props {
params: Promise<{ slug: string }>
}
export async function generateStaticParams() {
return projects.map((project) => ({
slug: project.slug,
}))
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const project = projects.find((p) => p.slug === slug)
if (!project) return { title: "Project Not Found" }
return {
title: project.title,
description: project.shortDescription,
openGraph: {
title: project.title,
description: project.shortDescription,
url: `${siteConfig.url}/projects/${project.slug}`,
type: "article",
images: [{ url: project.imageUrl, width: 1200, height: 630 }],
},
}
}
export default async function ProjectPage({ params }: Props) {
const { slug } = await params
const project = projects.find((p) => p.slug === slug)
if (!project) notFound()
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "CreativeWork",
name: project.title,
description: project.fullDescription,
url: project.deployUrl || `${siteConfig.url}/projects/${project.slug}`,
author: { "@type": "Person", name: siteConfig.name },
keywords: project.techStack.join(", "),
}),
}}
/>
<ProjectDetailClient project={project} />
</>
)
}
"use client"
import { motion } from "motion/react"
import Link from "next/link"
import { Project } from "@/data/projects"
export default function ProjectDetailClient({ project }: { project: Project }) {
return (
<div className="min-h-screen pt-24 pb-16 px-6 max-w-4xl mx-auto">
{/* Hero section: title and short description */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h1 className="font-heading text-5xl font-bold text-persona-white mb-4">
{project.title}
</h1>
<p className="text-xl text-persona-white/70 mb-8">
{project.shortDescription}
</p>
</motion.div>
{/* Tech stack tags with angular clip-path */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="flex flex-wrap gap-2 mb-12"
>
{project.techStack.map((tech) => (
<span
key={tech}
className="px-3 py-1 bg-persona-red/10 text-persona-red text-sm font-heading"
style={{ clipPath: "polygon(4% 0%, 100% 0%, 96% 100%, 0% 100%)" }}
>
{tech}
</span>
))}
</motion.div>
{/* Full project description */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="mb-12"
>
<h2 className="font-heading text-2xl text-persona-red mb-4">Overview</h2>
<p className="text-persona-white/80 leading-relaxed">
{project.fullDescription}
</p>
</motion.section>
{/* Challenge and solution narrative */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mb-12 grid md:grid-cols-2 gap-8"
>
<div>
<h2 className="font-heading text-2xl text-persona-red mb-4">Challenge</h2>
<p className="text-persona-white/80 leading-relaxed">
{project.challenge}
</p>
</div>
<div>
<h2 className="font-heading text-2xl text-persona-teal mb-4">Solution</h2>
<p className="text-persona-white/80 leading-relaxed">
{project.solution}
</p>
</div>
</motion.section>
{/* GitHub and optional deploy links */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="flex gap-4"
>
<Link
href={project.githubUrl}
target="_blank"
className="px-6 py-3 bg-persona-red text-persona-white font-heading uppercase tracking-wider hover:bg-red-700 transition-colors"
style={{ clipPath: "polygon(5% 0%, 100% 0%, 95% 100%, 0% 100%)" }}
>
View on GitHub
</Link>
{project.deployUrl && (
<Link
href={project.deployUrl}
target="_blank"
className="px-6 py-3 border border-persona-teal text-persona-teal font-heading uppercase tracking-wider hover:bg-persona-teal/10 transition-colors"
style={{ clipPath: "polygon(5% 0%, 100% 0%, 95% 100%, 0% 100%)" }}
>
Live Demo
</Link>
)}
</motion.div>
</div>
)
}
"use client"
import Link from "next/link"
import { motion } from "motion/react"
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden">
<div
className="absolute inset-0 bg-persona-red/5"
style={{ clipPath: "polygon(0 0, 60% 0, 40% 100%, 0 100%)" }}
/>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-center relative z-10"
>
<h1 className="font-heading text-8xl font-bold text-persona-red mb-4">404</h1>
<p className="font-heading text-2xl text-persona-white/70 mb-8">
You've entered the Shadow World.
</p>
<Link
href="/"
className="px-8 py-3 bg-persona-red text-persona-white font-heading uppercase tracking-wider hover:bg-red-700 transition-colors inline-block"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
>
Return to Reality
</Link>
</motion.div>
</div>
)
}
Every project in your data file now has its own pre-rendered page with unique SEO metadata and structured data. Next up, you will build the Resume section so hirers can view and download your resume directly from the portfolio.
Build the Resume and Contact Sections
Your portfolio has project pages, an about section, and a custom 404 page. Two critical pieces remain: a place for hirers to view your resume and a clear way to contact you.
In this step, you will build a resume page with an embedded PDF viewer and a contact section with compelling social links. These sections close the loop for anyone evaluating your work. They can see your projects, read your resume, and reach out without leaving your site.
In this step, get ready to:
- Create a resume page with an embedded PDF viewer and download button.
- Add a contact section with angular P5R-styled social link buttons and a CTA.
- Update the footer with an EasterEgg trigger placeholder and social links.
Create the resume page with a PDF viewer
Hirers expect a one-click path from your portfolio to your resume. An embedded viewer lets them scan it without downloading, and a download button gives them the file for their ATS system.
- Create the file app/resume/page.tsx by adding a new folder resume inside app/ and creating page.tsx inside it.
- Add the following code to app/resume/page.tsx:
import type { Metadata } from "next"
import { siteConfig } from "@/data/site"
// SEO metadata for the resume page
export const metadata: Metadata = {
title: "Resume",
description: `View and download ${siteConfig.name}'s resume.`,
openGraph: {
title: `Resume | ${siteConfig.name}`,
description: `View and download ${siteConfig.name}'s resume.`,
url: `${siteConfig.url}/resume`,
},
}
export default function ResumePage() {
return (
<section className="min-h-screen py-24 px-6 relative">
{/* Angular background accent */}
<div
className="absolute inset-0 bg-persona-red/5"
style={{ clipPath: "polygon(0 0, 100% 0, 100% 80%, 0 100%)" }}
/>
<div className="max-w-4xl mx-auto relative z-10">
<h1 className="font-heading text-5xl font-bold text-persona-white mb-8">
Resume
</h1>
{/* Embedded PDF viewer with angular frame */}
<div
className="w-full border-2 border-persona-red/30 bg-persona-dark"
style={{ clipPath: "polygon(2% 0%, 100% 0%, 98% 100%, 0% 100%)" }}
>
<iframe
src="/resume.pdf"
className="w-full h-[800px]"
title="Resume PDF Viewer"
/>
</div>
{/* Download button with P5R parallelogram shape */}
<div className="mt-8 flex justify-center">
<a
href="/resume.pdf"
download
className="px-10 py-4 bg-persona-red text-persona-white font-heading text-lg uppercase tracking-wider hover:bg-red-700 transition-colors inline-block"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
>
Download Resume
</a>
</div>
</div>
</section>
)
}
What does this code do?
The metadata export tells Next.js what title and Open Graph tags to use when someone shares your resume page on social media.
The <iframe> element embeds your PDF directly in the browser so hirers can scan it without downloading. The clip-path on its container gives it a subtle angular frame in the P5R style.
The download link uses the HTML download attribute to trigger a file save dialog instead of navigating to the PDF in a new tab.
- Place your resume PDF file at public/resume.pdf in your project root. Next.js serves anything in public/ at the site root path.
- Save app/resume/page.tsx.
- Switch to your browser and navigate to http://localhost:3000/resume. You should see your PDF displayed in a tall viewer with a red angular download button beneath it.
Seeing a blank iframe?
Make sure you placed a real PDF file at public/resume.pdf. If the file is missing, the iframe will show a blank area or a browser error page.
If you do not have a resume PDF ready yet, create a placeholder by saving any short PDF to that path. You can replace it later.
Help me troubleshoot my resume page iframe not displaying.
Add the contact section to your landing page
A recruiter who likes your work needs a frictionless way to reach out. A contact section at the bottom of your landing page puts your GitHub, LinkedIn, and email one click away.
- Open app/page.tsx (your landing page).
- Add the following contact section at the end of your page, just before the closing tag of your main wrapper:
{/* Contact Section */}
<section className="py-24 px-6 relative">
{/* Angular background shape */}
<div
className="absolute inset-0 bg-persona-red/5"
style={{ clipPath: "polygon(0 20%, 100% 0, 100% 100%, 0 100%)" }}
/>
<div className="max-w-4xl mx-auto text-center relative z-10">
<h2 className="font-heading text-4xl font-bold text-persona-white mb-6">
Get In Touch
</h2>
{/* CTA - tell hirers what you're looking for */}
<p className="text-persona-white/70 text-lg mb-10">
I'm looking for frontend roles where I can build ambitious, user-facing products.
</p>
{/* Social link buttons with P5R angular styling */}
<div className="flex flex-wrap justify-center gap-6">
<a
href={siteConfig.links.github}
target="_blank"
rel="noopener noreferrer"
className="px-8 py-3 bg-persona-dark border border-persona-red text-persona-white font-heading uppercase tracking-wider hover:bg-persona-red transition-colors"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
>
GitHub
</a>
<a
href={siteConfig.links.linkedin}
target="_blank"
rel="noopener noreferrer"
className="px-8 py-3 bg-persona-dark border border-persona-red text-persona-white font-heading uppercase tracking-wider hover:bg-persona-red transition-colors"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
>
LinkedIn
</a>
<a
href={`mailto:${siteConfig.links.email}`}
className="px-8 py-3 bg-persona-red text-persona-white font-heading uppercase tracking-wider hover:bg-red-700 transition-colors"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
>
Email Me
</a>
</div>
</div>
</section>
What does this code do?
The CTA paragraph is the single most important sentence on your portfolio. It tells hirers exactly what you are looking for in one line. Customize this to match your goals.
Each social link pulls its URL from siteConfig.links so your contact info lives in one place. The clip-path parallelogram shape on each button matches the P5R angular design language you have been using across the site.
- Make sure siteConfig is already imported at the top of app/page.tsx. If it is not, add this import:
import { siteConfig } from "@/data/site"
- Save app/page.tsx.
- Return to your browser at http://localhost:3000 and scroll to the bottom. You should see a "Get In Touch" heading, your CTA sentence, and three angular buttons for GitHub, LinkedIn, and Email.
Buttons look like plain rectangles?
The clip-path property trims the element's visible area. If the buttons appear as normal rectangles, check that the style attribute is on the <a> tag and not on a parent <div>.
Help me fix my angular clip-path buttons not rendering correctly.
Update the footer with an EasterEgg trigger
The footer needs a hidden trigger element for the Secret Mission's Confidant easter egg. You will also make sure it shows social links and a "built with" note.
- Open src/components/Footer.tsx.
- Replace the contents of the file with the following code:
import { siteConfig } from "@/data/site"
export default function Footer() {
return (
<footer className="py-12 px-6 border-t border-persona-white/10">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
{/* Social Links */}
<div className="flex gap-6">
<a
href={siteConfig.links.github}
target="_blank"
rel="noopener noreferrer"
className="text-persona-white/60 hover:text-persona-red transition-colors"
>
GitHub
</a>
<a
href={siteConfig.links.linkedin}
target="_blank"
rel="noopener noreferrer"
className="text-persona-white/60 hover:text-persona-red transition-colors"
>
LinkedIn
</a>
<a
href={`mailto:${siteConfig.links.email}`}
className="text-persona-white/60 hover:text-persona-red transition-colors"
>
Email
</a>
</div>
{/* Easter Egg Trigger - the Secret Mission will wire this up */}
<button
aria-label="Hidden easter egg"
className="text-persona-white/20 hover:text-persona-white/40 transition-colors text-sm cursor-default"
>
\uD83C\uDCCF
</button>
{/* Built With note */}
<p className="text-persona-white/40 text-sm">
Built with Next.js, Tailwind CSS, and Motion.
</p>
</div>
</footer>
)
}
What does this code do?
The footer is split into three sections using flexbox. Social links on the left pull from siteConfig.links so updates propagate automatically.
The button in the center is almost invisible (20% opacity). It is the trigger for the Secret Mission's Confidant easter egg. For now it does nothing. The Secret Mission will add click-count logic and the rank-up modal to it later.
The "Built with" note adds credibility and subtly tells hirers what tech stack powered the site.
- Save src/components/Footer.tsx.
- Return to your browser and scroll to the very bottom of any page. You should see social links on the left, a barely visible card emoji in the center, and "Built with Next.js, Tailwind CSS, and Motion." on the right.
Footer not showing the updated content?
Check that you saved the file. Hot-reload should pick up the change within a second or two.
If you see a hydration error in the console, make sure the Footer component does not have a "use client" directive. It is a Server Component that imports only data, not Motion or browser APIs.
Help me debug my Footer component not re-rendering after edits.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files: app/resume/page.tsx, app/page.tsx, and src/components/Footer.tsx.
ⓧ I'd like to double check the full code
Here is the full app/resume/page.tsx:
import type { Metadata } from "next"
import { siteConfig } from "@/data/site"
// SEO metadata for the resume page
export const metadata: Metadata = {
title: "Resume",
description: `View and download ${siteConfig.name}'s resume.`,
openGraph: {
title: `Resume | ${siteConfig.name}`,
description: `View and download ${siteConfig.name}'s resume.`,
url: `${siteConfig.url}/resume`,
},
}
export default function ResumePage() {
return (
<section className="min-h-screen py-24 px-6 relative">
{/* Angular background accent */}
<div
className="absolute inset-0 bg-persona-red/5"
style={{ clipPath: "polygon(0 0, 100% 0, 100% 80%, 0 100%)" }}
/>
<div className="max-w-4xl mx-auto relative z-10">
<h1 className="font-heading text-5xl font-bold text-persona-white mb-8">
Resume
</h1>
{/* Embedded PDF viewer with angular frame */}
<div
className="w-full border-2 border-persona-red/30 bg-persona-dark"
style={{ clipPath: "polygon(2% 0%, 100% 0%, 98% 100%, 0% 100%)" }}
>
<iframe
src="/resume.pdf"
className="w-full h-[800px]"
title="Resume PDF Viewer"
/>
</div>
{/* Download button with P5R parallelogram shape */}
<div className="mt-8 flex justify-center">
<a
href="/resume.pdf"
download
className="px-10 py-4 bg-persona-red text-persona-white font-heading text-lg uppercase tracking-wider hover:bg-red-700 transition-colors inline-block"
style={{ clipPath: "polygon(8% 0%, 100% 0%, 92% 100%, 0% 100%)" }}
>
Download Resume
</a>
</div>
</div>
</section>
)
}
Here is the full src/components/Footer.tsx:
import { siteConfig } from "@/data/site"
export default function Footer() {
return (
<footer className="py-12 px-6 border-t border-persona-white/10">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
{/* Social Links */}
<div className="flex gap-6">
<a
href={siteConfig.links.github}
target="_blank"
rel="noopener noreferrer"
className="text-persona-white/60 hover:text-persona-red transition-colors"
>
GitHub
</a>
<a
href={siteConfig.links.linkedin}
target="_blank"
rel="noopener noreferrer"
className="text-persona-white/60 hover:text-persona-red transition-colors"
>
LinkedIn
</a>
<a
href={`mailto:${siteConfig.links.email}`}
className="text-persona-white/60 hover:text-persona-red transition-colors"
>
Email
</a>
</div>
{/* Easter Egg Trigger - the Secret Mission will wire this up */}
<button
aria-label="Hidden easter egg"
className="text-persona-white/20 hover:text-persona-white/40 transition-colors text-sm cursor-default"
>
\uD83C\uDCCF
</button>
{/* Built With note */}
<p className="text-persona-white/40 text-sm">
Built with Next.js, Tailwind CSS, and Motion.
</p>
</div>
</footer>
)
}
Your portfolio now has a complete conversion funnel. Hirers can discover your projects, view your resume, and reach out in one session. Next up, you will add page transitions and loading states to polish the experience between route changes.
Add Page Transitions and SEO Infrastructure
Your portfolio has every page built and styled. Now it needs the connective tissue that makes it feel like a polished product. Smooth animated transitions between routes give visitors a sense of spatial continuity instead of abrupt page swaps.
In this step, you will add a P5R-inspired diagonal-wipe page transition that fires on every route change. You will also wire up the SEO infrastructure that makes your portfolio discoverable: a dynamic sitemap, a robots file, and a verification pass across all your metadata.
In this step, get ready to:
- Create a page transition wrapper with a diagonal clip-path entrance animation.
- Generate a dynamic sitemap and robots file from your data layer.
- Verify that every route has unique metadata, Open Graph tags, and structured data.
Create the page transition wrapper
In the Next.js App Router, a template.tsx file remounts on every navigation. This makes it the ideal place for entrance animations because Motion can re-trigger the initial state on each route change.
- Create a new file at src/components/PageTransition.tsx and add the following code:
"use client"
import { motion, AnimatePresence } from "motion/react"
import { usePathname } from "next/navigation"
// P5R diagonal-wipe page transition
// The offset between top and bottom clip-path points creates an angular leading edge
export default function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
initial={{ clipPath: "polygon(0 0, 15% 0, 0 100%, 0 100%)" }}
animate={{ clipPath: "polygon(0 0, 100% 0, 100% 100%, 0 100%)" }}
exit={{ clipPath: "polygon(85% 0, 100% 0, 100% 100%, 100% 100%)" }}
transition={{ duration: 0.4, ease: [0.76, 0, 0.24, 1] }}
>
{children}
</motion.div>
</AnimatePresence>
)
}
What does this code do?
This component wraps page content in a motion.div that animates its clip-path on every route change. The usePathname hook provides a key that changes with each navigation, triggering the animation.
The initial polygon is a thin sliver on the left (nearly invisible). As it animates to animate, the top-right point races ahead of the bottom-right point. This creates the signature P5R angular wipe edge during the transition.
The custom cubic-bezier easing [0.76, 0, 0.24, 1] gives the wipe a snappy, aggressive feel that matches Persona 5's UI tempo.
Now you need to apply this transition component to all routes. The cleanest way in App Router is to use a template.tsx file, which remounts on every navigation.
- Create a new file at app/template.tsx and add:
import PageTransition from "@/components/PageTransition"
// Template remounts on every navigation, triggering the entrance animation
export default function Template({ children }: { children: React.ReactNode }) {
return <PageTransition>{children}</PageTransition>
}
Why template.tsx instead of layout.tsx?
A layout.tsx persists across navigations and never remounts. That is great for shared UI like your Navbar, but terrible for animations that need to re-trigger on each route change.
A template.tsx receives a unique key on every navigation. Its children remount, which means Motion's initial state fires fresh each time. This is exactly what a page transition needs.
- Save both files.
- Switch to your browser and navigate between pages. You should see a quick angular wipe revealing each new page from the left, with the leading edge slightly angled.
Not seeing the transition?
Make sure app/template.tsx is in the app/ directory at the same level as layout.tsx. If it is nested inside a route folder, it only applies to that route.
Check that PageTransition.tsx has "use client" at the very top. Without it, the usePathname hook throws a server-side error.
help me debug my page transition animation
Create the sitemap and robots files
Search engines need a sitemap to discover all your pages. Next.js App Router supports a special sitemap.ts convention that auto-generates /sitemap.xml from a function you export.
- Create a new file at app/sitemap.ts and add the following code:
import type { MetadataRoute } from "next"
import { projects } from "@/data/projects"
import { siteConfig } from "@/data/site"
export default function sitemap(): MetadataRoute.Sitemap {
// Generate a URL entry for each project from the data layer
const projectUrls = projects.map((project) => ({
url: `${siteConfig.url}/projects/${project.slug}`,
lastModified: new Date(),
changeFrequency: "monthly" as const,
priority: 0.8,
}))
return [
{ url: siteConfig.url, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
{ url: `${siteConfig.url}/about`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.9 },
{ url: `${siteConfig.url}/projects`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.9 },
{ url: `${siteConfig.url}/resume`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.7 },
...projectUrls,
]
}
What does this code do?
This function reads your projects array and generates a sitemap entry for every project slug. Static routes (home, about, projects grid, resume) are listed manually with their own priorities.
The key benefit: when you add a new project to src/data/projects.ts, it automatically appears in your sitemap on the next build. No manual XML editing needed.
- Save app/sitemap.ts.
Next, you need a robots.ts file that tells crawlers they are welcome and points them to the sitemap.
- Create a new file at app/robots.ts and add:
import type { MetadataRoute } from "next"
import { siteConfig } from "@/data/site"
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: `${siteConfig.url}/sitemap.xml`,
}
}
What does this code do?
The userAgent: "*" rule allows all search engine crawlers to index every page. The sitemap field tells crawlers exactly where to find your sitemap URL.
- Save app/robots.ts.
- In your browser, visit http://localhost:3000/sitemap.xml. You should see an XML document listing your home page, about, projects, resume, and each individual project URL.
- Visit http://localhost:3000/robots.txt. You should see a plain text file with User-agent: * and Allow: / followed by a Sitemap URL.
Seeing a 404 for sitemap.xml?
Confirm the file is named exactly sitemap.ts (not sitemap.xml) and placed directly inside the app/ directory. Next.js transforms the TypeScript function into an XML response at build/dev time.
help me fix my sitemap generation
Verify SEO metadata across all pages
Your portfolio already has generateMetadata exports on the projects grid, project detail, resume, and about pages. The root layout includes a JSON-LD Person schema. Now you need to verify everything is wired up correctly.
- Open your browser's DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Navigate to your home page at http://localhost:3000.
- In the Elements tab, expand the <head> tag. Confirm you see <title>, <meta name="description">, and <meta property="og:title"> tags.
- Scroll to the bottom of the <body> and confirm you see the <script type="application/ld+json"> block containing your Person schema with your name and URLs.
- Navigate to http://localhost:3000/projects/project-one.
- In the <head>, confirm the <title> shows the project name (not the site-wide default). Confirm og:title matches the project title.
- Check for a second <script type="application/ld+json"> block on this page containing CreativeWork structured data with the project's title and tech stack.
Why verify metadata manually?
Each page needs its own unique title and description for Google to index them as distinct results. If every page shows the same default title, search engines treat them as duplicate content.
The JSON-LD Person schema on the root layout tells Google who you are. The CreativeWork schema on project pages tells Google what you built. Together, they create a rich knowledge graph entry for your professional identity.
- Navigate to a few more routes (/about, /projects, /resume) and confirm each has a unique <title> and og:title that differs from the home page.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files.
ⓧ I'd like to double check the full code
Here is the full code for each new file:
"use client"
import { motion, AnimatePresence } from "motion/react"
import { usePathname } from "next/navigation"
// P5R diagonal-wipe page transition
// The offset between top and bottom clip-path points creates an angular leading edge
export default function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
initial={{ clipPath: "polygon(0 0, 15% 0, 0 100%, 0 100%)" }}
animate={{ clipPath: "polygon(0 0, 100% 0, 100% 100%, 0 100%)" }}
exit={{ clipPath: "polygon(85% 0, 100% 0, 100% 100%, 100% 100%)" }}
transition={{ duration: 0.4, ease: [0.76, 0, 0.24, 1] }}
>
{children}
</motion.div>
</AnimatePresence>
)
}
import type { MetadataRoute } from "next"
import { projects } from "@/data/projects"
import { siteConfig } from "@/data/site"
export default function sitemap(): MetadataRoute.Sitemap {
const projectUrls = projects.map((project) => ({
url: `${siteConfig.url}/projects/${project.slug}`,
lastModified: new Date(),
changeFrequency: "monthly" as const,
priority: 0.8,
}))
return [
{ url: siteConfig.url, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
{ url: `${siteConfig.url}/about`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.9 },
{ url: `${siteConfig.url}/projects`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.9 },
{ url: `${siteConfig.url}/resume`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.7 },
...projectUrls,
]
}
import type { MetadataRoute } from "next"
import { siteConfig } from "@/data/site"
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: `${siteConfig.url}/sitemap.xml`,
}
}
Your portfolio now has animated transitions and full SEO infrastructure. Every page is discoverable, every project has its own rich preview card, and the diagonal-wipe transition gives the whole experience a Phantom Thief level of polish. Next up, you will deploy it live to Vercel so the world can see it.
Deploy to Vercel
Your portfolio is fully built, animated, and SEO-optimized. Every page renders as pre-built HTML with unique metadata, structured data, and Open Graph tags. The only thing left is getting it live so recruiters and the world can actually see it.
In this step, you will push your project to GitHub, connect the repository to Vercel, and verify your live deployment. You will also test that your Open Graph previews render correctly when shared on LinkedIn or Twitter.
In this step, get ready to:
- Push your project to a new GitHub repository.
- Connect the GitHub repo to Vercel and deploy.
- Verify the live site, test Open Graph previews, and confirm the sitemap is accessible.
Push the project to GitHub
Vercel deploys directly from a Git repository. Every time you push to the main branch, Vercel automatically rebuilds and redeploys your site.
- Open your terminal in VS Code by pressing Ctrl+` (macOS and Windows).
- Initialize a Git repository and make your first commit by running these commands:
git init
git add -A
git commit -m "feat: complete persona portfolio"
What do these commands do?
- git init creates a new Git repository in your project folder.
- git add -A stages all files for commit.
- git commit -m saves a snapshot of your project with a descriptive message.
- Open your browser and go to github.com.
- Click the + icon in the top right corner, then select New repository.
- Enter persona-portfolio as the repository name.
- Leave all checkboxes unchecked (do not initialize with a README, .gitignore, or license).
- Click Create repository.
- Copy the remote URL that GitHub displays (it starts with https://github.com/).
- Back in your VS Code terminal, connect to GitHub and push by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/yourusername/persona-portfolio.git"]]
git branch -M main
git push -u origin main
What do these commands do?
- git remote add origin links your local repository to the GitHub remote.
- git branch -M main renames the default branch to main.
- git push -u origin main uploads all your code to GitHub and sets main as the default push target.
- Refresh your GitHub repository page in the browser.
- You should see all your project files listed under the main branch.
Push failed or permission denied?
- If you see a permission error, make sure you are signed in to the correct GitHub account in your terminal. Run git config user.name to check.
- If the remote URL is wrong, remove it with git remote remove origin and re-add the correct one.
Help me push my code to GitHub.
Connect the GitHub repo to Vercel and deploy
Vercel is built by the Next.js team and auto-detects Next.js projects. Once connected, every push to main triggers a fresh production deployment automatically.
- Go to vercel.com and sign in to your Vercel account.
- Click Add New... in the top right, then select Project.
- You will see a list of your GitHub repositories. Find persona-portfolio and click Import.
What if I don't see my repository?
If your GitHub repos are not listed, Vercel needs permission to access them. Click the link to configure your GitHub App permissions and grant access to the persona-portfolio repository.
- On the configuration page, Vercel auto-detects your framework as Next.js. Leave all settings at their defaults:
- Click Deploy.
Vercel will start building your project. You can watch the build logs in real time. The build takes roughly 30 to 60 seconds.
What to look for in the build output
- Routes marked with ● (SSG) are pre-rendered using generateStaticParams. Your project detail pages should show this symbol.
- Routes marked with ○ are pure static pages (home, about, resume).
- No ƒ symbols should appear. That would indicate server-rendered pages, which you do not have in this project.
- Once the build completes, Vercel shows a Congratulations screen with your production URL.
- Click Visit to open your live site in a new tab.
Build failed?
- Check the build log for the specific error. The most common cause is a TypeScript type error that was not caught locally. Run npm run build in your local terminal to reproduce and fix the issue.
- If the error mentions a missing module, make sure all dependencies are listed in your package.json and committed to Git.
Help me fix my Vercel build error.
Verify the live site, Open Graph previews, and sitemap
Your site is live. Now you need to confirm that everything works the way you built it: pages load, social previews look correct, and search engines can find your sitemap.
- On your live site, click through to at least two project detail pages to confirm they load with animations.
- Visit a URL that does not exist (for example, add /this-page-does-not-exist to your domain). You should see your custom Persona-styled 404 page.
- Confirm your sitemap is accessible by visiting https://yourname.vercel.app/sitemap.xml in your browser. You should see an XML file listing all your routes.
Why test the sitemap?
The sitemap.xml file tells search engines like Google which pages exist on your site. Your robots.ts file points crawlers to this sitemap automatically. Without it, new project pages might take weeks longer to appear in search results.
- Test your Open Graph previews by going to opengraph.xyz.
- Paste your production URL into the input field and check the preview.
- You should see your site title, description, and OG image rendered as a social media card.
- Test a project detail page URL as well (for example, https://yourname.vercel.app/projects/project-one). Each project should show its own unique title and description in the preview.
Test on LinkedIn Post Inspector too
LinkedIn caches Open Graph data aggressively. You can also test with the LinkedIn Post Inspector to see exactly how your portfolio will look when you share it in a LinkedIn post. This is especially useful since recruiters are your primary audience.
- Confirm automatic deployments work by making a small change. Open any file in VS Code, add a comment, then push:
git add -A
git commit -m "test: verify auto-deploy"
git push
- Go back to your Vercel dashboard. You should see a new deployment appear automatically within seconds of pushing.
How does auto-deploy work?
Vercel watches your GitHub repository. Every push to the main branch triggers a production deployment. Pushes to other branches create preview deployments with unique URLs, so you can test changes before merging.
OG preview showing wrong or missing data?
- Make sure the url field in your src/data/site.ts matches your actual Vercel deployment URL. If it still says a placeholder, update it and redeploy.
- If the image is missing, confirm your og-default.png file exists in the public folder and is committed to Git.
Help me fix my Open Graph previews.
Your Persona-inspired portfolio is live, deployed, and discoverable. Every push to GitHub automatically updates your live site. Next up, you will add the finishing touches with a secret Confidant easter egg, a loading screen, and a custom animated cursor.
Secret mission
Confidant Easter Egg, Loading Screen, and Custom Cursor
Your portfolio is live and looking sharp. But every Persona game has secrets hidden for the curious. In this bonus mission, you will add three hidden-in-plain-sight features that prove your animation mastery: a dramatic loading screen, a custom animated cursor, and a secret Confidant Rank Up easter egg triggered by a hidden card in the footer.
Clean Up Your Resources
Clean Up Your Resources
This project uses Vercel's free Hobby tier for deployment and runs entirely on local files. There are no ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local project folder (persona-portfolio on your Desktop).
- GitHub repository containing your portfolio source code.
- Vercel project and deployment (free Hobby tier).
Keep everything running
No action needed. Choose this if you're still actively building or want your portfolio live for recruiters to find.
- Your Vercel deployment stays live at no cost on the Hobby tier.
- To update content, edit your data files in src/data/ and push to GitHub. Vercel auto-deploys on every push.
- To add a custom domain, go to your Vercel project's Settings tab, then Domains. Domain purchase is separate and optional.
Pause - I'll come back to this later
Since this project is free to keep running on Vercel, pausing just means stopping local development. Your site stays live with no cost.
- Stop the local dev server if it's still running by pressing Ctrl+C in your terminal.
- Your Vercel deployment remains live. No action required there.
- Your GitHub repo and local files stay intact for when you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
Delete the Vercel project:
- Open your Vercel dashboard and select the persona-portfolio project.
- Click Settings in the project navigation.
- Scroll to the bottom of the General tab to find the Delete Project section.
- Click Delete and confirm by entering the project name.
Delete the GitHub repository:
- Open your repository on GitHub.
- Click Settings in the repository navigation.
- Scroll down to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
Delete the local project folder:
- Run this command in your terminal to remove the project folder from your Desktop:
macOS
cd ~/Desktop
rm -rf persona-portfolio
Windows
cd ~\Desktop
Remove-Item -Recurse -Force persona-portfolio
- Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see persona-portfolio listed.
That's a Wrap!
That's a Wrap!
Your Persona-inspired portfolio is live, SEO-indexed, and channeling serious Phantom Thief energy. You've built a production-grade animated web application that proves frontend mastery, design system thinking, and advanced Next.js skills to any recruiter who lands on your site.
Here's what you accomplished:
- Built a complete SEO-optimized portfolio with Next.js 16 App Router, TypeScript, and Tailwind CSS v4. Every page is server-rendered with per-route metadata, Open Graph tags, JSON-LD structured data, and a dynamic sitemap that auto-updates when you add projects.
- Created a data-driven content architecture where adding a new project requires editing only a single TypeScript data file. No component changes needed.
- Designed and built a custom Persona-inspired design system with angular clip-paths, P5R red/black and P3R blue aesthetics, smooth page transitions with Motion, and deployed the whole thing to Vercel with automatic CI/CD from GitHub.
- Built a Persona-themed loading screen, a custom animated cursor that switches between P5R red and P3R teal, and a secret Confidant Rank Up easter egg with a 3D tarot card flip and staggered text reveal.
Ready to quiz yourself?