← Back to all posts
Tutorial

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

Karan Kashyap

Karan Kashyap

July 16, 2026

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

Part 3 left us with a working editor and one open bug: markdown links render as literal [text](url) instead of clickable <a> tags. This part fixes that, then wires everything built so far into a finished, deployable portfolio.

Why the naive fix doesn't work

The instinctive first fix is: check if a token's className looks like plain text (string, comment, plain, or no highlighting class at all) and run a link-parsing regex only on those "safe" tokens, leaving anything else — anything Prism gave a specific type to — alone.

That's wrong, and it's wrong in a way that only shows up on some files. A link sitting in a normal paragraph passes through as a plain token and gets parsed correctly. A link inside a # Heading doesn't, because Prism's markdown grammar tokenizes the entire heading — including any link inside it — as one title token, not plain. Same failure for links inside **bold** or > blockquote text: they're bold and blockquote tokens respectively, not plain. A gate that allow-lists "safe" token types will always be one markdown construct behind, because Prism's markdown grammar has more than a handful of opaque, multi-word token types, and every one of them can legally contain a link.

The actual fix: stop gating, always check

The regex that finds [text](url) is cheap to run and returns instantly on any string that doesn't contain the pattern. There's no performance reason to gate it by token type — the entire justification for the allow-list was avoiding wasted work, and the wasted work doesn't exist. Run it unconditionally on every markdown token's content, and the whole class of "which token types did I forget" bugs disappears:

components/vscode-portfolio/Editor.tsx
typescript
1// components/vscode-portfolio/Editor.tsx (excerpt)
2
3const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
4
5function renderTextWithLinks(text: string, keyPrefix: string): React.ReactNode {
6 LINK_RE.lastIndex = 0;
7 if (!LINK_RE.test(text)) return text;
8 LINK_RE.lastIndex = 0;
9
10 const parts: React.ReactNode[] = [];
11 let lastIndex = 0;
12 let match: RegExpExecArray | null;
13 let i = 0;
14
15 while ((match = LINK_RE.exec(text)) !== null) {
16 if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
17 parts.push(
18 <a
19 key={`${keyPrefix}-link-${i++}`}
20 href={match[2]}
21 {...linkAttrs(match[2])}
22 className="text-[#3794ff] hover:underline"
23 >
24 {match[1]}
25 </a>
26 );
27 lastIndex = LINK_RE.lastIndex;
28 }
29 if (lastIndex < text.length) parts.push(text.slice(lastIndex));
30 return <Fragment key={keyPrefix}>{parts}</Fragment>;
31}

The early-return (if (!LINK_RE.test(text)) return text;) is what makes "always check" cheap instead of wasteful — for the overwhelming majority of tokens that contain no link syntax at all, this is a single regex test against a short string, not a full parse-and-rebuild.

Applying it to every token, markdown or not, would be wasted work on files that have no markdown grammar to break — so it's scoped to files whose language is markdown:

components/vscode-portfolio/Editor.tsx
typescript
1// components/vscode-portfolio/Editor.tsx (excerpt)
2
3const isMarkdown = language === 'markdown';
4
5// ...inside the token-rendering loop:
6{isMarkdown
7 ? renderTextWithLinks(token.content, `${lineIndex}-${tokenIndex}`)
8 : token.content}

Headings and emphasis still need their own visual weight — a title token containing a link should still render bold and slightly larger, the <a> just needs to inherit that styling rather than reset it:

components/vscode-portfolio/Editor.tsx
typescript
1// components/vscode-portfolio/Editor.tsx (excerpt)
2
3const markdownStyle = (types: string[]): React.CSSProperties => {
4 if (types.includes('title')) return { fontWeight: 'bold', fontSize: '1.1em' };
5 if (types.includes('bold')) return { fontWeight: 'bold' };
6 if (types.includes('italic')) return { fontStyle: 'italic' };
7 if (types.includes('code')) return { background: 'rgba(255,255,255,0.12)', borderRadius: 3, padding: '0 3px' };
8 if (types.includes('blockquote')) {
9 return { opacity: 0.75, fontStyle: 'italic', borderLeft: '3px solid #569cd6', paddingLeft: 8 };
10 }
11 return {};
12};

applied to the wrapping <span> for every token, so the style and the link-aware content live on the same element:

components/vscode-portfolio/Editor.tsx
typescript
1// components/vscode-portfolio/Editor.tsx (excerpt)
2
3{line.map((token, tokenIndex) => {
4 const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex });
5 const style = isMarkdown ? markdownStyle(token.types) : undefined;
6 return (
7 <span key={tokenKey as React.Key} {...tokenProps} style={{ ...tokenProps.style, ...style }}>
8 {isMarkdown ? renderTextWithLinks(token.content, `${lineIndex}-${tokenIndex}`) : token.content}
9 </span>
10 );
11})}

Open your README.md node again — the heading link and any bold/italic links now render as real, clickable <a> tags, without a single token.types.includes(...) allow-list to maintain.

The lesson, generalized: when a fix requires an allow-list of "which cases to handle," ask whether the check you actually care about (does this text contain a link?) is cheap enough to just always run. An allow-list is a bet that you've enumerated every case correctly today and will remember to update it every time the input format changes. That bet loses eventually — usually silently, on whichever content type nobody tested.

Wiring it together: VSCodeWorkspace

One component owns the only state in this app: which files are open, and which one is active. Sidebar and Editor are both stateless with respect to that — they render props and call callbacks:

components/vscode-portfolio/VSCodeWorkspace.tsx
typescript
1// components/vscode-portfolio/VSCodeWorkspace.tsx
2'use client';
3
4import { useState } from 'react';
5import Sidebar from './Sidebar';
6import Editor from './Editor';
7import { fileTree, type FileNode } from '@/lib/data';
8
9export default function VSCodeWorkspace() {
10 const visibleTree = fileTree.filter((node) => !node.hidden);
11 const readme = fileTree.find((f) => f.id === 'readme.md') ?? null;
12
13 const [openFiles, setOpenFiles] = useState<FileNode[]>(readme ? [readme] : []);
14 const [activeFileId, setActiveFileId] = useState<string | null>(readme?.id ?? null);
15
16 const handleOpenFile = (file: FileNode) => {
17 if (file.type !== 'file') return;
18 setOpenFiles((prev) => (prev.some((f) => f.id === file.id) ? prev : [...prev, file]));
19 setActiveFileId(file.id);
20 };
21
22 const handleCloseFile = (fileId: string, e: React.MouseEvent) => {
23 e.stopPropagation();
24 const next = openFiles.filter((f) => f.id !== fileId);
25 setOpenFiles(next);
26 if (activeFileId === fileId) {
27 setActiveFileId(next.length > 0 ? next[next.length - 1].id : null);
28 }
29 };
30
31 return (
32 <div className="flex h-screen w-screen bg-[#1e1e1e] text-[#cccccc] overflow-hidden fixed inset-0">
33 <Sidebar fileTree={visibleTree} onOpenFile={handleOpenFile} activeFileId={activeFileId} />
34 <Editor
35 openFiles={openFiles}
36 activeFileId={activeFileId}
37 onCloseFile={handleCloseFile}
38 onSelectFile={setActiveFileId}
39 />
40 </div>
41 );
42}

visibleTree is where hidden from Part 1 finally does something — one .filter(), run once, before anything touches the sidebar. The README opens by default via fileTree.find(...) so a first-time visitor sees your bio immediately instead of a blank "no file open" state.

Drop it into the page and you have a working portfolio:

app/page.tsx
typescript
1// app/page.tsx
2
3import type { Metadata } from 'next';
4import VSCodeWorkspace from '@/components/vscode-portfolio/VSCodeWorkspace';
5
6export const metadata: Metadata = {
7 title: 'Jane Doe — Portfolio',
8 description: 'An interactive, VSCode-styled portfolio.',
9};
10
11export default function HomePage() {
12 return (
13 <main>
14 <VSCodeWorkspace />
15 </main>
16 );
17}

Making it yours

Everything customizable lives in two files, and nothing else needs to change:

  • lib/data.ts — swap the content strings for your own README, skills, and projects. Add nodes, remove nodes, nest directories as deep as you want.
  • lib/config.ts — your name and the social links in the activity bar.

Shipping it

bash
bash
1npm run build

confirms the whole tree — every file's content — type-checks and compiles cleanly before you deploy. Push to GitHub and import the repo on Vercel; zero configuration is needed beyond the framework preset it detects automatically.

That's the series — a typed data model, a recursive file-tree sidebar, a Prism-backed editor, and a markdown-link fix that generalizes past this one bug. The companion repo has all four parts' code in a working, clonable state — fork it, replace lib/data.ts with your own story, and ship it.

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.