Build a Next.js Developer Portfolio

Build and deploy a responsive Next.js portfolio with Tailwind CSS.

Introduction

30 Second Summary

A developer portfolio can decide whether someone keeps reading after only a few seconds. Generic layouts make strong work easy to overlook.

In this project, you will build an original wizard-inspired developer portfolio with Next.js, React, and Tailwind CSS. You will publish the finished site through GitHub and Vercel.

What You'll Build

Open your public portfolio to a parchment-lit introduction that leads visitors from your skills into an instantly filterable project spellbook.

By the end of this project, you'll have:

  • A bold developer introduction that adapts from mobile to desktop. Your skills stay easy to scan at every width.
  • An interactive project spellbook where visitors choose Frontend, Full Stack, or All to reveal matching work immediately.
  • A public portfolio URL connected to your source repository. New pushes trigger automatic deployments.
  • Secret Mission: Add a manual Lumos and Nox switch that lets visitors choose a candlelit or moonlit appearance.

Are there any prerequisites?

Basic familiarity with HTML and JavaScript is helpful. The setup covers Node.js, npm, Next.js, TypeScript, Tailwind CSS, and Vercel.

Before We Start

A clear creative direction keeps every later design choice focused on the visitor you want to reach. This commitment also keeps your wizard-inspired portfolio original by excluding protected franchise artwork, logos, and fonts.

Set Up the Enchanted Workshop

Your portfolio needs a dependable local foundation before its visual identity can take shape. A working foundation keeps styling work from getting buried under setup errors.

Node.js powers the local Next.js development server. The App Router connects your project files to pages in the browser.

You will use npm to install TypeScript plus Tailwind CSS. The step ends with a styled starter heading running locally.

In this step, get ready to:
  • Verify Node.js and npm on your Mac.
  • Create the App Router project files in Visual Studio Code.
  • Install the dependencies and launch the development server.
Check Node.js and npm

This project uses Node.js v24.21.0 LTS. Checking the installed version first prevents an incompatible runtime from disrupting the project later.

  • Press Command-Space bar on your Mac to open Spotlight.
  • Type Terminal into Spotlight and press Return.
  • Check your installed Node.js version by running this command:
node -v

What does this check do?

This command asks Node.js to print its installed version. The result tells you whether the required runtime is available.

  • Check that npm is available by running this command:
npm -v

What does this check do?

This command prints the installed npm version. A version number confirms that the package manager is ready to install the project dependencies.

  • Choose the tab that matches the results in your Terminal window.

✔️ I see v24.21.0

Your Node.js runtime matches the project version. If the second check also printed a version number, npm is ready too.

Good start. Your Mac can now run the exact development stack used by this project.

ⓧ I see an older version

Your current runtime does not match the version pinned for this project. Installing the LTS release gives every dependency the environment it expects.

  • Visit the official Node.js download page.
  • Choose the macOS installer for v24.21.0 LTS.
  • Complete the installation using the default options.
  • Repeat the Spotlight steps above to open a fresh Terminal window.
  • Repeat both version checks above.

ⓧ Command not found

Your Mac cannot find Node.js yet. The official installer adds Node.js plus npm to your system.

  • Visit the official Node.js download page.
  • Choose the macOS installer for v24.21.0 LTS.
  • Complete the installation using the default options.
  • Repeat the Spotlight steps above to open a fresh Terminal window.
  • Repeat both version checks above.

Still missing Node.js or npm?

Close every existing Terminal window after the installation. A fresh Terminal window reloads the system path that locates Node.js.

If one of the checks still fails, help me troubleshoot my Node.js installation.

Create the project foundation

A Visual Studio Code workspace keeps the project folders and files together. You will create the workspace manually so every path matches the portfolio code exactly.

  • Press Command-Space bar to open Spotlight.
  • Type Visual Studio Code and press Return.
  • Select File in the top menu.
  • Select Open Folder....
  • Navigate to your Desktop in the folder dialog.
  • Select New Folder.
  • Enter enchanted-portfolio and press Return.
  • Select Open.

You should see enchanted-portfolio at the top of the Explorer sidebar. This confirms that Visual Studio Code has opened the correct workspace.

  • Select New Folder in the Explorer sidebar.
  • Enter app and press Return.
  • Select the enchanted-portfolio folder in Explorer.
  • Select New Folder again.
  • Enter components and press Return.

You should now see empty app and components folders in Explorer.

  • Select the enchanted-portfolio folder in Explorer.
  • Select New File....
  • Enter package.json and press Return.

The new package.json file should appear at the top level of the workspace.

  • Add the project scripts and pinned dependencies by pasting this code into package.json:
{"name":"enchanted-portfolio","version":"1.0.0","private":true,"scripts":{"dev":"next dev","build":"next build","start":"next start"},"dependencies":{"next":"16.3.6","react":"19.3.0","react-dom":"19.3.0"},"devDependencies":{"@tailwindcss/postcss":"4.3.3","@types/node":"26.6.2","@types/react":"19.3.0","@types/react-dom":"19.3.0","postcss":"8.5.28","tailwindcss":"4.3.3","typescript":"7.0.2"}}

What does this file control?

  • The scripts define how npm starts the development server and prepares production builds.
  • The dependencies pin Next.js 16.3.6 with React 19.3.0.
  • The development dependencies provide Tailwind CSS 4.3.3 plus TypeScript 7.0.2.
  • Save package.json.
  • Confirm that package.json remains visible beside the two folders in Explorer.

Is package.json in the wrong folder?

The file belongs directly inside enchanted-portfolio. It should sit beside the app and components folders.

If the file is nested elsewhere, help me place package.json in the correct folder.

  • Select the enchanted-portfolio folder in Explorer.
  • Select New File....
  • Enter postcss.config.mjs and press Return.
  • Connect Tailwind CSS to PostCSS by pasting this configuration into postcss.config.mjs:
const config = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};

export default config;

What does this configuration do?

PostCSS processes the project stylesheet during development. The plugin entry lets Tailwind CSS generate styles from the utility classes in your components.

  • Save postcss.config.mjs.
  • Confirm that postcss.config.mjs appears beside package.json in Explorer.

Is the configuration missing?

Check that the filename ends with .mjs. A different extension prevents Next.js from finding this configuration.

If the filename or location looks different, help me check my PostCSS configuration file.

  • Select View in the Visual Studio Code menu.
  • Select Terminal.

The integrated terminal should show a path ending in enchanted-portfolio. This is where npm reads package.json.

  • Install the pinned project dependencies by running:
npm install

What does this installation do?

npm reads package.json and downloads the specified packages. The installation gives the project its Next.js runtime plus its TypeScript and Tailwind CSS tooling.

When the command finishes, the terminal prompt returns without a fatal installation message. The new dependency files in Explorer confirm that npm completed its work.

Did the installation fail?

Confirm that the terminal path ends in enchanted-portfolio. Check that your package.json content matches the code above.

If npm still cannot install the packages, help me debug npm install.

  • Select the app folder in Explorer.
  • Select New File....
  • Enter globals.css and press Return.
  • Enable Tailwind CSS by pasting this import into app/globals.css:
@import "tailwindcss";

What does this import do?

This import loads Tailwind CSS into the global stylesheet. Utility classes in the page can now generate visible styles.

  • Save app/globals.css.
  • Confirm that globals.css appears inside the app folder in Explorer.
  • Select the app folder in Explorer.
  • Select New File....
  • Enter layout.tsx and press Return.
  • Define the root document by pasting this code into app/layout.tsx:
import "./globals.css";

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

What does the root layout do?

  • The stylesheet import makes the Tailwind utilities available throughout the app.
  • The RootLayout component provides the required HTML document structure.
  • The children value places each route inside the page body.
  • Save app/layout.tsx.
  • Confirm that layout.tsx appears beside globals.css in Explorer.

Is the layout file misplaced?

Both files belong inside the app folder. Check that layout.tsx imports ./globals.css with the same spelling.

If the paths do not line up, help me fix my App Router layout files.

  • Select the app folder in Explorer.
  • Select New File....
  • Enter page.tsx and press Return.
  • Create the starter route by pasting this component into app/page.tsx:
export default function Home() {
  return <main className="p-8 text-2xl font-bold">Enchanted Portfolio</main>;
}

What does this component show?

The Home component supplies the content for the root route. Its Tailwind utility classes add spacing plus larger bold text.

  • Save app/page.tsx.
  • Confirm that page.tsx appears beside the other files inside app.

Is page.tsx missing from app?

The starter route must be saved as app/page.tsx. Check the Explorer path plus the .tsx extension.

If the route file is hard to locate, help me find my Next.js page file.

✔️ Awesome, I've got everything!

Your project foundation is ready. Double check that every file is saved before you start the server.

ⓧ I'd like to double check the full code

Compare each saved file with the complete versions below.

{"name":"enchanted-portfolio","version":"1.0.0","private":true,"scripts":{"dev":"next dev","build":"next build","start":"next start"},"dependencies":{"next":"16.3.6","react":"19.3.0","react-dom":"19.3.0"},"devDependencies":{"@tailwindcss/postcss":"4.3.3","@types/node":"26.6.2","@types/react":"19.3.0","@types/react-dom":"19.3.0","postcss":"8.5.28","tailwindcss":"4.3.3","typescript":"7.0.2"}}

What should match?

This file contains the exact scripts plus package versions required by the starter project.

const config = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};

export default config;

What should match?

This file contains the complete PostCSS plugin configuration used by Tailwind CSS.

@import "tailwindcss";

What should match?

This file contains the global Tailwind CSS import used by the root layout.

import "./globals.css";

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

What should match?

This file contains the complete root layout plus the global stylesheet import.

export default function Home() {
  return <main className="p-8 text-2xl font-bold">Enchanted Portfolio</main>;
}

What should match?

This file contains the complete starter page with its Tailwind utility classes.

Run the starter page

The development server compiles the App Router files whenever they change. It stays active in the terminal while you view the result in your browser.

Before you start the server, what do you expect the browser to show once Next.js compiles the starter route?

  • Return to the integrated Terminal panel in Visual Studio Code.
  • Start the development server by running:
npm run dev

What does this command do?

npm runs the development script from package.json. Next.js compiles the project and serves it from http://localhost:3000.

The process keeps control of this terminal while the server is running. Leave the terminal open so the page remains available.

  • Press Command-Space bar to open Spotlight.
  • Type the name of your preferred browser and press Return.
  • Enter http://localhost:3000 in the address bar.
  • Press Return to load the local site.

You should see Enchanted Portfolio in large bold text with space around it. That visible styling confirms that the page and Tailwind CSS are both loading.

Is the starter page missing?

  • Confirm that the development server is still running in the Visual Studio Code terminal.
  • Check that the browser address is exactly http://localhost:3000.
  • Compare each project file with the full-code tab above.

If the page still does not load, help me debug my local Next.js starter page.

Your enchanted workshop is running. Next, you will replace this starter heading with the portfolio hero and its responsive sections.

Raise the Portfolio Castle

Your Tailwind CSS starter proves that the local development loop works. Right now, visitors only see one heading with no sense of who you are.

This step gives the portfolio its visual identity through the Next.js App Router. The page stays a Server Component while responsive utilities shape the parchment-inspired interface.

In this step, get ready to:
  • Add the portfolio title and description as typed metadata.
  • Build responsive navigation with an enchanted hero section.
  • Add the skills grid, contact links, and original-design disclaimer.
Add typed portfolio metadata

Typed metadata gives the browser a title and description for your portfolio. Keeping it in the root layout applies those details across the entire site.

  • Select app/layout.tsx in the Visual Studio Code Explorer sidebar.
  • Select the current contents with Cmd+A on macOS or Ctrl+A on Windows.
  • Replace the selected contents by pasting this code:
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "The Enchanted Developer",
  description: "An original wizard-inspired developer portfolio built with Next.js and Tailwind CSS.",
};

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return <html lang="en"><body>{children}</body></html>;
}

What Does This Layout Do?

  • The Metadata type checks the shape of the title and description during development.
  • The exported metadata object supplies information for the portfolio page.
  • The RootLayout function wraps every route with the required HTML structure.
  • The children value represents the page rendered inside the layout.
  • Save app/layout.tsx.
  • Return to the portfolio tab in your browser.
  • Check the browser tab title.

Good progress. The browser tab now reads The Enchanted Developer, so your portfolio introduces itself before anyone reads the page.

Browser Title Still Unchanged?

  • Confirm that you saved app/layout.tsx inside the enchanted-portfolio folder.
  • Check that the development server from the previous step is still running.
  • Reload the browser tab after the layout finishes compiling.

Help me diagnose why my Next.js metadata is not appearing.

✔️ Awesome, I've got everything!

Your root layout is saved. The browser can now identify the portfolio as The Enchanted Developer.

ⓧ I'd like to double check the full code

  • Compare your complete app/layout.tsx file with this reference:
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "The Enchanted Developer",
  description: "An original wizard-inspired developer portfolio built with Next.js and Tailwind CSS.",
};

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return <html lang="en"><body>{children}</body></html>;
}
Build the hero and navigation

The hero gives visitors an immediate picture of your development focus. Responsive grid utilities let the same introduction fit narrow screens and wide displays.

  • Select app/page.tsx in the Visual Studio Code Explorer sidebar.
  • Select the current contents with Cmd+A on macOS or Ctrl+A on Windows.
  • Replace the selected contents by pasting this first portfolio section:
export default function Home() {
  return (
    <main className="min-h-screen bg-[#f4e6c4] text-slate-950 transition-colors duration-500 dark:bg-slate-950 dark:text-amber-50">
      <div aria-hidden="true" className="fixed inset-0 bg-[radial-gradient(circle_at_top,rgba(251,191,36,0.32),transparent_42%)] dark:bg-[radial-gradient(circle_at_top,rgba(99,102,241,0.2),transparent_38%)]" />
      <div className="relative mx-auto max-w-6xl px-6 py-8 sm:px-10">
        <nav className="flex items-center justify-between rounded-full border border-amber-950/20 bg-white/40 px-5 py-3 backdrop-blur dark:border-amber-200/20 dark:bg-slate-900/60"><a className="font-serif text-lg font-bold tracking-wide" href="#home">The Enchanted Developer</a><div className="flex gap-4 text-sm font-semibold"><a href="#skills">Skills</a><a href="#projects">Projects</a></div></nav>
        <section id="home" className="grid min-h-[72vh] items-center gap-10 py-16 lg:grid-cols-[1.2fr_0.8fr]"><div><p className="mb-4 text-sm font-bold uppercase tracking-[0.35em] text-amber-800 dark:text-amber-300">Frontend developer and curious spellsmith</p><h1 className="max-w-3xl font-serif text-5xl font-black leading-tight sm:text-7xl">I turn ambitious ideas into memorable web experiences.</h1><p className="mt-6 max-w-2xl text-lg leading-8 text-slate-700 dark:text-slate-300">I build fast, accessible interfaces with Next.js, React, and a careful eye for visual storytelling.</p><div className="mt-8 flex flex-wrap gap-4"><a href="#projects">Open the spellbook</a><a href="#contact">Send an owl</a></div></div><aside className="rounded-[2rem] border border-amber-950/20 bg-white/55 p-8 dark:border-amber-200/20 dark:bg-slate-900/70"><p>Current quest</p><h2>Build useful software with a little wonder.</h2><div className="mt-8 grid grid-cols-3 gap-3 text-center text-sm"><div>3 Projects</div><div>3 Disciplines</div><div>1 Open quest</div></div></aside></section>
      </div>
    </main>
  );
}

How Does the Hero Take Shape?

  • The outer main element establishes the parchment background and the moonlit color palette.
  • The decorative gradient uses aria-hidden="true" because it adds no information for screen readers.
  • The sm: utility increases horizontal space on wider screens.
  • The lg: utility changes the hero into a two-column layout on large screens.
  • The navigation links point to page section IDs so visitors can jump through the portfolio.
  • Save app/page.tsx.
  • Return to the portfolio in your browser.
  • Refresh http://localhost:3000.

The visual identity is now clear. You should see a parchment background with rounded navigation, a large introduction, and a current-quest panel.

Hero Not Appearing?

  • Confirm that app/globals.css still contains the Tailwind import.
  • Check the terminal for a syntax error caused by a missing quote or closing tag.
  • Confirm that the browser is showing http://localhost:3000.

Help me find why my portfolio hero is not rendering.

Add skills, contact, and the disclaimer

A reusable skills array lets React render each discipline from the same structure. The contact section prepares visible email and GitHub placeholders for you to personalize before deployment through Vercel.

The footer also states that the design is an original learning project. This keeps the wizard-inspired theme separate from protected artwork or franchise branding.

  • Select all the contents of app/page.tsx.
  • Replace the selected contents by pasting the complete portfolio shell:
import ProjectExplorer from "../components/ProjectExplorer";

const skills = [
  { title: "Interface Charms", detail: "Responsive layouts, accessible components, and polished interactions." },
  { title: "React Runes", detail: "Reusable components, state, and clear client and server boundaries." },
  { title: "Deployment Portals", detail: "Git workflows and reliable releases through Vercel." },
];

export default function Home() {
  return (
    <main className="min-h-screen bg-[#f4e6c4] text-slate-950 transition-colors duration-500 dark:bg-slate-950 dark:text-amber-50">
      <div aria-hidden="true" className="fixed inset-0 bg-[radial-gradient(circle_at_top,rgba(251,191,36,0.32),transparent_42%)] dark:bg-[radial-gradient(circle_at_top,rgba(99,102,241,0.2),transparent_38%)]" />
      <div className="relative mx-auto max-w-6xl px-6 py-8 sm:px-10">
        <nav className="flex items-center justify-between rounded-full border border-amber-950/20 bg-white/40 px-5 py-3 backdrop-blur dark:border-amber-200/20 dark:bg-slate-900/60"><a className="font-serif text-lg font-bold tracking-wide" href="#home">The Enchanted Developer</a><div className="flex gap-4 text-sm font-semibold"><a href="#skills">Skills</a><a href="#projects">Projects</a></div></nav>
        <section id="home" className="grid min-h-[72vh] items-center gap-10 py-16 lg:grid-cols-[1.2fr_0.8fr]"><div><p className="mb-4 text-sm font-bold uppercase tracking-[0.35em] text-amber-800 dark:text-amber-300">Frontend developer and curious spellsmith</p><h1 className="max-w-3xl font-serif text-5xl font-black leading-tight sm:text-7xl">I turn ambitious ideas into memorable web experiences.</h1><p className="mt-6 max-w-2xl text-lg leading-8 text-slate-700 dark:text-slate-300">I build fast, accessible interfaces with Next.js, React, and a careful eye for visual storytelling.</p><div className="mt-8 flex flex-wrap gap-4"><a href="#projects">Open the spellbook</a><a href="#contact">Send an owl</a></div></div><aside className="rounded-[2rem] border border-amber-950/20 bg-white/55 p-8 dark:border-amber-200/20 dark:bg-slate-900/70"><p>Current quest</p><h2>Build useful software with a little wonder.</h2><div className="mt-8 grid grid-cols-3 gap-3 text-center text-sm"><div>3 Projects</div><div>3 Disciplines</div><div>1 Open quest</div></div></aside></section>
        <section id="skills" className="py-16"><p>Magical disciplines</p><h2>Skills gathered along the journey</h2><div className="mt-8 grid gap-5 md:grid-cols-3">{skills.map((skill) => <article key={skill.title}><h3>{skill.title}</h3><p>{skill.detail}</p></article>)}</div></section>
        <section id="projects" className="py-16"><p>Selected enchantments</p><h2>The project spellbook</h2><ProjectExplorer /></section>
        <section id="contact" className="my-16 rounded-[2rem] bg-slate-950 px-7 py-12 text-center text-amber-50 dark:bg-indigo-950"><p>The next chapter</p><h2>Let us build something remarkable.</h2><p>Replace these starter links with your real email address and GitHub username before sharing the portfolio.</p><a href="mailto:your-email@example.com">Email me</a><a href="https://github.com/your-username" rel="noreferrer" target="_blank">Visit GitHub</a></section>
        <footer>Original wizard-inspired design for learning. Not affiliated with any film, book, studio, or franchise.</footer>
      </div>
    </main>
  );
}

What Does the Portfolio Shell Add?

  • The skills array stores consistent titles and descriptions for the three disciplines.
  • The skills.map() expression turns every discipline into a reusable article.
  • The md:grid-cols-3 utility widens the skills section from one column to three columns.
  • The contact section contains starter destinations for email and GitHub.
  • The footer makes the original-design status visible to every visitor.
  • Save app/page.tsx.
  • Return to http://localhost:3000 in your browser.

Before you refresh, how do you expect the skills layout to change when the browser becomes wider?

  • Refresh the portfolio page.
  • Narrow the browser window until the skills use one column.
  • Widen the browser window until the skills form a broader grid.
  • Scroll to the contact section near the bottom of the page.

You should see the enchanted hero, navigation, skills, and contact areas. The skills move from one column to a wider grid as the available width changes.

You have raised the full portfolio shell. Visitors can now understand your focus, scan your skills, and find a clear way to contact you.

Responsive Sections Look Wrong?

  • Confirm that every Tailwind class remains inside its original className value.
  • Check that the skills.map() expression remains inside the skills grid.
  • Compare every closing tag near the contact section with the full reference below.

Help me diagnose why my responsive portfolio sections are not displaying correctly.

✔️ Awesome, I've got everything!

Your portfolio shell is saved. Check that the browser shows the hero, skills, contact section, and disclaimer.

ⓧ I'd like to double check the full code

  • Compare your complete app/page.tsx file with this reference:
import ProjectExplorer from "../components/ProjectExplorer";

const skills = [
  { title: "Interface Charms", detail: "Responsive layouts, accessible components, and polished interactions." },
  { title: "React Runes", detail: "Reusable components, state, and clear client and server boundaries." },
  { title: "Deployment Portals", detail: "Git workflows and reliable releases through Vercel." },
];

export default function Home() {
  return (
    <main className="min-h-screen bg-[#f4e6c4] text-slate-950 transition-colors duration-500 dark:bg-slate-950 dark:text-amber-50">
      <div aria-hidden="true" className="fixed inset-0 bg-[radial-gradient(circle_at_top,rgba(251,191,36,0.32),transparent_42%)] dark:bg-[radial-gradient(circle_at_top,rgba(99,102,241,0.2),transparent_38%)]" />
      <div className="relative mx-auto max-w-6xl px-6 py-8 sm:px-10">
        <nav className="flex items-center justify-between rounded-full border border-amber-950/20 bg-white/40 px-5 py-3 backdrop-blur dark:border-amber-200/20 dark:bg-slate-900/60"><a className="font-serif text-lg font-bold tracking-wide" href="#home">The Enchanted Developer</a><div className="flex gap-4 text-sm font-semibold"><a href="#skills">Skills</a><a href="#projects">Projects</a></div></nav>
        <section id="home" className="grid min-h-[72vh] items-center gap-10 py-16 lg:grid-cols-[1.2fr_0.8fr]"><div><p className="mb-4 text-sm font-bold uppercase tracking-[0.35em] text-amber-800 dark:text-amber-300">Frontend developer and curious spellsmith</p><h1 className="max-w-3xl font-serif text-5xl font-black leading-tight sm:text-7xl">I turn ambitious ideas into memorable web experiences.</h1><p className="mt-6 max-w-2xl text-lg leading-8 text-slate-700 dark:text-slate-300">I build fast, accessible interfaces with Next.js, React, and a careful eye for visual storytelling.</p><div className="mt-8 flex flex-wrap gap-4"><a href="#projects">Open the spellbook</a><a href="#contact">Send an owl</a></div></div><aside className="rounded-[2rem] border border-amber-950/20 bg-white/55 p-8 dark:border-amber-200/20 dark:bg-slate-900/70"><p>Current quest</p><h2>Build useful software with a little wonder.</h2><div className="mt-8 grid grid-cols-3 gap-3 text-center text-sm"><div>3 Projects</div><div>3 Disciplines</div><div>1 Open quest</div></div></aside></section>
        <section id="skills" className="py-16"><p>Magical disciplines</p><h2>Skills gathered along the journey</h2><div className="mt-8 grid gap-5 md:grid-cols-3">{skills.map((skill) => <article key={skill.title}><h3>{skill.title}</h3><p>{skill.detail}</p></article>)}</div></section>
        <section id="projects" className="py-16"><p>Selected enchantments</p><h2>The project spellbook</h2><ProjectExplorer /></section>
        <section id="contact" className="my-16 rounded-[2rem] bg-slate-950 px-7 py-12 text-center text-amber-50 dark:bg-indigo-950"><p>The next chapter</p><h2>Let us build something remarkable.</h2><p>Replace these starter links with your real email address and GitHub username before sharing the portfolio.</p><a href="mailto:your-email@example.com">Email me</a><a href="https://github.com/your-username" rel="noreferrer" target="_blank">Visit GitHub</a></section>
        <footer>Original wizard-inspired design for learning. Not affiliated with any film, book, studio, or franchise.</footer>
      </div>
    </main>
  );
}

Your portfolio castle now has its public rooms. Next, you will fill the project spellbook with reusable cards and category controls.

Display the Project Spellbook

Your responsive portfolio now gives visitors a clear sense of your style. The project section still needs examples that show what you can build.

A reusable Server Component can turn structured data into a consistent gallery. In this step, you will render three project cards.

You will also test the category controls from a visitor's perspective.

In this step, get ready to:
  • Define the typed gallery data.
  • Render reusable project cards.
  • Test the static category controls.
Build the static project gallery

The existing app/page.tsx file already reserves the gallery's place. The ProjectExplorer component supplies its category row.

The same component maps structured project data into repeated cards. Each card follows one reusable layout.

  • In the Explorer sidebar in Visual Studio Code, select the components folder.
  • Create ProjectExplorer.tsx inside components using the New File icon.
  • Build the gallery by pasting this code into components/ProjectExplorer.tsx:
const categories = ["All", "Frontend", "Full Stack"] as const;
type Category = (typeof categories)[number];

const projects: Array<{ title: string; description: string; category: Exclude<Category, "All">; tools: string[] }> = [
  { title: "Potion Pantry", description: "A responsive catalog interface with clear search and inventory states.", category: "Frontend", tools: ["Next.js", "Tailwind CSS", "Accessibility"] },
  { title: "Owl Post Board", description: "A message dashboard concept with reusable cards and status filters.", category: "Full Stack", tools: ["React", "TypeScript", "Data modeling"] },
  { title: "Celestial Timetable", description: "A mobile-first schedule that turns dense information into a calm visual system.", category: "Frontend", tools: ["Responsive UI", "Design systems", "Testing"] },
];

export default function ProjectExplorer() {
  return <div className="mt-8"><div aria-label="Filter projects" className="flex flex-wrap gap-3" role="group">{categories.map((category) => <button key={category} type="button">{category}</button>)}</div><div className="mt-7 grid gap-5 md:grid-cols-2 xl:grid-cols-3">{projects.map((project) => <article key={project.title}><span>{project.category}</span><h3>{project.title}</h3><p>{project.description}</p><ul aria-label={`${project.title} tools`}>{project.tools.map((tool) => <li key={tool}>{tool}</li>)}</ul></article>)}</div></div>;
}

What does this code do?

  • The categories array establishes the three labels shown above the gallery.
  • The Category type derives its allowed values from those labels using TypeScript.
  • The projects array keeps each title with its description.
  • The projects array also stores each category with its tools.
  • The map() calls create one button per category.
  • The second map() call creates one card per project.
  • Save components/ProjectExplorer.tsx.
  • Switch back to the browser tab from earlier.
  • Refresh http://localhost:3000.

You'll see All, Frontend, and Full Stack above a responsive project grid.

The grid displays Potion Pantry, Owl Post Board, and Celestial Timetable.

Gallery missing from the page?

  • Confirm that ProjectExplorer.tsx sits inside the components folder.
  • Check that the file name uses the same capital letters as the import in app/page.tsx.
  • Check every closing bracket in the projects array.

Help me debug the missing project gallery.

✔️ Awesome, I've got everything!

Your gallery file now contains its typed data. Its reusable markup is ready for the control test.

ⓧ I'd like to double check the full code

  • Compare your complete components/ProjectExplorer.tsx file with this reference:
const categories = ["All", "Frontend", "Full Stack"] as const;
type Category = (typeof categories)[number];

const projects: Array<{ title: string; description: string; category: Exclude<Category, "All">; tools: string[] }> = [
  { title: "Potion Pantry", description: "A responsive catalog interface with clear search and inventory states.", category: "Frontend", tools: ["Next.js", "Tailwind CSS", "Accessibility"] },
  { title: "Owl Post Board", description: "A message dashboard concept with reusable cards and status filters.", category: "Full Stack", tools: ["React", "TypeScript", "Data modeling"] },
  { title: "Celestial Timetable", description: "A mobile-first schedule that turns dense information into a calm visual system.", category: "Frontend", tools: ["Responsive UI", "Design systems", "Testing"] },
];

export default function ProjectExplorer() {
  return <div className="mt-8"><div aria-label="Filter projects" className="flex flex-wrap gap-3" role="group">{categories.map((category) => <button key={category} type="button">{category}</button>)}</div><div className="mt-7 grid gap-5 md:grid-cols-2 xl:grid-cols-3">{projects.map((project) => <article key={project.title}><span>{project.category}</span><h3>{project.title}</h3><p>{project.description}</p><ul aria-label={`${project.title} tools`}>{project.tools.map((tool) => <li key={tool}>{tool}</li>)}</ul></article>)}</div></div>;
}
Test the category controls

The controls are visible beside the completed gallery. Testing them now reveals whether the displayed cards follow a visitor's selection.

Before you click the filter, which cards do you expect to remain under Full Stack?

  • Click Full Stack in the category controls.

You'll still see all three project cards after selecting Full Stack.

That planned shortfall confirms the controls are static. The gallery always maps over the full projects array.

What did this test prove?

The buttons provide visible controls without changing a selection value. The projects.map() expression therefore renders every card after each click.

You now have a clear baseline for measuring the interactive version.

You found the exact interaction gap this test was designed to reveal. Your spellbook now displays three reusable project cards.

Next up, you'll connect the category selection to React state so each control changes the visible cards.

Enchant the Project Filter

The project spellbook now has reusable cards. Its category controls still cannot change what visitors see.

React state gives the selected category a value that can change after a click.

The main page stays a Server Component. You'll turn only ProjectExplorer into a focused Client Component.

In this step, get ready to:
  • Create typed category state inside the client boundary.
  • Derive the visible project cards from the selected category.
  • Connect accessible filter buttons to the category state.
Give the gallery category state

State needs to run in the visitor's browser. A client boundary gives this component access to state while the surrounding portfolio stays server-rendered.

  • Select components/ProjectExplorer.tsx in the Visual Studio Code Explorer sidebar.
  • Find the categories declaration at the top of the file.
  • Replace that declaration with the client module preamble below:
"use client";

import { useState } from "react";

const categories = ["All", "Frontend", "Full Stack"] as const;

Why Add a Client Boundary?

  • The use client directive tells Next.js that this file needs browser-side interactivity.
  • The useState import provides the hook that stores the selected category.
  • The categories declaration stays in place. The existing Category type remains available.
  • The app/page.tsx file remains a Server Component. Only its interactive child crosses the client boundary.
  • Find the opening brace of ProjectExplorer().
  • Add the state line directly below that opening brace by pasting:
  const [selectedCategory, setSelectedCategory] = useState<Category>("All");

What Does This State Hold?

  • The selectedCategory value records the active category.
  • The setSelectedCategory function changes that value after a visitor clicks a category.
  • The Category type limits the state to the three values already defined in categories.
  • The initial All value starts the gallery with every project included.
  • Save components/ProjectExplorer.tsx.
  • Return to the local portfolio at http://localhost:3000.

You should see the All, Frontend, and Full Stack controls above all three project cards. This confirms the new client boundary compiles successfully.

Did the Gallery Stop Loading?

Confirm that use client is the first statement in components/ProjectExplorer.tsx. Check that the useState import appears before the categories declaration.

Ask for help to fix the ProjectExplorer client boundary.

Connect state to the gallery

The selected value changes the gallery when rendering reads it. The visibleProjects array turns the current category into the exact cards the visitor should see.

  • Find the complete ProjectExplorer() function in components/ProjectExplorer.tsx.
  • Replace that function with the interactive version below:
export default function ProjectExplorer() {
  const [selectedCategory, setSelectedCategory] = useState<Category>("All");
  const visibleProjects = selectedCategory === "All" ? projects : projects.filter((project) => project.category === selectedCategory);
  return <div className="mt-8"><div aria-label="Filter projects" className="flex flex-wrap gap-3" role="group">{categories.map((category) => { const isActive = category === selectedCategory; return <button key={category} aria-pressed={isActive} className={isActive ? "rounded-full bg-amber-500 px-5 py-2 font-bold text-slate-950 shadow" : "rounded-full border border-amber-950/20 bg-white/40 px-5 py-2 font-bold dark:border-amber-100/20 dark:bg-slate-900/50"} onClick={() => setSelectedCategory(category)} type="button">{category}</button>; })}</div><div className="mt-7 grid gap-5 md:grid-cols-2 xl:grid-cols-3">{visibleProjects.map((project) => <article key={project.title} className="flex min-h-72 flex-col rounded-3xl border border-amber-950/15 bg-white/55 p-6 dark:border-amber-100/15 dark:bg-slate-900/70"><span>{project.category}</span><h3>{project.title}</h3><p>{project.description}</p><ul aria-label={`${project.title} tools`}>{project.tools.map((tool) => <li key={tool}>{tool}</li>)}</ul></article>)}</div></div>;
}

How Does the Filter Work?

  • The visibleProjects array contains every project when All is selected.
  • The filter() method keeps projects whose category matches selectedCategory.
  • The onClick handler sends the clicked category to setSelectedCategory.
  • The isActive value controls the visible button style. It also supplies the button's aria-pressed state for assistive technology.
  • The visibleProjects.map() expression renders only the cards selected by the filter. This creates conditional rendering.
  • Save components/ProjectExplorer.tsx.
  • Return to the local portfolio in your browser.

You should see the All button highlighted in amber. The three project cards should have bordered parchment panels.

Is the Page Showing a Code Error?

Confirm that the old ProjectExplorer() function was fully replaced. Check that visibleProjects uses the same capitalization in its declaration and render expression.

Ask for help to debug the interactive ProjectExplorer function.

✔️ Awesome, I've got everything!

Great. Double check that you saved components/ProjectExplorer.tsx before testing each category.

ⓧ I'd like to double check the full code

Compare your complete components/ProjectExplorer.tsx file with this version:

"use client";

import { useState } from "react";

const categories = ["All", "Frontend", "Full Stack"] as const;
type Category = (typeof categories)[number];

const projects: Array<{ title: string; description: string; category: Exclude<Category, "All">; tools: string[] }> = [
  { title: "Potion Pantry", description: "A responsive catalog interface with clear search and inventory states.", category: "Frontend", tools: ["Next.js", "Tailwind CSS", "Accessibility"] },
  { title: "Owl Post Board", description: "A message dashboard concept with reusable cards and status filters.", category: "Full Stack", tools: ["React", "TypeScript", "Data modeling"] },
  { title: "Celestial Timetable", description: "A mobile-first schedule that turns dense information into a calm visual system.", category: "Frontend", tools: ["Responsive UI", "Design systems", "Testing"] },
];

export default function ProjectExplorer() {
  const [selectedCategory, setSelectedCategory] = useState<Category>("All");
  const visibleProjects = selectedCategory === "All" ? projects : projects.filter((project) => project.category === selectedCategory);
  return <div className="mt-8"><div aria-label="Filter projects" className="flex flex-wrap gap-3" role="group">{categories.map((category) => { const isActive = category === selectedCategory; return <button key={category} aria-pressed={isActive} className={isActive ? "rounded-full bg-amber-500 px-5 py-2 font-bold text-slate-950 shadow" : "rounded-full border border-amber-950/20 bg-white/40 px-5 py-2 font-bold dark:border-amber-100/20 dark:bg-slate-900/50"} onClick={() => setSelectedCategory(category)} type="button">{category}</button>; })}</div><div className="mt-7 grid gap-5 md:grid-cols-2 xl:grid-cols-3">{visibleProjects.map((project) => <article key={project.title} className="flex min-h-72 flex-col rounded-3xl border border-amber-950/15 bg-white/55 p-6 dark:border-amber-100/15 dark:bg-slate-900/70"><span>{project.category}</span><h3>{project.title}</h3><p>{project.description}</p><ul aria-label={`${project.title} tools`}>{project.tools.map((tool) => <li key={tool}>{tool}</li>)}</ul></article>)}</div></div>;
}
Test the category controls

Before you test the filter, which project titles do you expect the Frontend category to keep?

  • Click the Frontend button.

You should see Potion Pantry and Celestial Timetable. Owl Post Board disappears while the Frontend button gains the active amber style.

  • Click the All button.

You should see Potion Pantry, Owl Post Board, and Celestial Timetable return. The All button becomes the active control.

Do All Three Cards Stay Visible?

Check that each button's onClick handler calls setSelectedCategory(category). Confirm that the card grid maps over visibleProjects.

Ask for help to find why the project filter is not updating.

You did it. The project spellbook now responds to each category with the matching cards.

Your local portfolio is ready to share. Next, you'll store its source on GitHub and deploy it through Vercel.

Send the Portfolio Into the World

Your project filter is working locally. This final numbered step gives that experience a public address.

A portfolio becomes shareable when its source has a remote home. Git records the snapshot that leaves your Mac.

GitHub stores the source remotely. Vercel builds that repository into a public production site.

In this step, get ready to:
  • Capture the complete local portfolio in its first Git commit.
  • Publish the main branch to a public GitHub repository.
  • Deploy the GitHub repository as a working Vercel site.
Commit the portfolio with Git

A Git repository tracks the portfolio as a set of committed files. The first commit creates a stable snapshot before you publish anything.

  • Switch back to a terminal session from earlier whose prompt is inside enchanted-portfolio.
  • Capture the complete portfolio in its first Git commit by running these commands:
git init -b main
git add .
git commit -m "First commit"

What Do These Commands Do?

  • The first command creates a local Git repository with main as its initial branch.
  • The second command stages every project file for the snapshot.
  • The final command records the staged source with the message First commit.
  • Read the final commit summary in the terminal.

You should see a commit summary that identifies First commit and reports the portfolio files included in the snapshot.

Commit Did Not Complete?

A first commit can stop if Git does not have author details configured on your Mac. Read the terminal output to confirm whether it is asking for identity information.

Also confirm that your terminal prompt is inside enchanted-portfolio before trying the commands again.

Help me diagnose my first Git commit failure.

Publish the source to GitHub

Your local repository now needs a matching remote repository. GitHub can hold the source that Vercel deploys.

Your local project already contains every required file. An empty remote repository prevents starter files from introducing a merge conflict.

  • Sign in to GitHub in your browser.
  • Select New repository.
  • Choose your GitHub account under Owner.
  • Enter enchanted-portfolio as the repository name.
  • Choose the public visibility option.
  • Keep every initialization option unselected.
  • Click Create repository.

That repository is now a clean landing place for the commit on your Mac.

  • Record the HTTPS repository URL shown on the new repository page here: REMOTE-URL.
  • Return to the terminal session inside enchanted-portfolio.
  • Replace REMOTE-URL in the first command below with the value you recorded.

A Sign-In Window May Open

The first push from this Mac can trigger a GitHub authentication flow in your browser.

  • Approve GitHub access if the authentication flow appears after you run the commands.
  • Connect the local repository to GitHub and push the main branch by running both commands:
git remote add origin REMOTE-URL
git push -u origin main

What Does This Publish?

  • The first command saves the GitHub repository as the remote named origin.
  • The second command uploads the committed main branch.
  • The -u flag establishes upstream tracking for future pushes.
  • Return to the GitHub repository page from earlier.
  • Refresh the repository page.

You should see the app folder displayed beside the components folder. You should also see package.json on the main branch.

That is a major milestone. Your complete portfolio source now has a remote home that a deployment platform can reach.

Source Missing From GitHub?

Confirm that the repository page belongs to enchanted-portfolio. Check that the copied HTTPS URL points to the same repository.

If authentication interrupted the push, complete the browser sign-in flow. Return to the terminal after GitHub grants access.

Help me fix my GitHub push.

Deploy the portfolio with Vercel

The public deployment turns your repository into a site that anyone can open. Future pushes to the connected branch trigger new deployments automatically.

Why Vercel for Next.js?

Vercel provides zero-configuration deployment for Next.js. It preserves full framework support.

GitHub Pages would use a static export with limited feature support. Vercel also connects repository pushes to continuous deployment.

For this personal and non-commercial portfolio, Vercel Hobby is listed at $0/mo. The project does not require a paid domain.

  • Go to Vercel in your browser.
  • Authenticate with GitHub.
  • Approve repository access if GitHub asks you to authorize Vercel.

You should return to Vercel with your GitHub repositories available for import.

  • Select New Project.
  • Select the enchanted-portfolio repository.
  • Keep the detected Next.js settings unchanged.

The first production build needs time to install the project packages. Keep the browser tab available while Vercel works through the build.

  • Click Deploy.

You should see an active build log while Vercel compiles the portfolio.

  • Wait for Vercel to display the generated production URL.
  • Record the generated URL here: your Vercel production URL.

Deployment Did Not Finish?

Confirm that Vercel imported the public enchanted-portfolio repository. Check that the selected production branch is main.

Return any modified framework settings to the detected Next.js values. Start another deployment after the repository access issue or settings mismatch is resolved.

Help me fix my Vercel deployment.

  • Open your Vercel production URL in a new browser tab.

You should see the complete enchanted portfolio load from its public URL. The browser tab should show The Enchanted Developer.

Before you select Full Stack, which project cards do you expect to remain visible?

  • Click Full Stack in the project gallery.

You should see Owl Post Board remain. The two frontend cards should leave the gallery.

  • Click All.

You should see all three project cards return. This proves the deployed ProjectExplorer Client Component behaves like the local version.

You have crossed the finish line. Your interactive portfolio is now committed, stored remotely, and available from a public production URL.

Secret mission

Cast Lumos and Nox

Your portfolio follows the visitor's system color preference, but the visitor cannot choose an appearance for the current session. Add a manual Lumos and Nox switch that changes the entire page between candlelit and moonlit colors.

Clean Up Your Resources

Clean Up Your Resources

Your Vercel Hobby deployment uses the $0/mo. plan for personal, non-commercial work. Decide whether to keep your resources, pause development, or delete the local and hosted copies.

Cost warning

Usage caps apply to Vercel Hobby. This portfolio does not require a paid domain. It does not require a database or external API.

Resources you used:

  • Local enchanted-portfolio folder containing the project files plus installed npm dependencies.
  • Public GitHub repository named enchanted-portfolio.
  • Vercel Hobby project connected to the enchanted-portfolio GitHub repository.

Keep everything running

No action is needed. Choose this option if you plan to keep improving or sharing your enchanted portfolio.

  • Keep the local enchanted-portfolio folder for future development.
  • Leave the GitHub repository available as your remote source.
  • Keep the Vercel project connected for automatic deployments.
  • Push future commits to main whenever you want Vercel to deploy the latest version automatically.

Pause - I'll come back to this later

Pause new work while preserving every resource. The production portfolio stays available for visitors.

  • Stop pushing new commits to main.
  • Keep the local enchanted-portfolio folder on your Mac.
  • Keep the GitHub repository as your remote copy.
  • Leave the Vercel project connected to the repository.

Your public production URL remains available within Vercel Hobby usage limits.

Delete - I don't want to use this again

Deleting these resources is permanent. Each confirmation prompt gives you a final checkpoint before a hosted resource disappears.

Delete the Vercel project
  • Open the Vercel project connected to the enchanted-portfolio GitHub repository.
  • Open the project's settings area.
  • Use the permanent project deletion control inside the settings area.

Vercel asks you to confirm the permanent deletion.

  • Complete the confirmation prompt.
  • Return to your Vercel project list.

You should no longer see the project in Vercel. Its production URL is no longer available.

Delete the GitHub repository
  • Open the public enchanted-portfolio repository in GitHub.
  • Open the repository settings.
  • Use the permanent repository deletion control inside the settings area.

GitHub asks you to confirm the permanent deletion.

  • Complete the confirmation prompt.
  • Return to your GitHub repository list.

You should no longer see the enchanted-portfolio repository.

Delete the local project

Removing the local folder also removes its installed dependencies.

  • Press Cmd+Space to open your Mac search bar.
  • Search for Finder.
  • Press Enter.
  • Use Finder's search field to locate the enchanted-portfolio folder.
  • Move the enchanted-portfolio folder to your Mac's trash.
  • Empty your Mac's trash.
  • Search for enchanted-portfolio again.

Finder should return no enchanted-portfolio project folder.

Nice Work!

Nice Work!

Brilliant work! Your enchanted portfolio built with the Next.js App Router is now public on Vercel.

You've learned how to:

  • Compose a responsive portfolio interface with Tailwind CSS inside the Next.js App Router. Add typed static metadata with TypeScript.
  • Keep static content in a Server Component. Isolate project filtering in a Client Component powered by React state.
  • Publish your source in GitHub. Trigger automatic Vercel deployments from each push to main.
  • Secret Mission: Add a manual Lumos and Nox control that toggles the document-level Tailwind dark variant with React state.

Ready to quiz yourself?