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
Develop With Ai
Structure Business Logic With Di
Upgrade Putnami
Principles
Tooling & Workspace
Workspace
Cli
Jobs & Caching
Extensions
Templates
Error Handling
Frameworks
Typescript
ExtensionOverviewWebReact RoutingForms And ActionsStatic FilesApiErrors And ResponsesConfigurationLoggingHttp And MiddlewareDependency InjectionPlugins And LifecycleSessionsAuthPersistenceEventsStorageCachingWebsocketsTestingHealth ChecksTelemetryProto GrpcSmart ClientSchema
Go
ExtensionOverviewHttpDependency InjectionPlugins And LifecycleConfigurationSecurityPersistenceErrorsEventsStorageCachingLoggingTelemetryGrpcService ClientsValidationOpenapiTesting
Python
Extension
Platform
Ci
  1. DocsSeparator
  2. FrameworksSeparator
  3. TypescriptSeparator
  4. Web

Web

Use @putnami/web 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/web

Entry point

import { application, http, statics } from '@putnami/application';
import { react } from '@putnami/web';

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/web';

export default loader(async () => {
  const users = await fetchUsers();
  return { users };
});
// src/app/users/page.tsx
import { useLoaderData } from '@putnami/web';

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/web';
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/web';

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/web';

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/web';

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/web';

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/web';

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/web';

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/web';

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/web';

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/web';

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/web';

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/web';

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/web';

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/web';

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/web';

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/web';

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/web';

export default loader(async () => {
  const criticalData = await fetchCriticalData();

  return {
    critical: criticalData,
    deferred: fetchDeferredData(), // Don't await
  };
});
// page.tsx
import { Suspense } from '@putnami/web';
import { Await, useLoaderData } from '@putnami/web';

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/web';

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/web';

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