diff --git a/pygitweb/api/action.py b/pygitweb/api/action.py
index deabeaf..7b3c257 100644
--- a/pygitweb/api/action.py
+++ b/pygitweb/api/action.py
@@ -1,20 +1,21 @@
 from __future__ import annotations
 
-from abc import ABC, abstractmethod
+from typing import Protocol, runtime_checkable
 
+from abcreg import AnnotatedProtocolMeta  # type: ignore[import-untyped]
 from fastapi import Request
 from starlette.responses import Response
 
 
-class Action(ABC):
+@runtime_checkable
+class Action(Protocol, metaclass=AnnotatedProtocolMeta):
 	"""
 	Action: A per-project action provided by a plugin.
 
 	``project`` is the project path/slug as used in URLs (under PROJECTROOT).
+	Subclass this protocol, or register a virtual subclass with ``@Action.register``.
 	"""
 
-	@abstractmethod
 	def action_name(self) -> str: ...
 
-	@abstractmethod
 	def action(self, project: str, req: Request | None = None) -> Response | None: ...
diff --git a/pygitweb/api/subpage.py b/pygitweb/api/subpage.py
index 0bef74a..0991ec5 100644
--- a/pygitweb/api/subpage.py
+++ b/pygitweb/api/subpage.py
@@ -1,14 +1,54 @@
 from __future__ import annotations
 
-from abc import ABC, abstractmethod
+from typing import Protocol, runtime_checkable
 
+from abcreg import AnnotatedProtocolMeta  # type: ignore[import-untyped]
 
-class Subpage(ABC):
+_DEFAULT_METHODS = ("link_name", "subpage_html", "summary_value_suffix_html")
+
+
+def _install_default_methods(interface: type, impl: type) -> None:
+	"""Give implementations the protocol's optional methods before signature checks."""
+	if getattr(impl, "_is_protocol", False):
+		return
+	skip = set(interface.__mro__)
+	for name in _DEFAULT_METHODS:
+		if any(name in base.__dict__ for base in impl.__mro__ if base not in skip):
+			continue
+		for base in interface.__mro__:
+			default = base.__dict__.get(name)
+			if callable(default):
+				setattr(impl, name, default)
+				break
+
+
+class _SubpageMeta(AnnotatedProtocolMeta):
+	def __init__(
+		cls,
+		name: str,
+		bases: tuple[type, ...],
+		namespace: dict[str, object],
+		**kwargs: object,
+	) -> None:
+		if not getattr(cls, "_is_protocol", False):
+			for base in bases:
+				if isinstance(base, _SubpageMeta) and getattr(base, "_is_protocol", False):
+					_install_default_methods(base, cls)
+		super().__init__(name, bases, namespace, **kwargs)
+
+	def register(cls, subclass: type) -> type:
+		_install_default_methods(cls, subclass)
+		return super().register(subclass)
+
+
+@runtime_checkable
+class Subpage(Protocol, metaclass=_SubpageMeta):
 	"""
 	Subpage: A per-project subpage provided by a plugin.
+
+	Subclass this protocol, or register a virtual subclass with ``@Subpage.register``.
 	"""
 
-	@abstractmethod
 	def subpage_name(self) -> str: ...
 
 	def link_name(self, project: str) -> str | None:
diff --git a/pygitweb/plugin_loader.py b/pygitweb/plugin_loader.py
index 3bce93b..5a58761 100644
--- a/pygitweb/plugin_loader.py
+++ b/pygitweb/plugin_loader.py
@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import importlib.metadata
 import logging
-from collections.abc import Iterable
+from collections.abc import Callable, Iterable
 
 from pygitweb.api.action import Action
 from pygitweb.api.subpage import Subpage
@@ -48,6 +48,29 @@ def list_available_plugins() -> list[str]:
 	return sorted(names)
 
 
+def _register_plugin(
+	register: Callable[[type], type],
+	loaded: object,
+	ep_name: str,
+	kind: str,
+) -> type | None:
+	if not isinstance(loaded, type):
+		logger.error("pygitweb: entry point %r must be a %s class, got %r", ep_name, kind, loaded)
+		return None
+	try:
+		register(loaded)
+	except TypeError as exc:
+		logger.error(
+			"pygitweb: entry point %r must implement %s, got %r (%s)",
+			ep_name,
+			kind,
+			loaded,
+			exc,
+		)
+		return None
+	return loaded
+
+
 def load_subpages(enabled: frozenset[str] | None = None) -> list[Subpage]:
 	allowed = _enabled_set(enabled)
 	out: list[Subpage] = []
@@ -61,15 +84,11 @@ def load_subpages(enabled: frozenset[str] | None = None) -> list[Subpage]:
 		except Exception:
 			logger.exception("pygitweb: failed to load subpage entry point %r", ep.name)
 			continue
-		if not isinstance(loaded, type) or not issubclass(loaded, Subpage):
-			logger.error(
-				"pygitweb: entry point %r must be a Subpage subclass, got %r",
-				ep.name,
-				loaded,
-			)
+		plugin_cls = _register_plugin(Subpage.register, loaded, ep.name, "Subpage")
+		if plugin_cls is None:
 			continue
 		try:
-			instance = loaded()
+			instance = plugin_cls()
 		except Exception:
 			logger.exception("pygitweb: failed to instantiate subpage %r", ep.name)
 			continue
@@ -94,15 +113,11 @@ def load_plugin_actions(enabled: frozenset[str] | None = None) -> dict[str, Acti
 		except Exception:
 			logger.exception("pygitweb: failed to load action entry point %r", ep.name)
 			continue
-		if not isinstance(loaded, type) or not issubclass(loaded, Action):
-			logger.error(
-				"pygitweb: entry point %r must be an Action subclass, got %r",
-				ep.name,
-				loaded,
-			)
+		plugin_cls = _register_plugin(Action.register, loaded, ep.name, "Action")
+		if plugin_cls is None:
 			continue
 		try:
-			instance = loaded()
+			instance = plugin_cls()
 		except Exception:
 			logger.exception("pygitweb: failed to instantiate action %r", ep.name)
 			continue
diff --git a/pygitweb/plugin_loader_test.py b/pygitweb/plugin_loader_test.py
index 4679cce..5de4c27 100644
--- a/pygitweb/plugin_loader_test.py
+++ b/pygitweb/plugin_loader_test.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 from types import SimpleNamespace
 from unittest.mock import patch
 
+import pytest
 from fastapi import Request
 from starlette.responses import PlainTextResponse, Response
 
@@ -74,6 +75,59 @@ def test_load_skips_disabled_plugins() -> None:
 		assert set(actions) == {"demo_action"}
 		subpages = load_subpages(frozenset({"pygitweb_demo"}))
 		assert [s.subpage_name() for s in subpages] == ["demo_subpage"]
+		assert subpages[0].link_name("proj") is None
+		assert subpages[0].subpage_html("proj") is None
+		assert subpages[0].summary_value_suffix_html("proj", "proj") is None
+
+
+def test_load_plugin_actions_skips_annotation_mismatch() -> None:
+	class _MismatchedAction:
+		def action_name(self) -> int:
+			return 1
+
+		def action(self, project: str, req: Request | None = None) -> Response | None:
+			return None
+
+	eps = [_ep("bad", "pygitweb_bad", _MismatchedAction)]
+	with patch("pygitweb.plugin_loader._entry_points", return_value=eps):
+		assert load_plugin_actions(frozenset({"pygitweb_bad"})) == {}
+
+
+def test_load_subpages_fills_default_methods() -> None:
+	class _VirtualSubpage:
+		def subpage_name(self) -> str:
+			return "virtual"
+
+	eps = [_ep("virt", "pygitweb_virt", _VirtualSubpage)]
+	with patch("pygitweb.plugin_loader._entry_points", return_value=eps):
+		subpages = load_subpages(frozenset({"pygitweb_virt"}))
+	assert len(subpages) == 1
+	assert subpages[0].subpage_name() == "virtual"
+	assert subpages[0].link_name("p") is None
+	assert subpages[0].subpage_html("p") is None
+	assert subpages[0].summary_value_suffix_html("p", "p") is None
+
+
+def test_action_subclass_must_match_annotations() -> None:
+	with pytest.raises(TypeError, match="does not fully implement 'Action'"):
+
+		class _BadAction(Action):
+			def action_name(self) -> int:
+				return 1
+
+			def action(self, project: str, req: Request | None = None) -> Response | None:
+				return None
+
+
+def test_subpage_override_must_match_annotations() -> None:
+	with pytest.raises(TypeError, match="does not fully implement 'Subpage'"):
+
+		class _BadSubpage(Subpage):
+			def subpage_name(self) -> str:
+				return "bad"
+
+			def link_name(self, project: str) -> int:
+				return 1
 
 
 def test_apply_plugin_reload_updates_registry(monkeypatch) -> None:
diff --git a/pygitweb/pyproject.toml b/pygitweb/pyproject.toml
index dc480b2..810883b 100644
--- a/pygitweb/pyproject.toml
+++ b/pygitweb/pyproject.toml
@@ -21,6 +21,7 @@ dependencies = [
     "pydantic-settings>=2.13.0",
     "httpx>=0.27.0",
     "python-ripgrep==0.0.9",
+    "abcreg>=0.2.0",
 ]
 
 [tool.setuptools]
diff --git a/uv.lock b/uv.lock
index 6b44b96..83ab530 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1196,6 +1196,7 @@ name = "pygitweb"
 version = "0.2.3"
 source = { editable = "pygitweb" }
 dependencies = [
+    { name = "abcreg" },
     { name = "fastapi", extra = ["standard-no-fastapi-cloud-cli"] },
     { name = "httpx" },
     { name = "jinja2" },
@@ -1210,6 +1211,7 @@ dependencies = [
 
 [package.metadata]
 requires-dist = [
+    { name = "abcreg", specifier = ">=0.2.0" },
     { name = "fastapi", extras = ["standard-no-fastapi-cloud-cli"], specifier = ">=0.104.0" },
     { name = "httpx", specifier = ">=0.27.0" },
     { name = "jinja2", specifier = ">=3.1.0" },
