Web
Use @putnami/react for server-side rendering, file-based routing, data loading, and client hydration. Built on React 19 with streaming SSR support.
Getting started
Installation
bunx putnami deps add @putnami/reactEntry point
import { application, http, statics } from '@putnami/application';
import { react } from '@putnami/react';
export const app = () =>
application()
.use(http({ port: 3000 }))
.use(react())
.use(statics({ publicFolder: 'public' }));Configuration options
react({
scanFolder: 'app', // Folder to scan for routes (default: 'app')
autoScan: true, // Auto-discover routes (default: true)
publicFolder: 'public', // Static assets folder (default: 'public')
ssrTimeout: 3000, // SSR render timeout in ms (default: 3000)
minify: true, // Minify client bundles (default: true)
sourcemap: 'external', // 'none' | 'inline' | 'external' (default: 'external')
splitting: false, // Code splitting (default: false)
isDevelopment: false, // Development mode (default: NODE_ENV check)
})Module path inheritance
When used inside a module with .path(), the React plugin automatically inherits the module path as its basename. All routes are served under the module path prefix, and client-side navigation uses it as the router basename:
const dashboard = module('dashboard')
.path('/dashboard')
.use(react()); // Pages served at /dashboard, /dashboard/settings, etc.
const app = application()
.use(http({ port: 3000 }))
.use(dashboard);File-based routing
Routes are automatically discovered from your src/app/ folder. Each folder becomes a route segment, and special files define the route behavior.
Route files
- page.tsx - React component for the route
- loader.ts - Server-side data fetching
- action.ts - Form submission handler
- layout.tsx - Shared layout wrapper
- error.tsx - Error boundary component
Example structure
src/app/
page.tsx # /
loader.ts # Data for /
layout.tsx # Root layout
about/
page.tsx # /about
users/
page.tsx # /users
loader.ts # Data for /users
[id]/
page.tsx # /users/:id
loader.ts # Data for /users/:id
blog/
page.tsx # /blog
[...slug]/
page.tsx # /blog/* (catch-all)Dynamic routes
Use brackets for dynamic segments:
[id]/- Single dynamic segment (/users/123)[...slug]/- Catch-all segment (/blog/2024/my-post)
Pages and loaders
Basic page
// src/app/page.tsx
export default function HomePage() {
return (
<div>
<h1>Welcome to my app</h1>
</div>
);
}Page with loader
// src/app/users/loader.ts
import { loader } from '@putnami/react';
export default loader(async () => {
const users = await fetchUsers();
return { users };
});// src/app/users/page.tsx
import { useLoaderData } from '@putnami/react';
interface LoaderData {
users: { id: string; name: string }[];
}
export default function UsersPage() {
const { users } = useLoaderData<LoaderData>();
return (
<div>
<h1>Users</h1>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}Dynamic route loader
// src/app/users/[id]/loader.ts
import { loader } from '@putnami/react';
import { NotFoundException } from '@putnami/runtime';
export default loader(async (ctx) => {
const { id } = ctx.params;
const user = await findUser(id);
if (!user) {
throw new NotFoundException('User not found');
}
return { user };
});Loader with query parameters
// src/app/search/loader.ts
import { loader } from '@putnami/react';
export default loader(async (ctx) => {
const params = ctx.queryParams();
const query = params.get('q') || '';
const page = parseInt(params.get('page') || '1', 10);
const results = await search(query, page);
return { results, query, page };
});Layouts
Layouts wrap pages and persist across navigation. Use them for shared UI like headers, sidebars, and footers.
Root layout
// src/app/layout.tsx
import { Outlet } from '@putnami/react';
export default function RootLayout() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<header>My App</header>
<main>
<Outlet />
</main>
<footer>Copyright 2025</footer>
</body>
</html>
);
}Nested layouts
// src/app/dashboard/layout.tsx
import { Outlet } from '@putnami/react';
export default function DashboardLayout() {
return (
<div className="dashboard">
<aside>
<nav>Dashboard navigation</nav>
</aside>
<div className="content">
<Outlet />
</div>
</div>
);
}Layout with loader
// src/app/dashboard/layout.loader.ts
import { useUser, HttpResponse } from '@putnami/application';
import { loader } from '@putnami/react';
export default loader(async () => {
const user = await useUser();
if (!user) {
return HttpResponse.redirect('/login');
}
return { user };
});Sharing data with outlet context
// src/app/dashboard/layout.tsx
import { Outlet, useLoaderData } from '@putnami/react';
export default function DashboardLayout() {
const { user } = useLoaderData<{ user: User }>();
return (
<div>
<header>Welcome, {user.name}</header>
<Outlet context={{ user }} />
</div>
);
}// src/app/dashboard/settings/page.tsx
import { useOutletContext } from '@putnami/react';
export default function SettingsPage() {
const { user } = useOutletContext<{ user: User }>();
return <div>Settings for {user.email}</div>;
}Document and SEO
Control the document head with built-in components.
Setting page title and meta
import { Title, Meta, Lang, Favicon } from '@putnami/react';
export default function AboutPage() {
return (
<>
<Title>About Us - My App</Title>
<Meta name="description" content="Learn about our company" />
<Meta property="og:title" content="About Us" />
<Meta property="og:description" content="Learn about our company" />
<Lang value="en" />
<Favicon href="/favicon.ico" />
<h1>About Us</h1>
<p>Welcome to our company...</p>
</>
);
}Dynamic titles from loader data
import { Title, useLoaderData } from '@putnami/react';
export default function UserPage() {
const { user } = useLoaderData<{ user: { name: string } }>();
return (
<>
<Title>{user.name} - My App</Title>
<h1>{user.name}</h1>
</>
);
}Adding scripts and styles
import { Script, Style } from '@putnami/react';
export default function Page() {
return (
<>
<Script src="/analytics.js" async />
<Style>{`
.custom-class {
color: blue;
}
`}</Style>
<div className="custom-class">Styled content</div>
</>
);
}Client hooks
Navigation hooks
import {
useNavigate,
useLocation,
useParams,
useSearchParams,
} from '@putnami/react';
function NavigationExample() {
const navigate = useNavigate();
const location = useLocation();
const params = useParams();
const [searchParams, setSearchParams] = useSearchParams();
// Programmatic navigation
const goToUser = (id: string) => {
navigate(`/users/${id}`);
};
// Navigate with state
const goWithState = () => {
navigate('/dashboard', { state: { from: 'home' } });
};
// Navigate back
const goBack = () => {
navigate(-1);
};
// Update search params
const updateFilter = (value: string) => {
setSearchParams({ filter: value });
};
return (
<div>
<p>Current path: {location.pathname}</p>
<p>User ID: {params.id}</p>
<p>Filter: {searchParams.get('filter')}</p>
</div>
);
}Data hooks
import {
useLoaderData,
useActionData,
useNavigation,
useFetcher,
} from '@putnami/react';
function DataExample() {
// Get loader data
const data = useLoaderData<{ items: Item[] }>();
// Get action result after form submission
const actionData = useActionData<{ success: boolean; error?: string }>();
// Check navigation state
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
const isLoading = navigation.state === 'loading';
// Fetch data without navigation
const fetcher = useFetcher<{ count: number }>();
return (
<div>
{isLoading && <p>Loading...</p>}
{actionData?.error && <p className="error">{actionData.error}</p>}
<button onClick={() => fetcher.load('/api/count')}>
Refresh count: {fetcher.data?.count}
</button>
</div>
);
}Link component
import { Link } from '@putnami/react';
function Navigation() {
return (
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/users" prefetch="intent">Users</Link>
<Link to="/contact" replace>Contact</Link>
</nav>
);
}Blocking navigation
import { useBlocker } from '@putnami/react';
function FormWithUnsavedChanges() {
const [isDirty, setIsDirty] = useState(false);
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname
);
return (
<div>
<input onChange={() => setIsDirty(true)} />
{blocker.state === 'blocked' && (
<div>
<p>You have unsaved changes. Leave anyway?</p>
<button onClick={() => blocker.proceed()}>Leave</button>
<button onClick={() => blocker.reset()}>Stay</button>
</div>
)}
</div>
);
}Error boundaries
Handle errors gracefully at the route level.
Route error boundary
// src/app/users/error.tsx
import { useRouteError, isRouteErrorResponse } from '@putnami/react';
export default function UsersError() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div>
<h1>Something went wrong</h1>
<p>An unexpected error occurred.</p>
</div>
);
}Global error boundary
// src/app/error.tsx
export default function GlobalError() {
return (
<html>
<body>
<h1>Application Error</h1>
<p>Something went wrong. Please try again later.</p>
<a href="/">Go home</a>
</body>
</html>
);
}Streaming and Suspense
React 19 streaming SSR is supported out of the box.
Using Suspense
import { Suspense } from '@putnami/react';
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading stats...</p>}>
<AsyncStats />
</Suspense>
<Suspense fallback={<p>Loading chart...</p>}>
<AsyncChart />
</Suspense>
</div>
);
}Deferred data
Load non-critical data after initial render:
// loader.ts
import { loader } from '@putnami/react';
export default loader(async () => {
const criticalData = await fetchCriticalData();
return {
critical: criticalData,
deferred: fetchDeferredData(), // Don't await
};
});// page.tsx
import { Suspense } from '@putnami/react';
import { Await, useLoaderData } from '@putnami/react';
export default function Page() {
const { critical, deferred } = useLoaderData();
return (
<div>
<h1>{critical.title}</h1>
<Suspense fallback={<p>Loading more...</p>}>
<Await resolve={deferred}>
{(data) => <DeferredContent data={data} />}
</Await>
</Suspense>
</div>
);
}Caching
Putnami supports server-side caching and HTTP cache headers for loaders and pages.
Server-side cache
// Cache loader results server-side for 1 hour
import { loader } from '@putnami/react';
export default loader()
.cache({ ttl: 60 * 60 * 1000 })
.handle(async (ctx) => {
return { posts: await fetchPosts() };
});HTTP cache headers
// Browser cache for 5 minutes with ETag
export default loader()
.cache({ maxAge: 300, etag: true })
.handle(async (ctx) => {
return { posts: await fetchPosts() };
});Combined caching
// Server cache + HTTP cache for maximum performance
export default loader()
.cache({
ttl: 60 * 60 * 1000, // Server: 1 hour
maxAge: 300, // Browser: 5 minutes
etag: true // Conditional requests
})
.handle(async (ctx) => {
return { data: await fetchData() };
});Cache eviction in actions
import { action } from '@putnami/react';
export default action()
.evict('loader:/posts/*') // Evict after mutation
.handle(async (ctx) => {
await createPost(ctx);
return { ok: true };
});See Caching for full documentation.