How this site was designed
The CMS pipeline is documented elsewhere. This post is about the design and UX decisions — why each feature exists, and how it was built.
The starting point: nav.al/archive
The main inspiration is Naval Ravikant's archive page. Black. White. Text. Nothing else.
The principle: if you strip away everything that isn't text, is the information still readable? Here, the answer is yes.
Full palette:
- Background:
#090909 - Text:
#f0f0f0 - Meta:
#888888 - Separators:
#1e1e1e/#333333
Single typeface: Jost, weights 300/400/500. Monospace: JetBrains Mono, code only.
The homepage tagline
The first version just showed a list of posts. No context. No invitation.
The current version borrows from the Twitter style: a short sentence that establishes identity, a context subline, and an explicit invitation to understand why this site exists.
Je construis.
J'écris ce que j'apprends.
Builder SaaS · Cotonou, Bénin · Marché africain francophone
Journal public d'un dev qui fait, pas qui enseigne.
Minimalist. No hero image. No CTA button.
The "load more" feed
The initial problem: the homepage showed every post at once. No pagination. No end. Just a list that kept growing.
Solution: PostFeed is a client component with PAGE_SIZE = 12. It shows 12 posts, then a "Load more" button that loads 12 more, with a fade-in animation on each new batch.
const PAGE_SIZE = 12;
const [visible, setVisible] = useState(PAGE_SIZE);No pagination. No route. Just local state.
List previews
Every post in a list now shows an excerpt. getExcerpt() in lib/content.ts:
- Uses the frontmatter
descriptionif it exists - Otherwise, strips headings, code blocks, links, markdown — takes the first 130 characters
That stripping matters. An excerpt starting with ## Introduction or `const x = ` gives you no information at all. Only the raw text counts.
Search
Architectural challenge: Nav is a Server Component. SearchModal is a Client Component. They can't share state directly.
Solution: decoupling via custom events.
// NavSearchTrigger.tsx (client)
window.dispatchEvent(new CustomEvent("open-search"));
// SearchModal.tsx (client)
window.addEventListener("open-search", () => setOpen(true));The modal also listens for ⌘K / Ctrl+K and Escape.
The search index comes from an /api/search route that returns every post with its title, description, excerpt, and tags. Search runs client-side over that JSON — no need for Algolia for 50 posts.
Framer Motion bug: the modal used left: 50%; transform: translateX(-50%) for centering. Framer Motion overrides the inline transform with its own animation properties (y, scale). The fix: left: 0; right: 0; margin-inline: auto. Zero CSS transform — Framer Motion gets to do whatever it wants.
The animations
Stack: Framer Motion v12 (motion/react).
Page enter/exit
app/template.tsx — Next.js re-renders this file on every navigation.
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.28, ease: [0.25, 0.1, 0.25, 1] }}
>With a useEffect to reset the scroll to the top of the page on every navigation:
useEffect(() => {
window.scrollTo({ top: 0, behavior: "instant" });
}, []);The ↑ button
Circular, fixed at the bottom center (left: 0; right: 0; margin-inline: auto), appears after 400px of scroll. AnimatePresence handles enter/exit.
The FAB navigation
Three circular buttons at the bottom right: search, home, links. The links open in a bottom sheet with a spring animation (stiffness: 380, damping: 38).
The "See also" widget
On articles, a floating translucent widget shows related articles. It appears once the article header scrolls out of the viewport — detected with an IntersectionObserver.
const observer = new IntersectionObserver(
([entry]) => setVisible(!entry.isIntersecting),
{ threshold: 0 }
);
observer.observe(headerEl);The widget is collapsible. It remembers its open/closed state in local state.
View counter and likes
Why not node:sqlite: the built-in node:sqlite has existed since Node.js 22.5. Vercel runs on Node.js 18/20. Incompatible in production.
Solution: Turso (@libsql/client). SQLite-compatible, pure JS, free for a personal site. Locally: file:.data/site.db. In production: libsql://name.turso.io.
Minimal schema:
CREATE TABLE IF NOT EXISTS stats (
slug TEXT PRIMARY KEY,
views INTEGER DEFAULT 0,
likes INTEGER DEFAULT 0
)API routes:
GET /api/views/[slug]— counts a view + returns the totalGET /api/likes/[slug]— reads the like countPOST /api/likes/[slug]— toggles like/unlike
Fetch optimization: PostStats renders N times on a list page. A single /api/stats/all route returns everything in one request. The component uses a module-level cache:
let _cache: Record<string, Stats> | null = null;
let _promise: Promise<void> | null = null;N instances = 1 HTTP request.
Syntax highlighting
rehype-pretty-code with Shiki's one-dark-pro theme. keepBackground: true preserves the theme's background.
A language label appears at the top left of every code block:
.prose pre[data-language]::before {
content: attr(data-language);
/* ... */
}On mobile, code overflows the container slightly (margin-inline: -1.25rem) to use the full width of the screen.
What I'd do differently
Turso from the start. I first tried better-sqlite3 (failed — native bindings blocked by pnpm v10), then node:sqlite (failed — Node.js 22 only). Two detours to reach the obvious solution.
Test Framer Motion + CSS transform upfront. Resolving the transform: translateX(-50%) conflict took time. The rule is simple: never mix CSS transform and Framer Motion on the same element.
The whole site was built in a single Claude Code work session. The full stack, the animations, the database, the search — all from scratch.