Overview
MicroCBM uses Next.js 15 App Router with file-based routing. All routes are defined by the file structure in the src/app/ directory.
App Router Fundamentals
File Conventions
| File | Purpose | Example |
|---|
page.tsx | Route page component | app/assets/page.tsx → /assets |
layout.tsx | Shared layout for routes | app/assets/layout.tsx |
loading.tsx | Loading UI | app/assets/loading.tsx |
error.tsx | Error boundary | app/assets/error.tsx |
not-found.tsx | 404 page | app/not-found.tsx |
route.ts | API route handler | app/api/session/route.ts |
Server vs Client Components
Server Components
Client Components
Server Components are the default in Next.js 15. They run on the server and can directly access backend resources.// src/app/(home)/assets/page.tsx:1
"use server";
import React from "react";
import { AssetContent, AssetTable, AssetSummary } from "./components";
import {
getAssetsService,
getAssetsAnalyticsService,
getSitesService,
} from "@/app/actions";
export default async function AssetsPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const params = await searchParams;
const page = Math.max(1, parseInt(String(params?.page ?? 1), 10) || 1);
const limit = Math.max(1, Math.min(100, parseInt(String(params?.limit ?? 10), 10) || 10);
const search = typeof params?.search === "string" ? params.search : "";
const { data: assets, meta } = await getAssetsService({ page, limit, search });
const assetsAnalytics = await getAssetsAnalyticsService();
const sites = (await getSitesService()).data;
return (
<main className="flex flex-col gap-4">
<AssetContent sites={sites} />
{assetsAnalytics && <AssetSummary assetsAnalytics={assetsAnalytics} />}
<AssetTable data={assets} meta={meta} />
</main>
);
}
Benefits:
- Direct data fetching
- Zero JavaScript sent to client
- SEO-friendly
- Reduced bundle size
Client Components require the "use client" directive and run in the browser.// Example client component
"use client";
import React, { useState } from "react";
import { Button, Modal } from "@/components";
export function InteractiveComponent() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button onClick={() => setIsOpen(true)}>Open Modal</Button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
Content
</Modal>
</>
);
}
Use Client Components for:
- Event listeners (
onClick, onChange)
- State hooks (
useState, useReducer)
- Effect hooks (
useEffect, useLayoutEffect)
- Browser APIs (localStorage, window)
- Custom hooks that use client-only features
Important: Do NOT add loading.tsx to src/app/(home)/ as it breaks the build due to static analysis behavior with useSearchParams().
Route Groups
Route groups organize routes without affecting the URL structure. They’re created using parentheses (group-name).
(home) Route Group
All authenticated routes are under the (home) group:
src/app/(home)/
├── layout.tsx # Shared layout for all authenticated pages
├── page.tsx # Dashboard (/)
├── assets/ # /assets
├── alarms/ # /alarms
├── samples/ # /samples
└── recommendations/ # /recommendations
The layout provides:
- Navbar
- Sidebar navigation
- Authentication guard
- Consistent page structure
// src/app/(home)/layout.tsx:1
"use server";
import { RouteGuard } from "@/components/content-guard";
import Navbar from "@/components/shared/Navbar";
import Sidebar from "@/components/shared/Sidebar";
import { cookies } from "next/headers";
import { SessionUser } from "@/types";
function parseUserData(raw: string | undefined): SessionUser | null {
if (!raw) return null;
try {
return JSON.parse(raw) as SessionUser;
} catch {
return null;
}
}
export default async function HomeLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const user = parseUserData((await cookies()).get("userData")?.value);
return (
<div className="flex flex-col min-h-screen">
<Navbar />
<section className="flex-1 flex pt-[53.35px]">
<Sidebar user={user} />
<div className="flex-1 ml-64 overflow-y-auto h-[calc(100vh-64px)]">
<div className="p-6 bg-gray-50 min-h-full">
<RouteGuard loadingFallback={<div className="flex min-h-[200px] items-center justify-center text-sm text-gray-500">Loading…</div>}>
{children}
</RouteGuard>
</div>
</div>
</section>
</div>
);
}
Do NOT remove "use server" from the layout - it affects how Next.js treats the page tree during build.
Dynamic Routes
Dynamic routes use bracket notation for route parameters.
Single Dynamic Segment
src/app/(home)/assets/edit/[id]/page.tsx # /assets/edit/123
src/app/(home)/rca/[id]/page.tsx # /rca/abc-def-ghi
Accessing route parameters:
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function EditAssetPage({ params }: PageProps) {
const { id } = await params;
// Fetch data using the id
const asset = await getAssetById(id);
return <EditAssetForm asset={asset} />;
}
Catch-all Segments
For routes with multiple dynamic segments:
// src/app/blog/[...slug]/page.tsx
// Matches:
// /blog/a
// /blog/a/b
// /blog/a/b/c
interface PageProps {
params: Promise<{ slug: string[] }>;
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params;
// slug is an array: ["a", "b", "c"]
}
Search Parameters
Access URL query parameters in Server Components:
// src/app/(home)/recommendations/page.tsx:23
export default async function RecommendationsPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const params = await searchParams;
const [recommendations, sites, assets, recommendationAnalytics, users, samplingPoints] =
await Promise.all([
getRecommendationsService(params).catch(() => [] as Recommendation[]),
getSitesService().then((r) => r.data).catch(() => []),
getAssetsService().then((r) => r.data).catch(() => []),
getRecommendationAnalyticsService().catch(() => []),
getUsersService().catch(() => []),
getSamplingPointsService().then((r) => r.data).catch(() => []),
]);
return (
<main className="flex flex-col gap-4">
<RecommendationContent />
<RecommendationsSummary
recommendationAnalytics={recommendationAnalytics}
/>
<RecommendationTrend recommendations={recommendations} />
<RecommendationFilters
sites={sites}
assets={assets}
samplingPoints={samplingPoints}
users={users}
/>
<RecommendationTable
recommendations={recommendations}
assets={assets}
sites={sites}
/>
</main>
);
}
URL State Hook
For client-side URL state management, use the custom useUrlState hook:
// src/hooks/useUrlState.ts:1
"use client";
import { useCallback, useMemo } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export function useUrlState(key: string, defaultValue = "") {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const value = useMemo(() => {
return searchParams?.get(key) ?? defaultValue;
}, [searchParams, key, defaultValue]);
const setValue = useCallback(
(newValue: string) => {
const params = new URLSearchParams(searchParams?.toString());
if (newValue) {
params.set(key, newValue);
} else {
params.delete(key);
}
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
},
[searchParams, key, pathname, router]
);
return [value, setValue] as const;
}
Usage example:
"use client";
import { useUrlState } from "@/hooks";
function FilterComponent() {
const [status, setStatus] = useUrlState("status", "all");
return (
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="all">All</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
);
}
Navigation
Link Component
Use Next.js Link for client-side navigation:
import Link from "next/link";
<Link href="/assets">
View Assets
</Link>
// With dynamic routes
<Link href={`/assets/edit/${asset.id}`}>
Edit Asset
</Link>
Programmatic Navigation
Use the useRouter hook for navigation in client components:
"use client";
import { useRouter } from "next/navigation";
function CreateAssetButton() {
const router = useRouter();
const handleCreate = async () => {
// Create asset logic
const newAsset = await createAsset(data);
// Navigate to the new asset
router.push(`/assets/edit/${newAsset.id}`);
};
return <button onClick={handleCreate}>Create Asset</button>;
}
router.push()
router.replace()
router.back()
router.refresh()
Navigate to a new route and add it to the history stack: Navigate to a new route without adding to the history stack:router.replace("/assets");
Navigate to the previous page: Refresh the current route and re-fetch data:
Middleware
MicroCBM uses middleware for authentication and route protection:
// src/middleware.ts:15
export default async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const token = req.cookies.get("token")?.value;
const isPublic = isPublicPath(pathname);
if (token && isTokenExpired(token)) {
const response = isPublic
? NextResponse.next()
: NextResponse.redirect(new URL(ROUTES.AUTH.LOGIN, req.nextUrl));
response.cookies.delete("token");
response.cookies.delete("userData");
return response;
}
if (!isPublic && !token) {
return NextResponse.redirect(new URL(ROUTES.AUTH.LOGIN, req.nextUrl));
}
if (isPublic && token) {
return NextResponse.redirect(new URL(ROUTES.HOME, req.nextUrl));
}
return NextResponse.next();
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|assets|favicon.ico|robots.txt|sitemap.xml|manifest.webmanifest).*)",
],
};
Middleware flow:
Check token existence
Verify if authentication token exists in cookies
Validate token expiry
Check if token is expired and clear cookies if necessary
Route protection
Redirect unauthenticated users from protected routes to login
Authenticated redirects
Redirect authenticated users away from auth pages to home
API Routes
API routes handle backend requests:
src/app/api/
├── session/
│ └── route.ts # GET /api/session
└── files/
└── presigned/
└── route.ts # GET /api/files/presigned
Example API route:
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(request: NextRequest) {
const cookieStore = await cookies();
const userData = cookieStore.get("userData")?.value;
if (!userData) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}
return NextResponse.json({
user: JSON.parse(userData),
});
}
Define metadata for pages:
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Assets",
description: "Manage your asset inventory",
};
export default function AssetsPage() {
return <main>...</main>;
}
Generate metadata based on route parameters:
import type { Metadata } from "next";
interface Props {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const asset = await getAssetById(id);
return {
title: `Edit ${asset.name}`,
description: `Edit asset: ${asset.tag}`,
};
}
All pages use the title template "%s | MicroCBM" defined in the root layout.
Best Practices
Use Server Components by default
Only use Client Components when you need interactivity, state, or browser APIs.
Colocate data fetching
Fetch data in the component that needs it rather than passing through multiple layers.
Parallel data fetching
Use Promise.all() to fetch multiple resources in parallel:const [assets, sites, users] = await Promise.all([
getAssetsService(),
getSitesService(),
getUsersService(),
]);
Handle loading states
Wrap async operations in Suspense boundaries or provide loading states.
Use URL state for filters
Store filter state in URL parameters for shareable links and browser history support.
Next Steps
State Management
Learn about Zustand stores and React Query
Forms & Validation
Explore React Hook Form and Zod schemas