Skip to content

Commit 273c4c1

Browse files
committed
feat: shell mode
1 parent 0a66147 commit 273c4c1

File tree

10 files changed

+287
-27
lines changed

10 files changed

+287
-27
lines changed

docs/start/config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@
5353
"label": "SSR",
5454
"to": "framework/react/ssr"
5555
},
56+
{
57+
"label": "SPA Shell",
58+
"to": "framework/react/spa-shell"
59+
},
5660
{
5761
"label": "Hosting",
5862
"to": "framework/react/hosting"
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
---
2+
id: spa-shell
3+
title: SPA Shell
4+
---
5+
6+
## What the heck is an SPA Shell?
7+
8+
For applications that do not require SSR for either SEO, crawlers, or performance reasons, it may be desirable to ship a single HTML document to your users containing the "shell" of your application, or more specifically, the bare-minimum `html`, `head`, and `body` tags necessary to bootstrap your application only on the client.
9+
10+
## Why use Start to create an SPA Shell?
11+
12+
**No SSR doesn't mean giving up server-side features!** SPA Shells actually pair very nicely with server-side features like server functions and/or server routes. 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 JS**.
13+
14+
## Benefits of an SPA Shell
15+
16+
- **Easier to deploy** - A CDN that can serve static assets is all you need.
17+
- **Cheaper** to host - CDNs are cheap compared to Lambda functions or long-running processes.
18+
- **Client-side Only is simpler** - No SSR means less to go wrong with hydration, rendering, and routing.
19+
20+
## Caveats of an SPA Shell
21+
22+
- **Slower time to full content** - Time to full content is longer since all JS must download and execute before anything below the shell can be rendered.
23+
- **Less SEO friendly** - Robots, crawlers and link unfurlers _may_ have a harder time indexing your application unless they are configured to execute JS and your application can render within a reasonable amount of time.
24+
25+
## How does it work?
26+
27+
After enabling the SPA Shell mode, running a Start build will have an additional prerendering step afterwards to generate the shell. This is done by:
28+
29+
- **Prerendering** your application's **root route only**
30+
- Where your application would normally render your matched routes, your router's configured **pending fallback component is rendered instead**.
31+
- The resulting HTML is stored to a static HTML page called `/_shell.html` (configurable)
32+
- Default rewrites are configured to redirect all 404 requests to the SPA shell
33+
34+
## Enabling the SPA Shell
35+
36+
To configure the SPA shell, you can add the following to your Start plugin's options:
37+
38+
```tsx
39+
// vite.config.ts
40+
export default defineConfig({
41+
plugins: [
42+
TanStackStart({
43+
shell: {
44+
enabled: true,
45+
},
46+
}),
47+
],
48+
})
49+
```
50+
51+
## Shell Mask Path
52+
53+
The default pathname used to generate the 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.
54+
55+
> [!NOTE]
56+
> It's recommended to keep the default value of `/` as the shell mask path.
57+
58+
```tsx
59+
// vite.config.ts
60+
export default defineConfig({
61+
plugins: [
62+
TanStackStart({
63+
shell: {
64+
maskPath: '/app',
65+
},
66+
}),
67+
],
68+
})
69+
```
70+
71+
## Auto Redirects
72+
73+
When shell mode is enabled, the default behavior is to add a catch-all redirect from all 404 requests to the output shell HTML file. This behavior can be disabled by setting the `autoRedirect` option to `false`.
74+
75+
```tsx
76+
// vite.config.ts
77+
export default defineConfig({
78+
plugins: [TanStackStart({ shell: { autoRedirect: false } })],
79+
})
80+
```
81+
82+
## Prerendering Options
83+
84+
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.
85+
86+
**By default, the following `prerender` options are set:**
87+
88+
- `outputPath`: `/_shell.html`
89+
- `crawlLinks`: `false`
90+
- `retryCount`: `0`
91+
92+
This means that by default, the shell will not be crawled for links to follow for additional prerendering, and will not retry prerendering fails.
93+
94+
You can always override these options by providing your own prerender options:
95+
96+
```tsx
97+
// vite.config.ts
98+
export default defineConfig({
99+
plugins: [
100+
TanStackStart({
101+
shell: {
102+
prerender: {
103+
outputPath: '/custom-shell',
104+
crawlLinks: true,
105+
retryCount: 3,
106+
},
107+
},
108+
}),
109+
],
110+
})
111+
```
112+
113+
## Customizing the SPA Shell
114+
115+
Customizing the SPA shell can be useful if you want to:
116+
117+
- Provide generic head tags for SPA routes
118+
- Provide a custom pending fallback component
119+
- Change literally anything about the shell's HTML, CSS, and JS
120+
121+
To make this process simple, an `isShell` boolean can be found on the `router` instance:
122+
123+
```tsx
124+
// src/routes/root.tsx
125+
export default function Root() {
126+
const isShell = useRouter().isShell
127+
128+
if (isShell) console.log('Rendering the shell!')
129+
}
130+
```
131+
132+
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.**
133+
134+
## Dynamic Data in your Shell
135+
136+
Since the shell is prerendered using the SSR build of your application, any `loader`s, or server-specific functionality defined on your **Root Route** will run during the prerendering process and the data will be included in the shell.
137+
138+
This means that you can use dynamic data in your shell by using a `loader` or server-specific functionality.
139+
140+
```tsx
141+
// src/routes/__root.tsx
142+
143+
export const RootRoute = createRootRoute({
144+
loader: async () => {
145+
return {
146+
name: 'Tanner',
147+
}
148+
},
149+
component: Root,
150+
})
151+
152+
export default function Root() {
153+
const { name } = useLoaderData()
154+
155+
return (
156+
<html>
157+
<body>
158+
<h1>Hello, {name}!</h1>
159+
<Outlet />
160+
</body>
161+
</html>
162+
)
163+
}
164+
```

examples/react/start-basic-static/src/routes/__root.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ export const Route = createRootRoute({
5353
}),
5454
errorComponent: (props) => {
5555
return (
56-
<RootDocument>
56+
<RootLayout>
5757
<DefaultCatchBoundary {...props} />
58-
</RootDocument>
58+
</RootLayout>
5959
)
6060
},
6161
notFoundComponent: () => <NotFound />,
@@ -64,13 +64,13 @@ export const Route = createRootRoute({
6464

6565
function RootComponent() {
6666
return (
67-
<RootDocument>
67+
<RootLayout>
6868
<Outlet />
69-
</RootDocument>
69+
</RootLayout>
7070
)
7171
}
7272

73-
function RootDocument({ children }: { children: React.ReactNode }) {
73+
function RootLayout({ children }: { children: React.ReactNode }) {
7474
return (
7575
<html>
7676
<head>

examples/react/start-basic-static/vite.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export default defineConfig({
1111
projects: ['./tsconfig.json'],
1212
}),
1313
tanstackStart({
14-
prerender: {
14+
shell: {
1515
enabled: true,
1616
},
1717
}),

packages/react-router/src/Match.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,17 @@ export const Outlet = React.memo(function OutletImpl() {
306306
},
307307
})
308308

309+
const pendingElement = router.options.defaultPendingComponent ? (
310+
<router.options.defaultPendingComponent />
311+
) : null
312+
313+
if (router.isShell)
314+
return (
315+
<React.Suspense fallback={pendingElement}>
316+
<ShellInner />
317+
</React.Suspense>
318+
)
319+
309320
if (parentGlobalNotFound) {
310321
return renderRouteNotFound(router, route, undefined)
311322
}
@@ -316,10 +327,6 @@ export const Outlet = React.memo(function OutletImpl() {
316327

317328
const nextMatch = <Match matchId={childMatchId} />
318329

319-
const pendingElement = router.options.defaultPendingComponent ? (
320-
<router.options.defaultPendingComponent />
321-
) : null
322-
323330
if (matchId === rootRouteId) {
324331
return (
325332
<React.Suspense fallback={pendingElement}>{nextMatch}</React.Suspense>
@@ -328,3 +335,7 @@ export const Outlet = React.memo(function OutletImpl() {
328335

329336
return nextMatch
330337
})
338+
339+
function ShellInner(): React.ReactElement {
340+
throw new Error('ShellBoundaryError')
341+
}

packages/react-start-server/src/defaultStreamHandler.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ export const defaultStreamHandler = defineHandlerCallback(
5454
},
5555
}),
5656
onError: (error, info) => {
57+
if (
58+
error instanceof Error &&
59+
error.message === 'ShellBoundaryError'
60+
)
61+
return
5762
console.error('Error in renderToPipeableStream:', error, info)
5863
},
5964
},

packages/router-core/src/router.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,8 @@ export class RouterCore<
844844
// router can be used in a non-react environment if necessary
845845
startTransition: StartTransitionFn = (fn) => fn()
846846

847+
isShell = false
848+
847849
update: UpdateFn<
848850
TRouteTree,
849851
TTrailingSlashOption,
@@ -932,6 +934,10 @@ export class RouterCore<
932934
'selector(:active-view-transition-type(a)',
933935
)
934936
}
937+
938+
if ((this.latestLocation.search as any).__TSS_SHELL) {
939+
this.isShell = true
940+
}
935941
}
936942

937943
get state() {

packages/start-plugin-core/src/nitro/nitro-plugin.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,52 @@ export function nitroPlugin(
8080
rollupConfig: {
8181
plugins: [virtualBundlePlugin(getSsrBundle())],
8282
},
83+
routeRules: {
84+
// TODO: We need to expose *some* kind of routeRules configuration
85+
// and it needs to translate to this for now. But we should
86+
// be cognizant of the probability that we will not use Nitro's
87+
// routeRules configuration in the future.
88+
...(options.shell.enabled && options.shell.autoRedirect
89+
? {
90+
'/**': {
91+
// @ts-expect-error We are using this as a marker
92+
__TSS_SHELL: true,
93+
redirect: {
94+
to: `${options.shell.prerender.outputPath.replace(/[/]{1,}$/, '')}`,
95+
statusCode: 200,
96+
},
97+
},
98+
}
99+
: {}),
100+
},
83101
}
84102

85103
const nitro = await createNitro(nitroConfig)
86104

87105
await buildNitroEnvironment(nitro, () => build(nitro))
88106

107+
if (options.shell.enabled) {
108+
options.prerender = {
109+
...options.prerender,
110+
enabled: true,
111+
}
112+
113+
const maskUrl = new URL(
114+
options.shell.maskPath,
115+
'http://localhost',
116+
)
117+
118+
maskUrl.searchParams.set('__TSS_SHELL', 'true')
119+
120+
options.pages.push({
121+
path: maskUrl.toString().replace('http://localhost', ''),
122+
prerender: options.shell.prerender,
123+
sitemap: {
124+
exclude: true,
125+
},
126+
})
127+
}
128+
89129
if (options.prerender?.enabled) {
90130
await prerender({
91131
options,

0 commit comments

Comments
 (0)