diff --git a/assets/blog/cover-1.svg b/assets/blog/cover-1.svg
new file mode 100644
index 00000000..40f37a18
--- /dev/null
+++ b/assets/blog/cover-1.svg
@@ -0,0 +1,14 @@
+
diff --git a/assets/blog/cover-2.svg b/assets/blog/cover-2.svg
new file mode 100644
index 00000000..6f4ae860
--- /dev/null
+++ b/assets/blog/cover-2.svg
@@ -0,0 +1,14 @@
+
diff --git a/assets/blog/cover-3.svg b/assets/blog/cover-3.svg
new file mode 100644
index 00000000..6aa96998
--- /dev/null
+++ b/assets/blog/cover-3.svg
@@ -0,0 +1,15 @@
+
diff --git a/assets/blog/cover-4.svg b/assets/blog/cover-4.svg
new file mode 100644
index 00000000..dded0fd1
--- /dev/null
+++ b/assets/blog/cover-4.svg
@@ -0,0 +1,15 @@
+
diff --git a/components/Blog/Byline/index.jsx b/components/Blog/Byline/index.jsx
new file mode 100644
index 00000000..d4ace2b0
--- /dev/null
+++ b/components/Blog/Byline/index.jsx
@@ -0,0 +1,83 @@
+import classNames from 'classnames';
+import AvatarGroup from '@node-core/ui-components/Common/AvatarGroup';
+import Avatar from '@node-core/ui-components/Common/AvatarGroup/Avatar';
+
+import styles from './index.module.css';
+
+const fullDate = new Intl.DateTimeFormat('en-US', {
+ day: 'numeric',
+ month: 'short',
+ timeZone: 'UTC',
+ year: 'numeric',
+});
+
+const shortDate = new Intl.DateTimeFormat('en-US', {
+ day: 'numeric',
+ month: 'short',
+ timeZone: 'UTC',
+});
+
+/**
+ * ui-components avatar descriptor for a GitHub username. `image` pulls the
+ * profile picture straight from GitHub; `url` is omitted on purpose so the
+ * Avatar renders as a `
` rather than an `
` (a post card already wraps
+ * the byline in a link, and nested anchors are invalid).
+ */
+const toAvatar = username => ({
+ nickname: username,
+ name: username,
+ image: `https://github.com/${username}.png`,
+ fallback: username.slice(0, 2).toUpperCase(),
+});
+
+/** Readable author label: one name, "A & B", or "A & N others". */
+const authorLabel = authors => {
+ if (authors.length <= 1) return authors[0] ?? 'webpack';
+ if (authors.length === 2) return `${authors[0]} & ${authors[1]}`;
+ return `${authors[0]} & ${authors.length - 1} others`;
+};
+
+/**
+ * GitHub avatar(s) of the post author(s) beside the author name(s) and the
+ * publish date. A single author renders one {@link Avatar}; several render an
+ * {@link AvatarGroup}. `size="md"` stacks the name above the date (featured
+ * card); `size="sm"` is a compact inline row (grid card).
+ *
+ * @param {{
+ * authors: string[],
+ * date: string,
+ * size?: 'sm'|'md',
+ * className?: string,
+ * }} props
+ */
+export default function Byline({ authors, date, size = 'sm', className }) {
+ const format = size === 'md' ? fullDate : shortDate;
+ const avatarSize = size === 'md' ? 'medium' : 'small';
+
+ return (
+
+ {CLIENT && authors.length > 0 && (
+
+ {authors.length === 1 ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {authorLabel(authors)}
+
+ ·
+
+
+
+
+ );
+}
diff --git a/components/Blog/Byline/index.module.css b/components/Blog/Byline/index.module.css
new file mode 100644
index 00000000..b9501492
--- /dev/null
+++ b/components/Blog/Byline/index.module.css
@@ -0,0 +1,66 @@
+@reference "../../../styles/index.css";
+
+.byline {
+ @apply flex
+ items-center
+ gap-3;
+}
+
+.avatars {
+ @apply inline-flex
+ shrink-0
+ items-center;
+}
+
+.meta {
+ @apply flex
+ min-w-0
+ items-center
+ gap-1.5;
+}
+
+.who {
+ @apply truncate
+ font-semibold
+ text-neutral-900
+ dark:text-white;
+}
+
+.date {
+ @apply shrink-0
+ font-mono
+ text-neutral-500
+ dark:text-neutral-400;
+}
+
+.sep {
+ @apply text-neutral-300
+ dark:text-neutral-600;
+}
+
+.sm {
+ @apply gap-2.5;
+}
+
+.sm .who,
+.sm .date {
+ @apply text-[13px];
+}
+
+.md .meta {
+ @apply flex-col
+ items-start
+ gap-0.5;
+}
+
+.md .sep {
+ @apply hidden;
+}
+
+.md .who {
+ @apply text-sm;
+}
+
+.md .date {
+ @apply text-[13px];
+}
diff --git a/components/Blog/CategoryFilter/index.jsx b/components/Blog/CategoryFilter/index.jsx
new file mode 100644
index 00000000..b2286cbc
--- /dev/null
+++ b/components/Blog/CategoryFilter/index.jsx
@@ -0,0 +1,37 @@
+import classNames from 'classnames';
+
+import styles from './index.module.css';
+
+const ALL = 'all';
+
+/**
+ * Category chips rendered as links. Selecting one navigates to `?category=…`
+ * (and back to `/blog` for "All posts"), which the layout reads from the URL.
+ *
+ * @param {{
+ * categories: string[],
+ * active: string,
+ * hrefFor: (category: string) => string,
+ * }} props
+ */
+export default function CategoryFilter({ categories, active, hrefFor }) {
+ const chips = [
+ { key: ALL, label: 'All posts' },
+ ...categories.map(category => ({ key: category, label: category })),
+ ];
+
+ return (
+
+ {chips.map(({ key, label }) => (
+
+ {label}
+
+ ))}
+
+ );
+}
diff --git a/components/Blog/CategoryFilter/index.module.css b/components/Blog/CategoryFilter/index.module.css
new file mode 100644
index 00000000..e3279570
--- /dev/null
+++ b/components/Blog/CategoryFilter/index.module.css
@@ -0,0 +1,43 @@
+@reference "../../../styles/index.css";
+
+.filters {
+ @apply flex
+ flex-wrap
+ gap-2
+ pt-7;
+}
+
+.chip {
+ @apply cursor-pointer
+ rounded-full
+ border
+ border-transparent
+ bg-neutral-100
+ px-3.5
+ py-1.5
+ text-[13px]
+ font-semibold
+ text-neutral-600
+ no-underline
+ transition-colors
+ duration-150
+ hover:bg-neutral-200
+ hover:text-neutral-900
+ dark:bg-neutral-900
+ dark:text-neutral-300
+ dark:hover:bg-neutral-800
+ dark:hover:text-white;
+}
+
+.active {
+ @apply border-blue-200
+ bg-blue-100
+ text-blue-700
+ hover:bg-blue-100
+ hover:text-blue-700
+ dark:border-blue-900
+ dark:bg-blue-950
+ dark:text-blue-300
+ dark:hover:bg-blue-950
+ dark:hover:text-blue-300;
+}
diff --git a/components/Blog/Cover/index.jsx b/components/Blog/Cover/index.jsx
new file mode 100644
index 00000000..4abe8e99
--- /dev/null
+++ b/components/Blog/Cover/index.jsx
@@ -0,0 +1,31 @@
+import classNames from 'classnames';
+
+import Cube from '../../Icons/Webpack.jsx';
+
+import styles from './index.module.css';
+
+/**
+ * Geometric brand cover for a post. Draws the hairline pattern and the cube
+ * placeholder, layering the hero `image` over it when present. `tag` renders
+ * the category chip.
+ *
+ * @param {{
+ * image?: string|null,
+ * tag?: string|null,
+ * alt?: string,
+ * className?: string,
+ * }} props
+ */
+export default function Cover({ image, tag, alt = '', className }) {
+ return (
+
+ {tag && {tag}}
+
+
+
+ {image && (
+
+ )}
+
+ );
+}
diff --git a/components/Blog/Cover/index.module.css b/components/Blog/Cover/index.module.css
new file mode 100644
index 00000000..f3f5f1d8
--- /dev/null
+++ b/components/Blog/Cover/index.module.css
@@ -0,0 +1,68 @@
+@reference "../../../styles/index.css";
+
+.cover {
+ @apply relative
+ flex
+ items-end
+ overflow-hidden
+ rounded-lg
+ border
+ border-neutral-200
+ bg-neutral-100
+ dark:border-neutral-800
+ dark:bg-neutral-900;
+}
+
+.cover::before {
+ background-image: repeating-linear-gradient(
+ 135deg,
+ rgb(28 120 192 / 7%) 0 1px,
+ transparent 1px 14px
+ );
+ content: '';
+ inset: 0;
+ position: absolute;
+}
+
+.tag {
+ @apply absolute
+ left-3.5
+ top-3.5
+ z-20
+ inline-flex
+ items-center
+ rounded-full
+ bg-white
+ px-2.5
+ py-1
+ text-xs
+ font-bold
+ text-blue-700
+ shadow-sm
+ dark:bg-neutral-900
+ dark:text-blue-300;
+}
+
+.cube {
+ @apply pointer-events-none
+ absolute
+ -bottom-7
+ -right-7
+ z-0
+ block
+ w-36
+ opacity-[0.14];
+}
+
+.cube svg {
+ height: auto;
+ width: 100%;
+}
+
+.image {
+ @apply absolute
+ inset-0
+ z-10
+ size-full
+ object-cover;
+}
diff --git a/components/Blog/PostCard/index.jsx b/components/Blog/PostCard/index.jsx
new file mode 100644
index 00000000..efee9ffb
--- /dev/null
+++ b/components/Blog/PostCard/index.jsx
@@ -0,0 +1,34 @@
+import Byline from '../Byline/index.jsx';
+import Cover from '../Cover/index.jsx';
+
+import styles from './index.module.css';
+
+/**
+ * A post tile for the recent-posts grid: cover, category, title, optional
+ * excerpt, and a compact byline.
+ *
+ * @param {{ post: object }} props
+ */
+export default function PostCard({ post }) {
+ return (
+
+
+ {post.category && (
+ {post.category}
+ )}
+ {post.title}
+ {post.description && {post.description}
}
+
+
+ );
+}
diff --git a/components/Blog/PostCard/index.module.css b/components/Blog/PostCard/index.module.css
new file mode 100644
index 00000000..b75748c5
--- /dev/null
+++ b/components/Blog/PostCard/index.module.css
@@ -0,0 +1,59 @@
+@reference "../../../styles/index.css";
+
+.card {
+ @apply flex
+ h-full
+ flex-col
+ no-underline;
+}
+
+.cover {
+ @apply mb-4
+ aspect-[16/10]
+ transition-shadow
+ duration-150;
+}
+
+.card:hover .cover {
+ @apply shadow-md;
+}
+
+.category {
+ @apply mb-2
+ text-xs
+ font-bold
+ tracking-wide
+ uppercase
+ text-blue-600
+ dark:text-blue-400;
+}
+
+.title {
+ @apply m-0
+ mb-2
+ text-lg
+ font-semibold
+ leading-snug
+ tracking-tight
+ text-neutral-900
+ dark:text-white;
+}
+
+.card:hover .title {
+ @apply text-blue-700
+ dark:text-blue-300;
+}
+
+.excerpt {
+ @apply m-0
+ mb-4
+ text-sm
+ leading-relaxed
+ text-neutral-600
+ dark:text-neutral-300;
+}
+
+.foot {
+ @apply mt-auto
+ pt-1;
+}
diff --git a/components/Layout.jsx b/components/Layout.jsx
index 66c1b1c7..e06d5d6e 100644
--- a/components/Layout.jsx
+++ b/components/Layout.jsx
@@ -1,11 +1,15 @@
import DefaultLayout from '@node-core/doc-kit/src/generators/web/ui/components/Layout/index.jsx';
import HomeLayout from '../layouts/Home/index.jsx';
import SponsorsLayout from '../layouts/Sponsors/index.jsx';
+import BlogLayout from '../layouts/Blog/index.jsx';
+import PostLayout from '../layouts/Post/index.jsx';
import '../styles/index.css';
const LAYOUTS = {
home: HomeLayout,
sponsors: SponsorsLayout,
+ blog: BlogLayout,
+ post: PostLayout,
};
export default function Layout(props) {
diff --git a/layouts/Blog/index.jsx b/layouts/Blog/index.jsx
new file mode 100644
index 00000000..ee2e1e98
--- /dev/null
+++ b/layouts/Blog/index.jsx
@@ -0,0 +1,118 @@
+import { useEffect, useState } from 'react';
+
+import BasePagination from '@node-core/ui-components/Common/BasePagination';
+
+import NavBar from '../../components/NavBar.jsx';
+import Footer from '../../components/Footer/index.jsx';
+import CategoryFilter from '../../components/Blog/CategoryFilter/index.jsx';
+import PostCard from '../../components/Blog/PostCard/index.jsx';
+import data from '#theme/blog' with { type: 'json' };
+
+import styles from './index.module.css';
+
+const ALL = 'all';
+const PAGE_SIZE = 6;
+
+// Unique categories in first-appearance order (data is sorted newest-first).
+const CATEGORIES = [
+ ...new Set(data.map(post => post.category).filter(Boolean)),
+];
+
+const PAGINATION_LABELS = {
+ aria: 'Blog pages',
+ next: 'Next',
+ nextAriaLabel: 'Next page',
+ previous: 'Previous',
+ previousAriaLabel: 'Previous page',
+};
+
+/** Build a `/blog` URL carrying the active category and page (defaults omitted). */
+const hrefFor = ({ category, page }) => {
+ const params = new URLSearchParams();
+ if (category && category !== ALL) params.set('category', category);
+ if (page && page > 1) params.set('page', String(page));
+ const query = params.toString();
+ return query ? `/blog?${query}` : '/blog';
+};
+
+/**
+ * @param {{ metadata: object }} props
+ */
+export default function BlogLayout({ metadata }) {
+ const [{ category, page }, setView] = useState({ category: ALL, page: 1 });
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const requested = Number.parseInt(params.get('page'), 10);
+ setView({
+ category: params.get('category') ?? ALL,
+ page: Number.isInteger(requested) && requested > 1 ? requested : 1,
+ });
+ }, []);
+
+ const pool =
+ category === ALL ? data : data.filter(post => post.category === category);
+
+ const pageCount = Math.max(1, Math.ceil(pool.length / PAGE_SIZE));
+ const current = Math.min(Math.max(page, 1), pageCount);
+ const start = (current - 1) * PAGE_SIZE;
+ const visible = pool.slice(start, start + PAGE_SIZE);
+
+ const pages = Array.from({ length: pageCount }, (_, index) => ({
+ url: hrefFor({ category, page: index + 1 }),
+ }));
+
+ return (
+ <>
+
+
+
+
+
+
+
+ The webpack blog
+
+ Notes from the bundler.
+
+ Release notes, deep dives into the chunk graph, and engineering
+ write-ups from the webpack core team and the wider community.
+
+ hrefFor({ category: key, page: 1 })}
+ />
+
+
+
+ Recent posts
+
+ {visible.length > 0 ? (
+
+ {visible.map(post => (
+
+ ))}
+
+ ) : (
+ No posts in this category yet.
+ )}
+
+ {pageCount > 1 && (
+
+ `Page ${number}`}
+ labels={PAGINATION_LABELS}
+ />
+
+ )}
+
+
+
+
+
+ >
+ );
+}
diff --git a/layouts/Blog/index.module.css b/layouts/Blog/index.module.css
new file mode 100644
index 00000000..3452bca5
--- /dev/null
+++ b/layouts/Blog/index.module.css
@@ -0,0 +1,101 @@
+@reference "../../styles/index.css";
+
+.page {
+ @apply bg-white
+ text-neutral-900
+ dark:bg-neutral-950
+ dark:text-white;
+}
+
+.wrap {
+ @apply mx-auto
+ max-w-[1200px]
+ px-8
+ max-md:px-5;
+}
+
+.head {
+ @apply border-b
+ border-neutral-200
+ pb-10
+ pt-16
+ dark:border-neutral-800;
+}
+
+.eyebrow {
+ @apply m-0
+ mb-3.5
+ inline-flex
+ items-center
+ gap-2
+ text-sm
+ font-bold
+ tracking-wide
+ uppercase
+ text-blue-600
+ dark:text-blue-400;
+}
+
+.dot {
+ @apply inline-block
+ size-1.5
+ rounded-full
+ bg-blue-600
+ dark:bg-blue-400;
+}
+
+.title {
+ @apply m-0
+ mb-4
+ text-5xl
+ font-semibold
+ tracking-tight
+ text-neutral-900
+ max-sm:text-4xl
+ dark:text-white;
+}
+
+.deck {
+ @apply m-0
+ max-w-xl
+ text-lg
+ leading-relaxed
+ text-neutral-600
+ dark:text-neutral-300;
+}
+
+.recent {
+ @apply pb-20
+ pt-12;
+}
+
+.recentHead {
+ @apply m-0
+ mb-6
+ text-sm
+ font-bold
+ tracking-wider
+ uppercase
+ text-neutral-500
+ dark:text-neutral-400;
+}
+
+.grid {
+ @apply grid
+ grid-cols-1
+ gap-x-6
+ gap-y-7
+ sm:grid-cols-2
+ lg:grid-cols-3;
+}
+
+.empty {
+ @apply m-0
+ py-8
+ text-neutral-500
+ dark:text-neutral-400;
+}
+
+.pagination {
+ @apply mt-12;
+}
diff --git a/layouts/Post/index.jsx b/layouts/Post/index.jsx
new file mode 100644
index 00000000..94e8618c
--- /dev/null
+++ b/layouts/Post/index.jsx
@@ -0,0 +1,105 @@
+import Article from '@node-core/ui-components/Containers/Article';
+import BaseCrossLink from '@node-core/ui-components/Common/BaseCrossLink';
+
+import NavBar from '../../components/NavBar.jsx';
+import Footer from '../../components/Footer/index.jsx';
+import Byline from '../../components/Blog/Byline/index.jsx';
+import Cover from '../../components/Blog/Cover/index.jsx';
+import MetaBar from '#theme/Metabar';
+import posts from '#theme/blog' with { type: 'json' };
+
+import styles from './index.module.css';
+
+/**
+ * Article layout for a single blog post, mirroring the nodejs.org `Post` layout:
+ * navigation, a three-column shell (empty rail · article · meta bar), and the
+ * footer. The shell reuses doc-kit's {@link Article} container so the rendered
+ * markdown body gets the same grid, spacing, and typography as the API docs.
+ *
+ * A cover image opens the post; the header reuses {@link Byline} for the author
+ * avatars and publish date; the body (including its `# ` title) arrives as
+ * `children`. The right rail reuses doc-kit's MetaBar for the table of contents,
+ * reading time, and edit link. Previous/next cross-links close the article,
+ * walking the listing order (newest-first, so "previous" is the newer post).
+ *
+ * @param {{
+ * metadata: import('@node-core/doc-kit/src/generators/web/ui/types').SerializedMetadata
+ * & { authors?: string[], date?: string, category?: string },
+ * headings: Array