Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/example-pathless-ssr/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
53 changes: 53 additions & 0 deletions packages/example-pathless-ssr/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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: <Layout />,
children: [
bindRoute(homeRoute, {
component: <HomePage />,
}),
bindRoute(recipeListRoute, {
component: <RecipeListPage />,
}),
bindRoute(newRecipeRoute, {
component: <NewRecipeForm />,
}),
bindRoute(recipeDetailRoute, {
component: <RecipeDetailPage />,
}),
bindRoute(favoritesRoute, {
component: FavoritesList,
}),
],
}),
];

export default function App() {
return <ClientApp routes={routes} />;
}
10 changes: 10 additions & 0 deletions packages/example-pathless-ssr/src/ClientApp.tsx
Original file line number Diff line number Diff line change
@@ -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 <Router routes={routes} fallback="static" />;
}
18 changes: 18 additions & 0 deletions packages/example-pathless-ssr/src/Root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ReactNode } from "react";

export default function Root({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Recipe Book - FUNSTACK Router Pathless SSR Example</title>
<meta
name="description"
content="Pathless SSR example app demonstrating shell rendering without the ssr prop"
/>
</head>
<body>{children}</body>
</html>
);
}
28 changes: 28 additions & 0 deletions packages/example-pathless-ssr/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className="header">
<h1 className="header-title">Recipe Book</h1>
<nav className="header-nav">
{navItems.map((item) => (
<NavLink
key={item.path}
href={item.path}
className="nav-link"
activeClassName="active"
exact={item.exact}
>
{item.label}
</NavLink>
))}
</nav>
</header>
);
}
19 changes: 19 additions & 0 deletions packages/example-pathless-ssr/src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Outlet } from "@funstack/router";
import { Header } from "./Header.js";

export function Layout() {
return (
<div className="layout">
<Header />
<main className="main">
<Outlet />
</main>
<footer className="footer">
<p>
FUNSTACK Router &mdash; Pathless SSR Example (shell rendered without{" "}
<code>ssr</code> prop)
</p>
</footer>
</div>
);
}
57 changes: 57 additions & 0 deletions packages/example-pathless-ssr/src/components/NavLink.tsx
Original file line number Diff line number Diff line change
@@ -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 <ActiveNavLink {...props} />;
}

// During SSR or initial hydration render: plain link without active styling
return (
<a href={props.href} className={props.className}>
{props.children}
</a>
);
}

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 (
<a
href={href}
className={className + (isActive ? " " + activeClassName : "")}
>
{children}
</a>
);
}
125 changes: 125 additions & 0 deletions packages/example-pathless-ssr/src/data/recipes.ts
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 52 additions & 0 deletions packages/example-pathless-ssr/src/entries.tsx
Original file line number Diff line number Diff line change
@@ -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: <App />,
};
});
}
Loading