Use this if: you're building with TanStack Start (TSS) and need
server-set flash toasts — staging a toast from a server function and
having it surface after redirect. Equivalent to remix-toast for the
TSS server-fn / h3-cookie model. Headless: bring your own toast UI.
Keywords: TanStack Start, TSS, toast, flash message, notification, server function, cookie flash, redirect-with-success, headless toast, remix-toast-equivalent.
Server-set toast notifications for TanStack Start. A 1:1 adaptation of
remix-toastfor TanStack Start's server-fn / cookie-bridge model. Published on npm asreact-start-toast.
If you've used remix-toast, you already know the API — setFlashToast, redirectWithSuccess, consumeFlashToast, etc. — they work the same way. This lib closes the equivalent gap for TanStack Start.
Community library by @stevan-borus. Built for TanStack Start; not maintained by the TanStack team. Expect breaking changes on
0.xminor bumps until1.0.0.
- Headless — bring your own toast UI (sonner, react-toastify, your own). The lib only fires
notify(toast)and lets you render the rest. - Type-safe — every export is fully typed; the wire format is a
zodschema. - Strict server/client split — server helpers ship under
/server; only the React adapter touches the client bundle. - Composable — orthogonal helpers (
setFlashToast,redirectWithError,<ToastProvider>) compose inside your own server fns. - Footgun-proofed —
<ToastProvider>bakes in the source-order rule so subscribe-on-mount toast UIs receive the event.
pnpm add react-start-toastThe lib peer-depends on @tanstack/react-router, @tanstack/react-start, react, and react-dom.
The lib has two entrypoints, by design:
| Import path | Use from | Provides |
|---|---|---|
react-start-toast |
Client-bundled files | FlashToastEffect, ToastProvider, type re-exports |
react-start-toast/server |
Server-only files | setFlashToast, consumeFlashToast, setFlashCookieOptions, redirectWith*, replaceWith* |
Why the split: the /server entry imports h3's getCookie/setCookie (which pull node:async_hooks). If the renderer and the server helpers shared one entry, any consumer who bundled the renderer for the browser would crash hydration with AsyncLocalStorage is not a constructor. Splitting keeps server-only code purely server-side.
Set START_TOAST_SECRET (≥32 characters) in your server environment. That's the only required setup.
For runtime-resolved secrets (Vault, AWS Secrets Manager, etc.), call setFlashCookieOptions once at app boot from a server-bundled file:
// In e.g. src/server.ts (anywhere server-only)
import { setFlashCookieOptions } from 'react-start-toast/server'
setFlashCookieOptions({
name: 'my-flash',
maxAge: 30,
secret: () => myVault.read('flash-secret'),
})Precedence: explicit config > env var > throw.
Create one local file in your source tree that re-exports the server-only helpers you'll use. The .server.ts extension activates TanStack Start's import-protection plugin (matches **/*.server.*), which tells Vite to externalize anything imported from this file from the client bundle.
// src/flash-toast-bridge.server.ts
export {
consumeFlashToast,
redirectWithError,
redirectWithSuccess,
setFlashToast,
// ...whichever helpers you actually use
} from 'react-start-toast/server'Every server-only import in the rest of your app goes through this file at the top level — no dynamic-import ceremony needed at call sites. See Why a .server.ts re-export? below for why this matters.
consumeFlashToastFn is the one RPC seam the bridge needs — your __root.tsx loader calls it to surface the staged toast. It's not published from the lib (TSS's compiler only strips server-fn handler bodies for files in your source tree, not pre-built lib files), so each consumer defines it locally:
// src/flash-toast.functions.ts
import { createServerFn } from '@tanstack/react-start'
import type { FlashToast } from 'react-start-toast'
import { consumeFlashToast } from './flash-toast-bridge.server'
export const consumeFlashToastFn = createServerFn({ method: 'POST' }).handler(
async (): Promise<FlashToast | null> => consumeFlashToast(),
)import { createRootRoute, Outlet, Scripts } from '@tanstack/react-router'
import { ToastProvider } from 'react-start-toast'
import { Toaster, toast } from 'sonner'
import { consumeFlashToastFn } from '../flash-toast.functions'
export const Route = createRootRoute({
loader: async () => ({ flashToast: await consumeFlashToastFn() }),
component: RootComponent,
})
function RootComponent() {
const { flashToast } = Route.useLoaderData()
return (
<ToastProvider
toaster={<Toaster />}
toast={flashToast}
notify={(t) => toast[t.type](t.message, t)}
>
<Outlet />
</ToastProvider>
)
}<ToastProvider> mounts the toaster first, then the bridge, so subscribe-on-mount UIs (sonner, react-toastify, etc.) are listening before the bridge fires notify. If you compose the pieces yourself with <FlashToastEffect>, the same source-order rule applies — see Source-order constraint.
Top-level imports from your .server.ts bridge work everywhere — no per-handler dynamic imports, no special handling.
// src/auth.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { redirectWithSuccess } from './flash-toast-bridge.server'
export const loginFn = createServerFn({ method: 'POST' }).handler(async () => {
await myAuth.signIn(/* ... */)
return redirectWithSuccess('/dashboard', 'Welcome back!')
})Or stage without redirecting:
import { setFlashToast } from './flash-toast-bridge.server'
// ...inside any server fn handler:
await setFlashToast({ message: 'Saved', type: 'success' })
return dataFlash toasts work via a cookie set on one response and read on the next request. That two-request handshake means the trigger has to be a full page navigation — not a client-side router transition.
The patterns that already are full navigations:
- HTML form submissions (login, signup, settings save, etc.)
- OAuth callback redirects
- Email verification link clicks
- Sign-out forms
If you trigger a redirectWith* from a client-side <Link> click or a useMutation callback, the destination loader's consume-fetch races the browser's cookie commit and the toast is silently dropped — no error, just nothing. The user clicks, sees the destination page, never knows a toast was meant to fire. For client-side actions, fire your toast UI directly in the mutation's onSuccess — toast.success('Saved') straight to sonner, no cookie involved. The cookie bridge exists for full-navigation flows; if your trigger is already in client land, you don't need it.
<!-- ✅ Form submission — full navigation, cookie commits, toast fires -->
<form method="POST" action="/login">...</form>// ✅ Client mutation — fire your toast UI directly, no cookie bridge
const mutation = useMutation({
mutationFn: () => loginFn({ data }),
onSuccess: () => toast.success('Welcome back!'),
})// ❌ <Link> through a redirect-throwing loader — toast silently dropped
<Link to="/trigger">Go</Link>The example app's index page is a form submission to demonstrate the cookie-bridge path exactly.
| Export | Signature | Notes |
|---|---|---|
setFlashToast |
(input: FlashToastInput, defaultType?: FlashToastType) => Promise<void> |
Stage a toast on the response cookie. Last write wins. |
consumeFlashToast |
() => Promise<FlashToast | null> |
Read + clear the staged toast. Returns null if none. |
setFlashCookieOptions |
(opts: FlashCookieOptions) => void |
Override defaults: name, maxAge, secret, path, sameSite, secure, httpOnly. |
redirectWithToast |
(href: string, input: FlashToastInput) => Promise<never> |
Stage + throw redirect(href). Defaults type: 'info'. |
redirectWithSuccess |
same | Defaults type: 'success'. |
redirectWithError |
same | Defaults type: 'error'. |
redirectWithInfo |
same | Defaults type: 'info'. |
redirectWithWarning |
same | Defaults type: 'warning'. |
replaceWith* (5 of) |
(href, input) => Promise<never> |
Same as redirectWith* but replace: true — back button skips the trigger page. |
| Export | Signature | Notes |
|---|---|---|
<FlashToastEffect> |
{ toast: FlashToast | null, notify: (t: FlashToast) => void } |
Effect-only renderer. Dedupes by _id in sessionStorage. |
<ToastProvider> |
{ toaster, toast, notify, children } |
Composes <Toaster> + <FlashToastEffect> in safe source order. |
FlashToast (type) |
{ message, type, _id, description?, duration? } |
The wire format. |
FlashToastInput |
string | { message, type?, description?, duration? } |
What setFlashToast and redirectWith* accept. |
FlashToastType |
'info' | 'success' | 'error' | 'warning' |
A direct top-level import from react-start-toast/server from a route file or root layout — e.g. import { consumeFlashToast } from 'react-start-toast/server' at the top of __root.tsx — crashes the production build with:
[plugin vite:resolve] Module "node:async_hooks" has been externalized for browser compatibility,
imported by ".../start-server-core/.../request-response.js"
Reason: route files don't go through TanStack Start's server-fn handler-stripping transform, so any top-level import is just a regular module import. react-start-toast/server transitively imports h3 (getCookie/setCookie), which transitively imports node:async_hooks. The whole chain ends up in the client graph and Vite refuses to bundle the Node-only module.
The same import inside a server-fn module (*.functions.ts) sometimes works because TSS's compile-time transform replaces the handler body with a fetch wrapper — and if the only reference to the imported binding is inside that body, the import gets tree-shaken on the client. But that's a fragile guarantee: a single component-level reference to the binding, or a different file shape, breaks it. Empirically we've seen leaks from non-route files too.
The .server.ts extension on a local re-export file fixes this categorically: TSS's import-protection plugin matches the **/*.server.* glob and tells Vite to externalize the module from client bundles entirely, regardless of what file imports it or how. Your other files import from the bridge with normal top-level import statements — the boundary is enforced by the .server.ts filename, not by per-call-site discipline or per-file luck.
The example app includes a Playwright bundle-leak guard that fails CI if AsyncLocalStorage (or any other server-only marker) ever appears in a client asset. Worth copying if you publish your own consumer of this lib.
Alternative — dynamic import inside the handler. If you can't use a
.server.tsfilename for some reason (custom build configs, restricted file naming), you can move the import inside the server-fn handler body where it becomes part of the dead body the transform removes:.handler(async () => { const { consumeFlashToast } = await import('react-start-toast/server') return consumeFlashToast() })Works, but you pay the ceremony at every call site. The
.server.tsre-export is the recommended pattern.
Subscribe-on-mount toast UIs (sonner, react-toastify, sonner-react, custom) buffer events that arrive before they've subscribed but don't replay them. React commits sibling effects in source order, so:
// ✅ Correct — toaster mounts first, subscribes, then the bridge fires
<Toaster />
<FlashToastEffect toast={t} notify={notify} />
// ❌ Wrong — bridge fires before toaster subscribes; toast is silently dropped
<FlashToastEffect toast={t} notify={notify} />
<Toaster /><ToastProvider> bakes the correct order in. Reach for <FlashToastEffect> directly only if you need explicit control.
A working end-to-end example lives in examples/react/basic:
- One
flash-toast-bridge.server.tsre-export, two thin*.functions.tsfiles, top-level imports throughout - All 10 helpers exercised through real navigation (HTML form submission)
- Playwright e2e suite (12 tests) verifying every helper through SSR + hydration + DOM assertions, plus a bundle-leak guard that fails CI if any client asset references
AsyncLocalStorage
cd examples/react/basic
pnpm install
START_TOAST_SECRET=$(openssl rand -hex 32) pnpm devPublished as a single package on npm: react-start-toast. The repo also contains a start-toast-core workspace-internal package — the framework-agnostic primitives (schema, seal/unseal, ID gen) — but it is bundled into the React adapter at build time and is not published independently. Future framework adapters (Solid, etc.) would do the same: depend on start-toast-core via the workspace, bundle it into their own tarball.
- File issues at github.com/stevan-borus/start-toast/issues
- Open a PR — see
docs/adr/0001-roadmap.mdfor the design target before adding new exports