A production Next.js App Router codebase is typically organized around the app/ directory, where folders map to URL segments and special files define behavior.
Example structure:
src/
app/
layout.tsx
page.tsx
globals.css
(marketing)/
about/
page.tsx
pricing/
page.tsx
dashboard/
layout.tsx
page.tsx
migrations/
page.tsx
[migrationId]/
page.tsx
loading.tsx
error.tsx
api/
migrations/
route.ts
migrations/
[migrationId]/
route.ts
login/
page.tsx
not-found.tsx
components/
migrations/
MigrationTable.tsx
MigrationStatusBadge.tsx
MigrationDetail.tsx
ui/
Button.tsx
Table.tsx
lib/
api/
migrations.ts
auth/
session.ts
db/
client.ts
validation/
migrationSchemas.ts
types/
migration.ts
Key App Router conventions
| File/folder |
Purpose |
app/page.tsx |
Page at / |
app/layout.tsx |
Shared layout for a route tree |
app/dashboard/page.tsx |
Page at /dashboard |
app/migrations/[id]/page.tsx |
Dynamic route, e.g. /migrations/123 |
loading.tsx |
Loading UI while a route segment is rendered |
error.tsx |
Error boundary for a route segment |
not-found.tsx |
404 UI |
route.ts |
HTTP endpoint / route handler |
(groupName) |
Route group; organizes files without affecting the URL |
_components/ |
Common convention for private colocated components; underscore prevents it from becoming a route |
Example: migration dashboard
A page for listing migrations:
// src/app/dashboard/migrations/page.tsx
import { MigrationTable } from "@/components/migrations/MigrationTable";
import { getMigrations } from "@/lib/api/migrations";
export default async function MigrationsPage() {
const migrations = await getMigrations();
return (
<main>
<h1>Customer migrations</h1>
<MigrationTable migrations={migrations} />
</main>
);
}
This maps to:
A detail page uses a dynamic route:
// src/app/dashboard/migrations/[migrationId]/page.tsx
import { notFound } from "next/navigation";
import { getMigration } from "@/lib/api/migrations";
type PageProps = {
params: Promise<{ migrationId: string }>;
};
export default async function MigrationDetailPage({ params }: PageProps) {
const { migrationId } = await params;
const migration = await getMigration(migrationId);
if (!migration) {
notFound();
}
return (
<main>
<h1>{migration.customerName}</h1>
<p>Status: {migration.status}</p>
<p>Records processed: {migration.recordsProcessed}</p>
</main>
);
}
That supports URLs such as:
/dashboard/migrations/mig_12345
In a real application, authorization should happen before returning data:
import { redirect } from "next/navigation";
import { requireUser } from "@/lib/auth/session";
export default async function MigrationDetailPage({ params }: PageProps) {
const user = await requireUser();
if (!user.canManageMigrations) {
redirect("/dashboard");
}
// Fetch only data that user is permitted to view.
}
Adding a route
To add a route, create a folder under app/ and add a page.tsx.
For example, to create:
/dashboard/migrations/new
add:
src/app/dashboard/migrations/new/page.tsx
// src/app/dashboard/migrations/new/page.tsx
import { MigrationCreateForm } from "@/components/migrations/MigrationCreateForm";
export default function NewMigrationPage() {
return (
<main>
<h1>Create migration</h1>
<MigrationCreateForm />
</main>
);
}
A corresponding navigation link might be:
import Link from "next/link";
export function MigrationActions() {
return (
<Link href="/dashboard/migrations/new">
Create migration
</Link>
);
}
Use Link rather than a plain anchor tag for internal navigation. It enables client-side navigation and route prefetching.
Adding a nested layout
If all migration pages share tabs, permissions, or a sidebar, add a layout:
// src/app/dashboard/migrations/layout.tsx
import Link from "next/link";
export default function MigrationsLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<section>
<nav aria-label="Migration navigation">
<Link href="/dashboard/migrations">All migrations</Link>
<Link href="/dashboard/migrations/new">New migration</Link>
</nav>
{children}
</section>
);
}
That layout applies to:
/dashboard/migrations
/dashboard/migrations/new
/dashboard/migrations/[migrationId]
but not unrelated routes such as /dashboard/users.
Adding a route handler / API endpoint
In App Router, API-style endpoints use route.ts, not pages/api.
For example:
src/app/api/migrations/route.ts
// src/app/api/migrations/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { createMigration, listMigrations } from "@/lib/api/migrations";
import { requireUser } from "@/lib/auth/session";
const createMigrationSchema = z.object({
customerId: z.string().min(1),
sourceSystem: z.enum(["legacy_catalog", "spreadsheet", "archive_export"]),
});
export async function GET() {
const user = await requireUser();
const migrations = await listMigrations({
organizationId: user.organizationId,
});
return NextResponse.json({ data: migrations });
}
export async function POST(request: Request) {
const user = await requireUser();
const body = await request.json();
const parsed = createMigrationSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid migration request", details: parsed.error.flatten() },
{ status: 400 },
);
}
const migration = await createMigration({
...parsed.data,
organizationId: user.organizationId,
createdByUserId: user.id,
});
return NextResponse.json({ data: migration }, { status: 201 });
}
This serves:
GET /api/migrations
POST /api/migrations
A dynamic endpoint:
src/app/api/migrations/[migrationId]/route.ts
can support:
GET /api/migrations/mig_123
PATCH /api/migrations/mig_123
DELETE /api/migrations/mig_123
Removing a route
Removing a route is mostly a filesystem operation:
- Delete the route’s
page.tsx folder or file.
- Remove links, redirects, tests, documentation, and navigation entries that point to it.
- Check for API consumers if deleting a
route.ts handler.
- Consider compatibility before removing externally bookmarked or integrated routes.
For example, to remove:
/dashboard/migrations/new
delete:
src/app/dashboard/migrations/new/
Then remove links to it:
<Link href="/dashboard/migrations/new">Create migration</Link>
For production applications, abruptly removing a route can create broken bookmarks or integrations. Often it is safer to redirect it first.
// src/app/dashboard/migrations/new/page.tsx
import { redirect } from "next/navigation";
export default function DeprecatedNewMigrationPage() {
redirect("/dashboard/migrations");
}
For a permanent redirect at the framework/configuration level:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async redirects() {
return [
{
source: "/dashboard/migrations/new",
destination: "/dashboard/migrations",
permanent: true,
},
];
},
};
export default nextConfig;
Use a 308/permanent redirect only when the old URL should never return. Use a temporary redirect while a feature is being migrated or rolled out.
Production route design considerations
For a migration-focused product, I would generally use routes resembling:
/dashboard
/dashboard/migrations
/dashboard/migrations/new
/dashboard/migrations/[migrationId]
/dashboard/migrations/[migrationId]/records
/dashboard/migrations/[migrationId]/validation
/dashboard/migrations/[migrationId]/runs
/dashboard/migrations/[migrationId]/runs/[runId]
/dashboard/migrations/[migrationId]/errors
This corresponds naturally to the domain: a migration has source files, validation results, execution runs, and record-level errors.
A useful convention is to keep route-level code close to the route while keeping reusable business logic outside the UI tree:
app/dashboard/migrations/[migrationId]/
page.tsx
loading.tsx
error.tsx
_components/
MigrationSummary.tsx
MigrationRunList.tsx
lib/migrations/
service.ts
queries.ts
validation.ts
permissions.ts
The page should orchestrate rendering and authorization; service-layer functions should contain migration-domain logic; database access should be encapsulated rather than scattered through React components.
One important App Router distinction: components are Server Components by default. Add "use client" only where browser interactivity is needed—for example, an upload widget, a filterable table, or a form with local state. That keeps data access and initial rendering on the server by default.