One Project, Frontend and Backend: A Next.js App Router Todo Walkthrough
Opening
If you are a React developer, you've probably run into these scenarios:
- You built a React SPA with Vite, launched it, and found search engines couldn't see your pages — crawlers got empty HTML.
- You wanted to add a backend API to store data, but had to spin up a separate Node service, switching between two projects.
- Routing required installing
react-router-dom, state management needed a pile of libraries, and config files kept multiplying.
Next.js was born to solve these pain points.
It is the full-stack application framework for the React ecosystem — in a single project, you can write both frontend pages and backend JSON APIs. Routing follows filesystem conventions, and SEO is naturally friendly. Today, a large number of AI product websites, corporate sites, and content platforms are built on Next.js.
Let's clarify a conceptual distinction first; many beginners confuse these three names:
- Next.js: React full-stack framework (pages + APIs integrated)
- Nuxt.js: Vue full-stack framework, corresponding to the Vue tech stack
- NestJS: Pure Node backend framework, not responsible for page rendering
This article is based on the App Router pattern, taking you from core concepts to a complete Todo full-stack hands-on, systematically getting started with Next.js.
1. Core Rendering Modes: CSR and SSR
Before understanding Next.js, first grasp the essential difference between the two rendering modes — where exactly the component generates HTML.
1.1 CSR (Client-Side Rendering)
This is the traditional Vite + React SPA pattern. The server returns an almost empty HTML file and a JS bundle; the browser downloads the JS, executes it locally, and dynamically generates the DOM.
Browser request → receives empty HTML + JS → browser executes JS → generates DOM → user sees content
Obvious drawbacks:
- Search engine crawlers get an empty page, poor SEO performance
- First screen has a white-screen delay, poor user experience
1.2 SSR (Server-Side Rendering)
This is Next.js's core capability. Components execute on the server (Node environment) and generate a complete HTML string returned to the browser.
Browser request → server executes components to generate complete HTML → browser renders directly → user sees content
Advantages:
- Search engines directly receive complete content, SEO-friendly
- Faster first screen, shorter time for users to see content
1.3 A Table to See the Difference at a Glance
| Comparison Item | CSR Client-Side Rendering | SSR Server-Side Rendering |
|---|---|---|
| Rendering Location | Browser | Node Server |
| HTML Crawlers Get | Empty shell | Complete content |
| SEO Performance | Poor | Excellent |
| First Screen Speed | Slower (waiting for JS execution) | Fast |
| Representative | Vite + React SPA | Next.js |
1.4 Three-Layer SEO Optimization Logic
Good SEO essentially means helping search engines better understand your page, divided into three layers:
Layer 1: Head Metadata
title: Page title, tells search engines "who you are, what you do"description: Page description, tells search engines "what value it has"keywords: Keywords- Corresponds to HTML
<head>tags
Layer 2: Body Content
- The real text content inside
<body>is the core of what crawlers capture - Why users come to your page depends on this content
- The real text content inside
Layer 3: Rendering Mode
- Use SSR to ensure the server outputs complete HTML, not an empty shell
In Next.js, you configure the first layer of metadata by exporting a metadata object:
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "My Todo List - Efficient Task Management",
description: "An online todo management tool built with Next.js, supporting add, delete, and status toggle",
keywords: "todo, task management, Next.js, React",
};
2. App Router: Files as Routes, No Manual Configuration
The App Router pattern, promoted since Next.js 13, replaces traditional routing configuration code with filesystem conventions, eliminating the need to install react-router-dom.
2.1 Core Conventions
- Folder = Route path:
app/about/page.tsxcorresponds to the access address/about page.tsx= Page entry: A folder must containpage.tsxfor that route to be publicly accessiblelayout.tsx= Shared layout: Defines the common layout (navigation, footer, etc.) for that level and all child routes; thechildrenslot renders child page content
2.2 Typical Directory Structure
app/
├── layout.tsx # Root layout, shared by all pages
├── page.tsx # Home page, corresponds to /
├── about/
│ └── page.tsx # About page, corresponds to /about
├── dashboard/
│ └── page.tsx # Dashboard page, corresponds to /dashboard
├── todos/
│ ├── page.tsx # Todo page, corresponds to /todos
│ └── type.ts # Todo type definition
└── api/
└── todos/
└── route.ts # Backend API, corresponds to /api/todos
2.3 Root Layout Example
app/layout.tsx is the shell of the entire application; all pages are wrapped inside it:
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Link from "next/link";
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,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="zh-CN"
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<body className="min-h-screen flex flex-col">
<nav>
<ul style={{ display: "flex", gap: 20, listStyle: "none", padding: 0 }}>
<li><Link href="/" style={{ color: "#df1721" }}>Home</Link></li>
<li><Link href="/about" style={{ color: "#df1721" }}>About</Link></li>
<li><Link href="/dashboard" style={{ color: "#df1721" }}>Dashboard</Link></li>
</ul>
</nav>
{/* The current route's page.tsx content renders here */}
{children}
</body>
</html>
);
}
Note: The
Linkcomponent is imported fromnext/link. It is Next.js's built-in routing navigation component, supports prefetching, and performs better than native<a>tags.
3. Server Components vs Client Components
Under App Router, components are Server Components (RSC) by default, rendered directly on the server without sending extra JS to the browser. But if a component needs interaction (clicks, input, state management), it must be marked as a Client Component.
3.1 Usage Rules
- Default (nothing written): Server Component, suitable for static content display, SEO-friendly
- Add
'use client'at the top: Client Component, supportsuseState,useEffect, event binding, and other browser interactions
3.2 Quick Decision Table
| Scenario | Component Type |
|---|---|
| Plain text display, headings, paragraphs | Server Component (default) |
| Button clicks, form input, modal control | Client Component (add 'use client') |
| Using React Hooks (useState/useEffect etc.) | Client Component (add 'use client') |
| Needing access to browser APIs (window/document) | Client Component (add 'use client') |
3.3 A Common Misconception
Many people think "adding 'use client' means it renders entirely in the browser, the server does nothing." That's not true:
- Static text parts still render to HTML on the server (ensuring SEO)
- Interactive logic (event binding, state management) executes in the browser
- This is a hybrid rendering mode, balancing SEO and interactivity
4. Full-Stack Core: Route Handlers for Writing Backend APIs
The most powerful part of Next.js: write backend APIs directly in the same project, no need to spin up a separate Node service.
4.1 Convention Rules
app/api/xxx/route.tsfile defines the API- Exported function names are the HTTP method names:
GET,POST,PUT,DELETE, etc. - Uses the Web standard
RequestandResponseAPI
4.2 Complete API Example: Todo API
First define the type app/todos/type.ts:
export interface Todo {
id: number;
title: string;
completed: boolean;
}
Write the API app/api/todos/route.ts:
import { Todo } from "../../todos/type";
// Force dynamic rendering to avoid API caching causing stale data
export const dynamic = "force-dynamic";
// In-memory mock database (for development demo; use a real database in production)
let todos: Todo[] = [
{ id: 1, title: "Learn AppRouter routing rules", completed: true },
{ id: 2, title: "Develop Next.js personal website", completed: false },
];
// GET /api/todos —— Get todo list
export async function GET() {
return Response.json(todos);
}
// POST /api/todos —— Add new todo
export async function POST(req: Request) {
// Parse request body JSON
const body = await req.json();
const newTodo: Todo = {
id: Date.now(),
title: body.title,
completed: false,
};
todos.push(newTodo);
// 201 indicates resource created successfully
return Response.json(newTodo, { status: 201 });
}
4.3 Key Detail Explanations
export const dynamic = "force-dynamic"- Forces the API to re-execute code on every request
- Avoids the Next.js static caching problem where "new data is added but GET still returns the old list"
- This is one of the most common pitfalls for beginners
await req.json()- Reads the HTTP request body and parses it into a JS object
- Must add
await, otherwise you get a Promise object - The frontend must set the
Content-Type: application/jsonrequest header when sending, otherwise parsing fails
Response.json()- Returns a JSON format response
- The second parameter can pass a status code, e.g.,
{ status: 201 }
Limitations of the In-Memory Array
let todos: Todo[] = [...]is only for development demonstration- All data is lost after server restart
- Production projects must connect to a database (e.g., PostgreSQL + Prisma)
5. Complete Hands-On: Todo Page Connecting to the API
Now we write a frontend page that calls the above API to implement todo list display and add functionality.
5.1 Page Code app/todos/page.tsx
"use client";
import { useState, useEffect } from "react";
import { Todo } from "../todos/type";
export default function TodosPage() {
const [todos, setTodos] = useState<Todo[]>([]);
const [text, setText] = useState("");
// Fetch todo list
const fetchTodos = async () => {
const res = await fetch("/api/todos", { cache: "no-store" });
const data: Todo[] = await res.json();
setTodos(data);
};
// Fetch list on page load
useEffect(() => {
fetchTodos();
}, []);
// Add new todo
const handleAdd = async () => {
if (!text.trim()) return;
const res = await fetch("/api/todos", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title: text }),
});
const newItem = await res.json();
// Directly append to local state, avoiding an extra request
setTodos((prev) => [...prev, newItem]);
setText("");
};
return (
<div style={{ maxWidth: 600, margin: "40px auto" }}>
<h1>Todo List</h1>
<div style={{ marginBottom: 20, display: "flex", gap: 10 }}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter a new todo task"
style={{ flex: 1, padding: "8px 12px" }}
/>
<button onClick={handleAdd} style={{ padding: "8px 20px" }}>
Add
</button>
</div>
<ul style={{ listStyle: "none", padding: 0 }}>
{todos.map((item) => (
<li
key={item.id}
style={{
padding: "12px 0",
borderBottom: "1px solid #eee",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span
style={{
textDecoration: item.completed ? "line-through" : "none",
cursor: "pointer",
}}
>
{item.title}
</span>
<button>Delete</button>
</li>
))}
</ul>
</div>
);
}
5.2 Key Detail Breakdown
① "use client" is mandatory
Because useState, useEffect, and click events are used — all browser-side capabilities — the file must be marked with 'use client' at the top, otherwise it will error.
② GET request adds cache: "no-store"
const res = await fetch("/api/todos", { cache: "no-store" });
Disables browser caching to ensure the latest server data is fetched every time. Without it, after adding a new item, a GET might return the cached old list.
③ POST request must set request headers
headers: {
"Content-Type": "application/json",
}
Tells the backend "I'm sending JSON format", so the backend's req.json() can parse correctly. Libraries like axios add this header automatically, but native fetch requires it manually.
④ State update strategy: direct append, no extra GET
const newItem = await res.json();
setTodos((prev) => [...prev, newItem]);
The POST API returns the newly created todo object; the frontend directly appends it to local state, saving a GET request and completely avoiding the caching pitfall.
A common question here: "Isn't this adding it twice? Once on the backend, once on the frontend?" No. The backend
todos.push()is the actual data write; the frontendsetTodosonly updates the UI display so the user immediately sees the new data. The authoritative source of data is always the backend.
5.3 Alternative Approach: Re-fetch Full List After Adding
If you feel "direct append" isn't rigorous enough, you can also re-call fetchTodos() to pull the full list after adding:
const handleAdd = async () => {
if (!text.trim()) return;
await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: text }),
});
// Re-fetch the full list
await fetchTodos();
setText("");
};
This approach has more straightforward logic, but you must ensure:
- The API side has
export const dynamic = "force-dynamic" - The GET request has
cache: "no-store"
Both are indispensable; otherwise, you'll encounter the problem of "added but page doesn't update, must manually F5."
6. Creating the Project and Starting
6.1 Initialize Project
npx create-next-app@latest
Default config selections:
- TypeScript ✔
- Tailwind CSS ✔
- ESLint ✔
- App Router ✔
6.2 Common Commands
npm run dev # Start development server (default http://localhost:3000)
npm run build # Build production version
npm run start # Run production build
npm run lint # Code linting
7. Common Beginner Pitfalls Summary
| Problem | Cause | Solution |
|---|---|---|
| 500 Internal Server Error | API code error, check VSCode terminal | Check if await req.json() has await, if Response is returned |
| Page doesn't update after adding, must manually F5 | GET API is cached | API add dynamic = "force-dynamic", fetch add cache: "no-store" |
| Objects are not valid as a React child | Rendering an object directly | Check if {item} was written instead of {item.title} |
'use client' added but still errors |
Added in a child component called by a server component, not effective | Ensure the component file needing interaction has it at the top |
| ESLint reports indent errors | Config file indent rules don't match actual | Run npx eslint . --fix to auto-fix, or adjust indent rules |
| fetch POST backend can't parse data | Content-Type request header not set |
Add headers: { "Content-Type": "application/json" } |
8. Summary and Next Steps
Next.js is not simply a "React enhanced version", but a full-stack solution for the React ecosystem:
- Implements filesystem routing with App Router, goodbye to routing configuration
- Naturally solves SEO and first-screen performance issues with Server Components
- Allows frontend developers to directly write backend APIs with Route Handlers, achieving true full-stack development
After mastering these basics, the next steps to dive deeper:
- Server Actions: Simpler server-side data operations, no need to manually write fetch
- Dynamic Routes
[id]: Implement detail pages, e.g.,/todos/1 - Data Caching and revalidate: Optimize API performance, balancing real-time and performance
- Connect to Database: Use Prisma + PostgreSQL to replace the in-memory array, achieving true data persistence
- Middleware middleware.ts: Implement login authentication, route redirection, etc.