stripe-external-redirect-navigation
Stripe External Redirect Navigation Fix
Problem
After returning from Stripe checkout (or similar external payment provider), clicking
a "back" button that uses browser history (router.back(), history.back()) navigates
back to Stripe instead of the previous app page. The browser history includes:
/settings → Stripe checkout → /settings?payment=success
So router.back() goes to Stripe.
Context / Trigger Conditions
- User completes Stripe checkout and returns to your app
- Back button uses
router.back()or browser history navigation document.referrercheck for external domains returns empty or doesn't work- Works on staging/production but staging uses a simple
<Link>instead of history-based back
Why document.referrer doesn't work:
Stripe and many payment providers set strict Referrer-Policy headers (e.g.,
strict-origin-when-cross-origin or no-referrer) that prevent the referrer from
being passed back to your app. The referrer may be empty or just the origin without path.
Solution
Use sessionStorage to explicitly track when returning from an external redirect:
1. Create a utility for tracking external returns
// Session storage key for tracking external payment returns
const EXTERNAL_RETURN_KEY = 'external_payment_return';
/**
* Mark that the user just returned from an external site (e.g., Stripe).
* Call this when handling payment returns or other external redirects.
*/
export function markExternalReturn() {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem(EXTERNAL_RETURN_KEY, 'true');
}
}
/**
* Clear the external return flag. Call after handling.
*/
export function clearExternalReturn() {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem(EXTERNAL_RETURN_KEY);
}
}
/**
* Check if the user just returned from an external site.
*/
export function hasExternalReturn(): boolean {
if (typeof sessionStorage === 'undefined') return false;
return sessionStorage.getItem(EXTERNAL_RETURN_KEY) === 'true';
}
2. Mark external return when handling payment callback
// In your payment return handler (e.g., /settings?payment=success)
function PaymentReturnHandler() {
const searchParams = useSearchParams();
useEffect(() => {
const payment = searchParams.get("payment");
if (payment === "success" || payment === "cancelled") {
// Mark that we returned from external payment
markExternalReturn();
// ... handle payment verification
}
}, [searchParams]);
}
3. Check flag in BackButton before using history
function BackButton({ fallback = '/app' }) {
const router = useRouter();
const handleBack = () => {
// Check if we explicitly marked an external return (most reliable)
const markedExternal = hasExternalReturn();
// Fallback checks (less reliable but catch edge cases)
const referrer = typeof document !== 'undefined' ? document.referrer : '';
const host = typeof window !== 'undefined' ? window.location.host : '';
const isExternalReferrer = referrer && !referrer.includes(host);
const historyTooShort = typeof window !== 'undefined' && window.history.length <= 2;
if (markedExternal || isExternalReferrer || historyTooShort) {
clearExternalReturn(); // Clear flag after use
router.push(fallback);
} else {
router.back();
}
};
return <button onClick={handleBack}>Back</button>;
}
Verification
- Go to your payment page (e.g.,
/settings) - Click "Purchase" to go to Stripe checkout
- Complete payment (use test card 4242 4242 4242 4242)
- After redirect back to your app, click the back button
- Expected: Navigate to your app's home/fallback page (e.g.,
/app) - Before fix: Would navigate back to Stripe checkout
Example
Full implementation in a Next.js settings page with Stripe:
// components/ui/BackButton.tsx
'use client';
import { useRouter } from 'next/navigation';
const EXTERNAL_RETURN_KEY = 'external_payment_return';
// Static method pattern allows: BackButton.markExternalReturn()
BackButton.markExternalReturn = function() {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem(EXTERNAL_RETURN_KEY, 'true');
}
};
export function BackButton({ fallback = '/app' }) {
const router = useRouter();
const handleBack = () => {
const hasExternal = sessionStorage?.getItem(EXTERNAL_RETURN_KEY) === 'true';
if (hasExternal) {
sessionStorage.removeItem(EXTERNAL_RETURN_KEY);
router.push(fallback);
} else {
router.back();
}
};
return (
<button onClick={handleBack} aria-label="Go back">
← Back
</button>
);
}
// app/settings/page.tsx
function PaymentReturnHandler() {
const searchParams = useSearchParams();
useEffect(() => {
if (searchParams.get("payment") === "success") {
BackButton.markExternalReturn(); // Mark before clearing URL
// Handle payment verification...
router.replace("/settings"); // Clean URL
}
}, [searchParams]);
}
export default function SettingsPage() {
return (
<>
<PaymentReturnHandler />
<BackButton fallback="/app" />
{/* ... */}
</>
);
}
Notes
-
sessionStorage vs localStorage: Use sessionStorage because it's tab-specific. If user opens multiple tabs, each has its own payment flow state.
-
Security: This flag only affects UX navigation, not payment security. Payment verification must still happen server-side with Stripe session validation.
-
Flag cleanup: Always clear the flag after use to prevent it from affecting future navigation in the same session.
-
Multiple fallback checks: Keep the
document.referrerandhistory.lengthchecks as fallbacks for edge cases, but don't rely on them for Stripe. -
Why not just use Link?: Using a simple
<Link href="/">works but loses the "back" behavior for internal navigation. The sessionStorage approach gives you the best of both: proper back navigation internally, fallback for external returns.