diff --git a/SELF-HOSTING.md b/SELF-HOSTING.md
index 228eb97..040f09a 100644
--- a/SELF-HOSTING.md
+++ b/SELF-HOSTING.md
@@ -56,6 +56,8 @@ cp .env.example .env.local
- `NEXT_PUBLIC_MAPTILER_KEY` is required for the map basemap. Free tier, no credit card, at
[cloud.maptiler.com](https://cloud.maptiler.com/account/keys/).
+- If the key is missing or empty, the map shows "Basemap unavailable" instead of loading tiles.
+ Add the key to `.env.local`, then restart the development server or redeploy the site.
- The Supabase variables are optional. Leave them blank and the map, filters, and calendar all
work; you just won't get sign-in or the two private features below. See step 5 to fill them in.
diff --git a/src/components/map/map-view.test.tsx b/src/components/map/map-view.test.tsx
new file mode 100644
index 0000000..ebabe4f
--- /dev/null
+++ b/src/components/map/map-view.test.tsx
@@ -0,0 +1,33 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import MapView from "@/components/map/map-view";
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+});
+
+describe("MapView configuration", () => {
+ it("shows actionable guidance when the MapTiler key is blank", () => {
+ vi.stubEnv("NEXT_PUBLIC_MAPTILER_KEY", " ");
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+
+ render();
+
+ expect(
+ screen.getByRole("status", { name: "Basemap unavailable" }),
+ ).toBeTruthy();
+ expect(screen.getByText("NEXT_PUBLIC_MAPTILER_KEY")).toBeTruthy();
+ expect(
+ screen
+ .getByRole("link", { name: "Open the self-hosting setup" })
+ .getAttribute("href"),
+ ).toBe("/docs/self-hosting");
+ expect(warn).toHaveBeenCalledOnce();
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining("NEXT_PUBLIC_MAPTILER_KEY is missing"),
+ );
+ });
+});
diff --git a/src/components/map/map-view.tsx b/src/components/map/map-view.tsx
index f823d2a..6216cf5 100644
--- a/src/components/map/map-view.tsx
+++ b/src/components/map/map-view.tsx
@@ -217,6 +217,35 @@ interface MapViewProps {
closePopupTrigger?: number;
}
+let didWarnAboutMissingMapTilerKey = false;
+
+function BasemapUnavailable() {
+ return (
+
+
+
Basemap unavailable
+
+ The site administrator needs to configure{" "}
+
+ NEXT_PUBLIC_MAPTILER_KEY
+ {" "}
+ before the map can load.
+
+
+ Open the self-hosting setup
+
+
+
+ );
+}
+
/** Closes all open popups whenever the trigger counter increments. */
function ClosePopupOnTrigger({ trigger }: { trigger: number }) {
const map = useMap();
@@ -409,6 +438,25 @@ export default function MapView({
onViewportChange,
closePopupTrigger = 0,
}: MapViewProps) {
+ const mapTilerKey = process.env.NEXT_PUBLIC_MAPTILER_KEY?.trim();
+
+ useEffect(() => {
+ if (
+ process.env.NODE_ENV !== "production" &&
+ !mapTilerKey &&
+ !didWarnAboutMissingMapTilerKey
+ ) {
+ didWarnAboutMissingMapTilerKey = true;
+ console.warn(
+ "[MapView] NEXT_PUBLIC_MAPTILER_KEY is missing. The basemap cannot load. See SELF-HOSTING.md for setup instructions.",
+ );
+ }
+ }, [mapTilerKey]);
+
+ if (!mapTilerKey) {
+ return ;
+ }
+
const focusPlace = focusId
? places.find((place) => place.id === focusId)
: undefined;
@@ -445,7 +493,7 @@ export default function MapView({