diff --git a/__tests__/app-config.test.ts b/__tests__/app-config.test.ts
new file mode 100644
index 000000000..c7085e4b5
--- /dev/null
+++ b/__tests__/app-config.test.ts
@@ -0,0 +1,74 @@
+import type { ConfigContext, ExpoConfig } from '@expo/config';
+
+type AppConfigFn = (context: ConfigContext) => ExpoConfig;
+
+const loadConfigFor = (appEnv: string | undefined): ExpoConfig => {
+ if (appEnv === undefined) {
+ delete process.env.APP_ENV;
+ } else {
+ process.env.APP_ENV = appEnv;
+ }
+ let appConfig: AppConfigFn | undefined;
+ jest.isolateModules(() => {
+ appConfig = require('../app.config').default;
+ });
+ if (!appConfig) {
+ throw new Error('app.config.ts could not be loaded');
+ }
+ return appConfig({ config: {} } as ConfigContext);
+};
+
+const getExpoRouterOptions = (config: ExpoConfig) => {
+ const entry = config.plugins?.find((plugin) =>
+ Array.isArray(plugin)
+ ? plugin[0] === 'expo-router'
+ : plugin === 'expo-router',
+ );
+ if (!Array.isArray(entry)) {
+ throw new Error(
+ `Expected the expo-router plugin to be configured as ['expo-router', options], received ${JSON.stringify(entry)}`,
+ );
+ }
+ return entry[1];
+};
+
+describe('app.config expo-router sitemap', () => {
+ const originalAppEnv = process.env.APP_ENV;
+
+ afterEach(() => {
+ if (originalAppEnv === undefined) {
+ delete process.env.APP_ENV;
+ } else {
+ process.env.APP_ENV = originalAppEnv;
+ }
+ });
+
+ it('disables the sitemap route in production', () => {
+ const config = loadConfigFor('production');
+
+ expect(getExpoRouterOptions(config)).toEqual({ sitemap: false });
+ });
+
+ it.each(['development', 'qa', 'staging'])(
+ 'keeps the sitemap route in %s',
+ (appEnv) => {
+ const config = loadConfigFor(appEnv);
+
+ expect(getExpoRouterOptions(config)).toEqual({ sitemap: true });
+ },
+ );
+
+ it('keeps the sitemap route when APP_ENV is unset (defaults to development)', () => {
+ const config = loadConfigFor(undefined);
+
+ expect(getExpoRouterOptions(config)).toEqual({ sitemap: true });
+ });
+
+ it('fails loudly when the expo-router entry has no options', () => {
+ const config = { plugins: ['expo-router'] } as ExpoConfig;
+
+ expect(() => getExpoRouterOptions(config)).toThrow(
+ /Expected the expo-router plugin/,
+ );
+ });
+});
diff --git a/agent_docs/commands.md b/agent_docs/commands.md
index 28d8ff501..334bd9783 100644
--- a/agent_docs/commands.md
+++ b/agent_docs/commands.md
@@ -64,6 +64,19 @@ Builds are created with Expo Application Services. Always set `APP_ENV` via the
---
+## Update (EAS)
+
+OTA updates are published with `eas update`. Always use these scripts so `APP_ENV` matches the target channel; publishing without it falls back to `development` config (e.g. re-enables the `/_sitemap` route in production). Pass a message with `--message "..."`.
+
+| Command | Description |
+| ----------------------------- | ---------------------------------- |
+| `pnpm run update:development` | EAS update — `development` channel |
+| `pnpm run update:qa` | EAS update — `qa` channel |
+| `pnpm run update:staging` | EAS update — `staging` channel |
+| `pnpm run update:production` | EAS update — `production` channel |
+
+---
+
## Setup
| Command | Description |
diff --git a/app.config.ts b/app.config.ts
index 6831bb657..0b7b4a169 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -86,7 +86,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
],
'expo-dev-client',
'expo-localization',
- 'expo-router',
+ ['expo-router', { sitemap: Env.APP_ENV !== 'production' }],
['app-icon-badge', appIconBadgeConfig],
['react-native-edge-to-edge'],
],
diff --git a/docs/src/assets/sitemap-screen.png b/docs/src/assets/sitemap-screen.png
new file mode 100644
index 000000000..c1f716929
Binary files /dev/null and b/docs/src/assets/sitemap-screen.png differ
diff --git a/docs/src/content/docs/guides/navigation.mdx b/docs/src/content/docs/guides/navigation.mdx
index 36cd82a8e..7294f602c 100644
--- a/docs/src/content/docs/guides/navigation.mdx
+++ b/docs/src/content/docs/guides/navigation.mdx
@@ -22,4 +22,24 @@ Here is a simple example of the tabs layout.
+## Sitemap route
+
+Expo Router injects a [`/_sitemap`](https://docs.expo.dev/router/reference/sitemap/) route that lists every route in the app, which is handy for debugging. The template removes this in-app route browser from production builds: the `expo-router` plugin in `app.config.ts` is configured with `sitemap: Env.APP_ENV !== 'production'`. This does not hide the route names themselves, which still ship in the JS bundle and the typed-routes output. On `development`, `qa` and `staging` builds you can open `://_sitemap` (or `/_sitemap` on web); on `production` builds that path is handled by the template's catch-all route, `src/app/[...messing].tsx`.
+
+If you remove that catch-all route, unmatched paths fall back to Expo Router's built-in not-found screen, which renders a hard-coded link to `/_sitemap`. In production that link leads nowhere, so keep a custom catch-all (or `+not-found`) route if you disable the sitemap.
+
+Here is what the sitemap screen looks like (captured on the web build, `development` environment):
+
+
+
+To change this behavior, update the `sitemap` option of the `expo-router` plugin in `app.config.ts` (set it to `true` to always include the route, or `false` to always remove it).
+
+Because the option is read from `APP_ENV` when the app config is evaluated, OTA updates must also be published with `APP_ENV` set. `env.js` falls back to `development` when it is unset, so an `eas update` published to the `production` channel without `APP_ENV=production` would re-enable `/_sitemap` in the production app. Use the per-environment scripts, which set `APP_ENV` and target the matching channel:
+
+```bash
+pnpm run update:production --message "Fix login copy"
+```
+
+`update:development`, `update:qa` and `update:staging` work the same way for the other channels.
+
Make sure to check the official docs for more information and examples about [expo-router](https://docs.expo.dev/router/introduction/).
diff --git a/package.json b/package.json
index 283cf1efd..5247a374d 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"build:development:ios": "cross-env APP_ENV=development EXPO_NO_DOTENV=1 eas build --profile development --platform ios",
"build:development:web": "cross-env APP_ENV=development EXPO_NO_DOTENV=1 expo export -c --platform web",
"submit:development:mobile": "cross-env APP_ENV=development eas submit --profile development",
+ "update:development": "cross-env APP_ENV=development EXPO_NO_DOTENV=1 eas update --channel development",
"start:qa": "cross-env APP_ENV=qa pnpm run start",
"prebuild:qa": "cross-env APP_ENV=qa pnpm run prebuild",
"android:qa": "cross-env APP_ENV=qa pnpm run android",
@@ -24,6 +25,7 @@
"build:qa:ios": "cross-env APP_ENV=qa EXPO_NO_DOTENV=1 eas build --profile qa --platform ios",
"build:qa:web": "cross-env APP_ENV=qa EXPO_NO_DOTENV=1 expo export -c --platform web",
"submit:qa:mobile": "cross-env APP_ENV=qa eas submit --profile qa",
+ "update:qa": "cross-env APP_ENV=qa EXPO_NO_DOTENV=1 eas update --channel qa",
"start:staging": "cross-env APP_ENV=staging pnpm run start",
"prebuild:staging": "cross-env APP_ENV=staging pnpm run prebuild",
"android:staging": "cross-env APP_ENV=staging pnpm run android",
@@ -33,6 +35,7 @@
"build:staging:ios": "cross-env APP_ENV=staging EXPO_NO_DOTENV=1 eas build --profile staging --platform ios",
"build:staging:web": "cross-env APP_ENV=staging EXPO_NO_DOTENV=1 expo export -c --platform web",
"submit:staging:mobile": "cross-env APP_ENV=staging eas submit --profile staging",
+ "update:staging": "cross-env APP_ENV=staging EXPO_NO_DOTENV=1 eas update --channel staging",
"start:production": "cross-env APP_ENV=production pnpm run start",
"prebuild:production": "cross-env APP_ENV=production pnpm run prebuild",
"android:production": "cross-env APP_ENV=production pnpm run android",
@@ -42,6 +45,7 @@
"build:production:ios": "cross-env APP_ENV=production EXPO_NO_DOTENV=1 eas build --profile production --platform ios",
"build:production:web": "cross-env APP_ENV=production EXPO_NO_DOTENV=1 expo export -c --platform web",
"submit:production:mobile": "cross-env APP_ENV=production eas submit --profile production",
+ "update:production": "cross-env APP_ENV=production EXPO_NO_DOTENV=1 eas update --channel production",
"credentials:ios": "eas credentials --platform ios",
"credentials:android": "eas credentials --platform android",
"prepare": "husky",