Putnami
DocsGitHub

Licensed under FSL-1.1-MIT

Getting Started
Concepts
How To
Build A Web App
Build An Api Service
Share Code Between Projects
Configure Your App
Add Persistence
Add Authentication
Add Background Jobs
Principles
Tooling & Workspace
Workspace Overview
Cli
Jobs & Commands
SDK
Error Handling
Extensions
Typescript
Go
Python
Docker
Ci
Frameworks
Typescript
OverviewWebReact RoutingForms And ActionsStatic FilesApiErrors And ResponsesConfigurationLoggingHttp And MiddlewareDependency InjectionPlugins And LifecycleSessionsAuthPersistenceEventsStorageCachingWebsocketsTestingHealth ChecksTelemetryProto GrpcSmart Client
Go
OverviewHttpDependency InjectionPlugins And LifecycleConfigurationSecurityPersistenceErrorsEventsStorageCachingLoggingTelemetryGrpcService ClientsValidationOpenapiTesting
Platform
  1. DocsSeparator
  2. FrameworksSeparator
  3. TypescriptSeparator
  4. Web

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/react

Entry 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.

Related guides

  • Build a web app
  • React routing
  • Forms and actions
  • Caching

On this page

  • Web
  • Getting started
  • Installation
  • Entry point
  • Configuration options
  • Module path inheritance
  • File-based routing
  • Route files
  • Example structure
  • Dynamic routes
  • Pages and loaders
  • Basic page
  • Page with loader
  • Dynamic route loader
  • Loader with query parameters
  • Layouts
  • Root layout
  • Nested layouts
  • Layout with loader
  • Sharing data with outlet context
  • Document and SEO
  • Setting page title and meta
  • Dynamic titles from loader data
  • Adding scripts and styles
  • Client hooks
  • Navigation hooks
  • Data hooks
  • Link component
  • Blocking navigation
  • Error boundaries
  • Route error boundary
  • Global error boundary
  • Streaming and Suspense
  • Using Suspense
  • Deferred data
  • Caching
  • Server-side cache
  • HTTP cache headers
  • Combined caching
  • Cache eviction in actions
  • Related guides