From ced8ddd45dbf8404a32843f4b86b16ecd838aa3e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 13:59:10 +0000 Subject: [PATCH] feat(example): add pathless SSR example package Add a new example-pathless-ssr package (Recipe Book app) that demonstrates SSR without the `ssr` prop on Router. During SSR, only pathless routes match (producing an app shell), and path-based content fills in on client hydration. Features demonstrated: loaders, actions, route params, route state, server/client components, and pathless layout routes. https://claude.ai/code/session_0184Be23W1ES2XZLv6ZQSwTw --- packages/example-pathless-ssr/package.json | 25 ++ packages/example-pathless-ssr/src/App.tsx | 53 +++ .../example-pathless-ssr/src/ClientApp.tsx | 10 + packages/example-pathless-ssr/src/Root.tsx | 18 + .../src/components/Header.tsx | 28 ++ .../src/components/Layout.tsx | 19 + .../src/components/NavLink.tsx | 57 +++ .../example-pathless-ssr/src/data/recipes.ts | 125 ++++++ packages/example-pathless-ssr/src/entries.tsx | 52 +++ .../src/features/favorites/FavoritesList.tsx | 74 ++++ .../src/features/favorites/loaders.ts | 8 + .../src/features/favorites/route.ts | 12 + .../src/features/home/HomePage.tsx | 57 +++ .../src/features/home/route.ts | 6 + .../src/features/recipes/NewRecipeForm.tsx | 60 +++ .../features/recipes/NewRecipeRedirect.tsx | 17 + .../src/features/recipes/RecipeActions.tsx | 59 +++ .../src/features/recipes/RecipeDetailPage.tsx | 5 + .../src/features/recipes/RecipeList.tsx | 28 ++ .../src/features/recipes/RecipeListPage.tsx | 15 + .../src/features/recipes/loaders.ts | 34 ++ .../src/features/recipes/route.ts | 26 ++ .../src/features/recipes/types.ts | 9 + packages/example-pathless-ssr/src/styles.css | 389 ++++++++++++++++++ .../example-pathless-ssr/src/vite-env.d.ts | 1 + packages/example-pathless-ssr/tsconfig.json | 22 + packages/example-pathless-ssr/vite.config.ts | 14 + pnpm-lock.yaml | 31 ++ 28 files changed, 1254 insertions(+) create mode 100644 packages/example-pathless-ssr/package.json create mode 100644 packages/example-pathless-ssr/src/App.tsx create mode 100644 packages/example-pathless-ssr/src/ClientApp.tsx create mode 100644 packages/example-pathless-ssr/src/Root.tsx create mode 100644 packages/example-pathless-ssr/src/components/Header.tsx create mode 100644 packages/example-pathless-ssr/src/components/Layout.tsx create mode 100644 packages/example-pathless-ssr/src/components/NavLink.tsx create mode 100644 packages/example-pathless-ssr/src/data/recipes.ts create mode 100644 packages/example-pathless-ssr/src/entries.tsx create mode 100644 packages/example-pathless-ssr/src/features/favorites/FavoritesList.tsx create mode 100644 packages/example-pathless-ssr/src/features/favorites/loaders.ts create mode 100644 packages/example-pathless-ssr/src/features/favorites/route.ts create mode 100644 packages/example-pathless-ssr/src/features/home/HomePage.tsx create mode 100644 packages/example-pathless-ssr/src/features/home/route.ts create mode 100644 packages/example-pathless-ssr/src/features/recipes/NewRecipeForm.tsx create mode 100644 packages/example-pathless-ssr/src/features/recipes/NewRecipeRedirect.tsx create mode 100644 packages/example-pathless-ssr/src/features/recipes/RecipeActions.tsx create mode 100644 packages/example-pathless-ssr/src/features/recipes/RecipeDetailPage.tsx create mode 100644 packages/example-pathless-ssr/src/features/recipes/RecipeList.tsx create mode 100644 packages/example-pathless-ssr/src/features/recipes/RecipeListPage.tsx create mode 100644 packages/example-pathless-ssr/src/features/recipes/loaders.ts create mode 100644 packages/example-pathless-ssr/src/features/recipes/route.ts create mode 100644 packages/example-pathless-ssr/src/features/recipes/types.ts create mode 100644 packages/example-pathless-ssr/src/styles.css create mode 100644 packages/example-pathless-ssr/src/vite-env.d.ts create mode 100644 packages/example-pathless-ssr/tsconfig.json create mode 100644 packages/example-pathless-ssr/vite.config.ts diff --git a/packages/example-pathless-ssr/package.json b/packages/example-pathless-ssr/package.json new file mode 100644 index 0000000..b1117b5 --- /dev/null +++ b/packages/example-pathless-ssr/package.json @@ -0,0 +1,25 @@ +{ + "name": "funstack-router-example-pathless-ssr", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@funstack/router": "workspace:*", + "@funstack/static": "^1.1.2", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "^6.0.2", + "vite": "^8.0.3" + } +} diff --git a/packages/example-pathless-ssr/src/App.tsx b/packages/example-pathless-ssr/src/App.tsx new file mode 100644 index 0000000..f1693c2 --- /dev/null +++ b/packages/example-pathless-ssr/src/App.tsx @@ -0,0 +1,53 @@ +import { bindRoute, route } from "@funstack/router/server"; +import { ClientApp } from "./ClientApp.js"; +import { Layout } from "./components/Layout.js"; + +// Phase 1 route imports (shared modules — loaders/actions are client references) +import { homeRoute } from "./features/home/route.js"; +import { + recipeListRoute, + recipeDetailRoute, + newRecipeRoute, +} from "./features/recipes/route.js"; +import { favoritesRoute } from "./features/favorites/route.js"; + +// Server component imports +import { HomePage } from "./features/home/HomePage.js"; +import { RecipeListPage } from "./features/recipes/RecipeListPage.js"; +import { RecipeDetailPage } from "./features/recipes/RecipeDetailPage.js"; +import { NewRecipeForm } from "./features/recipes/NewRecipeForm.js"; + +// Client component imports (passed as function reference to receive route props) +import { FavoritesList } from "./features/favorites/FavoritesList.js"; + +// Phase 2: Bind components to routes and assemble the route tree. +// The root Layout is a pathless route — it has no `path` property, so it +// always matches. During SSR (without the `ssr` prop), only this pathless +// layout renders, producing an app shell. Path-based children render on +// the client after hydration. +export const routes = [ + route({ + component: , + children: [ + bindRoute(homeRoute, { + component: , + }), + bindRoute(recipeListRoute, { + component: , + }), + bindRoute(newRecipeRoute, { + component: , + }), + bindRoute(recipeDetailRoute, { + component: , + }), + bindRoute(favoritesRoute, { + component: FavoritesList, + }), + ], + }), +]; + +export default function App() { + return ; +} diff --git a/packages/example-pathless-ssr/src/ClientApp.tsx b/packages/example-pathless-ssr/src/ClientApp.tsx new file mode 100644 index 0000000..928d53d --- /dev/null +++ b/packages/example-pathless-ssr/src/ClientApp.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { Router, type RouteDefinition } from "@funstack/router"; +import "./styles.css"; + +export function ClientApp({ routes }: { routes: RouteDefinition[] }) { + // No ssr prop — during SSR only pathless routes match, rendering the app shell. + // Path-based content fills in on client hydration. + return ; +} diff --git a/packages/example-pathless-ssr/src/Root.tsx b/packages/example-pathless-ssr/src/Root.tsx new file mode 100644 index 0000000..77fb994 --- /dev/null +++ b/packages/example-pathless-ssr/src/Root.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; + +export default function Root({ children }: { children: ReactNode }) { + return ( + + + + + Recipe Book - FUNSTACK Router Pathless SSR Example + + + {children} + + ); +} diff --git a/packages/example-pathless-ssr/src/components/Header.tsx b/packages/example-pathless-ssr/src/components/Header.tsx new file mode 100644 index 0000000..10ee54d --- /dev/null +++ b/packages/example-pathless-ssr/src/components/Header.tsx @@ -0,0 +1,28 @@ +import { NavLink } from "./NavLink.js"; + +const navItems = [ + { path: "/", label: "Home", exact: true }, + { path: "/recipes", label: "Recipes" }, + { path: "/favorites", label: "Favorites" }, +]; + +export function Header() { + return ( +
+

Recipe Book

+ +
+ ); +} diff --git a/packages/example-pathless-ssr/src/components/Layout.tsx b/packages/example-pathless-ssr/src/components/Layout.tsx new file mode 100644 index 0000000..739a2f9 --- /dev/null +++ b/packages/example-pathless-ssr/src/components/Layout.tsx @@ -0,0 +1,19 @@ +import { Outlet } from "@funstack/router"; +import { Header } from "./Header.js"; + +export function Layout() { + return ( +
+
+
+ +
+
+

+ FUNSTACK Router — Pathless SSR Example (shell rendered without{" "} + ssr prop) +

+
+
+ ); +} diff --git a/packages/example-pathless-ssr/src/components/NavLink.tsx b/packages/example-pathless-ssr/src/components/NavLink.tsx new file mode 100644 index 0000000..91b7f25 --- /dev/null +++ b/packages/example-pathless-ssr/src/components/NavLink.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useLocation } from "@funstack/router"; +import { useState, useEffect, type ReactNode } from "react"; + +type NavLinkProps = { + href: string; + className: string; + activeClassName: string; + exact?: boolean; + children: ReactNode; +}; + +/** + * NavLink with active styling that works with pathless SSR. + * + * During SSR (without the `ssr` prop), `useLocation` is not available because + * the URL is null. We defer active-class logic to a child component that only + * mounts after hydration, so `useLocation` is never called during SSR. + */ +export function NavLink(props: NavLinkProps) { + const [isClient, setIsClient] = useState(false); + useEffect(() => setIsClient(true), []); + + if (isClient) { + return ; + } + + // During SSR or initial hydration render: plain link without active styling + return ( + + {props.children} + + ); +} + +function ActiveNavLink({ + href, + className, + activeClassName, + exact, + children, +}: NavLinkProps) { + const location = useLocation(); + const isActive = exact + ? location.pathname === href + : location.pathname === href || location.pathname.startsWith(href); + + return ( + + {children} + + ); +} diff --git a/packages/example-pathless-ssr/src/data/recipes.ts b/packages/example-pathless-ssr/src/data/recipes.ts new file mode 100644 index 0000000..88599a4 --- /dev/null +++ b/packages/example-pathless-ssr/src/data/recipes.ts @@ -0,0 +1,125 @@ +import type { Recipe } from "../features/recipes/types.js"; + +const recipes: Recipe[] = [ + { + id: "1", + title: "Classic Margherita Pizza", + description: + "A simple Italian pizza with fresh tomatoes, mozzarella, and basil.", + ingredients: [ + "Pizza dough", + "San Marzano tomatoes", + "Fresh mozzarella", + "Fresh basil", + "Olive oil", + "Salt", + ], + instructions: + "Preheat oven to 475\u00b0F. Roll out dough, spread crushed tomatoes, add torn mozzarella. Bake 10\u201312 minutes. Top with fresh basil and a drizzle of olive oil.", + favorite: false, + createdAt: "2024-01-15", + }, + { + id: "2", + title: "Chicken Stir-Fry", + description: + "A quick weeknight stir-fry with vegetables and a savory sauce.", + ingredients: [ + "Chicken breast", + "Bell peppers", + "Broccoli", + "Soy sauce", + "Sesame oil", + "Garlic", + "Ginger", + "Rice", + ], + instructions: + "Slice chicken and vegetables. Heat sesame oil in a wok. Cook chicken until golden, then add vegetables. Stir in soy sauce, garlic, and ginger. Serve over steamed rice.", + favorite: true, + createdAt: "2024-01-16", + }, + { + id: "3", + title: "Banana Pancakes", + description: + "Fluffy pancakes made with ripe bananas for natural sweetness.", + ingredients: [ + "Ripe bananas", + "Eggs", + "Flour", + "Baking powder", + "Milk", + "Butter", + "Maple syrup", + ], + instructions: + "Mash bananas, mix with eggs and milk. Fold in flour and baking powder. Cook on a buttered griddle until bubbles form, then flip. Serve with maple syrup.", + favorite: true, + createdAt: "2024-01-17", + }, + { + id: "4", + title: "Caesar Salad", + description: + "Crisp romaine lettuce with homemade Caesar dressing and croutons.", + ingredients: [ + "Romaine lettuce", + "Parmesan cheese", + "Croutons", + "Egg yolk", + "Lemon juice", + "Garlic", + "Anchovies", + "Olive oil", + ], + instructions: + "Whisk egg yolk, lemon juice, minced garlic, and anchovies. Slowly drizzle in olive oil. Toss chopped romaine with dressing, top with shaved Parmesan and croutons.", + favorite: false, + createdAt: "2024-01-18", + }, +]; + +let nextId = 5; + +export function getRecipes(): Recipe[] { + return [...recipes]; +} + +export function getRecipe(id: string): Recipe | undefined { + return recipes.find((r) => r.id === id); +} + +export function createRecipe( + title: string, + description: string, + ingredients: string, + instructions: string, +): Recipe { + const recipe: Recipe = { + id: String(nextId++), + title, + description, + ingredients: ingredients + .split("\n") + .map((s) => s.trim()) + .filter(Boolean), + instructions, + favorite: false, + createdAt: new Date().toISOString().split("T")[0]!, + }; + recipes.push(recipe); + return recipe; +} + +export function getFavorites(): Recipe[] { + return recipes.filter((r) => r.favorite); +} + +export function toggleFavorite(id: string): Recipe | undefined { + const recipe = recipes.find((r) => r.id === id); + if (recipe) { + recipe.favorite = !recipe.favorite; + } + return recipe; +} diff --git a/packages/example-pathless-ssr/src/entries.tsx b/packages/example-pathless-ssr/src/entries.tsx new file mode 100644 index 0000000..27e4e63 --- /dev/null +++ b/packages/example-pathless-ssr/src/entries.tsx @@ -0,0 +1,52 @@ +import type { EntryDefinition } from "@funstack/static/entries"; +import type { RouteDefinition } from "@funstack/router"; +import { routes } from "./App.js"; +import App from "./App.js"; + +function collectPaths(routeDefs: RouteDefinition[], prefix: string): string[] { + const paths: string[] = []; + for (const r of routeDefs) { + const routePath = r.path; + if (routePath === undefined) { + // Pathless route: recurse with same prefix + if (r.children) { + paths.push(...collectPaths(r.children, prefix)); + } + } else if (routePath.includes(":")) { + // Parameterized route: skip (cannot be statically generated) + } else if (r.children) { + // Has path and children: recurse with new prefix + paths.push(...collectPaths(r.children, prefix + routePath)); + } else { + // Leaf route: collect the full path + const fullPath = routePath === "/" ? prefix || "/" : prefix + routePath; + paths.push(fullPath); + } + } + return paths; +} + +function toEntry(path: string): { outputPath: string } { + if (path === "/*") { + return { outputPath: "404.html" }; + } + if (path === "/") { + return { outputPath: "index.html" }; + } + // Remove leading slash for outputPath + const stripped = path.slice(1); + return { outputPath: `${stripped}.html` }; +} + +export default function getEntries(): EntryDefinition[] { + const paths = collectPaths(routes, ""); + return paths.map((path) => { + const { outputPath } = toEntry(path); + return { + path: outputPath, + root: () => import("./Root.js"), + // No ssrPath passed — the Router renders only pathless routes (app shell) + app: , + }; + }); +} diff --git a/packages/example-pathless-ssr/src/features/favorites/FavoritesList.tsx b/packages/example-pathless-ssr/src/features/favorites/FavoritesList.tsx new file mode 100644 index 0000000..0397ad4 --- /dev/null +++ b/packages/example-pathless-ssr/src/features/favorites/FavoritesList.tsx @@ -0,0 +1,74 @@ +"use client"; + +import type { RouteComponentPropsOf } from "@funstack/router"; +import { useRouteData } from "@funstack/router"; +import { favoritesRoute } from "./route.js"; +import type { FavoritesState } from "./route.js"; + +type Props = RouteComponentPropsOf; + +const defaultState: FavoritesState = { + sortBy: "name", +}; + +export function FavoritesList({ state, setStateSync }: Props) { + const recipes = useRouteData(favoritesRoute); + const { sortBy } = state ?? defaultState; + + const sorted = [...recipes].sort((a, b) => { + if (sortBy === "date") { + return b.createdAt.localeCompare(a.createdAt); + } + return a.title.localeCompare(b.title); + }); + + return ( +
+

Favorites

+
+ + +
+ + {sorted.length === 0 ? ( +

+ No favorites yet. Browse recipes and mark some + as favorites! +

+ ) : ( + + )} + +
+

Route State

+
{JSON.stringify(state ?? defaultState, null, 2)}
+
+
+ ); +} diff --git a/packages/example-pathless-ssr/src/features/favorites/loaders.ts b/packages/example-pathless-ssr/src/features/favorites/loaders.ts new file mode 100644 index 0000000..f0a2e8b --- /dev/null +++ b/packages/example-pathless-ssr/src/features/favorites/loaders.ts @@ -0,0 +1,8 @@ +"use client"; + +import { getFavorites } from "../../data/recipes.js"; +import type { Recipe } from "../recipes/types.js"; + +export function loadFavorites(): Recipe[] { + return getFavorites(); +} diff --git a/packages/example-pathless-ssr/src/features/favorites/route.ts b/packages/example-pathless-ssr/src/features/favorites/route.ts new file mode 100644 index 0000000..bdaef09 --- /dev/null +++ b/packages/example-pathless-ssr/src/features/favorites/route.ts @@ -0,0 +1,12 @@ +import { routeState } from "@funstack/router/server"; +import { loadFavorites } from "./loaders.js"; + +export type FavoritesState = { + sortBy: "name" | "date"; +}; + +export const favoritesRoute = routeState()({ + id: "favorites", + path: "/favorites", + loader: loadFavorites, +}); diff --git a/packages/example-pathless-ssr/src/features/home/HomePage.tsx b/packages/example-pathless-ssr/src/features/home/HomePage.tsx new file mode 100644 index 0000000..b6d2c13 --- /dev/null +++ b/packages/example-pathless-ssr/src/features/home/HomePage.tsx @@ -0,0 +1,57 @@ +export function HomePage() { + return ( +
+

Welcome to Recipe Book

+

+ This app demonstrates pathless SSR with FUNSTACK + Router. The <Router> has no ssr prop, so + during SSR only pathless routes (the layout shell) are rendered. + Path-based content fills in on client hydration. +

+ + + +
+

How Pathless SSR Works

+
    +
  • + The root Layout is a pathless route{" "} + (no path property) — it always matches +
  • +
  • + During SSR, the Router renders only pathless routes as an{" "} + app shell (header, footer, navigation) +
  • +
  • + Path-based routes (like this page) render on the{" "} + client after hydration +
  • +
  • + This contrasts with pathful SSR where{" "} + ssr={{ path }} is passed to render + full page content during SSR +
  • +
+
+
+ ); +} diff --git a/packages/example-pathless-ssr/src/features/home/route.ts b/packages/example-pathless-ssr/src/features/home/route.ts new file mode 100644 index 0000000..569ec12 --- /dev/null +++ b/packages/example-pathless-ssr/src/features/home/route.ts @@ -0,0 +1,6 @@ +import { route } from "@funstack/router/server"; + +export const homeRoute = route({ + id: "home", + path: "/", +}); diff --git a/packages/example-pathless-ssr/src/features/recipes/NewRecipeForm.tsx b/packages/example-pathless-ssr/src/features/recipes/NewRecipeForm.tsx new file mode 100644 index 0000000..655768d --- /dev/null +++ b/packages/example-pathless-ssr/src/features/recipes/NewRecipeForm.tsx @@ -0,0 +1,60 @@ +import { NewRecipeRedirect } from "./NewRecipeRedirect.js"; + +export function NewRecipeForm() { + return ( +
+

Create New Recipe

+ +
+
+ + +
+
+ + +
+
+ +