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

Karan Kashyap
July 16, 2026
![Let's Build a VSCode-Style Interactive Portfolio from scratch in Next.js & TypeScript [Part 4]](/_next/image/?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3e1sexdu%2Fproduction%2F229694f09ce3eb856898efb7a580df4b9c1a4cf9-1600x739.png%3Frect%3D246%2C0%2C1109%2C739%26w%3D1200%26h%3D800%26q%3D85%26fit%3Dcrop%26auto%3Dformat&w=3840&q=75)
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:
1// components/vscode-portfolio/Editor.tsx (excerpt)23const LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;45function 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;910 const parts: React.ReactNode[] = [];11 let lastIndex = 0;12 let match: RegExpExecArray | null;13 let i = 0;1415 while ((match = LINK_RE.exec(text)) !== null) {16 if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));17 parts.push(18 <a19 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:
1// components/vscode-portfolio/Editor.tsx (excerpt)23const isMarkdown = language === 'markdown';45// ...inside the token-rendering loop:6{isMarkdown7 ? 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:
1// components/vscode-portfolio/Editor.tsx (excerpt)23const 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:
1// components/vscode-portfolio/Editor.tsx (excerpt)23{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:
1// components/vscode-portfolio/VSCodeWorkspace.tsx2'use client';34import { useState } from 'react';5import Sidebar from './Sidebar';6import Editor from './Editor';7import { fileTree, type FileNode } from '@/lib/data';89export default function VSCodeWorkspace() {10 const visibleTree = fileTree.filter((node) => !node.hidden);11 const readme = fileTree.find((f) => f.id === 'readme.md') ?? null;1213 const [openFiles, setOpenFiles] = useState<FileNode[]>(readme ? [readme] : []);14 const [activeFileId, setActiveFileId] = useState<string | null>(readme?.id ?? null);1516 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 };2122 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 };3031 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 <Editor35 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:
1// app/page.tsx23import type { Metadata } from 'next';4import VSCodeWorkspace from '@/components/vscode-portfolio/VSCodeWorkspace';56export const metadata: Metadata = {7 title: 'Jane Doe — Portfolio',8 description: 'An interactive, VSCode-styled portfolio.',9};1011export 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 thecontentstrings 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
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.
![Let's Build a VSCode-Style Interactive Portfolio from scratch in Next.js & TypeScript [Part 3]](/_next/image/?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3e1sexdu%2Fproduction%2F229694f09ce3eb856898efb7a580df4b9c1a4cf9-1600x739.png%3Frect%3D61%2C0%2C1478%2C739%26w%3D800%26h%3D400%26q%3D85%26fit%3Dcrop%26auto%3Dformat&w=3840&q=75)