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:
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:
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>
);
}
metadatabecomes<title>and<meta name="description">(lesson 07).next/font/googledownloads 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 typeschildren(and any parallel-route slots) for that layout. A matchingPageProps<"/route">exists for pages. You don't import them.childrenis where the current page (or a nested layout) is rendered.
The three 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 ofnext buildwith 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:
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 withnext build && next start. - Committing
.next/. It's build output; the generated.gitignoreexcludes it. - Editing
next-env.d.ts. It's regenerated; put your own declarations elsewhere. - Forgetting to restart after changing
next.config.tsor.envfiles. - Putting
<head>tags by hand in the root layout. Use the Metadata API instead; manual tags can duplicate or conflict with generated ones.
Exercise¶
- Scaffold a project with
create-next-app. Runnpm run buildand copy the route table into a notes file. - Add
app/about/page.tsxthat returns<h1>About</h1>. Rebuild. Which symbol does/aboutget? - In the new page, add
import { cookies } from "next/headers"and make the componentasync, callingawait 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.