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 folder | What it does |
|---|---|
page.tsx | Renders the page for that URL. |
layout.tsx | Wraps every child page. Composes down the tree — a parent layout’s JSX surrounds every child. |
loading.tsx | Renders while the segment’s server components stream. |
error.tsx | Error boundary for the segment. Must be a client component. |
route.ts | REST 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:
- The component is
async. That marks it as a Server Component — it runs on the server and never ships JavaScript to the client. You canawaitanything here. paramsis 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.getEnvironmentAuthdoes a DB round-trip. That is fine — the request is already on the server, and Prisma access is cheap. The@/…alias resolves toapps/web/.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’sRedirectToActionbut 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
awaitanything, reads DB directly, returns HTML. Cannot useuseState/useEffect/browser APIs. - Client Component — runs in the browser, has hooks and event handlers. Cannot
awaita 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.
Read next
- State and data — how this server component fetched
getEnvironmentAuth()and how client components post back. - Backend / API routes — the
route.tsside of the App Router.