Next.js App Router folder structure: a blog from its first page

A Next.js App Router folder structure for a blog, taken from the real Tech Notes repo: page, layout, dynamic [slug] pages, the (site) route group and Vercel.

How should you organize a Next.js App Router folder structure? Every folder in app is a URL segment, a folder only becomes a page once it has a page.tsx, and a layout.tsx wraps every page below it. We'll build a first blog and see how Tech Notes applies these three rules.

Where did the previous post stop?

In the previous post, I explained why Tech Notes chose Next.js, Supabase and Vercel, and estimated how many posts fit in a 500 MB database. The stack is chosen, but a very practical question remains: what do you type when you open the terminal, and how should the folders be arranged so that adding a few dozen pages later doesn't turn into a mess?

This post answers it with two things. The first is a small project called blog-mini, built from scratch following the steps below. The second is Tech Notes' own Git history, so you can see how a real blog's folder structure changed after a few days of work.

Create the project with one command

Next.js has a tool called create-next-app to create a project. The quickest command is:

Create the project
npx create-next-app@latest blog-mini --yes --src-dir
cd blog-mini
npm run dev

Open http://localhost:3000 and you'll see Next.js's default welcome page. The --src-dir flag puts the code in a src/ folder like Tech Notes, for reasons I explain later. The --yes flag skips the other questions and uses the defaults. According to the Next.js 16 installation docs, the defaults are TypeScript, Tailwind CSS, ESLint, the App Router, Turbopack, the @/* import alias, and an AGENTS.md file. Next.js needs Node.js 20.9 or later, so if the command complains about the version, upgrade Node first.

The AGENTS.md file is new, and I find it quite interesting. It tells AI coding tools to read the Next.js documentation bundled in node_modules/next/dist/docs/ instead of relying on memory. The reason is that Next.js changes fast, and AI very often writes code for an older version. In the Tech Notes repo, this instruction sits at the end of AGENTS.md, and the post you're reading was checked against that same set of docs.

If you don't use --yes, the tool asks about each option. One question is worth pausing on: "Would you like your code inside a src/ directory?". The default is no, but Tech Notes chose yes, and that's what the --src-dir flag above does for you.

What was in Tech Notes' first commit?

Commit f1233d3, titled "establish Next.js blog foundation", laid the foundation of the blog. Its src folder held exactly five files:

git show --stat f1233d3 (the src part)
src/app/globals.css  |  111 +
src/app/layout.tsx   |   19 +
src/app/page.tsx     |  147 +
src/lib/site.test.ts |   10 +
src/lib/site.ts      |    5 +

These five files fall into two clear groups. The first group lives in src/app: the things Next.js reads to build the website, namely the root layout, the home page and the shared CSS. The second group lives in src/lib: ordinary project code, here a site.ts file with the blog's name and description, plus a test file.

Putting code in src separates your code from the configuration files at the root, such as package.json, tsconfig.json, next.config.ts and eslint.config.mjs. As the project grows, the root gains docs, scripts, supabase and content folders, and you'll be glad that everything belonging to the application sits in one place. This is more a preference than a rule, but I'd decide at the very start, because switching halfway means fixing a lot of paths.

The root layout in the first commit was only 19 lines long. The blog is written in Vietnamese, so the titles are Vietnamese ("Practical notes for developers"):

Tech Notes: src/app/layout.tsx at commit f1233d3
import type { Metadata } from "next";

import "./globals.css";

export const metadata: Metadata = {
  title: {
    default: "Tech Notes — Ghi chép thực hành cho developer",
    template: "%s | Tech Notes",
  },
  description: "Bài viết thực hành về lập trình, công cụ AI và hành trình sự nghiệp developer.",
};

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="vi">
      <body>{children}</body>
    </html>
  );
}

Two small details are worth learning. The root layout is the only place that may, and must, contain the <html> and <body> tags, so lang="vi" declared here applies to the whole website. And template: "%s | Tech Notes" lets each page declare only its own name, for example "Posts", and the browser tab title automatically becomes "Posts | Tech Notes".

Build blog-mini's first pages

Now I turn blog-mini's welcome page into a small blog with a home page, a list of posts and a page for each post. Once this part and the [slug] part below are done, the src folder looks like this:

blog-mini/src
app/
  layout.tsx              # root layout: <html>, <body>, shared header
  page.tsx                # home page "/"
  globals.css             # keep the file create-next-app generated
  posts/
    page.tsx              # "/posts": list of posts
    [slug]/page.tsx       # "/posts/<slug>": one post (later section)
components/
  post-card.tsx           # card showing one post, used on several pages
content/
  posts.ts                # post data, kept in code for now
lib/
  site.ts                 # the blog's name and description

The first two files have nothing to do with Next.js; they're just data. site.ts holds the blog's name so every page can share it. posts.ts holds the list of posts and a function that finds a post by its slug (the slug is the last part of the URL, like hello in /posts/hello):

src/lib/site.ts
export const site = {
  name: "Blog mini",
  description: "Sample blog for the series Building a blog with Next.js + Supabase.",
};
src/content/posts.ts
// Posts live right in the code. Handy to start with, but every one-word fix means a commit and a deploy.
export type Post = {
  slug: string;
  title: string;
  description: string;
  date: string;
  body: string;
};

export const posts: Post[] = [
  {
    slug: "hello",
    title: "Hello",
    description: "The blog's first post.",
    date: "2026-11-03",
    body: "This is the first post.\n\nIt lives in the file src/content/posts.ts.",
  },
  {
    slug: "why-i-write",
    title: "Why I write",
    description: "Notes on what I learn at work.",
    date: "2026-11-04",
    body: "Writing things down helps me understand them better.\n\nAnd it saves the next person some time.",
  },
];

export function getPost(slug: string): Post | undefined {
  return posts.find((post) => post.slug === slug);
}

The PostCard component shows a post's title and description, with a link to that post's page. Both the home page and the list page use it:

src/components/post-card.tsx
import Link from "next/link";

import type { Post } from "@/content/posts";

export function PostCard({ post }: { post: Post }) {
  return (
    <article>
      <h2>
        <Link href={`/posts/${post.slug}`}>{post.title}</Link>
      </h2>
      <p>{post.description}</p>
    </article>
  );
}

The path @/content/posts is shorthand for src/content/posts, thanks to the @/* alias that create-next-app already set up in tsconfig.json. That way, however deep a file is, you never have to write ../../../.

Next come three files in app. Replace the contents of the layout.tsx and page.tsx that create-next-app generated, and create posts/page.tsx. The root layout has a header with links to the home page and the post list, so this header appears on every page:

src/app/layout.tsx
import type { Metadata } from "next";
import Link from "next/link";

import { site } from "@/lib/site";

import "./globals.css";

export const metadata: Metadata = {
  title: { default: site.name, template: `%s | ${site.name}` },
  description: site.description,
};

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body>
        <header>
          <Link href="/">{site.name}</Link>
          <nav>
            <Link href="/posts">Posts</Link>
          </nav>
        </header>
        <main>{children}</main>
      </body>
    </html>
  );
}

The home page shows the three latest posts. It's a Server Component, meaning it runs on the server, so it reads the posts array directly without useEffect or an API call:

src/app/page.tsx
import { PostCard } from "@/components/post-card";
import { posts } from "@/content/posts";

export default function HomePage() {
  const latest = [...posts].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 3);
  return (
    <>
      <h1>Practical notes for developers</h1>
      {latest.map((post) => (
        <PostCard key={post.slug} post={post} />
      ))}
    </>
  );
}

The list page shows every post. The metadata line sets the title to "Posts", and thanks to the layout's template, the browser tab shows "Posts | Blog mini":

src/app/posts/page.tsx
import type { Metadata } from "next";

import { PostCard } from "@/components/post-card";
import { posts } from "@/content/posts";

export const metadata: Metadata = { title: "Posts" };

export default function PostsPage() {
  return (
    <>
      <h1>Posts</h1>
      {posts.map((post) => (
        <PostCard key={post.slug} post={post} />
      ))}
    </>
  );
}

Now open http://localhost:3000 and you'll see two posts; click "Posts" to go to /posts. Click a post and you get a 404, because the page for each post doesn't exist yet. I build it in a later section.

When does a folder become a page?

This is where beginners get confused most often, so let's start with a broken example.

In blog-mini, create a folder src/app/about and put a file content.md in it with a few lines about the blog. Sounds reasonable, right? A folder called about should give you /about. But open the browser and you get a 404.

The reason is that Next.js only treats a folder as a public page when it contains a page.tsx file (or route.ts for an API). The Project structure docs say clearly that folders define the route structure, but a route isn't publicly accessible until it has a page or route file. The upside of this rule is that you can keep other files in app without worrying that they'll accidentally become pages.

So I don't have to keep this in my head, I wrote a small script called route-map.ts, placed at the root of blog-mini (next to package.json, not inside src/app). It takes the path to the app folder, walks through every subfolder, and applies the four App Router conventions listed at the top of the file. Folders with a page.tsx are printed with their URL. Folders with nothing to show are listed separately at the end:

route-map.ts
// Prints a "folder → URL" table for an app/ folder, following App Router conventions.
//   - Only folders with page.tsx (or route.ts) become public URLs.
//   - (group-name): a route group, left out of the URL.
//   - _name: a private folder, ignored along with everything inside it.
//   - @slot: a slot of the parent layout, not a URL segment.
import { readdirSync, statSync } from "node:fs";
import { join, relative, sep } from "node:path";

const root = process.argv[2];
if (!root) {
  console.error("Usage: node route-map.ts <app-folder>");
  process.exit(1);
}

type Row = { url: string; file: string; kind: "page" | "route" | "layout" };
const rows: Row[] = [];
const notPages: string[] = [];
const privateDirs: string[] = [];
const rel = (path: string) => relative(root, path).split(sep).join("/");

function toUrl(dir: string): string {
  const segments = rel(dir)
    .split("/")
    .filter((s) => s && !(s.startsWith("(") && s.endsWith(")")) && !s.startsWith("@"));
  return "/" + segments.join("/");
}

function walk(dir: string) {
  const entries = readdirSync(dir);
  const files = entries.filter((e) => statSync(join(dir, e)).isFile());
  const subdirs = entries.filter((e) => statSync(join(dir, e)).isDirectory());
  const page = files.find((f) => /^page\.(tsx|ts|jsx|js|mdx)$/.test(f));
  const route = files.find((f) => /^route\.(ts|js)$/.test(f));
  const layout = files.find((f) => /^layout\.(tsx|ts|jsx|js)$/.test(f));

  if (page) rows.push({ url: toUrl(dir), file: rel(join(dir, page)), kind: "page" });
  if (route) rows.push({ url: toUrl(dir), file: rel(join(dir, route)), kind: "route" });
  if (layout) rows.push({ url: toUrl(dir), file: rel(join(dir, layout)), kind: "layout" });
  if (dir !== root && !page && !route && !layout && subdirs.length === 0) notPages.push(rel(dir) + "/");

  for (const sub of subdirs) {
    if (sub.startsWith("_")) privateDirs.push(rel(join(dir, sub)) + "/");
    else walk(join(dir, sub));
  }
}

walk(root);

const width = Math.max(...rows.map((r) => r.url.length), 10);
const headings = {
  page: "Public pages:",
  route: "Route handlers (return data, not pages):",
  layout: "Layouts (wrap the pages below them):",
};
for (const kind of ["page", "route", "layout"] as const) {
  const list = rows.filter((r) => r.kind === kind).sort((a, b) => a.url.localeCompare(b.url));
  if (list.length === 0) continue;
  console.log(headings[kind]);
  for (const r of list) console.log(`  ${r.url.padEnd(width)}  ←  ${r.file}`);
  console.log();
}
if (notPages.length) console.log(`Folders without page.tsx, so NOT pages: ${notPages.join(", ")}`);
if (privateDirs.length) console.log(`Private folders (start with _), ignored by Next.js: ${privateDirs.join(", ")}`);

The script runs directly with Node.js 22.18 or later. Here's the output on blog-mini, after the [slug] page from the next section is in place. Read each line as "this URL is created by this file":

node route-map.ts src/app
Public pages:
  /              ←  page.tsx
  /posts         ←  posts/page.tsx
  /posts/[slug]  ←  posts/[slug]/page.tsx

Layouts (wrap the pages below them):
  /              ←  layout.tsx

Folders without page.tsx, so NOT pages: about/

The last line is the 404 from earlier. Add a page.tsx to about and the page appears.

How does the page for each post use [slug]?

A blog has dozens of posts, and nobody creates dozens of folders. Next.js lets you name a folder in square brackets, like [slug], so that one page matches every value in that part of the URL. The page posts/[slug]/page.tsx serves both /posts/hello and /posts/why-i-write.

src/app/posts/[slug]/page.tsx
import { notFound } from "next/navigation";

import { getPost, posts } from "@/content/posts";

// Generate a page for every post at build time.
export function generateStaticParams() {
  return posts.map((post) => ({ slug: post.slug }));
}

type Props = { params: Promise<{ slug: string }> };

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = getPost(slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      {post.body.split("\n\n").map((paragraph, index) => (
        <p key={index}>{paragraph}</p>
      ))}
    </article>
  );
}

The easiest thing to get wrong is params. If you learned Next.js from an old tutorial or asked AI to write it, you may well see params.slug used directly.

Since Next.js 15, params is a Promise, so you have to await it before reading it. Next.js 15 still allowed the old way temporarily during the transition, but according to the upgrade guide for version 16, synchronous access has been removed entirely. Write params.slug now and TypeScript complains straight away: Property 'slug' does not exist on type 'Promise<{ slug: string; }>'. That's exactly the kind of change the AGENTS.md file above wants to remind AI tools about.

generateStaticParams tells Next.js the list of slugs in advance, so it can generate the HTML for every post at build time. And notFound() returns the 404 page when the slug doesn't exist, instead of letting the page crash because post is undefined. If you remember the Cannot read properties of undefined error from the post on reading a stack trace, this is how you stop it right where the data is read.

I type-checked the whole of blog-mini with Next.js 16.4 and React 19.3, the same versions Tech Notes uses.

Nested layouts and the (site) route group

After the first commit, the blog grew very quickly. On 3 November 2026 alone, Tech Notes gained pages for posts, topics, series and search, and then an admin area for writing posts. That's when I hit the first real folder structure problem.

Public pages and admin pages differ in two ways. In appearance, readers need the blog's header, menu and footer, while the author needs the admin's navigation bar. In how pages are built, public pages are generated ahead of time and refreshed at most once an hour so they're fast, while admin pages depend on who is logged in and must be built fresh on every visit.

If everything sat under the root layout, I'd have only two bad options. Either the root layout contains the blog's header and the admin pages have to find a way to hide it, or every public page adds the header itself and a dozen pages repeat the same code.

Commit f55fc16 solved this with a route group. A folder name in parentheses, like (site), is used by Next.js to group pages but is left out of the URL. The public pages moved into src/app/(site)/, so (site)/bai-viet/page.tsx is still /bai-viet (Tech Notes' URLs are in Vietnamese; *bai-viet* means "posts"). The group has its own layout:

Tech Notes: the (site) layout (real file, no need to copy it into blog-mini)
// Public pages are static and refresh at most hourly; publishing from the admin revalidates
// them immediately, and the hourly pass picks up scheduled posts once their time arrives.
export const revalidate = 3600;

export default function SiteLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <>
      <SiteFrame>{children}</SiteFrame>
      {/* The analytics scripts are served by Vercel itself; elsewhere (local, CI) they would 404. */}
      {process.env.VERCEL ? <SiteAnalytics /> : null}
    </>
  );
}

SiteFrame is the component with the blog's header, menu and footer, and SiteAnalytics adds Vercel's analytics script. You don't need these two components to follow the main idea. What matters is the revalidate = 3600 line, which means every page in the (site) group is generated ahead of time and refreshed at most once an hour.

src/app/admin/layout.tsx, on the other hand, declares export const dynamic = "force-dynamic" and uses the admin's own frame. Two separate worlds, each with its own layout.

Since then, Tech Notes has gained an English edition (the one you're reading), and the public pages have moved one level deeper, under a [lang] folder whose layout sets <html lang> for each language. Here's the script run on Tech Notes' real src/app today: 27 pages (including the admin ones), 3 route handlers and 3 layouts. These are the start and the end of the output:

node route-map.ts tech-notes/src/app (excerpt)
Public pages:
  /[lang]                      ←  [lang]/(site)/page.tsx
  /[lang]/bai-viet             ←  [lang]/(site)/bai-viet/page.tsx
  /[lang]/bai-viet/[slug]      ←  [lang]/(site)/bai-viet/[slug]/page.tsx
  …

Route handlers (return data, not pages):
  /[lang]/rss.xml              ←  [lang]/rss.xml/route.ts
  /admin/ban-tin/xuat-csv      ←  admin/ban-tin/xuat-csv/route.ts
  /rss.xml                     ←  rss.xml/route.ts

Layouts (wrap the pages below them):
  /[lang]                      ←  [lang]/layout.tsx
  /[lang]                      ←  [lang]/(site)/layout.tsx
  /admin                       ←  admin/layout.tsx

Notice the two layouts for the same /[lang]. [lang]/layout.tsx wraps everything in that language, while (site)/layout.tsx only wraps the pages inside the group. Same path, different scope, and that's the whole point of a route group.

Where do shared components and functions go?

Next.js lets you put other files next to page.tsx in the app folder, because only page and route become URLs. You can also start a folder name with an underscore, like _components, so Next.js ignores that folder and everything in it.

Tech Notes chose a different approach: app only holds pages and layouts. Components live in src/components (32 files today), logic lives in src/lib, and sample content lives in src/content. I have two reasons.

The first is that most components are used on several pages. post-card.tsx appears on the home page, the post list, topic pages and series pages. Put it next to any one of those pages and it's in the wrong place for the other three.

The second is that the functions in src/lib need tests, and they don't depend on Next.js. The slug function, the Markdown reader and the SEO checker all run in Vitest without building any page. When I open src/app, I see only the website's URLs, and when I open src/lib, I see only logic. In my view, this split is easier for someone new to the project to read, but if your project has many components used by exactly one page, keeping them next to that page is perfectly fine too.

Put the first page on Vercel

Once npm run dev works on your machine, putting it online takes a few minutes. Push the code to a GitHub repo, go to Vercel, create a new project and import that repo. Vercel recognizes a Next.js project, so keep the default settings and click Deploy. From then on, every push to the main branch produces a new production version, and every pull request gets its own preview URL.

There's one thing I recommend doing from day one: don't let Google index the previews. If Google finds a preview URL, an unfinished version could show up in search results and compete with the real one. Vercel already marks previews as noindex by default, but Tech Notes still declares it in code, so the rule lives in the repo and has a test, instead of depending on a setting someone could switch off. It's a small function in src/lib/site.ts:

Tech Notes: src/lib/site.ts (the isIndexable function)
/** Only Vercel's production deployment may be indexed; previews and local builds stay `noindex`. */
export function isIndexable(): boolean {
  return process.env.VERCEL_ENV === "production";
}

The root layout uses this function to declare robots: { index: indexable, follow: indexable }. Vercel sets the VERCEL_ENV variable to production, preview or development depending on the deployment, so only the production version may appear on Google. The kinds of environment are described in Vercel's Environments documentation.

One typo, one deploy

Back to blog-mini. The posts sit in src/content/posts.ts, an ordinary TypeScript array. Tech Notes started exactly the same way: at commit b7e58d5, a 330-line posts.ts held the first sample posts.

This is very handy while building the interface, but it has a cost you'll notice as soon as you start writing for real. To fix a typo, you have to open the code, edit the file, commit, push and wait for Vercel to rebuild. Writing a post on your phone is impossible. That's requirement number 2 from the previous post, and the stack can't meet it yet.

Later posts in the series move the posts from code into a Supabase table, write a migration to create that table, and keep the speed of static pages. You can follow the whole series at Building a blog with Next.js + Supabase. If you're preparing for interviews, the question about route groups and private folders is in the interview question bank.

Get the next post by email.

One email a week, only when there is something worth reading. No spam.