diff --git a/forgejo/templates/custom/footer.tmpl b/forgejo/templates/custom/footer.tmpl
new file mode 100644
index 0000000..efa7225
--- /dev/null
+++ b/forgejo/templates/custom/footer.tmpl
@@ -0,0 +1,68 @@
+
diff --git a/ifcurl/service.py b/ifcurl/service.py
index 4d0576e..6f2584a 100644
--- a/ifcurl/service.py
+++ b/ifcurl/service.py
@@ -40,19 +40,22 @@ import os
import tempfile
import threading
import time
+import urllib.error
+import urllib.request
from collections import OrderedDict
from pathlib import Path
import ifcopenshell
import ifcopenshell.util.selector
from fastapi import FastAPI, HTTPException
-from fastapi.responses import Response
+from fastapi.responses import HTMLResponse, Response
from platformdirs import user_cache_dir
from pydantic import BaseModel
from ifcurl import render as render_mod
from ifcurl.auth import get_token_for_host
from ifcurl.git import fetch_ifc
+from ifcurl.viewer import VIEWER_HTML
from ifcurl.url import IfcUrl
app = FastAPI(
@@ -218,6 +221,35 @@ class PreviewRequest(BaseModel):
# Endpoints
# ---------------------------------------------------------------------------
+@app.get("/viewer")
+def viewer(url: str = "") -> HTMLResponse:
+ """Serve the self-contained web IFC viewer page.
+
+ The ``url`` query parameter should be an ifc:// URL. The page loads
+ ``@thatopen/components`` from CDN and fetches the IFC file through
+ ``/proxy`` to avoid cross-origin restrictions.
+ """
+ return HTMLResponse(content=VIEWER_HTML)
+
+
+@app.get("/proxy")
+def proxy(url: str) -> Response:
+ """Proxy a raw IFC file URL to the browser, avoiding CORS restrictions.
+
+ Only intended for fetching IFC bytes from the co-located Forgejo instance.
+ No IFC processing is performed — bytes are forwarded as-is.
+ """
+ try:
+ with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310
+ data = resp.read()
+ content_type = resp.headers.get_content_type() or "application/octet-stream"
+ except urllib.error.HTTPError as exc:
+ raise HTTPException(status_code=exc.code, detail=str(exc.reason)) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+ return Response(content=data, media_type=content_type)
+
+
@app.get("/preview")
def preview_get(url: str) -> Response:
"""GET variant of POST /preview for use in HTML ```` tags.
diff --git a/ifcurl/viewer.py b/ifcurl/viewer.py
new file mode 100644
index 0000000..252db64
--- /dev/null
+++ b/ifcurl/viewer.py
@@ -0,0 +1,202 @@
+# IFC URL — web IFC viewer page
+# Copyright (C) 2026 Bruno Postle
+#
+# SPDX-License-Identifier: LGPL-3.0-or-later
+
+"""Self-contained HTML viewer page served at GET /viewer.
+
+Loads @thatopen/components v3 from CDN via importmap and renders an IFC model
+in the browser using WebGL.
+
+CDN notes
+---------
+- ``web-ifc`` is loaded directly from unpkg (not via esm.sh) to avoid esm.sh
+ injecting a Node.js process polyfill that makes web-ifc's browser ST-build
+ environment check throw "not compiled for this environment".
+- ``@thatopen/fragments`` worker is fetched and wrapped in a same-origin blob
+ URL because Firefox blocks cross-origin module workers.
+- web-ifc@0.0.75 is the latest version with a ``browser`` export condition;
+ 0.0.76+ dropped it, causing esm.sh to bundle the Node.js build by mistake.
+- Without COOP/COEP headers the page is not cross-origin isolated, so
+ ``crossOriginIsolated = false`` and web-ifc uses its single-threaded WASM
+ build (no SharedArrayBuffer / pthreads needed).
+"""
+
+VIEWER_HTML = """\
+
+
+
+
+ IFC Viewer
+
+
+
+
+
+