Skip to content

Latest commit

 

History

History
610 lines (487 loc) · 20 KB

File metadata and controls

610 lines (487 loc) · 20 KB

Data Loading

Pracht provides a unified data loading model that works across all rendering modes. Loaders fetch data and client hooks provide reactive access.


Loaders

A loader is an async function exported from a route module. It runs server-side and returns serializable data that flows into the route component.

// src/routes/dashboard.tsx
import type { LoaderArgs, RouteComponentProps } from "@pracht/core";

export async function loader({ request, params, context, signal }: LoaderArgs) {
  const user = await getUser(request);
  const projects = await db.projects.findMany({ userId: user.id });
  return { user, projects };
}

export default function Dashboard({ data }: RouteComponentProps<typeof loader>) {
  // data is typed as { user: User; projects: Project[] }
  return <h1>Welcome, {data.user.name}</h1>;
}

The route component can be a function default export or a named Component export. Named route exports such as loader, head, headers, markdown, ErrorBoundary, and getStaticPaths remain separate special exports.

A markdown string export opts the route into Markdown-for-Agents content negotiation: when a request arrives with Accept: text/markdown, the runtime still executes middleware, the route loader, and document header resolution first, then returns the raw markdown source with Content-Type: text/markdown instead of rendering the component. Both the HTML and markdown responses carry Vary: Accept; routes without a markdown export do not vary on Accept.

LoaderArgs

Field Type Description
request Request The incoming Web Request
params RouteParams Dynamic URL params (e.g. { slug: "hello" })
context TContext App-level context (from adapter's context factory)
signal AbortSignal Cancellation signal for timeouts
url URL Parsed URL
route ResolvedRoute Matched route metadata

When loaders run

Scenario Loader runs on
SSG build Build machine, once per path
SSR request Server, every request
ISG initial Build machine, then adapter runtime where supported
SPA Server, during route-state fetches; initial HTML stays shell-only
Client navigation Server (fetched as JSON via x-pracht-route-state-request)

Loaders never run in the browser. This keeps server secrets (DB connections, API keys) safe.

For inline loaders, pracht also loads a client-transformed copy of the route module in the browser. Server-only route exports such as loader, head, headers, and getStaticPaths are omitted from that client module, along with imports that were only referenced by those exports. This stripping happens as a Vite 8 post-transform pass on Rolldown/Oxc ASTs so it still works after user plugins turn Markdown/MDX or TypeScript route files into JavaScript.

Framework-generated route-state responses add Vary: x-pracht-route-state-request so caches keep HTML and JSON variants separate. Those JSON responses also default to Cache-Control: no-store.

Use loaderCache in route metadata when loader data can safely be reused by the same browser:

route("/settings", () => import("./routes/settings.tsx"), {
  render: "spa",
  loaderCache: 3600,
});

A positive integer sets Cache-Control: private, max-age=<seconds> on successful route-state data responses. loaderCache: false and loaderCache: 0 both produce no-store, which also lets a route opt out of a group default. Redirects and error responses remain no-store. If a loader returns a Response with an explicit Cache-Control header, that header takes precedence over route metadata.

loaderCache is client-side browser caching only. It does not change ISG revalidate, and the prefetcher's bounded 30-second in-memory cache remains independent so an intent prefetch can still be consumed by the navigation that immediately follows it. Explicit useRevalidate() calls bypass this browser cache so user-triggered refreshes and post-mutation reloads still re-run the loader.

For SPA routes, the initial HTML can still include the matched shell and an optional shell Loading export so the page is not blank before the route-state request resolves.

Error handling

Throw PrachtHttpError for structured error responses:

import { PrachtHttpError } from "@pracht/core";

export async function loader({ params }: LoaderArgs) {
  const post = await getPost(params.slug);
  if (!post) throw new PrachtHttpError(404, "Post not found");
  return { post };
}

If the route defines an ErrorBoundary, it catches the error and renders the fallback UI. Otherwise, the sanitized framework/global handler responds.

ErrorBoundary

Export an ErrorBoundary from any route module to catch errors from its loader or component:

import type { ErrorBoundaryProps } from "@pracht/core";

export function ErrorBoundary({ error }: ErrorBoundaryProps) {
  return (
    <div>
      <h1>{error.status ?? 500}</h1>
      <p>{error.message}</p>
    </div>
  );
}

Error boundaries compose: a route boundary catches route-level errors, while a shell boundary catches errors from any route inside that shell. If a route has no boundary, the error bubbles up to the shell, then to the global handler.

404s take a different path when the app declares a notFound page: a route boundary still wins, but the not-found page takes over from there instead of the shell boundary. "Not found" is an outcome, not a failure.

Custom 404 pages

Declare one page for "this URL has no content" and pracht uses it for both ways that happens — an unmatched URL, and a loader that cannot find what it was asked for:

// src/routes.ts
export const app = defineApp({
  shells: { public: () => import("./shells/public.tsx") },
  notFound: {
    component: () => import("./routes/not-found.tsx"),
    shell: "public",
  },
  routes: [
    // ... your routes
  ],
});
// src/routes/not-found.tsx
import { useLocation } from "@pracht/core";

export function Component() {
  const location = useLocation();

  return (
    <div>
      <h1>404</h1>
      <p>No page lives at {location.pathname}.</p>
      <a href="/">Go home</a>
    </div>
  );
}

Inside a loader or middleware, throw notFound() — sugar for new PrachtHttpError(404, message):

import { notFound } from "@pracht/core";

export async function loader({ params }: LoaderArgs) {
  const post = await getPost(params.slug);
  if (!post) throw notFound("Post not found");
  return { post };
}

The response is the notFound page with a 404 status — unless the route module exports its own ErrorBoundary, which stays the most specific handler and wins for that route. Shell-level error boundaries do not intercept 404s once notFound is configured; keep them for real failures.

notFound is deliberately not a route: it never matches a URL, so it cannot shadow static assets the way a trailing route("/*", ...) does. See ROUTING.md for the full configuration and the exact matrix of when it renders.

During a client-side navigation to a route whose loader throws a 404, the router falls back to a full document load so the server can render the not-found page with the correct status.

Error sanitization

Unexpected 5xx errors are sanitized by default in both SSR HTML and x-pracht-route-state-request JSON responses, including the hydration payload. Throw PrachtHttpError for expected client-facing failures; 4xx messages stay intact. If you need raw server error details while debugging, pass debugErrors: true to handlePrachtRequest(). For safety, debugErrors is ignored when NODE_ENV=production. When debug errors are enabled outside production, serialized route and API failures also include error.diagnostics with framework metadata such as phase, routeId, routePath, routeFile, loaderFile, shellFile, middlewareFiles, and status.

Dev error overlay

In dev, uncaught server errors render pracht's full-page error overlay instead of a bare 500. Stack frames from your app code (and the reported file path) are clickable — they open the file at the exact line and column in your editor through Vite's built-in /__open-in-editor endpoint. Frames from node_modules and Node internals are de-emphasized.

Manifest wiring mistakes fail loudly with a "did you mean" hint. Referencing an unregistered shell or middleware name (including api.middleware) throws during resolveApp(), and unknown route ids throw from href()/<Link route>:

Unknown shell "pubic" for route "/". Did you mean "public"? Registered shells: public, app.
Unknown middleware "auht" for route "/admin". Did you mean "auth"? Registered middleware: auth, logging.
Unknown pracht route id "prduct". Did you mean "product"? Registered route ids: home, product, docs.

These messages flow into the dev error overlay as soon as the dev server loads the manifest.


Head Metadata

The head export controls <head> content per route:

export function head({ data }: HeadArgs<typeof loader>) {
  return {
    title: `${data.post.title} — My Blog`,
    meta: [
      { name: "description", content: data.post.excerpt },
      { property: "og:title", content: data.post.title },
    ],
    link: [{ rel: "canonical", href: `https://example.com/blog/${data.post.slug}` }],
  };
}

Head metadata merges with the shell's head. Route-level values override shell values for title. Arrays (meta, link) are concatenated.

SEO & Open Graph

Use the meta array to set Open Graph and other SEO tags. The head export has full access to loader data, so these can be dynamic per page:

export function head({ data }: HeadArgs<typeof loader>) {
  return {
    title: `${data.product.name} — My Store`,
    meta: [
      { name: "description", content: data.product.description },
      // Open Graph
      { property: "og:title", content: data.product.name },
      { property: "og:description", content: data.product.description },
      { property: "og:image", content: data.product.imageUrl },
      { property: "og:type", content: "product" },
      { property: "og:url", content: `https://mystore.com/products/${data.product.slug}` },
      // Twitter Card
      { name: "twitter:card", content: "summary_large_image" },
      { name: "twitter:title", content: data.product.name },
      { name: "twitter:description", content: data.product.description },
      { name: "twitter:image", content: data.product.imageUrl },
    ],
    link: [
      { rel: "canonical", href: `https://mystore.com/products/${data.product.slug}` },
    ],
  };
}

Structured data (JSON-LD)

For structured data, include a script entry with type: "application/ld+json":

export function head({ data }: HeadArgs<typeof loader>) {
  return {
    title: data.article.title,
    meta: [
      { property: "og:type", content: "article" },
      { property: "og:title", content: data.article.title },
    ],
    script: [
      {
        type: "application/ld+json",
        children: JSON.stringify({
          "@context": "https://schema.org",
          "@type": "Article",
          headline: data.article.title,
          datePublished: data.article.publishedAt,
          author: { "@type": "Person", name: data.article.author },
        }),
      },
    ],
  };
}

Shell-level defaults

Shells can also export head to set site-wide defaults. Route-level title overrides the shell's title; meta and link arrays are concatenated:

// src/shells/public.tsx
export function head() {
  return {
    title: "My Site",
    meta: [
      { name: "description", content: "Default site description" },
      { property: "og:site_name", content: "My Site" },
    ],
    link: [
      { rel: "icon", href: "/favicon.svg" },
    ],
  };
}

Document Headers

The headers export controls HTTP headers for the route's document response. It receives the same data-aware arguments as head:

export function headers({ data }: HeadersArgs<typeof loader>) {
  return {
    "content-security-policy": `default-src 'self'; img-src 'self' ${data.cdnOrigin}`,
  };
}

Headers merge with the shell's headers export. Route-level headers override shell headers with the same name. They apply to HTML document responses, including prerendered SSG/ISG HTML, but not API routes or route-state JSON fetches.

SSG/ISG document headers are written into the prerender header manifest so adapters can replay them for static HTML. Pracht rejects dangerous headers such as Set-Cookie, Authorization, Proxy-Authenticate, WWW-Authenticate, and secret-shaped custom x-* headers during SSG/ISG prerendering because those values would become public static output or be replayed across visitors. Use API routes, middleware Responses, or SSR-only routes for cookies and authentication headers.

See docs/CSP.md for a Content Security Policy recipe.


Client Hooks

useRouteData()

Access the current route's loader data reactively. Updates on navigation and revalidation.

If your project runs pracht typegen, pass the route id and the data type is inferred from that route's loader — no generic needed:

export function Component() {
  const data = useRouteData("dashboard");
  // data is typed as the awaited return type of the dashboard route's loader
  return <span>{data.user.name}</span>;
}

Route ids autocomplete against the generated route map. The generated declaration points at the route module (or the separate loader module wired via the manifest), so changing a loader's return type flows through without re-running typegen; only adding, removing, or renaming routes requires a regeneration. Routes without a loader type their data as undefined. In development, pracht logs a warning when the id you pass is not the active route, since the hook always returns the active route's data.

For projects that do not run typegen, pass the loader type explicitly as a generic instead:

export function Component() {
  const data = useRouteData<typeof loader>();
  return <span>{data.user.name}</span>;
}

useRevalidate()

Imperatively re-run the current route's loader:

export function Component() {
  const revalidate = useRevalidate();
  return <button onClick={() => revalidate()}>Refresh</button>;
}

useNavigation()

Reactive pending state for the current client navigation or <Form> submission:

import { useNavigation } from "@pracht/core";

const navigation = useNavigation();
// { state: "idle" }
// { state: "loading", location }             — a navigation is fetching/committing
// { state: "submitting", location, formData } — a <Form> submission is in flight
  • state"idle", "loading", or "submitting".
  • location — the target { pathname, search, hash, href } while not idle.
  • formData — the submitted FormData while a <Form> submission is pending.

The hook updates through the router's full lifecycle: navigation start → route-state fetch → DOM commit → idle. During SSR it always returns { state: "idle" }.

Global progress bar

Because useNavigation() works from any component (a shell is the natural place), a top-of-page progress indicator is a few lines:

// src/shells/root.tsx
import { useNavigation } from "@pracht/core";

function NavigationProgress() {
  const navigation = useNavigation();
  if (navigation.state === "idle") return null;
  return <div class="nav-progress" role="progressbar" aria-label="Loading page" />;
}

export function Shell({ children }: ShellProps) {
  return (
    <>
      <NavigationProgress />
      {children}
    </>
  );
}
.nav-progress {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  width: 100%;
  background: linear-gradient(to right, #7c3aed, #db2777);
  animation: nav-progress-slide 1s ease-in-out infinite;
}

Pending buttons

function SubmitButton() {
  const navigation = useNavigation();
  const pending = navigation.state === "submitting";
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Saving…" : "Save"}
    </button>
  );
}

Optimistic UI

While a <Form> submission is pending, navigation.formData holds the values the user just submitted — render them immediately instead of waiting for the loader to revalidate:

export function Component({ data }: RouteComponentProps<typeof loader>) {
  const navigation = useNavigation();

  const optimisticTitle =
    navigation.state === "submitting" ? navigation.formData.get("title") : null;

  return (
    <ul>
      {data.todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
      {optimisticTitle ? <li class="pending">{String(optimisticTitle)}</li> : null}
    </ul>
  );
}

<Form> Component

Declarative form submission:

import { Form } from "@pracht/core";

export function Component() {
  return (
    <Form method="post" action="/api/projects">
      <input name="title" />
      <button type="submit">Create</button>
    </Form>
  );
}

The <Form> component:

  • Intercepts submit and sends via fetch to the specified action URL (no full page reload)
  • Handles redirects automatically
  • Falls back to native form submission if JavaScript fails
  • Publishes its pending state through useNavigation()state: "submitting" with the submitted FormData — for pending buttons and optimistic UI

API Routes (Phase 2)

Standalone server endpoints for REST APIs, webhooks, and health checks:

// src/api/health.ts
export function GET() {
  return new Response(JSON.stringify({ status: "ok" }), {
    headers: { "Content-Type": "application/json" },
  });
}

// src/api/users/[id].ts
export async function GET({ params, context }: ApiRouteArgs) {
  const user = await context.db.users.find(params.id);
  if (!user) return new Response("Not found", { status: 404 });
  return Response.json(user);
}

export async function DELETE({ params, context }: ApiRouteArgs) {
  await context.db.users.delete(params.id);
  return new Response(null, { status: 204 });
}

If you want to own method dispatch, export one default handler and branch on request.method:

// src/api/users/[id].ts
import type { ApiRouteArgs } from "@pracht/core";

export default async function handler({ params, request, context }: ApiRouteArgs) {
  if (request.method === "GET") {
    const user = await context.db.users.find(params.id);
    if (!user) return new Response("Not found", { status: 404 });
    return Response.json(user);
  }

  if (request.method === "DELETE") {
    await context.db.users.delete(params.id);
    return new Response(null, { status: 204 });
  }

  return new Response("Method not allowed", { status: 405 });
}

API routes:

  • Live in src/api/ with file-based path mapping
  • Support dynamic params (src/api/users/[id].ts/api/users/:id) and catch-alls (src/api/files/[...path].ts/api/files/*, accessible via params["*"])
  • Export named HTTP method handlers (GET, POST, PUT, PATCH, DELETE) or one default handler that branches on args.request.method
  • Return Response objects directly
  • Share the same request context shape as page routes
  • Can opt into app-level API middleware via defineApp({ api: { middleware } })
  • Are excluded from client bundles entirely

For request validation with Standard Schema (zod, valibot, …) and an end-to-end typed fetch client, wrap handlers with defineApi() — see docs/API_VALIDATION.md.