TanStack Start
The router, the HTML page, the entry files and the sign-in form that the TanStack Start apps of the repository share.
@zap-ts/tanstack-start holds the setup that web, admin and marketing
would otherwise each write for themselves. TanStack Start is the framework
that renders these apps on the server and then in the browser.
An app that uses this package writes four very short files. The package fills in the rest: the router settings, the HTML page, the theme, the toasts, the progress bar and Sentry.
The package gives you code. It does not give you a route. Your routes, your words and your look stay in your app.
What your app writes
Here is the whole client entry file of apps/web, apart from the service
worker:
import { hydrateApp } from "@zap-ts/tanstack-start/lib/hydrate";
hydrateApp();
Hydration is the moment the browser takes over a page the server already
drew. hydrateApp does that, inside startTransition and StrictMode.
Importing lib/hydrate also starts Sentry in the browser, because the file
imports @zap-ts/observability/client.
The start file is one line:
export { startInstance } from "@zap-ts/tanstack-start/lib/start";
startInstance adds the two Sentry middlewares, so every request and every
server function is traced.
The server entry file uses createAppServerEntry. It wraps the TanStack Start
handler so Sentry sees every request. You can pass your own fetch if the
app has to touch the request first, which is what apps/web does to pick the
language:
import { createAppServerEntry, handler } from "@zap-ts/tanstack-start/lib/server-entry";
export default createAppServerEntry((request) =>
paraglideMiddleware(request, ({ request: localizedRequest }) => handler.fetch(localizedRequest)),
);
The router
lib/router holds the router settings every app shares: scroll restoration,
preloading a page when the user shows intent, and the three views for “not
found”, “error” and “loading”.
Those three views need words, and the words differ by app and by language. So you pass them in.
appRouterDefaults takes the labels and returns the settings:
const router = createRouter({ routeTree, ...appRouterDefaults(labels) });
localizedRouterOptions is for an app that puts the language in the URL, like
/fr/pricing. It adds the URL rewrite of Paraglide on top of
appRouterDefaults. Paraglide is the translation library of the repository.
You pass your route tree, your messages and the two URL functions of your
app:
const router = createRouter(
localizedRouterOptions({ routeTree, messages: m, deLocalizeUrl, localizeUrl }),
);
The page
components/document exports Document, the whole HTML page. It takes two
props: cssHref, the address of the stylesheet of your app, and lang, the
language of the page.
Inside it you get:
- The head that TanStack Router fills with your titles and tags.
- A small script that picks the theme before the first paint, so the page never flashes the wrong colours.
ThemeProviderand the body styles from@zap-ts/ui.- The progress bar, the toasts and the router outlet.
- The TanStack Router devtools and the StyleX dev runtime, in development only.
Your root route stays short:
const RootComponent = () => <Document cssHref={appCss} lang={getLocale()} />;
Document already renders ProgressBar and Toaster, so you do not add
them yourself. The progress bar is the thin line at the top of the page. It
runs while the router loads a route, and it uses the primary colour token.
To show a message from anywhere, including code that is not a React component, use the shared toast manager:
import { toastManager } from "@zap-ts/tanstack-start/lib/toast";
toastManager.add({ title: "Saved", description: "Your changes are live." });
The sign-in form
components/sign-in/form exports SignInForm. It handles a password, a magic
link, a one-time code, a passkey and Google, and it marks the method the user
chose last time.
It takes four props: callbackUrl, the address Better Auth returns to;
labels, every piece of text; onSignedIn, what to do once the user is in;
and children, the links you want under the form.
Every label is a prop, so each app keeps its own words and its own translations.
Small helpers
lib/search reads the search part of a URL safely. A search parameter comes
from the user, so it can be anything:
pageOfturns a value into a page number. Anything wrong reads as the first page.directionOfreturns"asc"unless the value is exactly"desc".sortColumnOfkeeps only the columns you allow, and returnsnullfor the rest.
validateSearch: (input) => ({ page: pageOf(input.page) });
lib/form exports textField. It reads one text field of a submitted form,
and returns an empty string when the field is missing or holds a file. So you
always get a string back.
Related
- UI - the components, tokens and theme this package renders
- Observability - the Sentry setup it imports
- Authentication - what the sign-in form calls
- Web - an app built on this package