Mark Lowel Montealto — Full Stack Developer & DevOps Engineer

Mark Lowel
Montealto

Full Stack Developer & DevOps Engineer

Projects

Full-Stack Developer · 2025

·

7 min read

Developer Portfolio

SSR portfolio built for SEO — every page server-rendered and structured for Google to index and rank

Next.jsReactTypeScriptTailwind CSS
Developer Portfolio screenshot
Visit live site

Problem

Most developer portfolios are single-page React apps — visually impressive in a browser, invisible to a search crawler. When Googlebot visits a client-side-rendered site, it receives an almost-empty HTML shell and has to execute JavaScript to see the content. That works inconsistently and ranks poorly. I wanted potential clients and employers to find me organically when searching for "full stack developer Philippines" or "Angular developer Manila" — not just through a link I shared myself. A portfolio that nobody discovers is a portfolio that doesn't work. The challenge was building something that performed like a modern web app and delivered complete, crawlable HTML on every request, with structured metadata that search engines could act on.

Solution

I built an SSR-first portfolio using the Next.js 16 App Router, where every route is a React Server Component by default. No JavaScript is required for crawlers to read the full page content. All portfolio data — projects, work history, blog posts, certificates, and profile — is stored in Contentful and fetched at request time with ISR caching, so content updates without a redeploy.

On top of the rendering layer I added a complete SEO stack: generateMetadata on every route produces accurate <title>, <description>, Open Graph, and Twitter card tags. A dynamic app/sitemap.ts registers every blog post, project detail page, tag listing, and pagination page automatically. JSON-LD structured data injects Person, WebSite, ProfilePage, BlogPosting, and CreativeWork schema nodes so Google can build rich results. Canonical URLs prevent duplicate-content penalties across every page.

Architecture

The application uses the Next.js App Router with a (site) route group wrapping all portfolio pages inside a shared shell (header, sidebar nav, scrollable main panel). Every page component is an async Server Component — data is fetched directly in the component, not in a client hook.

Contentful serves as the headless CMS. All fetch functions use Next.js unstable_cache with content-type-specific cache tags (contentful, posts, projects, etc.) and a 300-second revalidation window. An /api/revalidate route accepts Contentful webhook payloads and calls revalidateTag to invalidate the relevant cache on publish, so the live site reflects content changes within seconds.

Blog posts and project case studies are stored as MDX strings in Contentful and rendered server-side via next-mdx-remote/rsc. The MDX pipeline runs remark-gfm for GitHub-flavored Markdown, rehype-slug for heading anchors, and rehype-pretty-code with a Shiki singleton highlighter for syntax highlighting. A separate remark AST walk (lib/toc.ts) extracts headings for the sticky table-of-contents sidebar. The application is packaged as a Docker image and deployed on a VPS behind Nginx with Let's Encrypt TLS.

Technologies Used

  • Next.js 16 (App Router) — Server Components and generateMetadata are first-class, not bolted on. Dynamic segments like /blog/[slug] and /projects/[slug] are trivial to add with file-based routing.
  • TypeScript (strict mode) — catches field mapping errors between Contentful's loosely-typed API response and the typed Post, Project, and Profile interfaces before they reach production.
  • Tailwind CSS v4 — uses a CSS-first configuration: @import "tailwindcss" and an @theme inline {} block instead of tailwind.config.js. Design tokens are CSS custom properties, making dark mode a single data-theme attribute swap with zero JavaScript in the theme toggle.
  • Contentful SDK — delivers content via the CDN API. unstable_cache wraps every query for ISR semantics without a file-system data layer.
  • next-mdx-remote — renders MDX strings from Contentful entirely on the server, keeping rehype/remark plugins and the Shiki highlighter out of the client bundle.

Challenges

Migrating away from Cloudflare Workers due to the 1 MB bundle limit

My original deployment target was Cloudflare Workers via OpenNext. The configuration worked until I added rehype-pretty-code and its Shiki dependency. Shiki bundles language grammars and theme JSON files — the combined Worker script exceeded Cloudflare's 1 MB compressed limit and the build started failing. I tried tree-shaking by registering only a single language set and pinning to one theme (github-dark), which helped but not enough. The constraint was the Worker runtime itself, not my code. I rewrote the deployment to Docker, added Nginx as a reverse proxy, and provisioned a VPS. The migration took a weekend and removed the bundle ceiling entirely.

Tailwind CSS v4's completely rewritten configuration API

Tailwind v4 dropped tailwind.config.js entirely. The theming model shifted to a CSS-first approach: custom properties defined in @theme inline {} blocks inside the stylesheet. Utility classes like bg-surface and text-muted no longer come from a colors config object — they come from --color-surface and --color-muted CSS variables mapped in the theme block. Every component that used bg-gray-900 in v3 needed rethinking. The upside is that dark mode became a pure CSS variable swap on data-theme="dark", with zero JavaScript involved beyond setting the attribute.

Implementation

The most technically interesting part was the JSON-LD structured data architecture. Rather than duplicating Person and WebSite data on every page, I defined stable @id anchors in a single root-layout graph emitted once by getGlobalGraph():

const PERSON_ID  = `${SITE_URL}/#person`;
const WEBSITE_ID = `${SITE_URL}/#website`;

Every page-level graph references those anchors by @id rather than repeating the full objects. A blog post page emits three separate <script type="application/ld+json"> blocks — the root graph (from the layout), a WebPage + BreadcrumbList graph, and a BlogPosting graph — all linked into a single knowledge graph that Google's Rich Results parser can walk.

The Contentful caching strategy tags every query with both "contentful" and a content-type tag (e.g. "posts", "projects"). The revalidation webhook calls revalidateTag("posts") when a blog post is published, invalidating only post-related caches without touching projects or profile. The revalidate: 300 fallback means the cache self-heals within five minutes even if the webhook fails.

The table of contents is extracted by running a second remark parse over the MDX source before it reaches MDXRemote. A custom collectHeadings plugin walks the AST and slugs headings with github-slugger — the same algorithm rehype-slug uses — so TOC anchor hrefs match rendered heading id attributes exactly.

Screenshots

Mark Lowel Montealto portfolio — homepage dark mode showing hero section, sidebar navigation, and featured projects

Portfolio blog post page with syntax-highlighted code block, reading time, and sticky table of contents sidebar

Results

The site achieves a Lighthouse SEO score of 100 on every page. All routes deliver complete HTML to crawlers — curl-ing any page returns the full article text, not a JavaScript shell. Blog and project pages are individually indexed by Google, and the site appears in search results for brand queries within days of a new post being published.

The structured data passes Google's Rich Results Test with no errors across all page types: Person, WebSite, ProfilePage, BlogPosting, and BreadcrumbList all resolve correctly. The dynamic sitemap registers every new blog post automatically on publish via ISR, so crawlers discover new content without manual submission. Core Web Vitals are green across the board — Server Components eliminate client-side hydration waterfalls, and next/image handles format optimisation and lazy loading automatically.

Lessons Learned

The biggest shift was internalising the difference between "renders correctly" and "is crawlable." A client-side React app can look identical in a browser and be nearly invisible to search crawlers. Every architectural decision in this project — Server Components, generateMetadata, JSON-LD, canonical URLs — exists specifically to serve crawlers, not users. Users get the benefits as a side effect.

Structured data is also a compounding investment. The first @id anchor takes effort to set up correctly; every subsequent page type just references it. Getting the graph right once pays dividends on every new page type added afterward. If I were starting over, I'd design the JSON-LD graph before writing a single component.

FAQ

Is the source code publicly available?

The repository is private, but the architecture and implementation details are documented in blog posts on this site. Posts covering Next.js data fetching, CI/CD pipelines, and Docker deployment draw directly from patterns used in this project.

Why Contentful instead of local MDX files?

Local MDX files require a Git commit and redeploy for every content change. Contentful lets me publish a blog post, update my work history, or add a project from a browser without touching the codebase. The ISR webhook (/api/revalidate) means the live site reflects changes within seconds of publishing — no deployment pipeline involved.

Why Docker instead of Vercel?

Vercel would have been the obvious choice, but the Shiki syntax-highlighting bundle pushed the serverless script past Cloudflare's 1 MB Worker limit on my original platform. A VPS with Docker gives a predictable monthly cost with no per-request billing and no platform-imposed bundle ceiling.

What was the hardest part to build?

The JSON-LD graph architecture. Getting @id references to resolve correctly across three separate <script> blocks on a single page — root layout, WebPage, and BlogPosting — required careful reading of the schema.org specification and several rounds of validation in Google's Rich Results Test. The breadcrumb list has a subtle rule: the final crumb should omit its item URL, which is easy to miss and breaks validation silently.