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. React Routing

React Routing

@putnami/react uses file-based routing built on React Router. Routes are automatically discovered from your file structure, with support for dynamic segments, nested layouts, and error boundaries.

File conventions

Route files

  • page.tsx - React component for the route, optionally with middleware via page() builder
  • loader.ts - Server-side data fetching
  • action.ts - Form submission handler
  • layout.tsx - Shared layout wrapper, optionally with middleware via layout() builder
  • layout.loader.ts - Layout data fetching
  • error.tsx - Error boundary component, optionally with middleware via error() builder
  • not-found.tsx - Not-found (404) page component, optionally with middleware via notFound() builder

Folder structure

src/app/
  page.tsx              # /
  loader.ts             # Data for /
  layout.tsx            # Root layout
  error.tsx             # Global error boundary
  about/
    page.tsx            # /about
  users/
    page.tsx            # /users
    loader.ts           # Data for /users
    layout.tsx          # Layout for /users/*
    [id]/
      page.tsx          # /users/:id
      loader.ts         # Data for /users/:id
  blog/
    page.tsx            # /blog
    [...slug]/
      page.tsx          # /blog/* (catch-all)
      loader.ts         # Data for catch-all

Dynamic routes

Single parameter

Use brackets for dynamic segments:

src/app/users/[id]/page.tsx → /users/:id
// src/app/users/[id]/page.tsx
import { useParams } from '@putnami/react';

export default function UserPage() {
  const { id } = useParams();
  return <h1>User {id}</h1>;
}
// src/app/users/[id]/loader.ts
import { loader } from '@putnami/react';
import { Uuid } from '@putnami/application';

export default loader()
  .params({ id: Uuid })
  .handle(async (ctx) => {
    // ctx.params.id is validated as a UUID
    const user = await fetchUser(ctx.params.id);
    return { user };
  });

Multiple parameters

src/app/posts/[year]/[month]/[slug]/page.tsx → /posts/:year/:month/:slug
// loader.ts
import { loader } from '@putnami/react';

export default loader()
  .params({ year: String, month: String, slug: String })
  .handle(async (ctx) => {
    const { year, month, slug } = ctx.params;
    const post = await fetchPost(year, month, slug);
    return { post };
  });

Catch-all routes

Use [...param] to match any remaining path segments:

src/app/docs/[...path]/page.tsx → /docs/*
// src/app/docs/[...path]/loader.ts
import { loader } from '@putnami/react';

export default loader(async (ctx) => {
  const { path } = ctx.params;
  // path = 'getting-started/installation' for /docs/getting-started/installation
  const doc = await fetchDoc(path);
  return { doc };
});

Optional catch-all

Catch-all routes also match the parent path:

/docs           → path = undefined
/docs/intro     → path = 'intro'
/docs/api/users → path = 'api/users'

Layouts

Layouts wrap child routes and persist across navigation.

Root layout

// src/app/layout.tsx
import { layout, Outlet } from '@putnami/react';

export default layout().render(function RootLayout() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>My App</title>
      </head>
      <body>
        <Outlet />
      </body>
    </html>
  );
});

Nested layouts

// src/app/dashboard/layout.tsx
import { layout, Outlet } from '@putnami/react';

export default layout().render(function DashboardLayout() {
  return (
    <div className="dashboard">
      <nav>
        <a href="/dashboard">Overview</a>
        <a href="/dashboard/settings">Settings</a>
      </nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
});

Layout with data

// src/app/dashboard/layout.loader.ts
import { loader } from '@putnami/react';
import { useUser, HttpResponse } from '@putnami/application';

export default loader(async (ctx) => {
  const user = await useUser();

  if (!user) {
    return HttpResponse.redirect('/login');
  }

  return { user };
});
// src/app/dashboard/layout.tsx
import { layout, Outlet, useLoaderData } from '@putnami/react';

export default layout().render(function DashboardLayout() {
  const { user } = useLoaderData<{ user: User }>();

  return (
    <div>
      <header>Welcome, {user.name}</header>
      <Outlet />
    </div>
  );
});

Layout middleware

Use the layout() builder to declare middleware that applies to all pages under a layout:

// src/app/admin/layout.tsx
import { layout, Outlet } from '@putnami/react';

export default layout()
  .secure({ roles: ['admin'] })
  .render(function AdminLayout() {
    return <Outlet />;
  });

All pages under /admin/* now require OAuth authentication with the admin role.

Page middleware

Declare middleware for a specific page directly in page.tsx using the page() builder:

// src/app/admin/settings/page.tsx
import { page } from '@putnami/react';

export default page()
  .secure()
  .rateLimit({ max: 50 })
  .render(function SettingsPage() {
    return <h1>Settings</h1>;
  });

Layout and page middleware compose: layout middleware runs first (outermost), then page middleware, then the handler. Nested layouts stack in order from root to leaf.

Sharing context

Pass data to child routes via outlet context:

// src/app/dashboard/layout.tsx
import { layout, Outlet, useLoaderData } from '@putnami/react';

export default layout().render(function DashboardLayout() {
  const { user, permissions } = useLoaderData();

  return (
    <Outlet context={{ user, permissions }} />
  );
});
// src/app/dashboard/settings/page.tsx
import { useOutletContext } from '@putnami/react';

export default function SettingsPage() {
  const { user, permissions } = useOutletContext<DashboardContext>();

  return (
    <div>
      <h1>Settings for {user.name}</h1>
      {permissions.canEdit && <EditForm />}
    </div>
  );
}

Error boundaries

Route error boundary

// src/app/users/error.tsx
import { error, useRouteError, isRouteErrorResponse, Link } from '@putnami/react';

export default error().render(function UsersError() {
  const err = useRouteError();

  if (isRouteErrorResponse(err)) {
    if (err.status === 404) {
      return (
        <div>
          <h1>User Not Found</h1>
          <p>The user you're looking for doesn't exist.</p>
          <Link to="/users">Back to users</Link>
        </div>
      );
    }

    return (
      <div>
        <h1>{err.status} {err.statusText}</h1>
        <p>{err.data}</p>
      </div>
    );
  }

  return (
    <div>
      <h1>Something went wrong</h1>
      <p>An unexpected error occurred.</p>
    </div>
  );
});

Global error boundary

// src/app/error.tsx
import { error } from '@putnami/react';

export default error()
  .status(500)
  .render(function GlobalError() {
    return (
      <html>
        <head>
          <title>Error</title>
        </head>
        <body>
          <h1>Application Error</h1>
          <p>Something went wrong. Please try again later.</p>
          <a href="/">Go home</a>
        </body>
      </html>
    );
  });

Error recovery

import { error, useRouteError, useNavigate } from '@putnami/react';

export default error().render(function ErrorBoundary() {
  const err = useRouteError();
  const navigate = useNavigate();

  const retry = () => {
    navigate(0); // Refresh current route
  };

  return (
    <div>
      <h1>Error</h1>
      <p>{err instanceof Error ? err.message : 'Unknown error'}</p>
      <button onClick={retry}>Try again</button>
    </div>
  );
});

Navigation

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={`/users/${userId}`}>User Profile</Link>
    </nav>
  );
}

Programmatic navigation

import { useNavigate } from '@putnami/react';

function LoginButton() {
  const navigate = useNavigate();

  const handleLogin = async () => {
    await login();
    navigate('/dashboard');
  };

  // Navigate with replace (no back button)
  const handleReplace = () => {
    navigate('/new-page', { replace: true });
  };

  // Navigate back
  const goBack = () => {
    navigate(-1);
  };

  return <button onClick={handleLogin}>Login</button>;
}

Active links

import { NavLink } from '@putnami/react';

function Navigation() {
  return (
    <nav>
      <NavLink
        to="/dashboard"
        className={({ isActive }) => isActive ? 'active' : ''}
      >
        Dashboard
      </NavLink>
    </nav>
  );
}

Route parameters and search params

URL parameters

import { useParams } from '@putnami/react';

function UserPage() {
  const { id, tab } = useParams();
  // /users/123/settings → { id: '123', tab: 'settings' }

  return <div>User {id}, Tab: {tab}</div>;
}

Search parameters

import { useSearchParams } from '@putnami/react';

function SearchPage() {
  const [searchParams, setSearchParams] = useSearchParams();

  const query = searchParams.get('q') || '';
  const page = parseInt(searchParams.get('page') || '1', 10);

  const updateSearch = (newQuery: string) => {
    setSearchParams({ q: newQuery, page: '1' });
  };

  const nextPage = () => {
    setSearchParams({ q: query, page: String(page + 1) });
  };

  return (
    <div>
      <input
        value={query}
        onChange={(e) => updateSearch(e.target.value)}
      />
      <button onClick={nextPage}>Next page</button>
    </div>
  );
}

Route matching

useMatch

import { useMatch } from '@putnami/react';

function Breadcrumb() {
  const isUsers = useMatch('/users/*');
  const isUserDetail = useMatch('/users/:id');

  return (
    <nav>
      <a href="/">Home</a>
      {isUsers && <a href="/users">Users</a>}
      {isUserDetail && <span>User Detail</span>}
    </nav>
  );
}

useLocation

import { useLocation } from '@putnami/react';

function Analytics() {
  const location = useLocation();

  useEffect(() => {
    trackPageView(location.pathname);
  }, [location]);

  return null;
}

Preventing navigation

useBlocker

import { useBlocker } from '@putnami/react';

function EditForm() {
  const [isDirty, setIsDirty] = useState(false);

  const blocker = useBlocker(
    ({ currentLocation, nextLocation }) =>
      isDirty && currentLocation.pathname !== nextLocation.pathname
  );

  return (
    <div>
      <form onChange={() => setIsDirty(true)}>
        {/* form fields */}
      </form>

      {blocker.state === 'blocked' && (
        <dialog open>
          <p>You have unsaved changes. Leave anyway?</p>
          <button onClick={() => blocker.proceed()}>Leave</button>
          <button onClick={() => blocker.reset()}>Stay</button>
        </dialog>
      )}
    </div>
  );
}

useBeforeUnload

import { useBeforeUnload } from '@putnami/react';

function EditForm() {
  const [isDirty, setIsDirty] = useState(false);

  useBeforeUnload(
    useCallback(
      (event) => {
        if (isDirty) {
          event.preventDefault();
        }
      },
      [isDirty]
    )
  );

  return <form onChange={() => setIsDirty(true)}>{/* ... */}</form>;
}

Related guides

  • Web
  • Forms and Actions

On this page

  • React Routing
  • File conventions
  • Route files
  • Folder structure
  • Dynamic routes
  • Single parameter
  • Multiple parameters
  • Catch-all routes
  • Optional catch-all
  • Layouts
  • Root layout
  • Nested layouts
  • Layout with data
  • Layout middleware
  • Page middleware
  • Sharing context
  • Error boundaries
  • Route error boundary
  • Global error boundary
  • Error recovery
  • Navigation
  • Link component
  • Programmatic navigation
  • Active links
  • Route parameters and search params
  • URL parameters
  • Search parameters
  • Route matching
  • useMatch
  • useLocation
  • Preventing navigation
  • useBlocker
  • useBeforeUnload
  • Related guides