skip to content

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?

level: seniorimportance: should knowfreq 30%

answer

  1. pass the destination as a param
  2. validate it is an internal path
  3. dismissTo pops or replaces
  4. back fails with no history
  5. keep sign-in out of history

basics

~20 s

Open 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 s

From 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
tsx
// 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

for a junior

Recall that the destination travels as a returnTo param and that the sign-in screen navigates there once sign-in succeeds.

for a middle

Explain why back and push fail, and how an href object turns returnTo into an encoded query parameter read with useLocalSearchParams.

for a senior

Use dismissTo to cover both history shapes, validate returnTo because absolute URLs leave the app, and handle modal sign-in stacks and anchors.

for a principal

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.