diff --git a/pygitweb/README.md b/pygitweb/README.md
index 550bcfd..169a7e2 100644
--- a/pygitweb/README.md
+++ b/pygitweb/README.md
@@ -59,11 +59,13 @@ See [ROUTES.md](ROUTES.md) for more detail.
 - `GET /{project}?a=commitdiff` — commit diff (unified diff rendered with [diff2html](https://github.com/rtfpessoa/diff2html))
 - `GET /{project}?a=patch&h=...` — single-commit patch (plain text)
 - `GET /{project}?a=patches&h=...&hb=...` — multi-commit patches for range `hb..h` (plain text)
+- `GET /{project}?a=blobdiff&h=...&hb=...&f=...&fp=...` — blob diff (two versions of a file) rendered with diff2html
+- `GET /{project}?a=blobpatch&h=...&hb=...&f=...&fp=...` — blob diff as plain unified diff
 - `GET /{project}?a=remotes` — list repo remotes
 
 **Project-scoped actions (stub only)**
 
-These actions are accepted but return a minimal placeholder page. Add handlers in `main.py` to implement: `blame`, `blame_incremental`, `blame_data`, `blobdiff`, `blobdiff_plain`, `rss`, `atom`, `search`, `search_help`, `snapshot`, `object`.
+These actions are accepted but return a minimal placeholder page. Add handlers in `main.py` to implement: `blame`, `blame_incremental`, `blame_data`, `rss`, `atom`, `search`, `search_help`, `snapshot`, `object`.
 
 ## Query parameter short names (CGI mapping)
 
diff --git a/pygitweb/config.py b/pygitweb/config.py
index 53ef0a6..ac3ded8 100644
--- a/pygitweb/config.py
+++ b/pygitweb/config.py
@@ -17,7 +17,7 @@ ACTIONS = {
     "blame_incremental",
     "blame_data",
     "blobdiff",
-    "blobdiff_plain",
+    "blobpatch",
     "blob",
     "blob_plain",
     "commitdiff",
diff --git a/pygitweb/git_helpers.py b/pygitweb/git_helpers.py
index 13876b7..8c382da 100644
--- a/pygitweb/git_helpers.py
+++ b/pygitweb/git_helpers.py
@@ -537,3 +537,24 @@ def get_commits_in_range(project: str, tip: str, base: str | None) -> list[str]:
         return [str(c.id) for c in walker]
     except (KeyError, pygit2.GitError, OSError):
         return []
+
+
+def get_blob_unified_diff(
+    project: str,
+    ref_old: str,
+    path_old: str,
+    ref_new: str,
+    path_new: str,
+) -> str | None:
+    """
+    Return unified diff between blob at ref_old:path_old and ref_new:path_new using pygit2.
+    Returns None if either blob is not found; otherwise returns the diff string (possibly empty).
+    """
+    result_old = get_blob_at_ref_path(project, ref_old, path_old)
+    result_new = get_blob_at_ref_path(project, ref_new, path_new)
+    if not result_old or not result_new:
+        return None
+    old_blob, _ = result_old
+    new_blob, _ = result_new
+    patch = old_blob.diff(new_blob, old_as_path=path_old, new_as_path=path_new)
+    return patch.text if patch.text else ""
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 5ff8e7e..ea63702 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -41,6 +41,7 @@ from pygitweb.config import (
 from pygitweb.formatting import age_string, esc_html, sanitize, to_utf8
 from pygitweb.git_helpers import (
     get_blob_at_ref_path,
+    get_blob_unified_diff,
     get_commit_history,
     get_commits_in_range,
     get_tree_at_ref_path,
@@ -423,6 +424,7 @@ def dispatch(
     h: Annotated[str | None, Query(alias="h")] = None,
     hb: Annotated[str | None, Query(alias="hb")] = None,
     f: Annotated[str | None, Query(alias="f")] = None,
+    fp: Annotated[str | None, Query(alias="fp")] = None,
     page: Annotated[str | None, Query(alias="page")] = None,
     pagecount: Annotated[str | None, Query(alias="pagecount")] = None,
 ):
@@ -439,6 +441,7 @@ def dispatch(
     action = a
     hash_param = h or hb
     file_name = f
+    file_parent = fp
     # If no action, infer: hash only -> object type; project only -> summary
     if not action:
         if hash_param and file_name:
@@ -464,6 +467,10 @@ def dispatch(
         return git_tree(proj, hash_param, file_name)
     if action in ("blob", "blob_plain"):
         return git_blob(proj, hash_param, file_name, raw=(action == "blob_plain"))
+    if action == "blobdiff":
+        return git_blobdiff(proj, h, hb, file_name, file_parent)
+    if action == "blobpatch":
+        return git_blobpatch(proj, h, hb, file_name, file_parent)
     if action == "log":
         p, pc = _parse_pagination(page, pagecount)
         return git_log(proj, hash_param, request, p, pc)
@@ -1154,6 +1161,71 @@ def git_patches(project: str, h: str | None, hb: str | None) -> PlainTextRespons
     )
 
 
+def git_blobdiff(
+    project: str,
+    h: str | None,
+    hb: str | None,
+    f: str | None,
+    fp: str | None,
+) -> HTMLResponse:
+    """Blob diff page: diff between two blob versions, rendered with diff2html."""
+    if not h or not hb:
+        raise HTTPException(status_code=400, detail="Both h and hb (refs) required for blob diff")
+    if not f:
+        raise HTTPException(status_code=400, detail="File path (f) required for blob diff")
+    if not is_valid_ref_format(h) or not is_valid_ref_format(hb):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    path_new = f or ""
+    path_old = fp if fp is not None else path_new
+    if not is_valid_pathname(path_old) or not is_valid_pathname(path_new):
+        raise HTTPException(status_code=400, detail="Invalid path")
+    diff_text = get_blob_unified_diff(project, hb, path_old, h, path_new)
+    if diff_text is None:
+        raise HTTPException(status_code=404, detail="Blob not found")
+    diff_b64 = base64.b64encode(diff_text.encode("utf-8")).decode("ascii")
+    blob_link = f"/{project}?a=blob&h={quote(h or '', safe='')}&f={quote(path_new, safe='/')}"
+    pre = PREAMBLE.render(
+        title=f"Blob diff - {esc_html(path_new)} - {esc_html(project)}",
+        site_name=config.SITE_NAME,
+    )
+    body = env.get_template("blobdiff.html").render(
+        project=project,
+        path_new=path_new,
+        diff_b64=diff_b64,
+        blob_link=blob_link,
+    )
+    return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+
+
+def git_blobpatch(
+    project: str,
+    h: str | None,
+    hb: str | None,
+    f: str | None,
+    fp: str | None,
+) -> PlainTextResponse:
+    """Blob diff as plain unified diff."""
+    if not h or not hb:
+        raise HTTPException(status_code=400, detail="Both h and hb (refs) required for blob diff")
+    if not f:
+        raise HTTPException(status_code=400, detail="File path (f) required for blob diff")
+    if not is_valid_ref_format(h) or not is_valid_ref_format(hb):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    path_new = f or ""
+    path_old = fp if fp is not None else path_new
+    if not is_valid_pathname(path_old) or not is_valid_pathname(path_new):
+        raise HTTPException(status_code=400, detail="Invalid path")
+    body = get_blob_unified_diff(project, hb, path_old, h, path_new)
+    if body is None:
+        raise HTTPException(status_code=404, detail="Blob not found")
+    filename = f"{path_new.split('/')[-1] or 'blob'}.patch"
+    return PlainTextResponse(
+        body,
+        media_type="text/x-diff; charset=utf-8",
+        headers={"Content-Disposition": f'inline; filename="{filename}"'},
+    )
+
+
 if __name__ == "__main__":
     import uvicorn
     uvicorn.run(app, host="0.0.0.0", port=8000)
diff --git a/pygitweb/templates/blobdiff.html b/pygitweb/templates/blobdiff.html
new file mode 100644
index 0000000..a88b3ba
--- /dev/null
+++ b/pygitweb/templates/blobdiff.html
@@ -0,0 +1,57 @@
+<h1>Blob diff {{ path_new }}</h1>
+<p>Project: <a href="/{{ project }}">{{ project }}</a> · <a href="{{ blob_link }}">blob</a></p>
+<div id="d2h-container" data-diff-b64="{{ diff_b64 }}" data-has-diff="{{ 'true' if diff_b64 else 'false' }}">
+  <div id="d2h-output"></div>
+  <p id="d2h-empty" class="text-muted" style="display: none;">No changes between these blobs.</p>
+</div>
+<script>
+(function () {
+  var container = document.getElementById('d2h-container');
+  var output = document.getElementById('d2h-output');
+  var emptyEl = document.getElementById('d2h-empty');
+  var diffB64 = container.getAttribute('data-diff-b64');
+  var hasDiff = container.getAttribute('data-has-diff') === 'true';
+
+  function render() {
+    if (!hasDiff || !diffB64) {
+      emptyEl.style.display = 'block';
+      return;
+    }
+    try {
+      var raw = atob(diffB64);
+      if (!raw) {
+        emptyEl.style.display = 'block';
+        return;
+      }
+      var diffStr = (typeof TextDecoder !== 'undefined')
+        ? new TextDecoder('utf-8').decode(Uint8Array.from(raw, function (c) { return c.charCodeAt(0); }))
+        : raw;
+      if (typeof Diff2HtmlUI === 'undefined') {
+        output.innerHTML = '<p class="text-warning">Diff2HtmlUI not loaded.</p>';
+        return;
+      }
+      var config = {
+        drawFileList: true,
+        matching: 'lines',
+        outputFormat: 'side-by-side',
+        synchronisedScroll: true,
+        highlight: true
+      };
+      var diff2htmlUi = new Diff2HtmlUI(output, diffStr, config);
+      diff2htmlUi.draw();
+      diff2htmlUi.highlightCode();
+    } catch (e) {
+      output.innerHTML = '<p class="text-danger">Failed to render diff: ' + (e.message || e) + '</p>';
+    }
+  }
+
+  var script = document.getElementById('diff2html-script');
+  if (script && (typeof Diff2HtmlUI !== 'undefined')) {
+    render();
+  } else if (script) {
+    script.addEventListener('load', render);
+  } else {
+    render();
+  }
+})();
+</script>
