See the App Router course introduction for more info.
npx create-next-app@latest next_app_router --example "https://github.com/vercel/next-learn/tree/main/dashboard/starter-example" --use-pnpmInstall the VSCode Prettier plugin:
pnpm install -D prettier prettier-plugin-tailwindcssCreate .prettierrc at the root level of the project and use the correct path for your CSS file.
{
"bracketSameLine": true,
"plugins": [
"prettier-plugin-tailwindcss"
],
"overrides": [
{
"files": "*.md",
"options": {
"parser": "markdown"
}
}
],
"tailwindStylesheet": "./app/ui/global.css"
}Enable TypeScript plugin for VSCode:
Next.js includes a custom TypeScript plugin and type checker, which VSCode and other code editors can use for advanced type-checking and auto-completion.
You can enable the plugin in VSCode by:
- Opening the command palette (Ctrl/⌘ + Shift + P)
- Searching for "TypeScript: Select TypeScript Version"
- Selecting "Use Workspace Version"
The styling issues, including an unsized right arrow SVG, are caused by an unimported stylesheet and will be addressed in Chapter 2: CSS Styling.
I initially encountered an error about a missing app/ui/fonts.ts file. That problem is addressed in Chapter 3: Optimizing Fonts and Images.
Associate CSS files with Tailwind in workspace settings:
"settings": {
"files.associations": {
"*.css": "tailwindcss",
},
}Vercel:
- Create free account
- Account Settings > Authentication > Sign-in Methods > GitHub -> Connect > (authorize GitHub)
- Vercel Dashboard > Import Project > Import Git Repository
By connecting your GitHub repository, whenever you push changes to your main branch, Vercel will automatically redeploy your application with no configuration needed.
- Select a Git Namespace > Add GitHub Account > (Install Vercel app)
- (repositories should appear in list after installation)
- Dashboard > Projects (left sidebar) > Add New... > Project
- Let's build something new -> Import Git Repository > (select repository
next-app-router) -> Import button - (review Importing from GitHub, Project Name, Application Preset)
- Press "Deploy" button
- Should say "Congratulations! You just deployed a new project"
- Press "Continue to Dashboard" button
Project Dashboard > Storage (left sidebar) > Neon > Create:
- Create new Neon account
- Installation Plans > Free
- Install Integration:
- Configuration and Plan:
- Installation Plans: Free
- Press "Continue" button
- Confirmation:
- Resource name:
next-app-router-db - Press "Create" button
- Resource name:
- Database Provisioning:
- Should say "Your Neon database is ready to use. The database
next-app-router-dbhas been successfully created" - Press "Continue" button
- Should say "Your Neon database is ready to use. The database
- Connect a Project:
- Make sure
next-app-routeris selected - Press "Connect" button
- Should see message "Connected project
next-app-routerto database."
- Make sure
- Configuration and Plan:
- Database dashboard: Project Dashboard > Storage > (select
next-app-router-db) - .env.local tab > "Show secret" button > "Copy Snippet" button
- Copy file
./env.exampleto./envand paste snippet into it- Keep AUTH lines, replace the rest with snippet
- Project:
- Project dashboard > Settings (left sidebar) > Delete Project
- Database:
- Database dashboard > Settings (left sidebar) > Delete Database
Project dashboard > (select project) > Storage tab > (select database) > Database Dashboard > "Open in Neon" button
Neon Console > (select database project)
- SQL Editor tab to write queries
- Tables tab to examine table structure
If there's an error when trying to seed the database use the Neon Console's "Tables" tab to drop all the tables. Otherwise there will be duplicate entries.
- Click the name of a table
- Click the three dots icon
- Select "Drop"
And then rerun the script by visiting localhost:3000/seed.
I found the instructions for how to modify app/query/route.ts unclear. Uncomment the entire file and remove the first return Response.json() statement in the function GET(). The code should look like this:
import postgres from 'postgres';
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
async function listInvoices() {
const data = await sql`
SELECT invoices.amount, customers.name
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
WHERE invoices.amount = 666;
`;
return data;
}
export async function GET() {
try {
return Response.json(await listInvoices());
} catch (error) {
return Response.json({ error }, { status: 500 });
}
}And here's the response after visiting localhost:3000/query. There should be only one invoice entry.
[
{
"amount": 666,
"name": "Evil Rabbit"
}
]I had enabled the Chrome extension Dark Reader on localhost:3000 and it caused hydration errors on two unrelated images. The error message specifically called this out.
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:
- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.I experienced an error in both createInvoice() and updateInvoice() of /app/lib/actions.ts:
Type '(formData: FormData) => Promise<{ message: string; }>' is not assignable to type 'string | ((formData: FormData) => void | Promise<void>) | undefined'.The form which uses createInvoice() in /app/ui/invoices/create-form.tsx expects a Promise and we're returning a string:
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
} catch (error) {
// We'll also log the error to the console for now
console.log(error);
return {
message: "Database Error: Failed to Create Invoice.",
};
}I removed the return messages as a temporary fix. If possible, ignore the error while following the tutorial instructions through the end of Chapter 13. When both functions are wrapped in useActionState() the formAction returned by the hook will have the correct type signature ((formData: FormData) => Promise<void>) that the form expects.
I experienced multiple errors with eslint due to compatibility issues. Some possible reasons from Claude:
"This is a compatibility issue between ESLint v10 and
eslint-plugin-reactv7.37.5. ESLint v10 changed its API, and the react plugin you have installed doesn't support it yet.""The issue is that
eslint.config.mjsuses the flat config format, which requires ESLint v9+. ESLint v8 doesn't support that import."
The solution that eventually worked required using different versions of those libraries.
- Discard any file changes you've made so far in this chapter
- Delete the entire
node_modulesfolder, prune unused packages, and reset dependencies
pnpm store prune
rm -rf node_modules pnpm-lock.yaml- Run
pnpm installto restore the project to the way it worked at the end of the previous chapter - Verify everything works by running the server:
pnpm dev - Ignore the tutorial's instructions to install
eslintandeslint-config-nextwithpnpm. Instead, manually add these lines topackage.jsonand runpnpm install:
"devDependencies": {
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.4",
"eslint-plugin-react": "^7.37.5",
},- After that the command
pnpm lintshould run without problems.
After rebuilding node_modules to fix another problem I encountered a seemingly unrelated error with postgres:
Cannot find module 'postgres' or its corresponding type declarations.The solution was to run the VSCode command "TypeScript: Restart TS server."
Here is Claude Code's explanation:
"When you cleared
node_modulesand ranpnpm install, the file system changed—postgres got reinstalled in a fresh location. However, the TypeScript language server (the process running in VS Code that checks your types) had cached its understanding of the module graph from before you cleared dependencies.The cache told it: "postgres doesn't exist" even though it actually did on disk now. When you restarted the server, it:
- Cleared all its cached module resolution info
- Re-scanned
node_modulesfrom scratch- Rebuilt its understanding of where postgres and its type definitions were located
This is a common gotcha with
node_moduleschanges—the language server doesn't automatically detect large filesystem changes like a fresh install. A restart forces it to rebuild its cache from the actual disk state."
Be careful when entering the code for this chapter. One missing character caused two big problems that took some time to diagnose.
auth.config.ts:
const isOnDashboard = nextUrl.pathname.startsWith("/dashboard");Without that leading slash before /dashboard I was able to access the dashboard without authenticating. And every attempt to log in resulted in an ERR_TOO_MANY_REDIRECTS loop.
The URL path is
/dashboard, notdashboard. The check is always failing.
- Unauthenticated users can access
/dashboard—isOnDashboardevaluates to false, so the code skips the dashboard protection and returns true- Redirect loop when logging in — When you're logged in,
isOnDashboardis still false, so it hitselse if (isLoggedIn)and redirects to/dashboardagain, creating the loop