Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@
"@hono/zod-openapi": "^1.5.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.10.12",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.102.3",
"@tanstack/react-router": "^1.170.32",
"@tanstack/react-router-devtools": "^1.167.1",
"@tanstack/react-router-ssr-query": "^1.167.1",
"@tanstack/react-start": "^1.168.49",
"@vitnode/blog": "workspace:*",
"@vitnode/core": "workspace:*",
Expand All @@ -33,10 +36,14 @@
"drizzle-kit": "1.0.0-rc.4",
"drizzle-orm": "1.0.0-rc.4",
"hono": "^4.12.31",
"lucide-react": "^1.25.0",
"motion": "^12.42.2",
"next-intl": "^4.13.7",
"nitro": "3.0.260610-beta",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"shadcn": "^4.14.0",
"sonner": "^2.0.7",
"tailwindcss": "^4.1.18",
"zod": "^4.4.3"
},
Expand All @@ -50,6 +57,7 @@
"@vitejs/plugin-react": "^6.0.1",
"@vitnode/config": "workspace:*",
"eslint": "^10.7.0",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.2",
"vite": "^8.0.0",
"vitest": "^4.1.10"
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { VitNodeI18nConfig } from '@vitnode/core/lib/i18n/types'

/**
* The languages this app serves.
*
* Its own module, the way `apps/docs` has one, so the web config and (later) the
* API config can point at the same object instead of drifting apart - the site
* and the emails it sends have to agree on which languages exist.
*
* Packages ship their own translations, so nothing here lists them: `pl` has no
* `messages` entry and falls back to `en` key by key. Add
* `messages: { pl: { "@vitnode/core": () => import("./locales/...") } }` to
* reword something without forking the package that owns it.
*/
export const i18n = {
defaultLocale: 'en',
/**
* Explicit, because the app renders on a server: without one, `use-intl`
* formats dates in whatever zone the server happens to run in and warns that
* the client will disagree. Stage 3, which owns the locale runtime, is where a
* per-visitor zone would come from.
*/
timeZone: 'UTC',
locales: [
{
code: 'en',
name: 'English',
},
{
code: 'pl',
name: 'Polski',
},
],
} satisfies VitNodeI18nConfig
36 changes: 36 additions & 0 deletions apps/web/src/lib/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { queryOptions } from '@tanstack/react-query'
import { createServerFn } from '@tanstack/react-start'

import { loadShellIntl } from '#/server/messages.server'

/**
* The shell's locale and its `core.global` strings, fetched on the server.
*
* A server function rather than a plain loader: the messages are read from JSON
* inside each package's `dist`, which only exists on the server, and the plugin
* registry they are merged from must never reach the browser bundle. Start
* strips the handler - and everything only it imports - out of the client build.
*/
export const getShellIntl = createServerFn().handler(
async () => await loadShellIntl(),
)

/**
* The same request, as a query.
*
* Going through the QueryClient rather than returning it from the loader is what
* makes the shell's copy of it *the* copy: the root loader warms it on the
* server, the SSR integration dehydrates it into the HTML, and the component
* reads it out of the hydrated cache instead of asking the server again. It is
* also the first real exercise of the Stage 2 pipeline - router context, loader,
* `ensureQueryData`, dehydrate, hydrate - which is worth having under something
* the page visibly needs rather than a synthetic query.
*
* `staleTime: Infinity`: a locale's messages change when the app is redeployed.
*/
export const shellIntlQueryOptions = () =>
queryOptions({
queryFn: async () => await getShellIntl(),
queryKey: ['vitnode', 'shell-intl'] as const,
staleTime: Infinity,
})
38 changes: 38 additions & 0 deletions apps/web/src/locales.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { LocaleMessagesMap } from '@vitnode/core/lib/i18n/types'

import { CONFIG_PLUGIN as BLOG } from '@vitnode/blog/const'
import { CONFIG_PLUGIN as CORE } from '@vitnode/core/config'
import { CONFIG_PLUGIN as EXAMPLE } from '@vitnode/example/const'

/**
* Where this app reads each installed package's translations from.
*
* Every VitNode package ships a locale barrel - `@vitnode/core/locales/index` -
* that loads its own files with a runtime
* `import("./en.json", { with: { type: "json" } })`. That is exactly right under
* Node, which is how `apps/api` and `apps/docs` read them, and unusable here: a
* bundler resolves that specifier relative to whichever chunk the barrel ended up
* in, the JSON is not next to it, and the built server silently loads nothing -
* every string renders as its own key.
*
* So the loaders are declared here instead, with static specifiers a bundler can
* follow. Each resolves through the package's `./locales/*.json` export to the
* real file and lands in the build as a chunk fetched on demand, which is the
* same laziness the barrels wanted.
*
* The cost is a line here per language a package ships. `apps/docs` already
* declares its own overrides this way, so the shape is not new - but it is a
* copy, and worth removing: make the barrels statically analysable and this file
* becomes one call to `buildMessagesSources`.
*/
export const packageMessages: Record<string, LocaleMessagesMap> = {
[BLOG.pluginId]: {
en: async () => await import('@vitnode/blog/locales/en.json'),
},
[CORE.pluginId]: {
en: async () => await import('@vitnode/core/locales/en.json'),
},
[EXAMPLE.pluginId]: {
en: async () => await import('@vitnode/example/locales/en.json'),
},
}
37 changes: 35 additions & 2 deletions apps/web/src/router.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,47 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import { createVitNodeQueryClient } from '@vitnode/core/lib/query-client'

import { routeTree } from './routeTree.gen'

/**
* The app's router, and the QueryClient it owns.
*
* Start calls this once per server request and once in the browser, which is
* exactly the lifetime a QueryClient should have: created here, it is per
* request on the server - never a module-level client shared by every visitor
* being rendered at once - and a single long-lived one on the client.
*
* It goes into the router context, so a route loader reaches it as
* `context.queryClient` and can `ensureQueryData` before its component renders.
* That is the whole point of putting it here rather than in a provider: a
* loader runs before React does, so a client mounted by a component would be
* out of reach of the code that most wants it.
*
* `setupRouterSsrQueryIntegration` wires the two together: it dehydrates the
* cache into the SSR stream (including queries that resolve mid-render),
* hydrates it on the client before the first render, routes `redirect()` thrown
* inside a query or mutation through the router, and wraps the app in the one
* `QueryClientProvider` for this client. Nothing else in this app may create a
* `QueryClient` or a provider for one - two clients in a page means a query a
* loader cached is invisible to the component that reads it.
*
* `defaultPreloadStaleTime: 0` leaves caching to Query rather than having the
* router keep a second copy of the same data with its own expiry.
*/
export function getRouter() {
const queryClient = createVitNodeQueryClient()

const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
context: { queryClient },
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
routeTree,
scrollRestoration: true,
})

setupRouterSsrQueryIntegration({ queryClient, router })

return router
}

Expand Down
148 changes: 116 additions & 32 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,136 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import type { QueryClient } from '@tanstack/react-query'

import { TanStackDevtools } from '@tanstack/react-devtools'
import { useSuspenseQuery } from '@tanstack/react-query'
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
import {
createRootRouteWithContext,
HeadContent,
Outlet,
Scripts,
} from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { ThemeScript } from '@vitnode/core/components/theme-script'
import { VitNodeProviders } from '@vitnode/core/views/layouts/providers'
import { VitNodeWebSocketProvider } from '@vitnode/core/ws/provider'
import { IntlProvider } from 'next-intl'

import { shellIntlQueryOptions } from '#/lib/i18n'
import { vitNodeShellConfig } from '#/vitnode.shell.config'

import appCss from '../styles.css?url'

export const Route = createRootRoute({
const { debug, i18n, metadata, theme } = vitNodeShellConfig

/**
* What every route in this app can count on having: the QueryClient the router
* owns. A loader reads it as `context.queryClient`.
*/
export interface RootRouterContext {
queryClient: QueryClient
}

export const Route = createRootRouteWithContext<RootRouterContext>()({
component: RootComponent,
head: () => ({
links: [{ href: appCss, rel: 'stylesheet' }],
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'TanStack Start Starter',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
{ charSet: 'utf-8' },
{ content: 'width=device-width, initial-scale=1', name: 'viewport' },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required root metadata and viewport fields

The new application shell only declares the viewport dimensions and title, leaving out a page description, theme-color, and user-scalable configuration. Every route inherits this root head, so production pages will ship without the repository-required SEO and viewport metadata; add the missing fields here.

AGENTS.md reference: AGENTS.md:L46-L46

Useful? React with 👍 / 👎.

// The default title, from the app's config. A route that names itself
// renders `"<page> - <shortTitle>"` instead, through `formatPageTitle` -
// the same rule Next.js applies through `title.template`.
{ title: metadata.title },
],
}),
/**
* Warm the shell's translations before anything renders.
*
* The one line that proves the Stage 2 pipeline: the loader reaches the
* QueryClient through the router context, and the component below reads the
* result out of the cache rather than fetching it again.
*/
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(shellIntlQueryOptions())
},
shellComponent: RootDocument,
})

/**
* The VitNode provider tree.
*
* Every provider here is shared with the Next.js app - `VitNodeProviders` is the
* same module `apps/docs` mounts - except the intl provider, which is this
* app's stand-in until Stage 3 brings the real locale runtime. `IntlProvider` is
* `use-intl`'s own provider, re-exported by `next-intl`; nothing in that entry
* imports `next/*`. The locale it is handed is the app's default one today, and
* the request's in Stage 3 - by then this reads it off the route rather than off
* the config.
*
* The QueryClient is deliberately absent: the router owns it and the SSR
* integration mounts its provider above this tree.
*/
function RootComponent() {
const { data: intl } = useSuspenseQuery(shellIntlQueryOptions())

return (
<IntlProvider
locale={intl.locale}
messages={intl.messages}
timeZone={i18n.timeZone}
>
<VitNodeProviders config={{ debug, locales: i18n.locales, theme }}>
<VitNodeWebSocketProvider>
<Outlet />
</VitNodeWebSocketProvider>
</VitNodeProviders>
</IntlProvider>
)
}

/**
* The document itself.
*
* `lang` comes from the app's configured default locale, not from the request:
* resolving a visitor's locale is Stage 3's job, and this is the smallest thing
* that is correct for a single-language install and honest about it for any
* other. When Stage 3 lands, this reads the matched route's locale instead.
*
* `ThemeScript` has to be in the head, and it has to be inline: it applies the
* stored theme to `<html>` before the browser paints, so the first frame is the
* theme the visitor chose rather than a flash of the default one.
* `suppressHydrationWarning` covers the attributes it writes, which by design
* differ from what the server rendered.
*/
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<html lang={i18n.defaultLocale} suppressHydrationWarning>
<head>
<HeadContent />
<ThemeScript {...theme} />
</head>
<body>

<body suppressHydrationWarning>
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>

{import.meta.env.DEV ? (
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'TanStack Router',
render: <TanStackRouterDevtoolsPanel />,
},
{
name: 'TanStack Query',
render: <ReactQueryDevtoolsPanel />,
},
]}
/>
) : null}

<Scripts />
</body>
</html>
Expand Down
Loading
Loading