App routes are fully dynamic, server-side rendered (SSR) routes that hydrate on the client. They function like a Single Page Application (SPA) after the initial load.

Characteristics

  • SSR: HTML is generated on the server for every request.
  • Hydration: Preact hydrates the page on the client, making it fully interactive.
  • Client-side Navigation: Clicking links uses client-side routing, avoiding full page reloads.
  • Shared State: Layouts persist state during navigation.

The client entry

App routes hydrate on the client from a single client entrysrc/main.tsx (or src/client.tsx; .ts/.jsx/.js variants are also detected). Every Neutron project starts with one, and the starter templates include it:

// src/main.tsx
import { init, registerRoutes } from "@neutron-build/core/client";
import { routes } from "virtual:neutron/routes";

registerRoutes(routes);
void init();

registerRoutes hands the client router the same route table the server used (via the virtual:neutron/routes module the build injects), and init() hydrates the server-rendered HTML and takes over client-side navigation. You normally never edit this file — add hydration-time setup (analytics, error reporting, a global store) before init() if you need it. If the entry is missing, pages still server-render but do not hydrate (no interactivity or client navigation).

Configuration

To make a route an app route, set the mode to app:

export const config = { mode: "app" };

Runtime Choice

Neutron allows you to choose the underlying runtime for your app routes.

  • Preact (Default): Optimized for performance and size (~3KB runtime). Highly compatible with the React ecosystem.
  • React: For apps that require specific React internals or libraries that are not fully compatible with Preact.

You can configure this in neutron.config.ts:

export default defineConfig({
  runtime: "preact", // or "react"
});

Regardless of the runtime, your code uses standard React-style hooks and JSX.

Data Loading

App routes use loader functions to fetch data on the server.

import { useLoaderData } from "@neutron-build/core";

export const config = { mode: "app" };

export async function loader({ request }: LoaderArgs) {
  const user = await getUser(request);
  return { user };
}

export default function Profile() {
  const { user } = useLoaderData<typeof loader>();
  return <h1>Hello, {user.name}</h1>;
}

Mutations

App routes use action functions to handle form submissions and data mutations.

import { Form } from "@neutron-build/core";

export async function action({ request }: ActionArgs) {
  const formData = await request.formData();
  // ... handle update
  return { success: true };
}

export default function Settings() {
  return (
    <Form method="post">
      <button>Update</button>
    </Form>
  );
}

See the Data Loading and Actions guides for more details.