there are a lot of ways to build a website in 2026.
you could reach for plain react and wire up your own router, your own data fetching, your own build pipeline. you could use a lighter meta-framework. you could go full vanilla and hand-roll everything.
or you could use next.js, which is what this blog is actually built with.
after using it across real projects, it's hard to go back. here's why.
it's not "react plus extras," it's a full application layer
plain react gives you components and state. that's it. everything else, routing, data fetching, bundling, server rendering, is on you to assemble from separate libraries that may or may not play nicely together.
next.js gives you all of that out of the box, already wired together correctly.
file-based routing is a good example. a file at app/[slug]/page.tsx automatically becomes a dynamic route, no router config, no manual path matching. this is the real page component behind every post on this blog:
// app/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
notFound();
}
const allPosts = await getAllPosts();
const currentIdx = allPosts.findIndex((p) => p.slug === slug);
const prevPost = currentIdx < allPosts.length - 1 ? allPosts[currentIdx + 1] : null;
const nextPost = currentIdx > 0 ? allPosts[currentIdx - 1] : null;
return (
<PostClientWrapper>
<article className="blog-article-content">
<CustomMarkdown content={post.content} />
</article>
</PostClientWrapper>
);
}one file. it handles routing, 404 handling, and the previous/next post logic for every entry in content/. no route table, no <Route path="/:slug"> boilerplate, no separate config file to keep in sync.
server components cut down what actually ships to the browser
this is the part that's genuinely hard to replicate outside of next.js.
with the app router, components are server components by default. they run on the server, fetch whatever data they need, and only send the resulting HTML to the browser, not the javascript required to fetch that data.
// lib/posts.ts, this is the actual function powering this blog's homepage
export async function getAllPosts(): Promise<PostData[]> {
if (!fs.existsSync(postsDirectory)) {
return [];
}
const fileNames = fs.readdirSync(postsDirectory);
const allPostsData = fileNames
.filter((fileName) => fileName.endsWith(".md"))
.map((fileName) => {
const slug = fileName.replace(/\.md$/, "");
const fullPath = path.join(postsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, "utf-8");
const { data, content } = matter(fileContents);
const rawTags = Array.isArray(data.tags) ? data.tags : [];
const readingTime = data.readingTime || calculateReadingTime(content);
return {
slug,
title: data.title || "Untitled Segment",
date: data.date || "2026-01-01",
summary: data.summary || "",
tags: rawTags.map((t: string) => t.toLowerCase().trim()),
cover: data.cover || null,
readingTime,
content,
pinned: data.pinned === true,
} as PostData;
});
return allPostsData.sort((a, b) => (a.date < b.date ? 1 : -1));
}that function reads every markdown file straight off disk, parses the frontmatter, and sorts by date, and it never ships to the browser. in a plain react app, this kind of logic either has to live behind a separate build step you wire up yourself, or gets pushed into the client where filesystem access doesn't even exist. in next.js, a server component just calls it directly.
only the components that actually need interactivity get marked as client components. the copy button on every code block in this blog is a good example of exactly how small that boundary can be:
// components/copyBtn.tsx
"use client";
import { useState } from "react";
import { Copy, Check } from "lucide-react";
export function CopyButton({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (err) {
console.error("Failed to copy code:", err);
}
};
return (
<button onClick={handleCopy} aria-label={copied ? "Copied" : "Copy code"}>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
);
}that's the entire mental model: server by default, client when you explicitly need it. less javascript reaches visitors, pages load faster, and there's no separate backend project to maintain alongside the frontend.
rendering strategy is a choice, not a rewrite
plain react apps are client-rendered by default. if you decide later that you need server-side rendering or static generation for SEO or performance, that's usually a significant rewrite, or a migration to a framework like next.js.
next.js lets you pick per-route, and switch later without ripping anything out. static generation:
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}that alone pre-renders every blog post at build time, so pages are served as static HTML instantly, with zero server work per request. try adding that to a create-react-app project without reaching for a different framework entirely.
it can be entirely static, and still gets everything else for free
this blog doesn't have a database or a login system behind it. every post is just a markdown file in content/, and generateStaticParams (the same function from the routing example above) turns that into a fully pre-rendered set of HTML pages at build time. no server has to run per request, no origin database gets hit when a visitor loads a post.
that's the part that's easy to miss about next.js: going static doesn't mean giving up the rest of the framework. routing, the server/client component split, image and font handling, all of it still applies, it just runs at build time instead of on a live server. a plain react setup (create-react-app, vite) gets you a client-rendered single bundle by default. getting to fully pre-rendered pages per-route means bolting on a separate static site generator. next.js does it with one function you were probably already going to write anyway.
typescript is where it gets even better
next.js and typescript were basically built for each other, and using them together closes a lot of gaps that plain javascript leaves wide open.
take the reading time calculator from this blog:
export interface PostData {
slug: string;
title: string;
date: string;
summary: string;
tags: string[];
cover?: string;
readingTime?: string;
content: string;
pinned?: boolean;
}
function calculateReadingTime(content: string): string {
const wordsPerMinute = 200;
const cleanContent = content.replace(/[#*`\-_\\[\]()]/g, "").trim();
const wordCount = cleanContent.split(/\s+/).filter(Boolean).length;
const minutes = Math.floor(wordCount / wordsPerMinute);
return `${minutes}m read`;
}because PostData is a defined interface, every place that touches a post, the homepage list, the individual post page, the prev/next navigation, knows exactly what fields exist and what type each one is. rename summary to description and typescript will immediately show you every file that breaks, before you ever run the app.
next.js takes this further with automatic route typing. params, search params, and even generateStaticParams return types are checked against how you actually use them:
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params; // typed as string, guaranteed
...
}miss an await, misspell slug, or return the wrong shape from generateStaticParams, and the build fails with a clear error instead of a runtime crash a user discovers for you.
compare that to plain javascript, where a typo like post.summry silently returns undefined and the bug doesn't surface until something breaks in production. typescript turns an entire category of bugs into build-time errors, and next.js's tooling, its ESLint config, its typed Link component, is built specifically to take advantage of that.
the tradeoffs are worth being honest about
next.js isn't free of downsides. the app router has a learning curve, especially the server/client component split. build times can get slower on very large projects. and because next.js is opinionated, projects that need a completely custom architecture sometimes fight the framework instead of working with it.
for most projects, blogs, dashboards, marketing sites, small-to-medium apps, those tradeoffs are small compared to what you get back: routing, a rendering strategy that scales from fully static to fully dynamic without a rewrite, and a typescript-first developer experience, all working together instead of duct-taped from five different packages.
conclusion
plain react gives you a UI library. next.js gives you an application framework built around that UI library, with routing and rendering strategy already solved, whether that ends up static, server-rendered, or a mix of both.
add typescript on top, and you get compile-time guarantees across the entire stack instead of just hoping the javascript holds together.
this blog runs on exactly that stack. the code samples above aren't hypothetical, they're pulled straight from how it actually works.
