Back to all work
Personal Website background
2024 → PresentActively iterating4 min read

Personal Website

Next.js 14 on the front, FastAPI on the back, and a Syncthing + Watchdog bridge that turns Obsidian Markdown into live blog posts without touching a CMS.

Syncthing mirrors Obsidian notes into a Watchdog worker, so a markdown save is all it takes to refresh the site.

Project Metrics

Sort modes6 client permutations
Frontmatter fields7 synced attributes
Next.jsTailwind CSSFastAPIPostgreSQLSyncthingWatchdogRadix UIFramer Motion

Personal Website

Why I Built It

  • I wanted a single digital studio where hiring managers, collaborators, and friends can find my work, résumé, and longer-form writing without bouncing between platforms.
  • Publishing should feel as easy as dropping a Markdown file into my Obsidian vault—no CMS logins, no copy-and-paste into web forms.
  • The site doubles as a playground for the stack I enjoy (Next.js 14 + FastAPI) and a place to experiment with motion, auth flows, and progressive enhancement.

Note-to-Web Pipeline

Personal site request lifecycle

  1. Posts start life in Obsidian with a frontmatter block that carries fields such as title, slug, publish, banner, and post_id.
  2. Syncthing mirrors the Blog/ folder from my laptop to the production VPS where FastAPI is running.
  3. A Watchdog-powered observer tails filesystem events, parses the frontmatter, and upserts rows in PostgreSQL through the FastAPI layer.
  4. The Next.js frontend reads from that API (process.env.API_URL), so publishing is as fast as hitting save in Obsidian.
Frontmatter FieldPurpose
post_idDetects whether FastAPI should create or update the row
publishToggle that lets me keep drafts in source control without exposing them
created_atPreserves the original writing date for the UI timeline
banner, description, slug, titleFeed the hero image, blog cards, and Open Graph meta

Frontend Experience

Global shell and navigation

  • The app router is wrapped in AuthProvider (hooks/AuthContext.tsx), which hydrates the user from /users/me, attaches the JWT to every request, and exposes login/logout.
  • ClientLayout coordinates the chromed header, StairTransition, and PageTransition so non-blog routes get animated entrances while /blog stays lean for long-form reading.
  • Header actions adapt: blog routes swap the “Hire me” button for the authenticated profile popover (components/ProfileMenu.tsx) that shows avatar uploads and quick sign-out.

Blog surfaces

  • app/blog/page.tsx runs on the server and calls GET {API_URL}/posts/ with cache: "no-store" to keep the list fresh. When the API is unreachable it gracefully renders the initial data set.
  • Once hydrated, components/blog/BlogListing.tsx gives readers six sort orders, section filters, and search with debouncing; it falls back to client-side filtering if a network call fails.
  • Detail pages (app/blog/[slug]/page.tsx) stitch together server-rendered Markdown with client-only interactions inside ClientSideExtras. The sidebar buttons (likes, comments, copy link) stay visible thanks to a responsive layout that swaps between a fixed rail and a floating pill on mobile.

Markdown renderer tuned for Obsidian

  • components/blog/MarkdownContent.tsx wires remarkCallout, remarkObsidianImages, and remarkObsidianInternalLinks so Obsidian callouts, embeds (![[...]]), and heading links survive the journey.
  • Images referenced with relative paths are automatically rewritten to API_URL/media/<section>/..., while videos fall back to a custom React component. Syntax highlighting uses highlight.js and a bespoke CodeBlock.
  • The Work case studies reuse the same renderers (components/work/CaseStudyMarkdown.tsx) for consistent typography.

Authenticated extras

  • usePostExtras fetches /posts/{slug}/extras via an axios instance that is cached for two seconds (lib/axios.tsx + axios-cache-interceptor) to dodge duplicate calls during rapid interactions.
  • Likes, comments, and share buttons do optimistic UI updates; if FastAPI answers with 401, an AuthModal (components/AuthModal.tsx) prompts the user to sign in without losing scroll position.
  • The profile area (app/profile/page.tsx) lets authenticated readers manage their avatar by sending multipart uploads to /users/avatar, with toast-driven feedback loops for success and failure.

Contact and résumé flows

  • Contact requests use a Next.js Server Action (lib/contactSubmitForm.tsx) that validates length, required fields, and target service before POSTing to /contact. Responses hydrate state so the CTA can flip to “Message sent successfully!” without an extra fetch.
  • Résumé and services pages lean on Framer Motion for subtle fades, CountUp-powered stats, and Radix UI primitives for tabs, tooltips, and selects, keeping motion consistent without re-inventing primitives.

Backend Interface & Operations

Ts
// lib/axios.tsx
const api = setupCache(
  axios.create({ baseURL: process.env.API_URL, headers: { "Content-Type": "application/json" } }),
  { ttl: 2000, interpretHeader: false, methods: ["get"] },
);

api.interceptors.request.use((config) => {
  const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});
  • Every client-side hook reuses this cached instance, keeping follow-up requests inside the 2 s TTL when users bounce between blog posts or re-open the sidebar.
  • Login/signup flows hit POST /users/authorize and POST /users/ respectively; once a JWT is stored, the provider rehydrates the session without a full reload.
  • process.env.API_URL is referenced consistently across server components (fetch) and client components (axios), so staging can be as simple as pointing the frontend at a different FastAPI base URL.

What’s Next

  • Build a FastAPI endpoint that streams metrics into the Stats component, replacing the current static counts with live GitHub, blog, and project numbers.
  • Finish the profile area with lists of liked posts and comments now that the backend exposes the data.
  • Extend the Obsidian plugins to cover audio embeds and inline diagrams so posts like architecture deep-dives can stay entirely within Markdown.

Explore another build

More systems I am growing in the open.