Jonas.dev
jonas-devnextjs-portfolio.mdx
Building My Portfolio with Next.jsPreview
/blog
Devlog··5 min read·1080 words

Building My Portfolio with Next.js

Making a portfolio that looks like a code editor: the shell layout, the tab navigation, a resizable preview pane, and the design tokens that keep it consistent.

Next.jsTailwind CSSVercelDesign
Outline · 7 sections

Most portfolio sites are a hero section, a grid of cards, and a footer. Mine is a window. The idea was simple: if the thing I spend my day inside is an editor, why should the site that describes my work look like something else? So the portfolio is dressed as a code editor — title bar, activity bar, explorer, tabs, an editor pane and a status bar — and every page opens as a file.

This post is about the parts that were actually interesting to build.

The shell

The whole visual frame lives in the root layout, and it is almost entirely flex with overflow-hidden set deliberately. Only the editor area scrolls:

// app/layout.js
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className="h-screen overflow-hidden bg-editor font-sans text-fg">
        <div className="flex h-full flex-col">
          <TitleBar />
 
          <div className="flex min-h-0 flex-1">
            <SidePanel />
 
            <div className="flex min-w-0 flex-1 flex-col">
              <EditorTabs />
              <main className="min-h-0 flex-1 overflow-y-auto">{children}</main>
            </div>
          </div>
 
          <StatusBar />
        </div>
      </body>
    </html>
  );
}

The min-h-0 on the middle row and on <main> is the detail that makes the layout behave. Flex items default to min-height: auto, which means a tall page pushes the status bar off the bottom of the screen instead of scrolling inside its own pane. Every scroll container in the shell has it.

Each route is a tab with a filename, a file-type icon and an accent colour: home.jsx, about.jsx, projects.jsx, and now blog.mdx. The tab row is the primary navigation, and the active tab is styled the way an open document is — a coloured top border and the editor background:

// components/vscode/EditorTabs.jsx
const active = pathname === tab.path;
 
<Link
  href={tab.path}
  className={`flex items-center gap-2 border-r border-chrome px-4 py-2 ${
    active
      ? "border-t-2 border-t-sky-400 bg-editor text-fg"
      : "border-t-2 border-t-transparent bg-tab-inactive text-fg-muted hover:bg-hover"
  }`}
>
  <FileIcon className={`h-4 w-4 ${tab.accent}`} />
  <span>{tab.name}</span>
</Link>

Because the tab bar has a fixed height and the list of tabs is data, adding a page is a two-line change: create the route, add an entry to the tab array.

Every page is an open file

A page does not render directly into <main>. It renders an EditorFile, which splits its own area into a live preview on top and a line-numbered code pane underneath, with a divider that can be dragged:

export default function Projects() {
  return (
    <EditorFile
      filename="projects.jsx"
      language="JavaScript JSX"
      previewLabel="Projects"
      preview={<>…the real page content…</>}
    >
      {/* the "source code" shown in the code pane */}
    </EditorFile>
  );
}

The preview is passed as a prop rather than as children. That is not cosmetic — it is the fix for a bug worth remembering.

The same rule shapes the code pane. Syntax highlighting is a handful of plain functions — no hooks, no state — so they stay server-side and the pages call them directly:

// components/vscode/syntax.js
export const kw = (children) => <span className="text-[#c586c0]">{children}</span>;
export const fn = (children) => <span className="text-[#dcdcaa]">{children}</span>;
export const str = (children) => <span className="text-[#ce9178]">{children}</span>;

One set of design tokens

The theme is a Tailwind v4 @theme block mapped onto CSS variables, so the same names work in class names and in hand-written CSS. Naming them after the editor they imitate keeps the palette honest:

/* app/globals.css */
:root {
  --vscode-editor: #1e1e1e;
  --vscode-editor-soft: #252526;
  --vscode-border: #3c3c3c;
  --vscode-border-strong: #454545;
  --vscode-status-bar: #007acc;
  --vscode-selection: #264f78;
  --vscode-fg: #cccccc;
  --vscode-fg-muted: #9d9d9d;
  --vscode-fg-dim: #6e6e6e;
}
@theme inline {
  --color-editor: var(--vscode-editor);
  --color-editor-soft: var(--vscode-editor-soft);
  --color-chrome: var(--vscode-border);
  --color-chrome-strong: var(--vscode-border-strong);
  --color-fg: var(--vscode-fg);
  --color-fg-muted: var(--vscode-fg-muted);
  --color-fg-dim: var(--vscode-fg-dim);
}

Two rules keep the UI from drifting as pages get added:

  • Cards are borders, not shadows. Every panel is a rounded-md box with a border-chrome or border-chrome-strong edge on the editor background. Hovering changes the border colour to an accent, nothing more.
  • Metadata is monospace and small. Dates, counts, languages and labels are font-mono text-[11px], in the dim foreground colours. That one convention is what makes the interface read as a developer tool rather than a brochure.

Typography and accessibility details

A few small decisions that matter more than they look:

ConcernDecision
FontsGeist Sans for content, Geist Mono for anything that looks like data
Long code linesOnly code panes and tables scroll horizontally — never the page itself
Small screensExplorer hides below md, tabs scroll, grids collapse to one column
MotionShort transitions on hover; new animations sit behind a reduced-motion check
ContrastBody copy uses the muted foreground; headings use the full foreground

A theme is not the colours you pick, it is the decisions you refuse to make twice.

Deploying

The site is static, so deployment is the boring part: push to GitHub, import the repository into Vercel, and every push to main ships. Because there is no database and no server state, the build is the deployment — and since the blog reads its posts from MDX files at build time, publishing an article is a commit.

That is also the reason the blog was built this way. Content that lives in the repository can be written in the editor, reviewed like code, and deployed with the same command as everything else.

What is next

  • Keep expanding the blog now that the MDX pipeline is in place
  • Add more interactive MDX components where an article genuinely benefits
  • Tighten the small-screen experience of the split editor pane
1---
2title:"Building My Portfolio with Next.js"
3description:"Making a portfolio that looks like a code editor: the shell layout, the tab navigation, a resizable preview pane, and the design tokens that keep it consistent."
4date:"2026-09-15"
5tags:
6 - Next.js
7 - Tailwind CSS
8 - Vercel
9 - Design
10category:"Devlog"
11---
12
13<Callout type="note" title="Example post">
14
15Sample content that ships with the blog. It documents how this site is built —
16if you rewrite it, keep the `Callout` and `CodeTabs` usage around for a moment
17first, since the two are good examples of what MDX can do.
18
19</Callout>
20
21Most portfolio sites are a hero section, a grid of cards, and a footer. Mine is
22a window. The idea was simple: if the thing I spend my day inside is an editor,
23why should the site that describes my work look like something else? So the
24portfolio is dressed as a code editor — title bar, activity bar, explorer,
25tabs, an editor pane and a status bar — and every page opens as a file.
26
27This post is about the parts that were actually interesting to build.
28
29## The shell
30
31The whole visual frame lives in the root layout, and it is almost entirely
32`flex` with `overflow-hidden` set deliberately. Only the editor area scrolls:
33
34```jsx
⋮ 186 more lines