Next Starter Logo
Tutorials

Adding a New Page

How to add a new page in Next.js with the App Router. Where the file goes, how layouts and auth apply, and how to set page metadata and navigation links.

Where a page file lives decides its chrome and how it's guarded. Two trees cover almost everything you'll add.

LocationURLLayoutAuth
app/(site)/publicHeader + Footernone
app/dashboard//dashboard/*sidebar + breadcrumbsigned-in required
app/dashboard/(admin)//dashboard/*same chromerole === "admin" required

(site) and (admin) are route groups. The parentheses keep them out of the URL.


Marketing page

A marketing page is any page under app/(site)/. Its layout, app/(site)/layout.tsx, wraps the page in the public Header and Footer and reads the session only to fill the nav. It never redirects, so the page stays public.

Create the page file

Add app/(site)/case-studies/page.tsx. The folder name becomes the URL (/case-studies).

app/(site)/case-studies/page.tsx
import type { Metadata } from "next";
import { generateMeta } from "@/lib/config";

export const metadata: Metadata = generateMeta({
  title: "Case Studies",
  description: "How teams ship faster with Next Starter.",
  pathname: "/case-studies",
});

export default function CaseStudiesPage() {
  return <div className="bg-background">{/* sections */}</div>;
}

Keep it a server component

Push "use client" down to the interactive piece, a form or a tab bar, and leave the page itself a server component. app/(site)/contact/page.tsx does exactly that. The page stays a server component and imports the client-side contact-form.tsx.

For a long page, build each visual section as its own file and compose them in page.tsx, the way app/(site)/page.tsx stacks the ten homepage sections.

Every page under app/(site)/ except the homepage opens with the shared PageHero (app/(site)/page-hero.tsx). It takes title plus optional eyebrow, description, and centered, and renders the dark gradient band. Import it as PageHero from "../page-hero" and your page matches About, Pricing, and Contact without extra styling.

The header and footer read their links from arrays. Add an entry to NAV_LINKS in lib/client/navigation.ts. components/header.tsx and components/mobile-nav.tsx both render that one list, so the link shows up at every breakpoint. If the page also belongs in the footer, add it to the matching section of the navigation array in components/footer.tsx.


Dashboard page

A dashboard page is any page under app/dashboard/. proxy.ts at the repo root gives /dashboard/* an early redirect when the session cookie is missing, but that's a speed optimization and proves nothing about the session. The gating that counts is app/dashboard/layout.tsx, which runs for every page below it:

  • it calls getSession() and redirects to /auth/sign-in if the user is signed out
  • it redirects to /onboarding if the user has not finished onboarding
  • it renders the sidebar, the mobile header, and the breadcrumb

Even so, every dashboard page in the starter re-checks the session itself, and yours should too. Layouts don't re-render on navigation, so a check that lives only in the layout won't run on every route change. You need session.user in the page anyway.

A protected section outside /dashboard inherits none of this. Authentication lists the layers to copy.

Create the page file

Add app/dashboard/reports/page.tsx. Pass noIndex: true so search engines skip it.

app/dashboard/reports/page.tsx
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { generateMeta } from "@/lib/config";
import { getSession } from "@/lib/server/auth-helpers";

export const metadata: Metadata = generateMeta({
  title: "Reports",
  description: "View your usage reports.",
  pathname: "/dashboard/reports",
  noIndex: true,
});

export default async function ReportsPage() {
  const session = await getSession();
  if (!session?.user) redirect("/auth/sign-in");

  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-semibold">Reports</h1>
      {/* content */}
    </div>
  );
}

Add an entry to the navigationItems array in components/dashboard/sidebar.tsx. Pick an icon from lucide-react and add it to that file's import at the top.

components/dashboard/sidebar.tsx
{ name: "Reports", href: "/dashboard/reports", icon: BarChart, group: "main" },

group decides where the link lands and who sees it: main at the top for everyone, system under a "SYSTEM" label for admins only, secondary at the bottom beside Billing and Settings. The active link is matched on the path, so you don't configure it. Dashboard Layout covers both in full.

Add a loading skeleton

Every dashboard route ships a loading.tsx next to its page.tsx. The convention here is to mirror the real layout with Skeleton blocks at roughly the same sizes rather than drop in a spinner, so copy the one sitting next to whichever existing page looks closest to yours.

Admin-only page

To restrict a page to admins, put it under app/dashboard/(admin)/. That group's layout (app/dashboard/(admin)/layout.tsx) sends signed-out visitors to /auth/sign-in and any signed-in non-admin to /dashboard. Both built-in admin pages re-check role in the page body too, for the same reason they re-check the session. Do the same.

(admin) is a route group, so it never shows in the URL: a file at app/dashboard/(admin)/reports/page.tsx still serves /dashboard/reports. Move the file into the group rather than copying it. Two files resolving to the same URL is a build error. Give its sidebar link group: "system" so only admins see it.

Nested and dynamic routes

app/dashboard/reports/
├── page.tsx          # /dashboard/reports
└── [id]/page.tsx     # /dashboard/reports/:id

Read dynamic params by awaiting the params prop. Type the props with the global PageProps helper, which next typegen generates (pnpm typecheck runs it first):

app/dashboard/reports/[id]/page.tsx
export default async function Page({
  params,
}: PageProps<"/dashboard/reports/[id]">) {
  const { id } = await params;
}

The breadcrumb builds itself from the path and drops UUID segments, so a nested route appears in the trail on its own. Record IDs that aren't UUIDs will show up in it. Widen the regex at the top of components/dashboard/breadcrumb.tsx if yours are numeric or slugs.


Metadata

Both page types export metadata built with generateMeta from lib/config.ts, which merges your values with the site defaults in APP_CONFIG. One call fills the title, the description, the canonical URL, and the Open Graph and Twitter tags. pathname is the only option you have to pass, and the canonical comes from it. Titles come out as Title - App Name; the homepage is the one page that passes titleFirst: true to flip that round. On anything behind a login, set noIndex: true, the way every dashboard and auth page does.

A new public page still misses one thing: append it to the publicPages array in app/sitemap.ts or it never reaches the sitemap. Keeping a whole route out of the index is a separate list, APP_CONFIG.noIndexRoutes, which adds an X-Robots-Tag: noindex header. Crawling stays allowed. SEO has the full option table and both crawler surfaces.

On this page