Skip to content

SEO for JavaScript-Heavy Portfolios

How I made a heavily interactive desktop-metaphor portfolio SEO-friendly using crawlable semantic HTML, structured data, llms.txt, and careful font loading, without sacrificing the creative UI.

My portfolio site has a desktop-metaphor UI with draggable windows, a taskbar, and interactive elements everywhere. It's the kind of design that makes Google's crawler deeply uncomfortable. All the actual content (project descriptions, blog posts, skills) lives inside JavaScript-rendered window components that a crawler might never meaningfully parse. I didn't want to compromise on the creative direction, but I also didn't want my site to be invisible to search engines and AI search tools. Here's how I solved it. ## The Problem: Content Trapped in JavaScript Windows The core issue is simple: my site's content isn't in semantic HTML that lives in the initial DOM. It's rendered inside React components that simulate desktop windows. A user opens a window, reads about a project, closes it, opens another. The content exists in component state and conditional renders. Google's crawler executes JavaScript, but it's not clicking around opening windows. It sees the initial render, a desktop background with a taskbar, and not much else. From a crawling perspective, my beautiful portfolio was a nearly empty page. ## The CrawlableSeoContent Pattern My solution was to create a parallel content layer: a `CrawlableSeoContent` component that renders the full semantic HTML version of all content, visually hidden from sighted users but fully accessible to crawlers and screen readers. ```tsx function CrawlableSeoContent() { return ( <div className="sr-only"> <main> <h1>Wes Dieleman - Full Stack Developer</h1> <section aria-label="About"> <h2>About</h2> <p>Full stack developer based in the Netherlands, specializing in Next.js, React, TypeScript, and Cloudflare Workers...</p> </section> <section aria-label="Projects"> <h2>Projects</h2> <article> <h3>Triqai</h3> <p>Transaction enrichment API built on Cloudflare Workers...</p> </article> {/* ... more projects */} </section> </main> </div> ); } ``` The `sr-only` class (from Tailwind) uses the standard screen-reader-only CSS pattern: `position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0)`. The content is in the DOM, crawlers read it, screen readers announce it, but sighted users see only the interactive desktop UI. The critical thing is keeping this content synchronized with what's actually displayed in the windows. I extract the content into a shared data layer that both the visual window components and the SEO component consume. When I update a project description, it updates in both places. ## Schema Markup Strategy I went deep on structured data to give search engines as much context as possible about who I am and what the site contains. The schema graph includes: **Person schema** for identity: name, job title, location, skills, social profiles. This grounds the site as a personal portfolio rather than a random JavaScript demo. **WebSite schema** with search action potential, establishing the site's identity and URL structure. **CreativeWork for each project** with technology stack, descriptions, and links. This tells Google "these are real software projects" rather than just page sections. **BlogPosting for each blog post** with proper author reference back to the Person entity, datePublished, and article sections. **BreadcrumbList** to establish navigation hierarchy even though the visual UI doesn't have traditional breadcrumbs. ```json { "@context": "https://schema.org", "@graph": [ { "@type": "Person", "@id": "https://wesdieleman.com/#person", "name": "Wes Dieleman", "jobTitle": "Full Stack Developer", "knowsAbout": ["Next.js", "React", "TypeScript", "Cloudflare Workers"], "address": { "@type": "PostalAddress", "addressCountry": "NL" } }, { "@type": "WebSite", "@id": "https://wesdieleman.com/#website", "url": "https://wesdieleman.com", "name": "Wes Dieleman", "author": { "@id": "https://wesdieleman.com/#person" } } ] } ``` I inject this as a single `@graph` array in a `<script type="application/ld+json">` tag in the layout, rather than scattering individual schema blocks across components. This makes validation easier and ensures the entity relationships (Person to WebSite to BlogPosting) are all connected via `@id` references. ## llms.txt and AI Search Readiness Search is increasingly driven by AI systems like Google's AI Overviews, ChatGPT's web browsing, and Perplexity. These systems consume content differently than traditional crawlers. I added several things to make my portfolio AI-discoverable. **llms.txt** is a proposed standard (similar to robots.txt) that tells AI crawlers what your site is about and where to find key content. I created one at the root that maps out who I am, what I do, and where each piece of content lives. **robots.txt configuration** that explicitly allows GPTBot, ClaudeBot, and PerplexityBot. Many sites block these by default, but for a portfolio, you want maximum AI visibility. **Content structure for citation.** AI systems tend to cite content that's structured in clear, self-contained passages. I made sure each project description and blog post has a strong opening sentence that summarizes the key point. This becomes the snippet that AI systems are most likely to quote. The goal is that when someone asks an AI "who are some developers experienced with Cloudflare Workers" or "Next.js portfolio examples," my site has a chance of being surfaced. Whether that actually works is hard to measure, but the implementation cost was minimal. ## Balancing Visual Creativity with Crawlability The CrawlableSeoContent approach has a philosophical tension: I'm maintaining two versions of my content, the visual one and the semantic one. This feels like a hack, and in some ways it is. The alternative would be to build the interactive UI on top of semantic HTML, making the windows contain proper `<article>` elements with headings and using CSS to create the desktop aesthetic. I tried this initially, and the problem is that the window paradigm requires content to be conditionally rendered (windows open and close), which means the content isn't always in the DOM. I could render everything and toggle visibility with CSS, but that bloats the initial page weight and causes layout complexity with absolute positioning of multiple overlapping windows. The sr-only approach is the pragmatic middle ground. It's the same pattern that accessible interactive applications use: complex visual UIs backed by semantic structures for assistive technology. I'm just extending "assistive technology" to include search engine crawlers. ## Font Loading and Core Web Vitals Custom fonts are the silent killer of portfolio Core Web Vitals. I use a distinctive display font (Geist) that's central to the site's design, and the default browser loading behavior caused visible layout shift as fonts swapped in. My approach: **`font-display: swap` with size-adjusted fallbacks.** I define a fallback font stack with `size-adjust`, `ascent-override`, and `descent-override` CSS properties tuned to match the custom font's metrics. This minimizes CLS when the font swaps from fallback to loaded. **Preload critical font files.** The regular and bold weights used above the fold are preloaded via `<link rel="preload">` in the document head. Italic and other weights load async. **Subset fonts.** I strip unused glyphs from font files using `glyphhanger`. The full Geist font family is ~400KB; subsetting to Latin characters brings it under 80KB. ```html <link rel="preload" href="/fonts/geist-regular-latin.woff2" as="font" type="font/woff2" crossorigin /> ``` For images, I use Next.js's `Image` component with proper `width` and `height` attributes to prevent CLS, and serve responsive `srcset` variants through the built-in image optimization pipeline. ## The Results After implementing these changes, Google Search Console showed a clear improvement: the site went from being indexed with a single mostly-empty page to having structured data recognized for Person, WebSite, and individual project entities. Rich result eligibility appeared for blog posts. More importantly, the site passes Core Web Vitals assessment with good scores across all metrics. LCP stays under 2 seconds, CLS is near zero thanks to the font strategy, and INP is fine because the interactive elements use requestAnimationFrame-based animations rather than blocking the main thread. The sr-only content approach is admittedly a workaround for an unconventional UI choice. If you're building a traditional portfolio with normal page navigation, you don't need any of this. Just write semantic HTML and let Next.js handle the rest. But if you want a creative, JavaScript-heavy UI that's also discoverable, the parallel content layer is a viable pattern. Just keep it synced with your visual content, and validate regularly with Google's Rich Results Test and Lighthouse.
Blog

Blog

Browse posts in explorer mode. Click a post to open its article in a new window with metadata, images, and code blocks.

Post List

Loading workspace

Taxi the dog desktop wallpaper icon