← Back to all posts
Tutorial

Let's Build a VSCode-Style Interactive Portfolio from scratch in Next.js & TypeScript [Part 1]

Karan Kashyap

Karan Kashyap

July 16, 2026

Let's Build a VSCode-Style Interactive Portfolio from scratch in Next.js & TypeScript [Part 1]

Most developer portfolios are a hero section, a grid of cards, and a contact form. They work, but they don't say anything about the person who built them. Mine is a working clone of VSCode — my bio is README.md, my skills are a skills/ directory, my projects are source files you can click open in a fake file tree. It's the one portfolio format where the UI itself is a credibility signal: if the editor renders, syntax-highlights, and the tabs behave correctly, you've already demonstrated the thing you're trying to prove.

Over this series I'll walk through how the whole thing is built, starting today with the least glamorous but most consequential decision: the shape of the data everything else renders from.

Every part in this series ships working code — clone the repo and follow along, or build it up file by file as we go.

Why the data model comes first

Get this wrong and every component after it fights the structure — you end up with if (file.name.endsWith('.py')) checks scattered across the editor, the sidebar, and the icon renderer, each one a slightly different guess at "what kind of file is this." Get it right, and adding a new project to your portfolio later is a five-line object literal, not a component change.

The trade-off is that a good data model asks you to decide things up front you'd rather defer — what fields does a "file" need? What distinguishes a file from a directory? — but that's a cheap decision to make on day one and an expensive one to unwind on day thirty.

The FileNode type

Everything in the sidebar and editor — your README, your skills, every project — is one shape:

lib/data.ts
typescript
1// lib/data.ts
2
3export type Language =
4 | 'markdown'
5 | 'json'
6 | 'javascript'
7 | 'typescript'
8 | 'jsx'
9 | 'tsx'
10 | 'python'
11 | 'go'
12 | 'yaml'
13 | 'bash'
14 | 'sql'
15 | 'graphql'
16 | 'text';
17
18export type FileNode = {
19 id: string; // must be unique across the whole tree
20 name: string; // shown in the sidebar + editor tab
21 type: 'file' | 'directory';
22 content?: string; // required for files, rendered as-is (use \n for newlines)
23 language?: Language; // drives syntax highlighting + the sidebar icon
24 children?: FileNode[]; // required for directories
25 hidden?: boolean; // kept in the tree, filtered out of the rendered sidebar
26};

Two decisions here are worth calling out.

language instead of a separate icon field. An earlier version of this had icon: string on every node and a getLanguageFromIcon() function in the editor translating icon names to Prism grammar names — two sources of truth for the same fact, and they drift the moment you add a file type and forget one of them. One field, language, drives both the sidebar icon (Part 2) and the Prism language (Part 3). If a file doesn't highlight right, there's exactly one place to look.

hidden instead of deleting the node. I wanted an experience/ directory I could hide from the sidebar without losing the content — the data should reflect what I've done, and what's currently shown is a separate, cheaper-to-change concern. hidden: true keeps the node in fileTree for anything that wants to read it later, while the component that renders the sidebar (VSCodeWorkspace, in Part 4) filters it out with a one-line .filter(). Toggle it back on by deleting one line, not by re-typing content you already wrote.

Writing your first files

fileTree is just an array of FileNode. Here's a minimal starting point — a README, a whoami script, and a skills directory:

lib/data.ts
typescript
1// lib/data.ts (continued)
2
3export const fileTree: FileNode[] = [
4 {
5 id: 'readme.md',
6 name: 'README.md',
7 type: 'file',
8 language: 'markdown',
9 content: `# [Jane Doe](https://example.com)
10
11Software engineer with 6+ years shipping production web apps.`,
12 },
13 {
14 id: 'about.sh',
15 name: 'whoami.sh',
16 type: 'file',
17 language: 'bash',
18 content: `#!/usr/bin/env bash
19NAME="Jane Doe"
20ROLE="Full-Stack Engineer"
21echo "-> $NAME - $ROLE"`,
22 },
23 {
24 id: 'skills',
25 name: 'skills',
26 type: 'directory',
27 children: [
28 {
29 id: 'skills-frontend.ts',
30 name: 'frontend.ts',
31 type: 'file',
32 language: 'typescript',
33 content: `const frameworks = ['Next.js', 'React'];
34export { frameworks };`,
35 },
36 ],
37 },
38];

Notice content is a plain template literal, not markdown-rendered HTML or MDX — it's rendered as literal source text through a syntax highlighter in Part 3. That's deliberate: it means any file type Prism understands (and a few it doesn't, until we teach it one in a later part) works with zero extra plumbing. Want a .sql file listing side projects? Add the node, set language: 'sql', done.

Project setup

Scaffold a Next.js App Router project with TypeScript and Tailwind v4:

bash
bash
1npx create-next-app@latest vscode-portfolio --typescript --tailwind --app --src-dir=false
2cd vscode-portfolio
3npm install lucide-react prism-react-renderer

lucide-react gives us icons for the sidebar without needing a folder of SVG assets (Part 2); prism-react-renderer is the syntax highlighter driving the editor pane (Part 3).

Confirm the scaffold runs before moving on:

bash
bash
1npm run dev

If localhost:3000 shows the default Next.js starter page, the wiring is correct and every problem from here on is ours, not the tooling's.

Next up: the sidebar — an activity bar and a file-tree accordion that turns fileTree into something clickable.

Blog series · 4 parts

Let's Build a VSCode-Style Interactive Portfolio from scratch in Next.js & TypeScript

with Next.js & Typescript

View on GitHub
Next.jsTypeScriptVercel

Ready to Build Something Extraordinary?

Let's discuss your idea. We'll show you how AI-powered development can compress your timeline and budget — without cutting corners.

We respond within 24 hours. No sales pitch — just a straight conversation about your project.