For applications that do not require SSR for either SEO, crawlers, or performance reasons, it may be desirable to ship static HTML to your users containing the "shell" of your application (or even prerendered HTML for specific routes) that contain the necessary html, head, and body tags to bootstrap your application only on the client.
No SSR doesn't mean giving up server-side features! SPA modes actually pair very nicely with server-side features like server functions and/or server routes or even other external APIs. It simply means that the initial document will not contain the fully rendered HTML of your application until it has been rendered on the client using JavaScript.
After enabling the SPA mode, running a Start build will have an additional prerendering step afterwards to generate the shell. This is done by:
Note
Other routes may also be prerendered and it is recommended to prerender as much as you can in SPA mode, but this is not required for SPA mode to work.
To configure SPA mode, there are a few options you can add to your Start plugin's options:
// vite.config.ts
export default defineConfig({
plugins: [
TanStackStart({
spa: {
enabled: true,
},
}),
],
})
// vite.config.ts
export default defineConfig({
plugins: [
TanStackStart({
spa: {
enabled: true,
},
}),
],
})
Deploying a purely client-side SPA to a host or CDN often requires the use of redirects to ensure that urls are properly rewritten to the SPA shell. The goal of any deployment should include these priorities in this order:
Let's use Netlify's _redirects file to rewrite all 404 requests to the SPA shell.
# Catch all other 404 requests and rewrite them to the SPA shell
/* /_shell.html 200
# Catch all other 404 requests and rewrite them to the SPA shell
/* /_shell.html 200
Again, using Netlify's _redirects file, we can allow-list specific subpaths to be routed through to the server.
# Allow requests to /_serverFn/* to be routed through to the server (If you have configured your server function base path to be something other than /_serverFn, use that instead)
/_serverFn/* /_serverFn/:splat 200
# Allow any requests to /api/* to be routed through to the server (Server routes can be created at any path, so you must ensure that any server routes you want to use are under this path, or simply add additional redirects for each server route base you want to expose)
/api/* /api/:splat 200
# Catch all other 404 requests and rewrite them to the SPA shell
/* /_shell.html 200
# Allow requests to /_serverFn/* to be routed through to the server (If you have configured your server function base path to be something other than /_serverFn, use that instead)
/_serverFn/* /_serverFn/:splat 200
# Allow any requests to /api/* to be routed through to the server (Server routes can be created at any path, so you must ensure that any server routes you want to use are under this path, or simply add additional redirects for each server route base you want to expose)
/api/* /api/:splat 200
# Catch all other 404 requests and rewrite them to the SPA shell
/* /_shell.html 200
The default pathname used to generate the SPA shell is /. We call this the shell mask path. Since matched routes are not included, the pathname used to generate the shell is mostly irrelevant, but it's still configurable.
Note
It's recommended to keep the default value of / as the shell mask path.
// vite.config.ts
export default defineConfig({
plugins: [
TanStackStart({
spa: {
maskPath: '/app',
},
}),
],
})
// vite.config.ts
export default defineConfig({
plugins: [
TanStackStart({
spa: {
maskPath: '/app',
},
}),
],
})
The prerender option is used to configure the prerendering behavior of the SPA shell, and accepts the same prerender options as found in our prerendering guide.
By default, the following prerender options are set:
This means that by default, the shell will not be crawled for links to follow for additional prerendering, and will not retry prerendering fails.
You can always override these options by providing your own prerender options:
// vite.config.ts
export default defineConfig({
plugins: [
TanStackStart({
spa: {
prerender: {
outputPath: '/custom-shell',
crawlLinks: true,
retryCount: 3,
},
},
}),
],
})
// vite.config.ts
export default defineConfig({
plugins: [
TanStackStart({
spa: {
prerender: {
outputPath: '/custom-shell',
crawlLinks: true,
retryCount: 3,
},
},
}),
],
})
Customizing the HTML output of the SPA shell can be useful if you want to:
To make this process simple, an isShell boolean can be found on the router instance:
// src/routes/root.tsx
export default function Root() {
const isShell = useRouter().isShell
if (isShell) console.log('Rendering the shell!')
}
// src/routes/root.tsx
export default function Root() {
const isShell = useRouter().isShell
if (isShell) console.log('Rendering the shell!')
}
You can use this boolean to conditionally render different UI based on whether the current route is a shell or not, but keep in mind that after hydrating the shell, the router will immediately navigate to the first route and the isShell boolean will be false. This could produce flashes of unstyled content if not handled properly.
Since the shell is prerendered using the SSR build of your application, any loaders, or server-specific functionality defined on your Root Route will run during the prerendering process and the data will be included in the shell.
This means that you can use dynamic data in your shell by using a loader or server-specific functionality.
// src/routes/__root.tsx
export const RootRoute = createRootRoute({
loader: async () => {
return {
name: 'Tanner',
}
},
component: Root,
})
export default function Root() {
const { name } = useLoaderData()
return (
<html>
<body>
<h1>Hello, {name}!</h1>
<Outlet />
</body>
</html>
)
}
// src/routes/__root.tsx
export const RootRoute = createRootRoute({
loader: async () => {
return {
name: 'Tanner',
}
},
component: Root,
})
export default function Root() {
const { name } = useLoaderData()
return (
<html>
<body>
<h1>Hello, {name}!</h1>
<Outlet />
</body>
</html>
)
}
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.