diff --git a/pygitweb/actions.py b/pygitweb/actions.py
index ea327b4..630e361 100644
--- a/pygitweb/actions.py
+++ b/pygitweb/actions.py
@@ -602,12 +602,28 @@ def _project_has_boards(project: str) -> bool:
 		return False
 
 
+BLOG_MANIFEST_PATH: str = "articles/manifest.json"
+
+
+def project_has_blog(project: str) -> bool:
+	try:
+		return get_blob_at_ref_path(project, "HEAD", BLOG_MANIFEST_PATH) is not None
+	except Exception:
+		return False
+
+
 class BoardLink(TypedDict):
 	href: str
 	text: str
 	style: str | None
 
 
+class BlogLink(TypedDict):
+	href: str
+	text: str
+	style: str | None
+
+
 def board_link_spec(project: str) -> BoardLink:
 	if _project_has_boards(project):
 		return {
@@ -623,6 +639,20 @@ def board_link_spec(project: str) -> BoardLink:
 	}
 
 
+def blog_link_spec(project: str) -> BlogLink:
+	if project_has_blog(project):
+		return {
+			"href": f"/project/{quote(project, safe='/')}/blog/",
+			"text": "view blog",
+			"style": None,
+		}
+	return {
+		"href": "#",
+		"text": "no blog",
+		"style": "color: #999; cursor: not-allowed;",
+	}
+
+
 def create_board_link(project: str) -> Cell:
 	href = "/board/create?project=" + quote(project, safe="")
 	if settings.AUTH:
@@ -656,6 +686,7 @@ def git_summary(project: str, extra_rows: list[list[Cell]] | None = None) -> HTM
 		project_url=f"/project/{project}",
 		project_url_enc=f"/project/{project_enc}",
 		board=board_link_spec(project),
+		blog=blog_link_spec(project),
 		hooks=_hooks_context(project, project_enc),
 		merge_request=_merge_request_context(project),
 		extra_rows=extra_rows or [],
@@ -706,6 +737,25 @@ def git_blob(project: str, h: str | None, f: str | None, *, raw: bool = False) -
 	)
 
 
+def git_blog_raw(project: str, path: str) -> Response:
+	"""Serve a blob from HEAD for the project blog viewer."""
+	if not is_valid_pathname(path):
+		raise HTTPException(status_code=404, detail="File not found")
+	result = get_blob_at_ref_path(project, "HEAD", path)
+	if not result:
+		raise HTTPException(status_code=404, detail="File not found")
+	blob, _ = result
+	data = blob.data
+	media_type, _ = mimetypes.guess_type(path.split("/")[-1])
+	if media_type is None:
+		try:
+			data.decode("utf-8")
+			media_type = "text/plain; charset=utf-8"
+		except UnicodeDecodeError:
+			media_type = "application/octet-stream"
+	return Response(content=data, media_type=media_type)
+
+
 def _blame_hunk_end_line(hunk: pygit2.BlameHunk) -> int:
 	return hunk.final_start_line_number + hunk.lines_in_hunk - 1
 
diff --git a/pygitweb/blog_routes_test.py b/pygitweb/blog_routes_test.py
new file mode 100644
index 0000000..c05734c
--- /dev/null
+++ b/pygitweb/blog_routes_test.py
@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+import json
+from collections.abc import Generator
+from pathlib import Path
+from unittest.mock import patch
+
+import pygit2
+import pytest
+from fastapi.testclient import TestClient
+from pygit2 import Signature
+
+from pygitweb.config import settings
+from pygitweb.main import app
+
+SIG = Signature("tester", "tester@example.com")
+
+
+def _commit_tree(repo: pygit2.Repository, repo_dir: Path, files: dict[str, str], message: str) -> None:
+	for rel, content in files.items():
+		path = repo_dir / rel
+		path.parent.mkdir(parents=True, exist_ok=True)
+		path.write_text(content, encoding="utf-8")
+	index = repo.index
+	for rel in files:
+		index.add(rel)
+	index.write()
+	tree = index.write_tree()
+	parents = [repo.head.target] if not repo.head_is_unborn else []
+	repo.create_commit("HEAD", SIG, SIG, message, tree, parents)
+
+
+@pytest.fixture
+def blog_env(tmp_path: Path) -> Generator[dict[str, str | TestClient], None, None]:
+	root = tmp_path / "blog-root"
+	repo_dir = root / "demo"
+	repo_dir.mkdir(parents=True)
+	repo = pygit2.init_repository(str(repo_dir), bare=False)
+	manifest = [
+		{
+			"name": "Hello Blog",
+			"url": "?article=articles/2024.01.01/post.md",
+			"tags": "intro test",
+			"created": "Jan 1 2024",
+			"preview": "",
+			"prev": "",
+			"next": "",
+		}
+	]
+	_commit_tree(
+		repo,
+		repo_dir,
+		{
+			"README.md": "seed\n",
+			"articles/manifest.json": json.dumps(manifest),
+			"articles/2024.01.01/post.md": "# Hello\n\nBody text.\n",
+			"assets/note.txt": "asset\n",
+		},
+		"add blog",
+	)
+	with (
+		patch.object(settings, "PROJECTROOT", str(root)),
+		patch.object(settings, "PROJECTS_LIST", str(root)),
+		patch.object(settings, "STRICT_EXPORT", False),
+		patch.object(settings, "EXPORT_OK", ""),
+		patch.object(settings, "AUTH", False),
+	):
+		yield {"project": "demo", "client": TestClient(app), "root": str(root)}
+
+
+@pytest.fixture
+def no_blog_env(tmp_path: Path) -> Generator[dict[str, str | TestClient], None, None]:
+	root = tmp_path / "no-blog-root"
+	repo_dir = root / "plain"
+	repo_dir.mkdir(parents=True)
+	repo = pygit2.init_repository(str(repo_dir), bare=False)
+	_commit_tree(repo, repo_dir, {"README.md": "no blog here\n"}, "initial")
+	with (
+		patch.object(settings, "PROJECTROOT", str(root)),
+		patch.object(settings, "PROJECTS_LIST", str(root)),
+		patch.object(settings, "STRICT_EXPORT", False),
+		patch.object(settings, "EXPORT_OK", ""),
+		patch.object(settings, "AUTH", False),
+	):
+		yield {"project": "plain", "client": TestClient(app)}
+
+
+def test_blog_page_renders_when_manifest_exists(blog_env: dict[str, str | TestClient]) -> None:
+	client = blog_env["client"]
+	assert isinstance(client, TestClient)
+	resp = client.get(f"/project/{blog_env['project']}/blog/")
+	assert resp.status_code == 200
+	assert 'id="blog-root"' in resp.text
+	assert 'data-manifest="articles/manifest.json"' in resp.text
+	assert f'data-content-base="/project/{blog_env["project"]}/blog/raw/"' in resp.text
+	assert "/static/blog.js" in resp.text
+
+
+def test_blog_page_404_without_manifest(no_blog_env: dict[str, str | TestClient]) -> None:
+	client = no_blog_env["client"]
+	assert isinstance(client, TestClient)
+	resp = client.get(f"/project/{no_blog_env['project']}/blog/")
+	assert resp.status_code == 404
+
+
+def test_blog_raw_serves_manifest_and_markdown(blog_env: dict[str, str | TestClient]) -> None:
+	client = blog_env["client"]
+	assert isinstance(client, TestClient)
+	project = blog_env["project"]
+	manifest_resp = client.get(f"/project/{project}/blog/raw/articles/manifest.json")
+	assert manifest_resp.status_code == 200
+	payload = manifest_resp.json()
+	assert payload[0]["name"] == "Hello Blog"
+
+	md_resp = client.get(f"/project/{project}/blog/raw/articles/2024.01.01/post.md")
+	assert md_resp.status_code == 200
+	assert "# Hello" in md_resp.text
+
+	asset_resp = client.get(f"/project/{project}/blog/raw/assets/note.txt")
+	assert asset_resp.status_code == 200
+	assert asset_resp.text == "asset\n"
+
+
+def test_blog_raw_rejects_missing_and_traversal(blog_env: dict[str, str | TestClient]) -> None:
+	client = blog_env["client"]
+	assert isinstance(client, TestClient)
+	project = blog_env["project"]
+	assert client.get(f"/project/{project}/blog/raw/missing.md").status_code == 404
+	assert client.get(f"/project/{project}/blog/raw/../README.md").status_code == 404
+
+
+def test_summary_includes_blog_link(blog_env: dict[str, str | TestClient]) -> None:
+	client = blog_env["client"]
+	assert isinstance(client, TestClient)
+	resp = client.get(f"/project/{blog_env['project']}")
+	assert resp.status_code == 200
+	assert ">Blog</td>" in resp.text or ">Blog<" in resp.text
+	assert f'href="/project/{blog_env["project"]}/blog/">view blog</a>' in resp.text
+
+
+def test_summary_shows_no_blog_when_missing(no_blog_env: dict[str, str | TestClient]) -> None:
+	client = no_blog_env["client"]
+	assert isinstance(client, TestClient)
+	resp = client.get(f"/project/{no_blog_env['project']}")
+	assert resp.status_code == 200
+	assert ">no blog</a>" in resp.text
+	assert 'style="color: #999; cursor: not-allowed;"' in resp.text
diff --git a/pygitweb/main.py b/pygitweb/main.py
index eb356ea..4c4e904 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -22,6 +22,7 @@ from fastapi.staticfiles import StaticFiles
 
 from pygitweb import __meta__
 from pygitweb.actions import (
+	BLOG_MANIFEST_PATH,
 	SearchFlagQuery,
 	SearchSortQuery,
 	SummaryRefsPayload,
@@ -33,6 +34,7 @@ from pygitweb.actions import (
 	git_blob,
 	git_blobdiff,
 	git_blobpatch,
+	git_blog_raw,
 	git_commit,
 	git_commitdiff,
 	git_heads,
@@ -50,6 +52,7 @@ from pygitweb.actions import (
 	git_tags,
 	git_tree,
 	parse_pagination,
+	project_has_blog,
 	summary_ref_options,
 	summary_ref_state,
 )
@@ -583,6 +586,31 @@ def project_board(
 	)
 
 
+@app.get("/project/{project:path}/blog/", response_class=HTMLResponse)
+def project_blog(project: ValidatedReadableProject) -> HTMLResponse:
+	"""Markdown blog viewer for repos with articles/manifest.json at HEAD."""
+	if not project_has_blog(project):
+		raise HTTPException(status_code=404, detail="Blog not found")
+	project_enc = quote(project, safe="/")
+	project_url = f"/project/{project_enc}"
+	blog_url = f"{project_url}/blog/"
+	return render_page(
+		"blog.html",
+		title=f"{project} - Blog",
+		project=project,
+		project_url=project_url,
+		blog_url=blog_url,
+		content_base=f"{blog_url}raw/",
+		manifest_path=BLOG_MANIFEST_PATH,
+	)
+
+
+@app.get("/project/{project:path}/blog/raw/{path:path}")
+def project_blog_raw(project: ValidatedReadableProject, path: str) -> Response:
+	"""Raw blob from HEAD for the blog viewer (markdown, manifest, assets)."""
+	return git_blog_raw(project, path)
+
+
 @app.get("/project/{project:path}/board-ref-manifest")
 def project_board_ref_manifest(
 	project: ValidatedReadableProject,
diff --git a/pygitweb/static/blog.js b/pygitweb/static/blog.js
new file mode 100644
index 0000000..67c16c8
--- /dev/null
+++ b/pygitweb/static/blog.js
@@ -0,0 +1,238 @@
+function contentUrl(contentBase, path) {
+	const clean = String(path || "").replace(/^\/+/, "");
+	return contentBase.replace(/\/?$/, "/") + clean;
+}
+
+function sortNum(a, b) {
+	return a - b;
+}
+
+function renderGraphs(root) {
+	if (typeof Viz !== "function") {
+		return;
+	}
+	for (const c of root.querySelectorAll("code.graphviz")) {
+		const p = document.createElement("p");
+		p.innerHTML = Viz(c.innerText, { format: "svg" });
+		p.className = "graph";
+		const pre = c.parentElement;
+		if (pre) {
+			pre.replaceWith(p);
+		}
+	}
+}
+
+function isRelativeResource(href) {
+	if (!href || href.charAt(0) === "#") {
+		return false;
+	}
+	if (href.charAt(0) === "/") {
+		return false;
+	}
+	if (/^[a-z][a-z0-9+.-]*:/i.test(href)) {
+		return false;
+	}
+	if (href.indexOf("//") === 0) {
+		return false;
+	}
+	if (href.charAt(0) === "?") {
+		return false;
+	}
+	return true;
+}
+
+function rewriteRelativeResources(root, contentBase) {
+	for (const img of root.querySelectorAll("img[src]")) {
+		const src = img.getAttribute("src");
+		if (src && isRelativeResource(src)) {
+			img.setAttribute("src", contentUrl(contentBase, src.replace(/^\.\//, "")));
+		}
+	}
+}
+
+function addLineNumbers(root) {
+	for (const block of root.querySelectorAll("code:not(.graphviz)")) {
+		const len = block.textContent.split("\n").length;
+		if (len <= 1) {
+			continue;
+		}
+		const lineref = [...Array(len).keys()].slice(1).join("\n");
+		const lineno = document.createElement("code");
+		lineno.className = "lineno";
+		lineno.textContent = lineref;
+		if (block.parentElement) {
+			block.parentElement.prepend(lineno);
+		}
+	}
+}
+
+function runWhenReady(tryFn, scriptId) {
+	try {
+		tryFn();
+	} catch (_ex) {
+		const script = document.querySelector("#" + scriptId);
+		if (script) {
+			script.addEventListener("load", tryFn);
+		}
+	}
+}
+
+async function loadmd(articleEl, contentBase, path) {
+	const converter = new showdown.Converter();
+	const resp = await fetch(contentUrl(contentBase, path));
+	if (!resp.ok) {
+		articleEl.innerHTML = "<p class=\"text-danger\">Failed to load article.</p>";
+		return;
+	}
+	const md = await resp.text();
+	articleEl.innerHTML = converter.makeHtml(md);
+	rewriteRelativeResources(articleEl, contentBase);
+	addLineNumbers(articleEl);
+	if (window.location.hash !== "") {
+		const target = articleEl.querySelector(window.location.hash);
+		if (target) {
+			target.scrollIntoView();
+		}
+	}
+	runWhenReady(() => {
+		if (typeof MathJax !== "undefined" && MathJax.typeset) {
+			MathJax.typeset();
+		}
+	}, "MathJax-script");
+	runWhenReady(() => {
+		if (typeof hljs !== "undefined" && hljs.highlightAll) {
+			hljs.highlightAll();
+		}
+	}, "Highlight-script");
+	runWhenReady(() => renderGraphs(articleEl), "GraphViz-script");
+}
+
+function archive(articleEl, manifest) {
+	const nestedByDate = {};
+	for (const item of manifest) {
+		const cdate = new Date(item.created);
+		const y = cdate.getFullYear();
+		const m = cdate.getMonth();
+		const d = cdate.getDate();
+		if (!nestedByDate[y]) {
+			nestedByDate[y] = {};
+		}
+		if (!nestedByDate[y][m]) {
+			nestedByDate[y][m] = {};
+		}
+		nestedByDate[y][m][d] = item;
+	}
+
+	let htmlStr = "";
+	let suffix = " open";
+	const dateOpts = { weekday: "long", year: "numeric", month: "long", day: "numeric" };
+	Object.keys(nestedByDate)
+		.sort(sortNum)
+		.reverse()
+		.forEach(function (y) {
+			htmlStr += `<blockquote><details${suffix}><summary>${y}</summary>`;
+			Object.keys(nestedByDate[y])
+				.sort(sortNum)
+				.reverse()
+				.forEach(function (m) {
+					const mName = new Date(2000, m, 1).toLocaleString("default", { month: "long" });
+					htmlStr += `<blockquote><details${suffix}><summary>${mName}</summary>`;
+					Object.keys(nestedByDate[y][m])
+						.sort(sortNum)
+						.reverse()
+						.forEach(function (d) {
+							const item = nestedByDate[y][m][d];
+							const dateStr = new Date(item.created).toLocaleString("default", dateOpts);
+							htmlStr += `<p>${dateStr}: <a href="${item.url}">${item.name}</a></p>`;
+						});
+					htmlStr += "</details></blockquote>";
+					suffix = "";
+				});
+			htmlStr += "</details></blockquote>";
+		});
+	articleEl.insertAdjacentHTML("beforeend", htmlStr);
+}
+
+function hideSearchResults(resultsEl) {
+	resultsEl.classList.remove("show");
+}
+
+function searchChange(searchEl, resultsEl, searchManifest) {
+	const s = searchEl.value;
+	resultsEl.innerHTML = "";
+	const q = s.toLowerCase();
+	for (const item of searchManifest) {
+		const name = String(item.name || "");
+		const tags = String(item.tags || "");
+		if (name.toLowerCase().includes(q) || tags.toLowerCase().includes(q)) {
+			const row = document.createElement("div");
+			row.className = "blog-search-result";
+			row.setAttribute("role", "option");
+			row.innerHTML =
+				`<div class="blog-search-result-title">${name}</div>` +
+				`<div class="blog-search-result-snippet">${tags}</div>` +
+				`<div class="blog-search-result-date">${item.created || ""}</div>`;
+			row.addEventListener("click", () => {
+				window.location.href = item.url;
+			});
+			resultsEl.appendChild(row);
+		}
+	}
+	resultsEl.classList.add("show");
+}
+
+async function populateManifest(contentBase, path) {
+	const resp = await fetch(contentUrl(contentBase, path));
+	if (!resp.ok) {
+		return [];
+	}
+	return await resp.json();
+}
+
+async function loadManifest(articleEl, contentBase, path) {
+	const manifest = await populateManifest(contentBase, path);
+	archive(articleEl, manifest);
+	return manifest;
+}
+
+function bindSearch(root, searchManifest) {
+	const searchEl = root.querySelector(".blog-search");
+	const resultsEl = root.querySelector(".blog-search-results");
+	const container = root.querySelector(".blog-search-container");
+	if (!searchEl || !resultsEl || !container) {
+		return;
+	}
+	searchEl.addEventListener("input", () => searchChange(searchEl, resultsEl, searchManifest));
+	document.addEventListener("keydown", (event) => {
+		if (event.key === "Escape") {
+			hideSearchResults(resultsEl);
+		}
+	});
+	document.addEventListener("click", (event) => {
+		if (!container.contains(event.target) && !resultsEl.contains(event.target)) {
+			hideSearchResults(resultsEl);
+		}
+	});
+}
+
+async function boot() {
+	const root = document.getElementById("blog-root");
+	const articleEl = document.getElementById("article");
+	if (!root || !articleEl) {
+		return;
+	}
+	const contentBase = root.dataset.contentBase || "";
+	const manifestPath = root.dataset.manifest || "articles/manifest.json";
+	const u = new URL(window.location.href);
+	const article = u.searchParams.get("article");
+	let searchManifest = [];
+	if (article) {
+		await loadmd(articleEl, contentBase, article);
+		searchManifest = await populateManifest(contentBase, manifestPath);
+	} else {
+		searchManifest = await loadManifest(articleEl, contentBase, manifestPath);
+	}
+	bindSearch(root, searchManifest);
+}
+
+boot();
diff --git a/pygitweb/static/main.css b/pygitweb/static/main.css
index 0c42ec9..f27c787 100644
--- a/pygitweb/static/main.css
+++ b/pygitweb/static/main.css
@@ -1343,3 +1343,151 @@ pre {
 	border: 1px solid var(--pgw-border);
 	background-color: var(--pgw-bg-raised);
 }
+
+/* Project blog viewer */
+.blog-article {
+	max-width: 64em;
+	margin: 0 auto;
+}
+
+.blog-article.markdown-body {
+	blockquote {
+		background-color: var(--pgw-bg-muted);
+		border-left: 2px solid var(--pgw-border);
+		padding: 1.25rem 1.75rem;
+		border-radius: var(--tblr-border-radius, 0.25rem);
+		color: var(--pgw-text);
+	}
+
+	img {
+		display: block;
+		margin-left: auto;
+		margin-right: auto;
+		width: 60%;
+		max-width: 100%;
+		background-color: transparent;
+	}
+
+	pre:has(code) {
+		display: flex;
+		overflow-x: auto;
+		max-height: none !important;
+		background-color: var(--pgw-bg-muted);
+		border: 1px solid var(--pgw-border);
+		border-radius: var(--tblr-border-radius, 0.25rem);
+		padding: 0.5px;
+		width: 100%;
+	}
+
+	code {
+		font-family: "Fira Code", var(--tblr-font-monospace, ui-monospace, monospace);
+	}
+
+	code.lineno {
+		border-right: 1px solid var(--pgw-border);
+		padding: 0.5em !important;
+		flex-grow: 0 !important;
+		user-select: none;
+		color: var(--pgw-text-muted);
+	}
+
+	code.hljs {
+		padding: 0.5em !important;
+		flex-grow: 1;
+	}
+
+	p > code {
+		background-color: var(--pgw-bg-muted);
+		border: 1px solid var(--pgw-border);
+		border-radius: var(--tblr-border-radius-sm, 0.125rem);
+		padding: 0.125rem 0.375rem;
+	}
+
+	p.graph {
+		display: flex;
+		align-items: center;
+	}
+
+	p > svg {
+		display: block;
+		margin-left: auto;
+		margin-right: auto;
+	}
+
+	.graph > polygon {
+		fill: transparent;
+	}
+
+	.node > text {
+		fill: var(--pgw-text);
+	}
+
+	.node > ellipse {
+		stroke: var(--pgw-text);
+	}
+
+	.edge > polygon {
+		fill: var(--pgw-text);
+	}
+
+	.edge > * {
+		stroke: var(--pgw-text);
+	}
+}
+
+.blog-search-container {
+	position: relative;
+}
+
+.blog-search-results {
+	position: absolute;
+	top: 100%;
+	left: 0;
+	width: 100%;
+	max-width: 28rem;
+	margin-top: 0.25rem;
+	display: none;
+	max-height: 70vh;
+	overflow-y: auto;
+	z-index: 20;
+}
+
+.blog-search-results.show {
+	display: block;
+}
+
+.blog-search-result {
+	padding: 0.75rem 1rem;
+	border-bottom: 1px solid var(--pgw-border);
+	color: var(--pgw-text);
+	text-decoration: none;
+	display: block;
+	cursor: pointer;
+	transition: background-color 0.15s ease;
+}
+
+.blog-search-result:hover {
+	background-color: var(--pgw-table-hover);
+}
+
+.blog-search-result:last-child {
+	border-bottom: none;
+}
+
+.blog-search-result-title {
+	font-weight: 600;
+	margin-bottom: 0.25rem;
+}
+
+.blog-search-result-snippet {
+	font-size: 0.875rem;
+	color: var(--pgw-text-muted);
+	line-height: 1.4;
+}
+
+.blog-search-result-date {
+	font-size: 0.75rem;
+	color: var(--pgw-text-dim, var(--pgw-text-muted));
+	margin-top: 0.25rem;
+}
+
diff --git a/pygitweb/templates/blog.html b/pygitweb/templates/blog.html
new file mode 100644
index 0000000..0101d97
--- /dev/null
+++ b/pygitweb/templates/blog.html
@@ -0,0 +1,38 @@
+{% extends "base.html" %}
+{% block head %}
+<script defer src="/static/vendor/showdown-2.1.0.min.js"></script>
+<script defer id="Highlight-script" src="/static/vendor/highlight-11.9.0.min.js"></script>
+<script async id="MathJax-script" src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-chtml.min.js"></script>
+<script async id="GraphViz-script" src="https://cdnjs.cloudflare.com/ajax/libs/viz.js/1.8.2/viz.js"></script>
+{% endblock %}
+{% block content %}
+<div
+	id="blog-root"
+	class="blog-root"
+	data-content-base="{{ content_base }}"
+	data-manifest="{{ manifest_path }}"
+	data-blog-url="{{ blog_url }}"
+>
+	<h1 class="page-title d-flex align-items-center gap-2 flex-wrap mb-3">
+		<a href="{{ project_url }}" class="text-reset">{{ project }}</a>
+		<span class="text-muted">/</span>
+		<span>blog</span>
+	</h1>
+	<div class="d-flex flex-wrap align-items-center gap-3 mb-3 blog-toolbar">
+		<a href="{{ blog_url }}" class="btn btn-sm btn-outline-secondary">Articles</a>
+		<div class="blog-search-container flex-grow-1" style="max-width: 28rem;">
+			<div class="input-icon w-100">
+				<span class="input-icon-addon">
+					<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" /><path d="M21 21l-6 -6" /></svg>
+				</span>
+				<input class="form-control blog-search" type="search" placeholder="Search..." aria-label="Search articles" autocomplete="off">
+			</div>
+			<div class="blog-search-results dropdown-menu" id="blog-search-results" role="listbox"></div>
+		</div>
+	</div>
+	<div id="article" class="blog-article markdown-body"></div>
+</div>
+{% endblock %}
+{% block scripts %}
+<script type="module" src="/static/blog.js"></script>
+{% endblock %}
diff --git a/pygitweb/templates/summary.html b/pygitweb/templates/summary.html
index f6b6afc..55d569d 100644
--- a/pygitweb/templates/summary.html
+++ b/pygitweb/templates/summary.html
@@ -61,6 +61,10 @@
 <td>{{ link(board.href, board.text, style=board.style) }}</td>
 </tr>
 <tr>
+<td>Blog</td>
+<td>{{ link(blog.href, blog.text, style=blog.style) }}</td>
+</tr>
+<tr>
 <td>Search</td>
 <td>{{ link(project_url_enc ~ "/search", "search files") }}</td>
 </tr>
diff --git a/pygitweb/templates_env_test.py b/pygitweb/templates_env_test.py
index ad7b92e..e66c10a 100644
--- a/pygitweb/templates_env_test.py
+++ b/pygitweb/templates_env_test.py
@@ -1,4 +1,4 @@
-from pygitweb.actions import project_board_link
+from pygitweb.actions import blog_link_spec, project_board_link
 from pygitweb.templates_env import env, html_format, html_join, html_link, render_page, trusted_html
 
 
@@ -93,3 +93,23 @@ def test_project_board_link_offers_create_when_missing(monkeypatch) -> None:
 	plain = str(project_board_link("my proj"))
 	assert "style=" not in plain
 	assert ">create board</a>" in plain
+
+
+def test_blog_link_spec_points_at_existing_blog(monkeypatch) -> None:
+	monkeypatch.setattr("pygitweb.actions.project_has_blog", lambda _project: True)
+	spec = blog_link_spec("demo")
+	assert spec == {
+		"href": "/project/demo/blog/",
+		"text": "view blog",
+		"style": None,
+	}
+
+
+def test_blog_link_spec_muted_when_missing(monkeypatch) -> None:
+	monkeypatch.setattr("pygitweb.actions.project_has_blog", lambda _project: False)
+	spec = blog_link_spec("my proj")
+	assert spec == {
+		"href": "#",
+		"text": "no blog",
+		"style": "color: #999; cursor: not-allowed;",
+	}
