In an Expo Router pharmacy app, how would you return the user to the cart after sign-in, including when sign-in was opened by a cold-start deep link?
answer
- pass the destination as a param
- validate it is an internal path
- dismissTo pops or replaces
- back fails with no history
- keep sign-in out of history
basics
~20 sOpen sign-in with an href object carrying a returnTo param, validate that it is an internal path, and on success call router.dismissTo(returnTo), which pops back to the cart if it is in history and otherwise replaces sign-in with it.
solid answer
~40 sFrom the cart, open sign-in with `router.push({ pathname: '/sign-in', params: { returnTo: '/cart' } })`, which resolves to `/sign-in?returnTo=%2Fcart`. On success, read `returnTo` with `useLocalSearchParams()`, **validate** it (a single leading `/`, not `//`, no scheme, because Expo Router hands absolute URLs to the operating system through `Linking`), and call `router.dismissTo(returnTo)`. dismissTo **pops** back to the existing cart entry when sign-in sits above it and **replaces** sign-in with the cart when there is no cart entry, which is exactly the cold-start case. The alternatives fail somewhere: `router.back()` has nothing to return to on a cold start, `router.push` leaves sign-in in history, and `router.replace` can leave a duplicate cart when the cart is already underneath.
code
tsx · 28 lines// src/app/sign-in.tsx
import { router, useLocalSearchParams } from 'expo-router';
import { Button, View } from 'react-native';
function safeReturnTo(value: string | undefined): string {
// Internal paths only: absolute URLs would be handed to the OS.
if (value && value.startsWith('/') && !value.startsWith('//')) return value;
return '/';
}
export default function SignInScreen() {
const { returnTo } = useLocalSearchParams<{ returnTo?: string }>();
return (
<View>
<Button
title="Sign in"
onPress={async () => {
await signIn();
// Pops to the cart if it is in history, otherwise replaces sign-in with it.
router.dismissTo(safeReturnTo(returnTo));
}}
/>
</View>
);
}
declare function signIn(): Promise<void>;go deeper
Recall that the destination travels as a returnTo param and that the sign-in screen navigates there once sign-in succeeds.
Explain why back and push fail, and how an href object turns returnTo into an encoded query parameter read with useLocalSearchParams.
Use dismissTo to cover both history shapes, validate returnTo because absolute URLs leave the app, and handle modal sign-in stacks and anchors.
Standardise return-after-auth across flows so every entry point, from taps to email links, lands correctly without per-screen special cases.
## The requirement In the pharmacy app, a signed-out user taps **Checkout** on `/cart`. Checkout needs an account, so the app opens `/sign-in`. When sign-in succeeds, the user must land back on the cart with the basket intact, and: - pressing back from the cart must **not** reopen the sign-in form; - the flow must also work when `/sign-in?returnTo=/cart` was opened by a **cold-start deep link**, such as an email "finish your order" link, where no cart screen exists in history. ## Passing the destination Send the destination as a **param** on the sign-in href: ```tsx router.push({ pathname: '/sign-in', params: { returnTo: '/cart' } }); ``` The href object resolves to `/sign-in?returnTo=%2Fcart`: `returnTo` matches no bracket in the pathname, so it becomes an encoded query parameter. On the sign-in screen, `useLocalSearchParams<{ returnTo?: string }>()` reads it back as a string. Carrying it in the URL, rather than in memory, is what makes the deep-link case work. ## Validating returnTo A `returnTo` value that arrives through a link is **untrusted input**. Expo Router decides that an href is **external** when it starts with a well-known scheme such as `https:` or `tel:`, with any scheme followed by `//`, or with a bare `//`, and then opens it with the operating system through `Linking` instead of routing it. Without a check, a crafted link could send a freshly signed-in user out of the app. A strict rule: 1. It must start with exactly one `/`. 2. It must not start with `//`. 3. Otherwise, fall back to a safe default such as `/`. ## Choosing the navigation call | Call on success | Cart below sign-in | Cold start, no cart in history | |---|---|---| | `router.back()` | returns to cart | nothing to go back to | | `router.push(returnTo)` | second cart; sign-in stays in history | cart on top of sign-in | | `router.replace(returnTo)` | cart, cart (duplicate) | works | | `router.dismissTo(returnTo)` | pops to the existing cart | replaces sign-in with cart | `router.dismissTo` pops screens until the href is on top, and when the href is **not** in history it **replaces** the current screen. That single call covers both rows, keeps sign-in out of history, and never duplicates the cart. ## Walking through both paths **Tap path.** The stack holds `(tabs)`, `cart`. Checkout pushes `/sign-in?returnTo=%2Fcart`, giving `(tabs)`, `cart`, `sign-in`. On success, `dismissTo('/cart')` pops `sign-in`; the stack is `(tabs)`, `cart`, and back returns to the tabs. **Cold-start path.** An email link opens `/sign-in?returnTo=%2Fcart`. The stack holds only `sign-in` (plus whatever initial route the layout inserts). On success, `dismissTo('/cart')` finds no cart entry and replaces `sign-in` with `cart`. Back then goes to the layout's initial route or leaves the app, but never to the sign-in form. In both paths the sign-in screen is gone from history and exactly one cart entry exists, which is the whole requirement expressed as navigation state. ## Edge cases a senior answer covers - **Sign-in presented as a modal stack.** When sign-in and a verification-code step form their own nested stack above the cart, dismissTo still targets the cart entry below; alternatively, `router.dismiss()` from the only remaining screen closes the whole nested stack. - **Cart inside another stack.** If the cart lives inside a stack that should have a screen beneath it, the `withAnchor` navigation option asks the router to load that stack's initial route underneath the cart, so back still has somewhere to go. - **Double submission.** Disable the button while signing in; two successful responses would issue two navigation calls. - **Guards.** If the whole checkout group is protected, the guard decides who may enter; this flow decides where to return. ## The resulting code - Cart: push sign-in with `returnTo`. - Sign-in: validate `returnTo`, await authentication, call `dismissTo`. - No global state is needed to remember the destination, and the flow is identical from a tap, a notification or an email link.
- Why is router.replace(returnTo) not enough when sign-in was pushed from the cart?replace swaps the sign-in entry for a new cart entry, leaving the original cart beneath it: cart, cart. Back from the new cart shows the old one. dismissTo pops to the existing cart instead of creating another.
- What happens if returnTo is 'https://example.com' and passed straight to the router?Expo Router treats hrefs with a scheme, or starting with `//`, as external and opens them with the operating system through `Linking`, so the signed-in user leaves the app. Accept only paths that start with a single `/`.
- Why not keep the destination in a global store instead of a param?A cold-start deep link starts with an empty store, so the destination would be lost. A URL param travels with the link, survives reloads on web, and makes sign-in reusable for any destination.
saying these in an interview costs you the question
- router.back() after sign-in always returns to the cart.
- router.push is fine because the sign-in screen disappears on its own.
- returnTo from a link can be passed to the router without validation.
- dismissTo throws when the cart is not in the history.
- The destination must be kept in global state because params do not survive links.