diff --git a/docs/functions/clean-html-urls.ts b/docs/functions/clean-html-urls.ts
new file mode 100644
index 0000000000..eadd654a68
--- /dev/null
+++ b/docs/functions/clean-html-urls.ts
@@ -0,0 +1,27 @@
+// Redirect legacy *.html URLs to their extensionless equivalents. Runs as an
+// edge function because a wildcard _redirects rule can't force a redirect
+// away from a file that physically exists at the "from" path.
+import { Context } from "@netlify/edge-functions";
+
+const excluded: Set = new Set([
+ "/netlify-forms.html",
+ "/docs/kubernetes-admission-control.html",
+]);
+
+export default async (
+ request: Request,
+ context: Context,
+): Promise => {
+ const url: URL = new URL(request.url);
+ const pathname: string = url.pathname;
+
+ if (!pathname.endsWith(".html") || excluded.has(pathname)) {
+ return undefined;
+ }
+
+ url.pathname = pathname === "/index.html"
+ ? "/"
+ : pathname.slice(0, -".html".length);
+
+ return Response.redirect(url.toString(), 301);
+};
diff --git a/docs/src/pages/ecosystem/index.jsx b/docs/src/pages/ecosystem/index.jsx
index 13422f43db..0722a67bf7 100644
--- a/docs/src/pages/ecosystem/index.jsx
+++ b/docs/src/pages/ecosystem/index.jsx
@@ -1,6 +1,7 @@
import React, { useState } from "react";
import Link from "@docusaurus/Link";
+import useBaseUrl from "@docusaurus/useBaseUrl";
import Heading from "@theme/Heading";
import Layout from "@theme/Layout";
@@ -94,7 +95,7 @@ const EcosystemIndex = (props) => {
return (
{
diff --git a/netlify.toml b/netlify.toml
index 7d1974c0f0..e76b4ff3a0 100644
--- a/netlify.toml
+++ b/netlify.toml
@@ -27,6 +27,11 @@ function = "badge"
path = "/docs/*"
function = "version-redirect"
+# Canonicalize legacy .html URLs onto their clean-path equivalents.
+[[edge_functions]]
+path = "/*"
+function = "clean-html-urls"
+
# function to process form notifications from netlify forms and send them to slack
[[edge_functions]]
path = "/api/feedback"