Building a SPA with AlabJS β
This guide covers building a client-side single-page application (SPA) with AlabJS. A SPA renders entirely in the browser β no server-side rendering, no Node.js process in production. The output is a folder of static files you can host on any CDN.
When to Use SPA Mode β
SPA mode is the right choice when:
- The app is behind authentication (pages don't need to be indexed by search engines)
- You are building a dashboard, admin panel, or internal tool
- You want to deploy to a static host (Netlify, GitHub Pages, S3, Cloudflare Pages)
- You need to call a separate backend API (your own server, Supabase, Firebase, etc.)
If you need SEO, public-facing content, or dynamic OG images, use SSR instead.
Creating a SPA Project β
npx create-alabjs@latest my-spa
cd my-spa
pnpm devBy default, all pages in AlabJS are client-rendered. A new project is already a SPA.
Project Structure β
my-spa/
βββ app/
β βββ layout.tsx β shell: nav bar, providers, auth context
β βββ page.tsx β / (landing or redirect to /dashboard)
β βββ login/
β β βββ page.tsx β /login
β βββ dashboard/
β βββ layout.tsx β authenticated layout with sidebar
β βββ page.tsx β /dashboard
β βββ settings/
β βββ page.tsx β /dashboard/settings
βββ app/globals.css
βββ package.jsonPages (All CSR) β
Pages render in the browser. No export const ssr needed.
// app/dashboard/page.tsx
import { useServerData } from "alabjs/client";
import type { getDashboardStats } from "./page.server";
export default function DashboardPage() {
const stats = useServerData<typeof getDashboardStats>("getDashboardStats");
return (
<div className="grid grid-cols-3 gap-6">
<StatCard label="Users" value={stats.users} />
<StatCard label="Revenue" value={stats.revenue} />
<StatCard label="Orders" value={stats.orders} />
</div>
);
}Navigation β
Use <Link> for client-side navigation without page reloads.
import { Link } from "alabjs/components";
export default function Sidebar() {
return (
<nav className="w-64 border-r h-screen p-4 flex flex-col gap-2">
<Link href="/dashboard" className="nav-item">Overview</Link>
<Link href="/dashboard/settings" className="nav-item">Settings</Link>
</nav>
);
}Authentication Guard β
Use middleware to redirect unauthenticated users.
// middleware.ts
import { redirect, next } from "alabjs/middleware";
export async function middleware(req: Request) {
const { pathname } = new URL(req.url);
if (pathname.startsWith("/dashboard")) {
const hasSession = req.headers.get("cookie")?.includes("session=");
if (!hasSession) return redirect("/login");
}
return next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};Mutations β
// app/dashboard/settings/page.tsx
import type { updateProfile } from "./page.server";
import { useMutation } from "alabjs/client";
export default function SettingsPage() {
const { mutate, isPending, isSuccess, error } =
useMutation<typeof updateProfile>("updateProfile");
return (
<form onSubmit={e => {
e.preventDefault();
const data = new FormData(e.currentTarget);
mutate({
name: data.get("name") as string,
email: data.get("email") as string,
});
}}>
<input name="name" />
<input name="email" type="email" />
<button disabled={isPending}>{isPending ? "Savingβ¦" : "Save"}</button>
{isSuccess && <p className="text-green-600">Saved.</p>}
{error && <p className="text-red-600">{error.message}</p>}
</form>
);
}Offline Support β
SPA users often expect the app to work when connectivity drops.
// app/layout.tsx
import { useOfflineMutations } from "alabjs/client";
export default function RootLayout({ children }) {
const { isOffline, queuedCount, replay } = useOfflineMutations();
return (
<>
{isOffline && (
<div className="fixed top-0 inset-x-0 bg-yellow-400 text-yellow-900 text-sm text-center py-1">
You are offline β {queuedCount} change(s) queued
<button onClick={replay} className="ml-2 underline">Sync now</button>
</div>
)}
{children}
</>
);
}When the user goes offline, any mutations that fail are queued in IndexedDB and replayed automatically when connectivity returns.
Real-Time Updates β
// app/dashboard/page.tsx
import { useSSE } from "alabjs/client";
export default function LiveDashboard() {
const { data: stats } = useSSE<{ users: number; revenue: number }>(
"/api/stats/stream",
{ event: "stats-update" },
);
return <StatGrid stats={stats} />;
}// app/api/stats/route.ts
import { defineSSEHandler } from "alabjs/server";
export const GET = defineSSEHandler(async function* () {
while (true) {
const stats = await db.getStats();
yield { event: "stats-update", data: stats };
await new Promise(r => setTimeout(r, 5_000));
}
});Building for Production β
# SPA build β outputs a static folder with index.html + hashed assets
alab build --mode spaThe output is in .alabjs/dist/spa/. Deploy the contents of that folder to:
- Netlify β
netlify deploy --dir .alabjsjs/dist/spa - Cloudflare Pages β point the build output to
.alabjs/dist/spa - GitHub Pages β push contents to the
gh-pagesbranch - AWS S3 + CloudFront β sync the folder to your bucket
Routing on a Static Host β
All routes in AlabJS are client-side, so you need to configure the host to serve index.html for any path.
Netlify β create public/_redirects:
/* /index.html 200Cloudflare Pages β create public/_routes.json:
{ "version": 1, "include": ["/*"], "exclude": [] }