Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions __tests__/app-config.test.ts
Original file line number Diff line number Diff line change
@@ -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'])(

@fernandatoledo fernandatoledo Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@devs-rootstrap these tests only assert the plugin tuple for four explicit APP_ENV values. Could you add a case for unset APP_ENV (which defaults to development), and make the expo-router lookup fail loudly if the entry reverts to the bare string 'expo-router' instead of returning undefined? Also worth noting that nothing here verifies the runtime behavior (/_sitemap unreachable in production), and the PR says it hasn't been checked on a device.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fernandatoledo done in b0a6b07. There is now a case for unset APP_ENV that expects { sitemap: true } (env.js falls back to development). The expo-router lookup now throws if the entry is a bare string or missing instead of returning undefined, and a test covers the bare 'expo-router' case. You are right that these tests only check the config and not the runtime behavior: /_sitemap being unreachable in a production build still needs a manual check on a device.

'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/,
);
});
});
13 changes: 13 additions & 0 deletions agent_docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
],
'expo-dev-client',
'expo-localization',
'expo-router',
['expo-router', { sitemap: Env.APP_ENV !== 'production' }],

@fernandatoledo fernandatoledo Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@devs-rootstrap this gate fails open: env.js defaults APP_ENV to development when it is unset, so anything other than an explicit production keeps the sitemap on. The production build profile in eas.json sets APP_ENV=production, but there is no eas update script or update profile. An OTA update published to the production channel without APP_ENV=production would ship extra.router.sitemap=true and re-enable /_sitemap in the production app. Please add an explicit update script that sets APP_ENV (or document the requirement) so this can't happen silently.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fernandatoledo good catch. In 0ded3f2 I added update:development, update:qa, update:staging and update:production scripts to package.json. They run eas update with APP_ENV set through cross-env, the same way the build scripts do, and each one publishes to the matching channel from eas.json. The navigation guide now says OTA updates have to go through these scripts (e.g. pnpm run update:production --message "..."), because env.js falls back to development and would turn /_sitemap back on. The scripts are also listed in agent_docs/commands.md. They have not been run against EAS yet.

['app-icon-badge', appIconBadgeConfig],
['react-native-edge-to-edge'],
],
Expand Down
Binary file added docs/src/assets/sitemap-screen.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions docs/src/content/docs/guides/navigation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,24 @@ Here is a simple example of the tabs layout.

<CodeBlock file="src/app/(app)/_layout.tsx" />

## 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 `<scheme>://_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):

![Expo Router sitemap screen listing the app routes, such as sign-in, sign-up, onboarding and feed, followed by a System Information card showing development mode](../../../assets/sitemap-screen.png)

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/).
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
Loading