Skip to content

02 · create-next-app & Project Structure

You could install next, react and react-dom by hand and write the config yourself, but create-next-app produces a working, typed project in one command and is what the rest of this course assumes.

Scaffolding a project

Check your Node version first — Next.js 16 needs 20.9 or newer:

node -v

Then create the app. The interactive form asks about TypeScript, linting, Tailwind, the src/ directory, the App Router and the import alias; you can answer on the command line instead:

npx create-next-app@latest my-app --ts --app --eslint --tailwind --import-alias "@/*" --use-npm
cd my-app
npm run dev

npm run dev starts the development server (by default on http://localhost:3000). Edit app/page.tsx, save, and the browser updates without a full reload.

Other package managers

--use-pnpm, --use-yarn and --use-bun work the same way. Everything in this course uses npm so that commands can be copied directly.

What was generated

A fresh App Router project with Tailwind looks like this (lock file and node_modules omitted):

my-app/
├── app/
│   ├── favicon.ico
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── public/
│   ├── file.svg  globe.svg  next.svg  vercel.svg  window.svg
├── AGENTS.md
├── eslint.config.mjs
├── next-env.d.ts
├── next.config.ts
├── package.json
├── postcss.config.mjs
├── README.md
└── tsconfig.json

File by file:

File Purpose
app/layout.tsx The root layout. Must render <html> and <body>. Wraps every page.
app/page.tsx The page for /.
app/globals.css Global styles, imported once in the root layout. With Tailwind v4 it starts with @import "tailwindcss";.
app/favicon.ico A special file: Next.js turns it into a <link rel="icon"> automatically.
public/ Files served as-is from the site root (/next.svg). Lesson 08.
next.config.ts Framework configuration (images, redirects, headers, output mode…).
tsconfig.json TypeScript config, including the @/* path alias and the next plugin.
next-env.d.ts Generated type references. Don't edit it; it's regenerated.
eslint.config.mjs ESLint flat config with Next.js rules.
postcss.config.mjs Loads the Tailwind PostCSS plugin.
AGENTS.md Newer versions of create-next-app add notes for AI coding assistants pointing at the version-matched docs in node_modules/next/dist/docs/. Optional (--agents-md flag).

The generated root layout (lightly trimmed) shows several ideas at once:

app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
      <body className="min-h-full flex flex-col">{children}</body>
    </html>
  );
}
  • metadata becomes <title> and <meta name="description"> (lesson 07).
  • next/font/google downloads the font at build time and self-hosts it (lesson 07).
  • LayoutProps<"/"> is a globally available type helper that Next.js generates from your routes; it types children (and any parallel-route slots) for that layout. A matching PageProps<"/route"> exists for pages. You don't import them.
  • children is where the current page (or a nested layout) is rendered.

The three scripts

package.json (scripts)
{
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "eslint"
}
  • next dev — development server. Compiles routes on demand, renders every request fresh, shows an error overlay. Fast feedback, not representative of production performance or caching.
  • next build — compiles the app for production, type-checks it, and prerenders every route it can. Fails on type errors and on prerender errors.
  • next start — serves the output of next build with a Node.js server.

Note that next lint was removed in version 16 and next build no longer runs the linter; npm run lint calls ESLint directly.

Reading the build output

Run npm run build. The end of the output is a route table. Here is one observed while writing this course, for an app that already had a few more routes than the starter:

Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/posts
├ ○ /blog
├   /blog/[slug]
│ ├ ● /blog/hello-next
│ └ ● /blog/routing
└ ƒ /notes

ƒ Proxy (Middleware)

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand

The symbols are the single most useful signal Next.js gives you:

  • ○ Static — rendered once at build time; served as a file.
  • ● SSG — a dynamic segment ([slug]) whose values were listed at build time, each prerendered.
  • ƒ Dynamic — rendered on each request, because the route reads something only known at request time (cookies, headers, search params) or opted out explicitly.

When a page you expected to be ○ shows up as ƒ, that's a bug worth chasing.

Configuration you'll touch early

next.config.ts is TypeScript and exports a typed object:

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [new URL("https://images.example.com/**")],
  },
  async redirects() {
    return [{ source: "/old-blog/:slug", destination: "/blog/:slug", permanent: true }];
  },
};

export default nextConfig;

Config is read when the dev server or build starts; restart next dev after changing it.

The src/ directory and the @/ alias

With --src-dir, app/ moves under src/app/. Nothing else changes; it is purely a preference for keeping config files apart from code. The @/* alias in tsconfig.json maps to the project root (or src/), so import { db } from "@/db" works from any depth instead of ../../../db.

How It Actually Works

next dev and next build both start by scanning app/ to build a route tree: each folder is a segment, and special files (page, layout, loading, error, not-found, route, …) attach behaviour to it. From that tree Next.js generates TypeScript definitions (that's where PageProps and LayoutProps come from — the build output literally prints "Generating route types"), bundler entry points per route, and manifests the server uses at runtime to match URLs.

In development, Turbopack compiles a route the first time you visit it and keeps an in-memory graph so later edits recompile only affected modules. In next build, every route is compiled up front, then Next.js executes each route that can be prerendered and writes the HTML and RSC payload into .next/. next start is a thin server over that directory: static results are served directly, dynamic routes are rendered by invoking the compiled server code. This is why next start fails if you haven't run next build — there's nothing in .next/ to serve.

Common mistakes

  • Judging performance from next dev. Dev mode renders everything per request, skips prefetching and adds instrumentation. Measure with next build && next start.
  • Committing .next/. It's build output; the generated .gitignore excludes it.
  • Editing next-env.d.ts. It's regenerated; put your own declarations elsewhere.
  • Forgetting to restart after changing next.config.ts or .env files.
  • Putting <head> tags by hand in the root layout. Use the Metadata API instead; manual tags can duplicate or conflict with generated ones.

Exercise

  1. Scaffold a project with create-next-app. Run npm run build and copy the route table into a notes file.
  2. Add app/about/page.tsx that returns <h1>About</h1>. Rebuild. Which symbol does /about get?
  3. In the new page, add import { cookies } from "next/headers" and make the component async, calling await cookies() before returning. Rebuild and compare the symbol. Remove it again and note in one sentence why reading cookies changed how the page is built.