diff --git a/README.md b/README.md
index ffa8b93..2e1255c 100644
--- a/README.md
+++ b/README.md
@@ -40,12 +40,13 @@ All commands are run from the root of the project, from a terminal:
## ⚙️ Configuration
-The app renders **server-side** (`prerender = false`) and proxies requests to a device API. It reads one env var:
+The app is a mix of **prerendered** content pages (about, how it works, editorial stance, imprint — one static file per locale, generated under `src/pages/[...locale]/`) and **server-rendered** pages that depend on the device API or on query parameters (home, browse, device detail, surveys, 404, `robots.txt`). It reads these env vars:
-| Variable | Required | Default | Description |
-| :-------------- | :------- | :---------------------- | :-------------------------------------------------------------------------------------------------- |
-| `API_AUTHORITY` | No | `http://localhost:3000` | Base URL of the device API. The app fetches `/api/unstable/derived/devices` from here, server-side. |
-| `NOINDEX` | No | `true` | Adds `noindex` for preview deploys. Set to `false` for a real production deploy. |
+| Variable | Required | Default | Description |
+| :------------------ | :------- | :---------------------- | :-------------------------------------------------------------------------------------------------------------------------- |
+| `API_AUTHORITY` | No | `http://localhost:3000` | Base URL of the device API. The app fetches `/api/unstable/derived/devices` from here, server-side. |
+| `CSR_API_AUTHORITY` | No | `http://localhost:3000` | Base URL of the device API for client-side fetches (search box, filters). Served to the browser by `/config.js` at runtime. |
+| `NOINDEX` | No | `true` | Adds `noindex` for preview deploys. Set to `false` for a real production deploy. |
Create a `.env` for local runs (auto-loaded by Astro and `netlify dev`):
@@ -88,7 +89,7 @@ docker run -p 4321:4321 \
device-database-frontend
```
-**Runtime config.** All app config is runtime-only: the server reads `API_AUTHORITY` and `NOINDEX` from the environment (`-e`), so a single image serves every environment — nothing is baked in at build time. `NOINDEX` defaults to `true` (no indexing) when unset; every page is server-rendered, so a flip takes effect on container restart (plus CDN cache TTL).
+**Runtime config.** All app config is runtime-only: the server reads `API_AUTHORITY`, `CSR_API_AUTHORITY` and `NOINDEX` from the environment (`-e`), so a single image serves every environment — nothing is baked in at build time. `NOINDEX` defaults to `true` (no indexing) when unset; a flip takes effect on container restart (plus CDN cache TTL). Two consequences of prerendering: the client-side API authority is served by the `/config.js` endpoint rather than inlined into the HTML, and prerendered pages carry no `robots` meta tag or `X-Robots-Tag` header (they would freeze the build-time value) — `robots.txt`, which is server-rendered, remains the crawl gate for preview deploys.
The server listens on `HOST=0.0.0.0` and `PORT=4321` (both overridable via env vars). The runtime image ships only the bundled server output — the node build bundles all dependencies into `dist/` (`vite.ssr.noExternal`), so there is no `node_modules` in the final image. One consequence: Astro's sharp-backed `/_image` endpoint is unavailable in the container (the project doesn't use `astro:assets`, so nothing depends on it — revisit if that changes).
diff --git a/astro.config.mjs b/astro.config.mjs
index 74bb0b7..3d55f6a 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -44,11 +44,15 @@ const prefixedLocales = [
// https://astro.build/config
export default defineConfig({
+ // `localizeHref()` emits links without a trailing slash; `/about/` redirects to `/about`.
+ trailingSlash: "never",
build: {
// External stylesheets persist correctly across ClientRouter navigations.
inlineStylesheets: "never",
+ // Write prerendered pages as `about.html`, with `about/index.html` Netlify
+ // would redirect every `/about` link to `/about/`.
+ format: "file",
},
- output: "server",
env: {
schema: {
// access: "secret" keeps the value out of the build output; it is read
@@ -66,7 +70,8 @@ export default defineConfig({
}),
// Preview edition is no-indexed by default. Set NOINDEX=false in the runtime
// environment for a real production deploy. Like API_AUTHORITY it is not baked
- // into the image: every page that consumes it is server-rendered.
+ // into the image: server-rendered responses consume it at request time,
+ // prerendered pages leave indexing to robots.txt (see Layout.astro).
NOINDEX: envField.boolean({
context: "server",
access: "secret",
diff --git a/src/io/index.ts b/src/io/index.ts
index 3c6f9a9..5818ef8 100644
--- a/src/io/index.ts
+++ b/src/io/index.ts
@@ -3,18 +3,37 @@ import { z } from "astro/zod";
import { exactlyOne } from "../types/exactly-one";
const REQUEST_TIMEOUT_MS = 8000;
-let API_AUTHORITY: string;
+let SERVER_API_AUTHORITY: string | undefined;
if (import.meta.env.SSR) {
- ({ API_AUTHORITY } = await import("astro:env/server"));
-} else {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any -- defined dynamically in `Layout.astro``
- API_AUTHORITY = (window as any).__API_AUTHORITY__;
+ ({ API_AUTHORITY: SERVER_API_AUTHORITY } = await import("astro:env/server"));
}
-export const ioBaseUrl = (): string =>
- /^https?:\/\//.test(API_AUTHORITY)
- ? API_AUTHORITY
- : `https://${API_AUTHORITY}`;
+declare global {
+ interface Window {
+ /** set by `/config.js`, see `src/pages/config.js.ts` */
+ __API_AUTHORITY__?: string;
+ }
+}
+
+// resolved per call rather than at module evaluation, so the island bundle
+// never depends on `/config.js` having run before it was imported
+const apiAuthority = (): string => {
+ const authority = import.meta.env.SSR
+ ? SERVER_API_AUTHORITY
+ : window.__API_AUTHORITY__;
+
+ if (typeof authority === "undefined") {
+ throw new Error("API authority is not configured");
+ }
+
+ return authority;
+};
+
+export const ioBaseUrl = (): string => {
+ const authority = apiAuthority();
+
+ return /^https?:\/\//.test(authority) ? authority : `https://${authority}`;
+};
export class IoError extends Error {
constructor(
diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro
index d0020ca..fabfca3 100644
--- a/src/layouts/Layout.astro
+++ b/src/layouts/Layout.astro
@@ -1,5 +1,5 @@
---
-import { CSR_API_AUTHORITY, NOINDEX } from "astro:env/server";
+import { NOINDEX } from "astro:env/server";
import { ClientRouter } from "astro:transitions";
import Footer from "../components/Footer.astro";
@@ -25,11 +25,21 @@ const { title, page = "page", back } = Astro.props;
- {NOINDEX && }
+ {
+ /* NOINDEX is a runtime value. A prerendered page would freeze whatever the
+ build saw, so it carries no robots meta; robots.txt (server-rendered)
+ remains the crawl gate for preview deploys. */
+ NOINDEX && !Astro.isPrerendered && (
+
+ )
+ }
-
+ {
+ /* Runtime API authority for client-side fetches, served by
+ src/pages/config.js.ts so prerendered pages pick it up too. `defer`
+ keeps it ahead of the island modules without blocking parsing. */
+ }
+