跪拜 Guibai
← Back to the summary

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:

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:

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:

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:

  1. 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
  2. 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
  3. 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

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 Link component is imported from next/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

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:


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

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

  1. 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
  2. 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/json request header when sending, otherwise parsing fails
  3. Response.json()

    • Returns a JSON format response
    • The second parameter can pass a status code, e.g., { status: 201 }
  4. 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 frontend setTodos only 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:

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:

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:

After mastering these basics, the next steps to dive deeper: