Building the Layout Shell: Navigation, Footer, and Shared Components
- Setting Up the Foundation: Next.js, Tailwind, and Contentlayer
- Building the Layout Shell: Navigation, Footer, and Shared Components
- Creating the Blog: MDX Content, Posts, and Category Filtering
- Polishing the Experience: Animations, Loading States, and Accessibility
- Adding Video Support: YouTube Integration and Feature Flags
- Integrating Tweets: Twitter API, Seed Data, and the Unicode Page
- Theming the Site: CSS Variables, Theme Presets, and a Settings Page
- Going Multilingual: English and Chinese Content with Language Switching
- The Backend: Prisma, API Routes, Metrics, and the Like Button
Welcome back to "Built My Personal Website with Next.js". In Part 1, I walked through the project setup, tooling, and overall architecture. Now it is time to start building actual components.
This post covers Phase 1: Layout Shell — the shared visual skeleton that wraps every page. By the end of this phase we will have a noise overlay, a gradient background, a glass navigation bar, a profile image, a footer, and custom SVG icons. Three tasks, zero content pages, and a whole lot of visual polish.
Why start with the layout? Because every page depends on it. If you build pages first and bolt on a layout later, you end up refactoring every single component to fit the new wrapper. Starting with the layout means everything that comes after slots into a known structure. It is the same reason you pour a foundation before building walls.
Let us get into it.
Task 1: Layout, Constants, and Types
The layout is the backbone of the entire site. Every single page renders inside it. It handles the visual layer stack — noise overlay on top, gradient background behind everything, and a content grid in the middle. If the layout breaks, everything breaks. So this is where we start.
The Layout Component
I wanted the site to feel layered and textured, not flat. That meant stacking multiple visual elements with careful z-index management. Here is the rough mental model:
z-50 → noise overlay (pointer-events-none, so it never blocks interaction)
z-20 → navigation bar
z-10 → content grid
z-0 → gradient background blobsThe full Layout.tsx component is essentially a wrapper that assembles these layers in order. The structure looks like this:
// Layout.tsx (simplified)
export function Layout({ children }: { children: React.ReactNode }) {
return (
<>
{/* Layer 1: Noise texture on top of everything */}
<NoiseOverlay />
{/* Layer 2: Gradient background blobs */}
<div className="pointer-events-none fixed inset-0 overflow-hidden">
<GradientBlob variant={1} />
<GradientBlob variant={2} />
</div>
{/* Layer 3: Content grid */}
<div className="content-grid">
<Navigation />
<main>{children}</main>
<Footer />
</div>
</>
)
}Notice the order matters. The noise overlay is a sibling of the content, not a parent. This is intentional — if it were a parent, it would create a new stacking context and the z-index layering would break. By keeping all layers as siblings inside a fragment, each one can independently target its z-level.
Let us build each layer.
The Noise Overlay
This is one of my favorite details on the site. It is a full-screen SVG that uses feTurbulence to generate a subtle film-grain texture. It sits on top of everything, gives the site character, and costs almost nothing in terms of performance.
<svg className="noise-overlay pointer-events-none fixed isolate z-50" width="100%" height="100%">
<filter id="noise-filter">
<feTurbulence type="fractalNoise" baseFrequency="0.80" numOctaves="4" stitchTiles="stitch" />
</filter>
<rect width="100%" height="100%" filter="url(#noise-filter)" />
</svg>A few things worth noting:
pointer-events-noneis critical. Without it the SVG would intercept every click and the site would be unusable. This is the single most important class on this element.fixedpositions it relative to the viewport so it covers everything regardless of scroll position.isolatecreates a new stacking context, keeping the z-index predictable and preventing it from bleeding into other stacking contexts on the page.baseFrequency="0.80"controls the grain density. I experimented with values from 0.2 to 1.0. Lower values gave large, blobby patterns that looked more like clouds. Higher values created tight, sharp noise. 0.80 gives the right balance — visible enough to notice, subtle enough not to distract.numOctaves="4"adds detail layers to the noise. More octaves mean more visual complexity. Four is the sweet spot — below that the noise looks too simple, above that you are adding computation for diminishing returns.stitchTiles="stitch"prevents seams when the pattern tiles across the viewport. Without it, you would see visible edges where the noise pattern repeats.
The CSS for the overlay uses mix-blend-mode and opacity to soften it:
.noise-overlay {
opacity: 0.05;
mix-blend-mode: overlay;
pointer-events: none;
}The mix-blend-mode: overlay is doing the real magic here. It multiplies the dark areas and screens the light areas of the noise against whatever is underneath. At 5% opacity, the result is a barely-perceptible texture that adds warmth without interfering with readability.
I tried a few other blend modes during development. soft-light was too subtle. multiply darkened everything too much. overlay at low opacity hit the sweet spot.
If you have never used SVG filters before, MDN has a great feTurbulence reference. The fractalNoise type generates Perlin noise, which is exactly the organic randomness we want.
The Gradient Background
Behind the content sits a pair of soft gradient blobs that drift slowly across the page. They use the --accent CSS custom property so the color adapts when you switch themes.
<div
className="gradient-blob absolute -left-1/4 -top-1/4 h-full w-full rounded-full"
style={{
background: `radial-gradient(ellipse at center, hsl(var(--accent) / 0.15), transparent 70%)`,
filter: "blur(120px)",
}}
/>Each blob has a different animation applied via CSS — one drifts clockwise, the other counter-clockwise. The keyframes are intentionally slow (20-30 second durations) so the movement feels ambient rather than distracting.
@keyframes blob-drift-1 {
0%,
100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(30px, -50px) scale(1.05);
}
66% {
transform: translate(-20px, 20px) scale(0.95);
}
}
@keyframes blob-drift-2 {
0%,
100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(-40px, 30px) scale(0.95);
}
66% {
transform: translate(25px, -35px) scale(1.05);
}
}The two blobs use opposing keyframe values — when one moves left, the other moves right. This creates a subtle push-pull effect that feels organic. The scale oscillation (between 0.95 and 1.05) adds a breathing quality.
The blur(120px) is doing a lot of heavy lifting here. Without it you would see hard-edged circles. With it, the blobs melt into soft washes of color that feel like they belong in the background. I also considered using CSS filter: blur() on the parent container, but that would blur the content too. Applying blur per-blob keeps the content crisp.
The Content Grid
The layout uses a clever CSS Grid technique to create a 3-column layout where the center column is capped at 640px and the side columns act as gutters:
<div
className={cx(
"relative z-10 grid grid-cols-[1fr,min(640px,100%),1fr]",
"gap-y-8 px-4 pt-48",
"font-sans text-base leading-[1.75] text-foreground/90",
"xl:grid-cols-[1fr,minmax(auto,240px),min(640px,100%),minmax(auto,240px),1fr]",
"xl:gap-x-9 xl:px-0",
)}
>This looks dense, so let me unpack it piece by piece.
Base layout (mobile and up):
grid-cols-[1fr,min(640px,100%),1fr]— three columns. The center column usesmin(640px, 100%)which means "640px wide, but never wider than the available space." On a 375px phone, the center column takes the full width. On a 1200px desktop, it is capped at 640px. The side columns (1fr) absorb the remaining space equally.gap-y-8— vertical gap between rows (32px). No horizontal gap on the base layout because the side columns are just empty space.px-4— horizontal padding (16px) on each side. This prevents content from touching the viewport edges on small screens.pt-48— a generous top padding (192px). The hero section needs this because the navigation bar is positioned absolutely at the top of the page. Without this padding, the hero content would be hidden behind the nav.leading-[1.75]— line height of 1.75. This is slightly more generous than the browser default (1.2) and makes long-form reading much more comfortable.
XL layout (1280px and up):
xl:grid-cols-[1fr,minmax(auto,240px),min(640px,100%),minmax(auto,240px),1fr]— five columns. The two new columns (minmax(auto, 240px)) sit on either side of the center column and hold sidebar content like a table of contents or related links. They grow with their content but are capped at 240px.xl:gap-x-9— horizontal gap between columns (36px).xl:px-0— removes the horizontal padding on large screens because the side columns provide their own spacing.
This approach is much cleaner than using max-width and margin: auto on a single column. The grid handles alignment, spacing, and responsive behavior all in one declaration.
Constants
I extracted shared design tokens into a constants.ts file. This keeps the Tailwind classes consistent across components and avoids the "copy-paste a slightly different hover style everywhere" problem.
// constants.ts
export const FOCUS_VISIBLE_OUTLINE = cx(
"focus-visible:outline focus-visible:outline-2",
"focus-visible:outline-offset-2 focus-visible:outline-primary",
)
export const LINK_STYLES = cx(
"font-medium text-foreground/90 underline decoration-foreground/30",
"underline-offset-2 transition-colors duration-150",
"hover:decoration-foreground/60",
FOCUS_VISIBLE_OUTLINE,
)
export const LINK_SUBTLE_STYLES = cx(
"text-foreground/60 underline decoration-foreground/20",
"underline-offset-2 transition-colors duration-150",
"hover:text-foreground/90 hover:decoration-foreground/40",
FOCUS_VISIBLE_OUTLINE,
)
export const HEADING_LINK_ANCHOR = cx(
"ml-1 opacity-0 transition-opacity duration-150",
"group-hover:opacity-100 group-focus-visible:opacity-100",
)A few design decisions here:
FOCUS_VISIBLE_OUTLINEuses thefocus-visiblepseudo-class so the outline only appears on keyboard navigation, not on mouse clicks. This is an accessibility win — keyboard users get a clear focus indicator, and mouse users get a clean look. I considered using:focusinstead, but that shows the outline on every click, which looks noisy.LINK_STYLESandLINK_SUBTLE_STYLESare two tiers of link styling. The main style is for in-content links that should stand out. The subtle style is for secondary references that should be discoverable but not shout. The difference is primarily in opacity —text-foreground/90vstext-foreground/60.HEADING_LINK_ANCHORis the#link icon that appears next to headings on hover. It usesgroup-hoverso it fades in when you hover the parent heading element. This pattern is borrowed from GitHub's markdown rendering.
I also added cx() here — it is a thin utility that merges class names without conflicts. Think of it as a local alternative to clsx or classnames. If you are using Tailwind, you have probably run into the problem of conditional classes being concatenated as strings and not deduplicating properly. cx() handles that.
Here is what cx() looks like internally:
// cx.ts
export function cx(...args: (string | boolean | undefined | null)[]) {
return args.filter(Boolean).join(" ")
}It is intentionally simple. It joins truthy values with a space. No class name parsing, no Tailwind-specific deduplication. It works because Tailwind handles specificity at the CSS level — later classes in the same string override earlier ones when they target the same property.
Types
Finally, a small types.ts file for shared TypeScript types:
// types.ts
export type CurrentFilters = {
tag?: string
category?: string
}This type is used by the blog index and tag pages to represent the current filter state in the URL. Keeping it in a shared file means both the page components and the filter UI can import from the same source without circular dependencies.
You might wonder why this is not just an inline type. The reason is reuse. The blog index page reads filters from the URL, the filter UI components set them, and the tag pages derive them from the route. Three different files, one shared type. If the shape changes (say we add a year filter), we update it in one place and TypeScript tells us everywhere that needs updating.
Task 2: Navigation and ProfileImage
With the layout shell in place, it is time to build the two components that give the site its personality — the floating navigation bar and the profile image.
Navigation
The navigation is a floating glass bar that appears when you scroll past the hero section. It has icon-based nav items with active states, a language toggle, and a theme toggle. It is the most interactive component in the layout shell, so let us break it down piece by piece.
The Glass Effect
First, the visual treatment. I wanted the nav to feel like it is floating above the content — translucent, blurred, with a subtle shadow:
<div
className={cx(
"pointer-events-auto col-start-2 -mx-px",
"rounded-2xl bg-background/70 px-4 py-2.5",
"shadow-surface-glass backdrop-blur will-change-transform",
)}
>The key ingredients:
bg-background/70— 70% opacity on the background color. This lets the gradient blobs show through slightly, creating the translucent feel.backdrop-blur— applies a Gaussian blur to whatever is behind the nav. This is the "glass" effect. The Tailwind class maps tobackdrop-filter: blur(8px).shadow-surface-glass— a custom shadow token that uses the accent color at low opacity, giving the shadow a colored tint rather than plain gray. This makes the shadow feel integrated with the design rather than bolted on.will-change-transform— a performance hint for the browser. Since the nav slides in and out on scroll, this tells the browser to promote it to its own compositor layer, avoiding repaints during the animation.rounded-2xl— a generous border radius (16px) that softens the edges and reinforces the floating card aesthetic.-mx-px— a negative margin trick that extends the nav by 1px on each side. This prevents a hairline gap between the nav and the grid column edge on certain zoom levels.
If you want to learn more about backdrop filters, CSS-Tricks has an excellent guide to backdrop-filter. Just be aware of browser support — it works in all modern browsers, but older Safari versions need the -webkit- prefix.
Icon Resolution Pattern
The nav items use icons, but I wanted the icon set to be swappable. Different sections of the site might use different icon libraries (Lucide React for general icons, Heroicons for specific cases). Here is how I handle that:
const iconSets = {
lucide: {
video: Video,
post: Newspaper,
twitter: TwitterIcon,
youtube: YoutubeIcon,
},
heroicons: {
video: VideoCameraIcon,
post: AnnotationIcon,
twitter: TwitterIcon,
youtube: YoutubeIcon,
},
}Each nav item in the config specifies which icon set to use, and the component resolves the actual icon component at render time. This keeps the config file clean (just a string like "lucide") and the rendering logic flexible.
The resolution happens at render time, not at import time:
function NavItem({ iconSet, icon, ...rest }) {
const Icon = iconSets[iconSet][icon]
// ...
}This pattern is sometimes called a "strategy map." It is a simple alternative to dependency injection that works well when the number of strategies is small and known at build time.
Feature Flag Filtering
Not every section of the site is always ready. Some pages are behind feature flags (the video section, the tweets section). The navigation filters these out dynamically:
{
siteConfig.nav
.filter((item) => {
if (item.href === "/videos" && !siteConfig.features.video) return false
if (item.href === "/tweets" && !siteConfig.features.tweets) return false
return true
})
.map((item) => <NavItem key={item.href} {...item} />)
}This is simple but effective. The siteConfig.features object acts as a single source of truth for what is enabled. When I am ready to launch the video section, I flip features.video to true and the nav link appears. No code changes needed.
I considered using environment variables for this, but feature flags in config are easier to test and do not require a rebuild to toggle during development. Environment variables also leak into the client bundle differently — config-based flags are tree-shaken naturally.
Nav Item with Active State
Each nav item has an active state ring that indicates the current page:
<Link
href={item.href}
className={cx(
"relative flex items-center gap-1.5 rounded-lg px-3 py-1.5",
"text-foreground/70 text-sm font-medium",
"transition-colors duration-150",
"hover:text-foreground/90 hover:bg-foreground/5",
isActive && "text-foreground bg-foreground/5 ring-foreground/10 ring-1",
FOCUS_VISIBLE_OUTLINE,
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>The active state combines three things: a stronger text color (text-foreground vs text-foreground/70), a subtle background fill (bg-foreground/5), and a thin ring (ring-1 ring-foreground/10). Together they create a pill-like highlight that clearly marks the current section without being heavy-handed.
The hover state uses bg-foreground/5 to match the active background, so the transition from "hovering" to "active" feels seamless rather than jarring. This is a small detail, but it matters. If the hover and active states used different backgrounds, there would be a visible flicker when you click an item.
The isActive check uses Next.js's usePathname() hook:
const pathname = usePathname()
const isActive = pathname.startsWith(item.href)Using startsWith instead of strict equality means that /blog/some-post still highlights the "Blog" nav item. This is important for nested routes — you want the parent section to stay highlighted when you are deep inside it.
ProfileImage
The profile image is used in the hero section and in the navigation bar (as a small avatar). It has a gradient ring border that uses the primary accent color.
<div
className={cx(
"from-primary/70 to-primary/40 rounded-full bg-gradient-to-tl",
"ring/10 shadow-lg",
{
"p-[2px]": size === "small",
"p-[3px]": size === "large",
},
)}
>
<Image
src="/images/profile.jpg"
alt="Profile photo"
width={size === "small" ? 36 : 128}
height={size === "small" ? 36 : 128}
className="rounded-full"
priority
/>
</div>A few details worth calling out:
bg-gradient-to-tl from-primary/70 to-primary/40creates a diagonal gradient from a stronger to a weaker shade of the accent color. Theto-tldirection means top-left, so the gradient flows from the top-left corner downward and to the right. This gives the ring a subtle sense of depth — it is not a flat color.p-[2px]vsp-[3px]— the padding creates the "ring" effect. The outer div has the gradient background, and the inner image sits on top of it with a small gap. A 2px gap at 36px diameter looks proportional; at 128px diameter, 3px feels right. This is the kind of detail that takes a few iterations to get right — I tried 1px, 2px, 3px, and 4px at both sizes before settling on these values.priorityon theImagecomponent tells Next.js to preload this image. Since the profile image is above the fold in the hero, we want it to load immediately rather than lazy-load. Withoutpriority, the image would flash in after the page hydrates.shadow-lgadds a drop shadow that lifts the image off the background. Combined with the gradient ring, it creates a badge-like effect.
The two sizes are intentional. The "small" variant is used in the navigation bar (36px) and the "large" variant is used in the hero section (128px). A single component with a size prop keeps the logic in one place.
Here is the full component for reference:
// ProfileImage.tsx
import Image from "next/image"
import { cx } from "~/lib/cx"
type ProfileImageProps = {
size: "small" | "large"
}
export function ProfileImage({ size }: ProfileImageProps) {
return (
<div
className={cx(
"from-primary/70 to-primary/40 rounded-full bg-gradient-to-tl",
"ring/10 shadow-lg",
{
"p-[2px]": size === "small",
"p-[3px]": size === "large",
},
)}
>
<Image
src="/images/profile.jpg"
alt="Profile photo"
width={size === "small" ? 36 : 128}
height={size === "small" ? 36 : 128}
className="rounded-full"
priority
/>
</div>
)
}Clean, focused, no branching logic beyond the size prop. This is the kind of component I like — it does one thing, does it well, and is easy to understand at a glance.
Responsive Behavior
The navigation adapts to screen size. On mobile, only the profile image and a hamburger menu are visible. On desktop, the full icon bar is shown. The transition between these states is handled by Tailwind's responsive prefixes:
<nav className="hidden items-center gap-1 md:flex">
{/* Full nav items */}
</nav>
<button className="md:hidden" aria-label="Open menu">
<MenuIcon className="h-5 w-5" />
</button>The hidden / md:flex pattern is one of the most common Tailwind responsive patterns. Content is hidden by default and shown at the md breakpoint (768px). The mobile menu button does the inverse — shown by default, hidden at md and above.
The mobile menu itself is a slide-down panel that appears below the nav bar when you tap the hamburger icon. It uses a simple state toggle:
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
// ...
{
isMobileMenuOpen && (
<div className="mt-2 flex flex-col gap-1 md:hidden">
{filteredNavItems.map((item) => (
<MobileNavItem key={item.href} {...item} />
))}
</div>
)
}I considered animating this with a slide-down transition, but decided against it. The menu is small (3-5 items) and the toggle is instant. Adding animation would mean managing enter/exit states, which adds complexity for minimal visual payoff. Keep it simple.
Scroll-Triggered Visibility
The navigation is not always visible. It slides in when you scroll past the hero section and slides out when you scroll back to the top. This keeps the hero clean and gives the nav a purposeful entrance.
The implementation uses IntersectionObserver to watch a sentinel element at the top of the page:
const sentinelRef = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
setIsVisible(!entry.isIntersecting)
},
{ threshold: 0 },
)
if (sentinelRef.current) {
observer.observe(sentinelRef.current)
}
return () => observer.disconnect()
}, [])When the sentinel scrolls out of view, entry.isIntersecting becomes false, and we set isVisible to true. The nav then animates in with a translate-y transition.
The animation class looks like this:
<div
className={cx(
"fixed top-4 z-20 w-full transition-transform duration-300 ease-in-out",
isVisible ? "translate-y-0" : "-translate-y-full",
)}
>
{/* Nav content */}
</div>When isVisible is false, the nav is translated upward by its full height (-translate-y-full), pushing it off-screen. When isVisible becomes true, it slides down to translate-y-0. The duration-300 and ease-in-out make the motion feel smooth without being slow.
This is much more performant than listening to scroll events. IntersectionObserver runs on the browser's compositor thread and does not trigger layout recalculations. If you are still using scroll listeners for this kind of thing, I highly recommend switching — here is the MDN IntersectionObserver guide.
One gotcha: the sentinel element needs to have actual dimensions. If it is a zero-height <div>, the observer may never trigger reliably. I give it a minimum height of 1px:
<div ref={sentinelRef} className="h-px" aria-hidden="true" />The aria-hidden="true" keeps it out of the accessibility tree since it is purely a visual trigger.
Task 3: Footer and Icons
The footer is the last thing a visitor sees, so it should be clean and useful. It serves two purposes: secondary navigation and tech credits.
The Footer Component
The footer at ui/Footer.tsx is straightforward — a row of links and a "Built with" line. But it follows the same feature flag pattern as the navigation:
<div className="flex space-x-5">
{siteConfig.features.video ? (
<Link href="/videos" className={cx(LINK_SUBTLE_STYLES, FOCUS_VISIBLE_OUTLINE)}>
Videos
</Link>
) : null}
<Link href="/posts" className={cx(LINK_SUBTLE_STYLES, FOCUS_VISIBLE_OUTLINE)}>
Posts
</Link>
<Link href="/categories" className={cx(LINK_SUBTLE_STYLES, FOCUS_VISIBLE_OUTLINE)}>
Categories
</Link>
{siteConfig.features.twitter ? (
<a
href={siteConfig.social.twitter.url}
className={cx(LINK_SUBTLE_STYLES, FOCUS_VISIBLE_OUTLINE)}
>
Twitter
</a>
) : null}
</div>The conditional rendering matches the navigation — features.video, features.twitter, features.youtube, features.github all control visibility. This means when you enable a feature flag, both the nav link AND the footer link appear together. Consistency.
The "Built with" line at the bottom is a nice touch — it credits the tech stack with links to each project:
<p className="text-muted-foreground/60 mt-8">
Built with <a href="https://nextjs.org">Next.js</a>, <a href="https://mdxjs.com">MDX</a>,{" "}
<a href="https://tailwindcss.com">Tailwind</a> and <a href="https://vercel.com">Vercel</a>
</p>The text-muted-foreground/60 keeps it visually subordinate — you can see it if you look for it, but it does not compete with the navigation links above.
Custom SVG Icons
For the Twitter and YouTube icons, I created simple SVG components rather than pulling in an icon library. At 7 lines each, they are lighter than importing from @heroicons/react or lucide-react:
// TwitterIcon.tsx
export default function TwitterIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
)
}The YouTube icon follows the same pattern — a single SVG path, accepting a className prop for styling. The fill="currentColor" means the icon inherits its color from the parent element, which is how the hover states work (the parent changes text color, and the icon follows).
These icons are used in both the navigation bar and the footer. The Navigation component imports them alongside the Lucide and Heroicons icons, using the icon resolution pattern we built in Task 2.
Wrapping Up Phase 1
At this point we have a beautiful layout shell — but no content to put in it. The noise overlay adds texture, the gradient blobs add color, the glass nav floats elegantly on scroll, and the profile image ties the identity together.
It might seem like a lot of work for "just a layout," but this foundation pays dividends. Every future page inherits this visual layer automatically. The constants ensure consistency. The types prevent bugs. The feature flags let us ship incrementally.
Here is a summary of what we built in Phase 1:
| Component | Purpose |
|---|---|
Layout.tsx | Assembles all visual layers into a single wrapper |
NoiseOverlay | Full-screen SVG grain texture via feTurbulence |
GradientBlob | Soft drifting color washes that adapt to theme |
Navigation.tsx | Glass floating bar with icons, active states, feature flags |
ProfileImage.tsx | Gradient-ringed avatar in two sizes |
Footer.tsx | Secondary navigation with feature flag links and tech credits |
TwitterIcon.tsx | Custom SVG Twitter/X icon |
YoutubeIcon.tsx | Custom SVG YouTube icon |
constants.ts | Shared focus, link, and heading styles |
types.ts | Shared TypeScript types for filters |
Phase 2 changes that. In the next post, we will build the actual content components — blog cards, MDX rendering, and the blog index page. The layout shell finally gets something to hold.
See you there.
- Setting Up the Foundation: Next.js, Tailwind, and Contentlayer
- Building the Layout Shell: Navigation, Footer, and Shared Components
- Creating the Blog: MDX Content, Posts, and Category Filtering
- Polishing the Experience: Animations, Loading States, and Accessibility
- Adding Video Support: YouTube Integration and Feature Flags
- Integrating Tweets: Twitter API, Seed Data, and the Unicode Page
- Theming the Site: CSS Variables, Theme Presets, and a Settings Page
- Going Multilingual: English and Chinese Content with Language Switching
- The Backend: Prisma, API Routes, Metrics, and the Like Button