Building davdav.tech: Engineering a Professional Brand with Next.js
A full account of building a personal brand platform from scratch — constraint-driven architecture, static export on shared hosting, dark mode without flash, WCAG AA compliance, MDX blog engine, and a 13-phase delivery plan.

Carlos David Duarte
Senior Software Engineer · Technical Lead
A professional website used to mean a static page with a photo and a list of jobs. That's not enough anymore — especially if you're positioning yourself as a Senior Software Engineer moving toward Solution Architecture.
This post covers the full build of davdav.tech: the goals, the constraints, the technology decisions, the problems that came up, and how they were resolved.
The goal: a brand ecosystem, not a portfolio
The difference matters. A portfolio is a snapshot. A brand ecosystem is a system that compounds over time — more content means more discoverability, deeper articles build authority, consistent positioning reinforces the narrative.
The platform has three layers:
- Identity pages — Who I am, what I've done, what I know, where I'm going
- Blog — The growth engine. Original writing on Java, Azure, DevOps, and architecture that earns search visibility and signals depth
- Offline artifact — A PDF resume that travels to recruiters and hiring managers independent of the site
The positioning goal is specific: Senior Software Engineer → Technical Lead → Solution Architect. Every decision — copy, structure, imagery, certifications roadmap — serves that trajectory.
Constraints first
Before choosing any technology, the constraint was clear: HostGator shared hosting. No Node.js runtime, no persistent server processes. The output has to be plain HTML, CSS, and JavaScript uploaded via FTP.
That constraint eliminated a lot of options immediately. It also simplified the architecture: if you can't run a server, you don't have to think about one.
Technology stack
Next.js 16 with static export
Next.js with output: 'export' generates a fully static site into an /out directory. The development experience stays modern, the output is a plain directory of files.
// next.config.ts
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
images: { unoptimized: true },
};
trailingSlash: true is required for Apache shared hosting — without it, navigating directly to /about returns a 404 because Apache expects /about/index.html. images: { unoptimized: true } disables Next.js image optimization, which requires a server runtime.
React 19 + TypeScript 5
React 19 ships with significant improvements to hydration. One subtle issue surfaced immediately: a dark mode toggle that reads localStorage on initialization causes a server/client mismatch if the initializer runs during render. The solution is useState(false) with useEffect to sync after mount.
Tailwind CSS 4
Tailwind 4 drops tailwind.config.js. Design tokens are defined directly in CSS using @theme:
@theme {
--color-primary: #0078D4; /* Azure Blue */
--color-accent: #38BDF8; /* Accent Blue */
--color-background: #0F172A; /* Dark Slate */
}
Dark mode uses a .dark class on <html>, toggled by a client component with localStorage persistence. One limitation: Tailwind 4 can't resolve opacity modifiers on CSS variables at build time. bg-[var(--color-primary)]/10 generates nothing. The workaround is an explicit CSS rule:
.nav-active {
background-color: rgba(0, 120, 212, 0.10);
}
MDX blog engine
Blog posts are .mdx files in src/content/blog/. Frontmatter carries metadata; the body is Markdown with optional React components.
The standard @next/mdx approach ran into a Turbopack serialization error — remark/rehype plugins can't be serialized when passed through next.config.ts in dev mode. The fix: next-mdx-remote/rsc renders MDX as a React Server Component. Plugins are passed directly to the component at render time, which runs in the Node.js process, not Turbopack.
// blog/[slug]/page.tsx
<MDXRemote
source={post.content}
options={{ mdxOptions: { remarkPlugins: [remarkGfm] } }}
/>
gray-matter parses frontmatter. getAllPosts() in src/lib/mdx.ts reads the filesystem at build time and returns sorted, filtered posts.
PHP contact form
Next.js API routes require a server runtime. contact.php with PHPMailer covers the entire use case: validate input, send via SMTP, return JSON.
All SMTP settings are environment variables — no hardcoded credentials:
$smtpHost = getenv('SMTP_HOST') !== false ? getenv('SMTP_HOST') : 'mail.davdav.tech';
$smtpPort = getenv('SMTP_PORT') !== false ? (int)getenv('SMTP_PORT') : 587;
$smtpAuth = getenv('SMTP_AUTH') !== 'false';
For local testing, a scripts/local-php.sh script spins up PHP's built-in server on port 8080 and Mailpit as a local SMTP sink on port 1025. No Nginx, no Docker — the whole stack runs in a single shell script.
The problems worth writing about
Dark mode flash
next/script strategy="beforeInteractive" seems like the right tool for a pre-render script. It isn't — in App Router, it injects at the start of <body>, after CSS has already been applied. The result: a visible flash from light to dark on every hard reload.
The fix is a raw synchronous <script> in <head>:
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){var t=localStorage.getItem('theme');if(t==='dark'||(!t&&window.matchMedia('(prefers-color-scheme:dark)').matches)){document.documentElement.classList.add('dark')}})()`,
}}
/>
</head>
This runs during HTML parsing, before the first CSS paint. suppressHydrationWarning on <html> handles the .dark class mismatch that React would otherwise flag.
WCAG AA color contrast
Azure Blue (#0078D4) against the dark background (#0F172A) produces a contrast ratio of 3.94:1. WCAG AA requires 4.5:1 for normal text. Changing the primary color globally would break buttons — white text on the lighter accent blue fails at 2.15:1.
The surgical fix: override only text elements in dark mode.
/* globals.css */
.dark .text-\[var\(--color-primary\)\] {
color: var(--color-accent); /* #38BDF8, ratio 8.66:1 */
}
Buttons use text-white, not text-[var(--color-primary)]. The override is scoped to text only. Lighthouse Accessibility went from 96 to 100.
Active navigation state
usePathname() returns /about/ with trailingSlash: true, but nav link href values are /about. The active check pathname === href always fails.
const rawPathname = usePathname();
const pathname = rawPathname.replace(/\/$/, "");
const isActive = (href: string) =>
href === "/" ? pathname === "" || pathname === "/" : pathname.startsWith(href);
SEO and structured data
Every page has:
<title>via Next.js metadata API with a site-level template (%s | Carlos David Duarte)- Canonical URL via
alternates.canonical - Open Graph and Twitter card metadata
- Schema.org
BreadcrumbListJSON-LD on inner pages
The root layout carries a Person schema with sameAs links to LinkedIn, GitHub, and ORCID — giving search engines a machine-readable identity graph.
{
"@type": "Person",
"name": "Carlos David Duarte",
"sameAs": [
"https://www.linkedin.com/in/dav-gill",
"https://github.com/RamRider89",
"https://orcid.org/0009-0009-1160-8517"
]
}
Blog posts add TechArticle schema with datePublished, author, and about.
Performance results
Lighthouse after all optimizations, on a local static server:
| Category | Mobile | Desktop |
|---|---|---|
| Performance | 95 | 100 |
| Accessibility | 100 | 100 |
| Best Practices | 100 | 100 |
| SEO | 100 | 100 |
The remaining 5 points on mobile performance come from render-blocking requests inherent to any web font loading — not something to over-optimize at this stage.
Delivery methodology
The build followed a 13-phase plan that treated this like a real software project:
- Foundation (scaffold, tokens, config)
- Layout and navigation 3–9. Content pages — each with SEO, schema, mobile-first design
- SEO infrastructure
- Brand photography integration
- QA — CV/site audit, cross-browser, Lighthouse, email verification
- Polish and deploy (favicon, 404, FTP)
Phases 0–12 are complete. Phase 13 is in progress.
The planning overhead paid off: every architectural decision was made before touching a file, which meant no structural rewrites mid-build.
What I deliberately skipped
Image optimization server: unoptimized: true is the right call for a text-heavy professional site on shared hosting.
API routes: A single contact.php is simpler, more portable, and has zero cold-start latency compared to an edge function.
i18n on launch: English first. Spanish localization is Phase 2. Launching in one language with good content beats launching in two with thin content.
CMS: MDX files in the repo are the right fidelity for a developer-owned blog. No dashboard to maintain, no API dependencies, content lives in git history.
Lessons
Constraints produce clarity. "No Node.js on the server" eliminated whole categories of decisions immediately and made the architecture obvious.
Dark mode is harder than it looks. The flash issue is subtle, the fix is not where you'd expect it, and it matters — it's the first thing a careful reader notices.
WCAG AA is a floor, not a ceiling. The color contrast fix was two lines of CSS. There's no excuse for skipping it.
A 13-phase plan for a personal site sounds like overkill. It wasn't. Having explicit requirements before writing code meant each phase had a clear definition of done, and "done" actually meant done.
The site is live at davdav.tech. The blog will continue with articles on Java architecture patterns, Azure cloud design, and technical leadership from the field.
More articles
Writing on Java, Azure, DevOps, and engineering leadership.