diff --git a/pygitweb/README.md b/pygitweb/README.md
index 6eb8ed0..0e35498 100644
--- a/pygitweb/README.md
+++ b/pygitweb/README.md
@@ -53,7 +53,9 @@ See [ROUTES.md](ROUTES.md) for more detail.
 - `GET /{project}?a=log&h=...` — commit log
 - `GET /{project}?a=shortlog&h=...` — shortlog
 - `GET /{project}?a=history&h=...&f=...` — history of a file or path
+- `GET /{project}?a=tags` — list all tags
+- `GET /{project}?a=tag&h=...` — single tag view (tag ref or hash)
 
 **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`, `commit`, `commitdiff`, `commitdiff_plain`, `heads`, `patch`, `patches`, `remotes`, `rss`, `atom`, `search`, `search_help`, `tag`, `tags`, `snapshot`, `object`.
+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`, `commit`, `commitdiff`, `commitdiff_plain`, `heads`, `patch`, `patches`, `remotes`, `rss`, `atom`, `search`, `search_help`, `snapshot`, `object`.
diff --git a/pygitweb/git_helpers.py b/pygitweb/git_helpers.py
index 53c3819..097837e 100644
--- a/pygitweb/git_helpers.py
+++ b/pygitweb/git_helpers.py
@@ -356,8 +356,8 @@ def parse_tag(project: str, oid: str) -> dict[str, Any]:
             return {}
         tag = obj
         return {
-            "object": str(tag.target_id),
-            "type": tag.target_type_str,
+            "object": str(tag.target),
+            "type": tag.type_str,
             "tagger": tag.tagger.name if tag.tagger else "",
             "tagger_email": tag.tagger.email if tag.tagger else "",
             "tagger_epoch": tag.tagger.time if tag.tagger else 0,
diff --git a/pygitweb/main.py b/pygitweb/main.py
index ce1768e..ceb6598 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -11,7 +11,7 @@ import tempfile
 import zipfile
 from datetime import datetime, timedelta, timezone
 from pathlib import Path
-from typing import Annotated
+from typing import Annotated, Any
 
 import pygit2
 from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
@@ -39,7 +39,9 @@ from pygitweb.git_helpers import (
     get_tree_at_ref_path,
     git_get_head_hash,
     git_get_project_description,
+    git_get_tags_list,
     parse_commit,
+    parse_tag,
 )
 from pygitweb.projects import (
     git_get_projects_list,
@@ -455,6 +457,10 @@ def dispatch(
         return git_shortlog(proj, hash_param)
     if action == "history":
         return git_history(proj, hash_param, file_name)
+    if action == "tags":
+        return git_tags(proj)
+    if action == "tag":
+        return git_tag(proj, hash_param)
     # Stub others with minimal response
     pre = PREAMBLE.render(title=f"{esc_html(action)} - {esc_html(proj)}", theme="dark", site_name=config.SITE_NAME)
     return HTMLResponse(
@@ -721,6 +727,105 @@ def git_history(project: str, h: str | None, f: str | None) -> HTMLResponse:
     )
 
 
+def git_tags(project: str) -> HTMLResponse:
+    """Tags list page. Port of git_tags."""
+    tags_list = git_get_tags_list(project)
+    if not tags_list:
+        raise HTTPException(status_code=404, detail="No tags found")
+    try:
+        from pygitweb.git_helpers import open_repo
+        repo = open_repo(project)
+    except Exception:
+        raise HTTPException(status_code=404, detail="Repository not found")
+    rows = []
+    for name, _ref, oid in tags_list:
+        tag_link = f"/{project}?a=tag&h={quote(oid, safe='')}"
+        target_oid = oid
+        target_type = "commit"
+        try:
+            obj = repo.revparse_single(oid)
+            if isinstance(obj, pygit2.Tag):
+                target_oid = str(obj.target)
+                target_type = obj.type_str
+        except (KeyError, pygit2.GitError):
+            pass
+        target_short = target_oid[:7] if target_oid else ""
+        if target_type == "commit":
+            target_link = f"/{project}?a=commit&h={quote(target_oid, safe='')}"
+        elif target_type == "tree":
+            target_link = f"/{project}?a=tree&h={quote(target_oid, safe='')}"
+        else:
+            target_link = None
+        obj_cell = f'<a href="{target_link}">{esc_html(target_short)}</a>' if target_link else esc_html(target_short)
+        rows.append([
+            f'<a href="{tag_link}">{esc_html(name)}</a>',
+            obj_cell,
+        ])
+    title = f"Tags - {esc_html(project)}"
+    pre = PREAMBLE.render(title=title, theme="dark", site_name=config.SITE_NAME)
+    table = env.get_template("table.html").render(
+        cols=["Tag", "Object"],
+        rows=rows,
+    )
+    return HTMLResponse(
+        f"{pre}<h1>{title}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{table}{POSTAMBLE}"
+    )
+
+
+def git_tag(project: str, h: str | None) -> HTMLResponse:
+    """Single tag view. Port of git_tag."""
+    if not h:
+        raise HTTPException(status_code=400, detail="Tag ref or hash (h) required")
+    if not is_valid_ref_format(h):
+        raise HTTPException(status_code=400, detail="Invalid ref or hash")
+    try:
+        from pygitweb.git_helpers import open_repo
+        repo = open_repo(project)
+        obj = repo.revparse_single(h)
+    except (KeyError, pygit2.GitError, OSError):
+        raise HTTPException(status_code=404, detail="Tag or object not found")
+    if not isinstance(obj, pygit2.Tag):
+        raise HTTPException(status_code=404, detail="Not a tag object")
+    tag_oid = str(obj.id)
+    tag_data = parse_tag(project, tag_oid)
+    if not tag_data:
+        raise HTTPException(status_code=404, detail="Tag not found")
+    target_oid = tag_data.get("object", "")
+    target_type = tag_data.get("type", "commit")
+    tagger = tag_data.get("tagger", "")
+    tagger_epoch = tag_data.get("tagger_epoch")
+    tagger_tz = tag_data.get("tagger_tz")
+    message = (tag_data.get("message") or "").strip()
+    tag_name = None
+    for name, _ref, oid in git_get_tags_list(project):
+        if oid == tag_oid:
+            tag_name = name
+            break
+    if tag_name is None:
+        tag_name = tag_oid[:7]
+    target_short = target_oid[:7] if target_oid else ""
+    if target_type == "commit":
+        object_link = f'<a href="/{project}?a=commit&h={quote(target_oid, safe="")}">{esc_html(target_short)}</a>'
+    elif target_type == "tree":
+        object_link = f'<a href="/{project}?a=tree&h={quote(target_oid, safe="")}">{esc_html(target_short)}</a>'
+    else:
+        object_link = esc_html(target_short)
+    table_rows = [
+        ["Tag", esc_html(tag_name)],
+        ["Object", f"{object_link} ({esc_html(target_type)})"],
+        ["Tagger", esc_html(tagger)],
+        ["Date", esc_html(_format_date(tagger_epoch, tagger_tz))],
+    ]
+    if message:
+        table_rows.append(["Message", f"<pre class='tag-message'>{esc_html(message)}</pre>"])
+    table = env.get_template("table.html").render(cols=[], rows=table_rows)
+    title = f"Tag {esc_html(tag_name)} - {esc_html(project)}"
+    pre = PREAMBLE.render(title=title, theme="dark", site_name=config.SITE_NAME)
+    return HTMLResponse(
+        f"{pre}<h1>Tag {esc_html(tag_name)}</h1><p>Project: <a href='/{project}'>{esc_html(project)}</a></p>{table}{POSTAMBLE}"
+    )
+
+
 if __name__ == "__main__":
     import uvicorn
     uvicorn.run(app, host="0.0.0.0", port=8000)
