FrontendApp Router

App Router

Next.jsNext.jsReact framework used by HiveCFM Core. Handles routing, server rendering, and API routes in one bundle.’s App Router turns folders under app/ into URLs. Every folder is a route segment; every page.tsx is the page that renders for that segment. There is no explicit route table — the filesystem is the routing table.

If you are coming from ASP.NET Core MVC, think of app/ as Controllers/ plus Views/ collapsed into one tree, where the folder name is both the controller route and the view name.

Key concepts

File in a segment folderWhat it does
page.tsxRenders the page for that URL.
layout.tsxWraps every child page. Composes down the tree — a parent layout’s JSX surrounds every child.
loading.tsxRenders while the segment’s server components stream.
error.tsxError boundary for the segment. Must be a client component.
route.tsREST handler (GET/POST/…). Replaces page.tsx when the URL is an API, not a page.
(group)/Route group. Organises files without adding to the URL.
[param]/Dynamic segment. Injected into params.

A concrete route — the environment landing page

File: hivecfm-core/apps/web/app/(app)/environments/[environmentId]/page.tsx

URL: /environments/<id>

import { redirect } from "next/navigation";
import { IS_HIVECFM_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
 
const EnvironmentPage = async (props) => {
  const params = await props.params;
  const { session, organization } = await getEnvironmentAuth(params.environmentId);
 
  const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
  const { isBilling } = getAccessFlags(currentUserMembership?.role);
 
  if (isBilling) {
    if (IS_HIVECFM_CLOUD) {
      return redirect(`/environments/${params.environmentId}/settings/billing`);
    }
    return redirect(`/environments/${params.environmentId}/settings/enterprise`);
  }
 
  return redirect(`/environments/${params.environmentId}/surveys`);
};
 
export default EnvironmentPage;

Four things worth noting:

  1. The component is async. That marks it as a Server Component — it runs on the server and never ships JavaScript to the client. You can await anything here.
  2. params is awaited. In Next.js 14, dynamic route params are a Promise. That is a deliberate design choice to allow Next.js to stream ahead of param resolution.
  3. getEnvironmentAuth does a DB round-trip. That is fine — the request is already on the server, and Prisma access is cheap. The @/… alias resolves to apps/web/.
  4. redirect() is a Next.js primitive. It throws a special error that the framework catches; control never returns to the caller. Equivalent to ASP.NET’s RedirectToAction but implemented as a thrown signal.

Route groups — (app) vs (auth) vs (main)

Folders wrapped in parentheses are route groups. They do not contribute to the URL. We use them to apply different layouts:

  • app/(app)/ — every route under here inherits the logged-in app shell (sidebar, topbar).
  • app/(auth)/ — login/signup pages. Different layout, no sidebar.
  • app/(redirects)/ — legacy URLs that just redirect.

URL example.com/environments/abc/surveys maps to app/(app)/environments/[environmentId]/surveys/page.tsx — the (app) group is invisible in the URL.

Server vs client components

By default, every component in app/ is a Server Component. To opt into the client, add "use client" at the very top of the file. The rule is:

  • Server Component — can await anything, reads DB directly, returns HTML. Cannot use useState/useEffect/browser APIs.
  • Client Component — runs in the browser, has hooks and event handlers. Cannot await a DB call directly.

The practical pattern HiveCFM uses everywhere: the page.tsx is a server component that fetches data, then passes it as props into a "use client" component that handles the interactive shell.

⚠️

Once a file is marked "use client", every component it imports is also included in the client bundle. Keep the client boundary as narrow as you can — push data fetching up to the server component above it.

Loading and streaming

Next to any page.tsx, you can drop a loading.tsx:

export default function Loading() {
  return <div className="p-8 animate-pulse">Loading…</div>;
}

The App Router shows loading.tsx while page.tsx’s async work is pending, streams the real page in when it resolves, and does this per segment. A slow nested segment does not block its parent.

  • State and data — how this server component fetched getEnvironmentAuth() and how client components post back.
  • Backend / API routes — the route.ts side of the App Router.