diff --git a/README.md b/README.md
index f5d3221..b4eed16 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,51 @@ The `req` JSX prop will be given the `Hono` request:
To put a file in `./src/pages` but prevent it from being server, preface it with `_`.
+### SPA Shell Mode
+
+If `src/client/index.tsx` (or `src/client/index.ts`) exists, hype automatically serves a minimal HTML shell at `/` - no `src/pages/index.tsx` needed.
+
+```
+src/
+ client/
+ index.tsx <- This triggers SPA shell mode!
+ server/
+ index.ts
+```
+
+The shell HTML served at `/`:
+
+```html
+
+
+
+ App
+
+
+
+
+
+
+
+
+
+```
+
+Your client code mounts to `#app`:
+
+```tsx
+// src/client/index.tsx
+import { render } from 'hono/jsx/dom'
+
+function App() {
+ return Hello from the SPA!
+}
+
+render( , document.getElementById('app')!)
+```
+
+See `examples/spa-shell` for a complete example.
+
### Layout
`hype` will wrap everything in a simple default layout unless `./src/pages/_layout.tsx` exists,
diff --git a/examples/spa-shell/README.md b/examples/spa-shell/README.md
new file mode 100644
index 0000000..51b412c
--- /dev/null
+++ b/examples/spa-shell/README.md
@@ -0,0 +1,44 @@
+# SPA Shell Mode Example
+
+This example demonstrates the SPA shell mode feature. When `src/client/index.tsx` (or `.ts`) exists, hype automatically serves a minimal HTML shell at `/` - no `src/pages/index.tsx` needed.
+
+## Structure
+
+```
+src/
+ client/
+ index.tsx <- This triggers SPA shell mode!
+ server/
+ index.ts
+```
+
+## Run
+
+```sh
+bun install
+bun dev
+```
+
+Visit http://localhost:3000 to see the SPA in action.
+
+## How it works
+
+When hype detects `src/client/index.tsx` or `src/client/index.ts`, it serves this HTML at `/`:
+
+```html
+
+
+
+ App
+
+
+
+
+
+
+
+
+
+```
+
+Your client code mounts to `#app` and takes over from there.
diff --git a/examples/spa-shell/package.json b/examples/spa-shell/package.json
new file mode 100644
index 0000000..379de9f
--- /dev/null
+++ b/examples/spa-shell/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "spa-shell-example",
+ "scripts": {
+ "start": "bun run src/server/index.ts",
+ "dev": "bun run --hot src/server/index.ts"
+ },
+ "dependencies": {
+ "hype": "link:../.."
+ }
+}
diff --git a/examples/spa-shell/src/client/index.tsx b/examples/spa-shell/src/client/index.tsx
new file mode 100644
index 0000000..fdc6179
--- /dev/null
+++ b/examples/spa-shell/src/client/index.tsx
@@ -0,0 +1,21 @@
+import { render } from 'hono/jsx/dom'
+import { useState } from 'hono/jsx/dom'
+
+function App() {
+ const [count, setCount] = useState(0)
+
+ return (
+ <>
+ SPA Shell Mode
+ This page is served from a minimal HTML shell - no src/pages/index.tsx needed!
+ Count: {count}
+
+ setCount(c => c + 1)}>+
+ {' '}
+ setCount(c => c && c - 1)}>-
+
+ >
+ )
+}
+
+render( , document.getElementById('app')!)
diff --git a/examples/spa-shell/src/server/index.ts b/examples/spa-shell/src/server/index.ts
new file mode 100644
index 0000000..d534b9f
--- /dev/null
+++ b/examples/spa-shell/src/server/index.ts
@@ -0,0 +1,8 @@
+import { Hype } from 'hype'
+
+const app = new Hype()
+
+// Add your API routes here
+app.get('/api/hello', (c) => c.json({ message: 'Hello from the API!' }))
+
+export default app.defaults
diff --git a/examples/spa-shell/tsconfig.json b/examples/spa-shell/tsconfig.json
new file mode 100644
index 0000000..1c47734
--- /dev/null
+++ b/examples/spa-shell/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "compilerOptions": {
+ "lib": ["ESNext", "DOM"],
+ "target": "ESNext",
+ "module": "Preserve",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx",
+ "allowJs": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false,
+ "baseUrl": ".",
+ "paths": {
+ "$*": ["src/server/*"],
+ "#*": ["src/client/*"],
+ "@*": ["src/shared/*"]
+ }
+ }
+}
diff --git a/src/index.tsx b/src/index.tsx
index f5c5947..faef3c8 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -19,6 +19,22 @@ export type { Context } from 'hono'
const pageCache = new Map()
+function generateShellHtml(title = 'App', mountId = 'app', clientEntry = '/client/index.js'): string {
+ return `
+
+
+ ${title}
+
+
+
+
+
+
+
+
+`
+}
+
export type HypeProps = {
pico?: boolean
reset?: boolean
@@ -68,7 +84,7 @@ export class Hype<
})
}
- registerRoutes() {
+ async registerRoutes() {
if (this.routesRegistered) return
this.routesRegistered = true
@@ -167,6 +183,15 @@ export class Hype<
return render404(c)
})
+ // SPA auto-detection: serve shell if src/client/index.tsx exists
+ const clientIndexPath = join(process.env.PWD ?? '.', 'src/client/index.tsx')
+ const clientIndexTsPath = join(process.env.PWD ?? '.', 'src/client/index.ts')
+ const hasSpaClient = await Bun.file(clientIndexPath).exists() || await Bun.file(clientIndexTsPath).exists()
+
+ if (hasSpaClient) {
+ this.get('/', (c) => c.html(generateShellHtml()))
+ }
+
// file based routing
this.on('GET', ['/', '/:page'], async c => {
const pageName = (c.req.param('page') ?? 'index').replace('.', '')