Bài 29.4: API Routes trong TypeScript

Chào mừng bạn trở lại với hành trình khám phá lập trình web hiện đại! Cho đến nay, chúng ta đã tập trung chủ yếu vào việc xây dựng giao diện người dùng (frontend) đẹp mắt và tương tác mượt mà với HTML, CSS, JavaScript, TypeScript và React/Next.js. Nhưng trong thế giới thực, các ứng dụng web thường cần tương tác với dữ liệu, thực hiện các tác vụ yêu cầu quyền riêng tư hoặc tài nguyên server-side. Đây là lúc khái niệm API Routes trong Next.js trở nên vô cùng mạnh mẽ và cần thiết.

Hãy tưởng tượng bạn đang xây dựng một ứng dụng Next.js và bạn cần:

  • Lưu dữ liệu vào database.
  • Xử lý thanh toán mà không muốn để lộ khóa API bí mật trên trình duyệt.
  • Kết nối đến một dịch vụ bên thứ ba yêu cầu xác thực server-side.
  • Hoặc đơn giản là tạo ra các "điểm cuối" (endpoints) API nội bộ để frontend của bạn giao tiếp.

Bạn có thể nghĩ đến việc xây dựng một server backend riêng biệt (ví dụ: với Node.js, Express, Python, Ruby...). Tuyệt vời! Đó là một cách tiếp cận phổ biến. Nhưng Next.js mang đến một giải pháp tích hợp ngay trong dự án frontend của bạn: API Routes.

API Routes là gì?

Hiểu một cách đơn giản, API Routes cho phép bạn tạo ra các điểm cuối API ngay trong thư mục pages/api (hoặc app/api trong Next.js 13+ App Router, nhưng chúng ta sẽ tập trung vào pages/api cho phù hợp với cấu trúc Pages Router phổ biến) của dự án Next.js của bạn. Thay vì xuất ra một component React để render giao diện, các file trong thư mục này sẽ xuất ra một hàm xử lý server-side (serverless function).

Khi một request HTTP đến một đường dẫn nằm trong /api/*, Next.js sẽ không tìm kiếm file page để render, mà thay vào đó, nó sẽ thực thi code trong file API Route tương ứng và gửi về response.

Điều tuyệt vời nhất khi kết hợp API Routes với TypeScript là bạn có được sự an toàn kiểu dữ liệu (type safety) cho cả request và response, giảm thiểu lỗi không đáng có và giúp code của bạn dễ bảo trì hơn rất nhiều.

Tại sao lại sử dụng API Routes?

  • Đơn giản hóa cấu trúc dự án: Đối với nhiều ứng dụng, đặc biệt là các ứng dụng vừa và nhỏ, việc có cả frontend và "backend nhẹ" trong cùng một dự án Next.js giúp quản lý dễ dàng hơn.
  • Serverless Native: Next.js được thiết kế để triển khai lên các nền tảng serverless (như Vercel, Netlify Functions...), và API Routes là cách tự nhiên để viết các hàm serverless trong ngữ cảnh của ứng dụng Next.js.
  • Truy cập tài nguyên Server-side: Bạn có thể viết code trong API Routes để tương tác trực tiếp với database, hệ thống file server, hoặc gọi các API bên ngoài sử dụng các khóa bí mật mà không cần lo lắng về việc client nhìn thấy chúng.
  • Giải quyết vấn đề CORS: Vì request đi từ frontend của bạn đến API Route cùng nguồn gốc (your-app.com -> your-app.com/api/...), bạn sẽ không gặp phải các vấn đề về Cross-Origin Resource Sharing (CORS) phức tạp thường thấy khi frontend và backend nằm trên các domain khác nhau.
  • Tích hợp TypeScript: Như đã đề cập, việc sử dụng TypeScript giúp bạn định nghĩa rõ ràng kiểu dữ liệu cho dữ liệu nhận được từ client (request body, query params) và dữ liệu trả về cho client (response), mang lại trải nghiệm phát triển tốt hơn.

Bắt đầu với API Route đơn giản

Hãy tạo API Route đầu tiên của bạn. Mở dự án Next.js và tạo file sau:

pages/api/hello.ts

Nội dung của file này sẽ như sau:

import { NextApiRequest, NextApiResponse } from 'next';

// Định nghĩa kiểu dữ liệu cho response
type Data = {
  message: string;
};

export default function handler(
  req: NextApiRequest, // req là NextApiRequest
  res: NextApiResponse<Data> // res là NextApiResponse, và chúng ta chỉ định kiểu dữ liệu cho response body là Data
) {
  // Kiểm tra phương thức HTTP
  if (req.method === 'GET') {
    // Trả về response JSON với status 200
    res.status(200).json({ message: 'Chào mừng bạn đến với API Route đầu tiên của tôi với TypeScript!' });
  } else {
    // Nếu không phải phương thức GET, trả về lỗi 405 Method Not Allowed
    res.setHeader('Allow', ['GET']); // Cho biết chỉ cho phép phương thức GET
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}

Giải thích code:

  • import { NextApiRequest, NextApiResponse } from 'next';: Chúng ta import hai kiểu dữ liệu quan trọng từ thư viện next. NextApiRequest đại diện cho đối tượng yêu cầu HTTP đến (chứa thông tin về request như method, headers, body, query params...), còn NextApiResponse đại diện cho đối tượng phản hồi HTTP mà chúng ta sẽ gửi lại cho client.
  • type Data = { message: string; };: Chúng ta định nghĩa một kiểu dữ liệu Data cho cấu trúc JSON mà API này sẽ trả về thành công.
  • export default function handler(...): Mỗi API Route file phải xuất ra một hàm default duy nhất. Next.js sẽ gọi hàm này khi request đến đường dẫn /api/hello. Hàm này nhận hai tham số: req (request) và res (response).
  • req: NextApiRequest: Chúng ta khai báo req có kiểu là NextApiRequest. TypeScript sẽ cung cấp gợi ý code và kiểm tra kiểu cho các thuộc tính của req (ví dụ: req.method, req.query, req.body, req.headers).
  • res: NextApiResponse<Data>: Chúng ta khai báo res có kiểu là NextApiResponse. Quan trọng hơn, chúng ta sử dụng kiểu generic <Data> để chỉ định rằng phương thức res.json() sẽ trả về dữ liệu có cấu trúc phù hợp với kiểu Data. Điều này giúp TypeScript kiểm tra xem bạn có đang trả về đúng cấu trúc dữ liệu mong đợi hay không.
  • if (req.method === 'GET'): Chúng ta kiểm tra xem phương thức HTTP của request có phải là GET hay không. API Routes thường được sử dụng để xử lý các phương thức HTTP khác nhau (GET để lấy dữ liệu, POST để tạo mới, PUT để cập nhật, DELETE để xóa...).
  • res.status(200).json({...});: Nếu là GET, chúng ta thiết lập status code của response là 200 OK (thành công) và gửi về một đối tượng JSON sử dụng phương thức res.json(). Nhờ kiểu <Data> trên res, TypeScript sẽ yêu cầu đối tượng bạn truyền vào json() phải có thuộc tính message kiểu string.
  • res.setHeader('Allow', ['GET']); res.status(405).end(...);: Nếu phương thức request không được hỗ trợ (ở đây là chỉ hỗ trợ GET), chúng ta trả về status code 405 Method Not Allowed. Phương thức res.setHeader giúp thông báo cho client biết những phương thức nào được chấp nhận. res.end() kết thúc response mà không gửi body.

Bây giờ, bạn có thể chạy ứng dụng Next.js (npm run dev hoặc yarn dev) và truy cập vào đường dẫn http://localhost:3000/api/hello trên trình duyệt hoặc dùng công cụ như Postman/Insomnia/curl. Bạn sẽ thấy response:

{
  "message": "Chào mừng bạn đến với API Route đầu tiên của tôi với TypeScript!"
}

Nếu bạn thử gửi request với phương thức khác (ví dụ: POST), bạn sẽ nhận được lỗi 405.

Xử lý nhiều phương thức HTTP

Một API Route thường cần xử lý nhiều phương thức HTTP khác nhau tùy thuộc vào hành động mong muốn. Ví dụ, cùng một đường dẫn /api/users có thể dùng để lấy danh sách người dùng (GET) hoặc tạo người dùng mới (POST).

Chúng ta có thể mở rộng file pages/api/users.ts (ví dụ) để xử lý cả GET và POST:

import { NextApiRequest, NextApiResponse } from 'next';

// Định nghĩa kiểu dữ liệu cho User
interface User {
  id: string;
  name: string;
  email: string;
}

// Kiểu dữ liệu cho request body khi tạo user
interface CreateUserRequestBody {
  name: string;
  email: string;
}

// Kiểu dữ liệu cho response khi lấy danh sách user
interface GetUsersResponse {
  users: User[];
}

// Kiểu dữ liệu cho response khi tạo user
interface CreateUserResponse {
  success: boolean;
  userId: string;
  message?: string;
}

// Kết hợp các kiểu response có thể có cho API này
type UsersApiResponse = GetUsersResponse | CreateUserResponse | { message: string }; // Thêm kiểu cho lỗi

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<UsersApiResponse> // Response có thể là một trong các kiểu đã định nghĩa
) {
  switch (req.method) {
    case 'GET':
      // Logic để lấy danh sách người dùng
      const users: User[] = [ // Dữ liệu giả định
        { id: 'user1', name: 'Alice', email: 'alice@example.com' },
        { id: 'user2', name: 'Bob', email: 'bob@example.com' },
      ];
      res.status(200).json({ users });
      break;

    case 'POST':
      try {
        // Lấy dữ liệu từ request body và ép kiểu (type assertion)
        // Next.js tự động parse JSON body cho bạn
        const { name, email } = req.body as CreateUserRequestBody;

        // Kiểm tra dữ liệu nhận được
        if (!name || !email) {
          return res.status(400).json({ success: false, message: 'Thiếu tên hoặc email.' });
        }

        // Logic để tạo người dùng mới (ví dụ: lưu vào database)
        const newUserId = `user_${Date.now()}`; // Tạo ID giả định

        // Trả về response thành công
        res.status(201).json({ success: true, userId: newUserId, message: 'Người dùng đã được tạo thành công!' });

      } catch (error: any) {
        console.error('Lỗi khi xử lý POST request:', error);
        res.status(500).json({ success: false, message: 'Đã xảy ra lỗi server.' });
      }
      break;

    default:
      // Phương thức không được hỗ trợ
      res.setHeader('Allow', ['GET', 'POST']);
      res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}

Giải thích code:

  • Chúng ta định nghĩa các interface cho cấu trúc dữ liệu User, CreateUserRequestBody (dữ liệu mong đợi trong body của POST request), GetUsersResponse, và CreateUserResponse.
  • type UsersApiResponse = ...: Chúng ta sử dụng kiểu kết hợp (union type) để báo cho TypeScript biết rằng response của API này có thể có nhiều hình dạng khác nhau tùy thuộc vào phương thức và kết quả.
  • Chúng ta sử dụng một khối switch dựa trên req.method để xử lý các phương thức GETPOST riêng biệt.
  • case 'GET':: Nếu là GET, chúng ta tạo một mảng users giả định và trả về nó trong một đối tượng { users: [...] } với status 200 OK. TypeScript đảm bảo cấu trúc này phù hợp với GetUsersResponse.
  • case 'POST':: Nếu là POST, chúng ta truy cập dữ liệu gửi lên trong req.body. Next.js tự động parse JSON body cho bạn, nên req.body sẽ là một đối tượng JavaScript. Tuy nhiên, TypeScript mặc định coi req.bodyany. Để có type safety, chúng ta sử dụng req.body as CreateUserRequestBody; (type assertion). Điều này nói với TypeScript rằng "tôi biết chắc chắn req.body sẽ có cấu trúc này", cho phép bạn truy cập các thuộc tính như nameemail một cách an toàn với gợi ý code. Lưu ý: type assertion không kiểm tra lúc runtime. Để an toàn hơn, bạn nên thêm logic kiểm tra dữ liệu (validation) sau khi nhận được req.body.
  • Chúng ta thêm một khối try...catch để bắt lỗi trong quá trình xử lý request POST và trả về status 500 Internal Server Error nếu có lỗi xảy ra. Status 201 Created được sử dụng khi tạo tài nguyên mới thành công. Status 400 Bad Request được trả về nếu dữ liệu gửi lên không hợp lệ.
  • Phần default xử lý các phương thức khác không được định nghĩa.

Với API Route này, bạn có thể:

  • Gửi request GET đến http://localhost:3000/api/users để lấy danh sách người dùng giả định.
  • Gửi request POST đến http://localhost:3000/api/users với body dạng JSON { "name": "Charlie", "email": "charlie@example.com" } để mô phỏng việc tạo người dùng mới.

API Routes Động (Dynamic Routes)

Giống như Pages Router, API Routes cũng hỗ trợ các đường dẫn động. Điều này cực kỳ hữu ích khi bạn muốn truy cập một tài nguyên cụ thể dựa trên ID hoặc slug.

Ví dụ, để lấy thông tin chi tiết của một người dùng dựa trên ID, bạn có thể tạo file:

pages/api/users/[id].ts

Nội dung file:

import { NextApiRequest, NextApiResponse } from 'next';

interface User {
  id: string;
  name: string;
  email: string;
}

// Kiểu dữ liệu cho response (có thể là User hoặc thông báo lỗi)
type UserDetailApiResponse = User | { message: string };

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<UserDetailApiResponse>
) {
  // Lấy giá trị của segment động [id] từ req.query
  const { id } = req.query;

  // req.query luôn là string hoặc mảng string, nên id sẽ là string | string[] | undefined
  // Trong trường hợp [id].ts, id thường là string hoặc undefined (nếu đường dẫn chỉ là /api/users/)
  const userId = Array.isArray(id) ? id[0] : id;

  if (!userId) {
      return res.status(400).json({ message: 'Thiếu ID người dùng.' });
  }

  if (req.method === 'GET') {
    // Logic để tìm người dùng theo userId (ví dụ: truy vấn database)
    // Dữ liệu giả định:
    const users: User[] = [
      { id: 'user1', name: 'Alice', email: 'alice@example.com' },
      { id: 'user2', name: 'Bob', email: 'bob@example.com' },
    ];

    const user = users.find(u => u.id === userId);

    if (user) {
      res.status(200).json(user); // Trả về thông tin người dùng
    } else {
      // Không tìm thấy người dùng
      res.status(404).json({ message: `Không tìm thấy người dùng với ID: ${userId}` });
    }

  } else if (req.method === 'PUT') {
      // Logic để cập nhật người dùng với userId
      // Ví dụ đơn giản:
      const updatedData = req.body as Partial<User>; // Mong đợi một phần dữ liệu User

      // ... xử lý cập nhật ...

      res.status(200).json({ message: `Người dùng ${userId} đã được cập nhật.` });

  } else if (req.method === 'DELETE') {
      // Logic để xóa người dùng với userId
      // ... xử lý xóa ...

      res.status(200).json({ message: `Người dùng ${userId} đã được xóa.` });

  } else {
    res.setHeader('Allow', ['GET', 'PUT', 'DELETE']);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}

Giải thích code:

  • File được đặt tên [id].ts trong thư mục users. Điều này cho Next.js biết rằng phần [id] của đường dẫn là một tham số động.
  • Chúng ta lấy giá trị của tham số động bằng cách truy cập req.query. Đối tượng req.query chứa các tham số từ query string (ví dụ: /api/users/user1?active=true) các tham số từ đường dẫn động. Tên thuộc tính trong req.query sẽ khớp với tên file trong ngoặc vuông (ví dụ: id trong [id].ts).
  • req.query.id có thể là string, string[] (nếu có nhiều segment động lồng nhau) hoặc undefined. Chúng ta cần xử lý trường hợp này, ở đây chúng ta giả định đơn giản là lấy phần tử đầu tiên nếu nó là mảng hoặc giữ nguyên giá trị.
  • Đối với phương thức GET, chúng ta sử dụng userId để tìm kiếm trong mảng users giả định. Nếu tìm thấy, trả về đối tượng user với status 200 OK. Nếu không, trả về status 404 Not Found và một thông báo lỗi.
  • Chúng ta cũng thêm ví dụ về cách xử lý phương thức PUT (cập nhật) và DELETE. Lưu ý cách sử dụng Partial<User> để chỉ định rằng body của request PUT có thể chỉ chứa một phần các thuộc tính của User.

Với cấu trúc này, bạn có thể truy cập:

  • http://localhost:3000/api/users/user1 (GET) để lấy thông tin Alice.
  • http://localhost:3000/api/users/user999 (GET) sẽ trả về lỗi 404.
  • http://localhost:3000/api/users/user1 (PUT) để cập nhật thông tin Alice.
  • http://localhost:3000/api/users/user2 (DELETE) để xóa Bob.

Tóm lại

API Routes trong Next.js, đặc biệt khi kết hợp với TypeScript, cung cấp một cách tiếp cận cực kỳ hiệu quả và an toàn kiểu dữ liệu để thêm các chức năng server-side vào ứng dụng web của bạn mà không cần một backend riêng biệt phức tạp.

Bạn đã học cách:

  • Tạo một API Route cơ bản trong thư mục pages/api.
  • Sử dụng các kiểu dữ liệu NextApiRequestNextApiResponse từ Next.js để định nghĩa rõ ràng kiểu của request và response.
  • Định nghĩa kiểu cho response body bằng cách sử dụng generic <Data> trên NextApiResponse.
  • Xử lý các phương thức HTTP khác nhau (GET, POST, v.v.) trong cùng một API Route file.
  • Truy cập dữ liệu từ request body (req.body) và sử dụng type assertion để làm việc với TypeScript.
  • Tạo Dynamic API Routes (ví dụ: [id].ts) và truy cập các tham số động từ req.query.
  • Quan trọng là luôn nhớ về xử lý lỗi và trả về các status code HTTP phù hợp (200, 201, 400, 404, 405, 500).

Sức mạnh của API Routes nằm ở sự linh hoạt và tích hợp chặt chẽ với môi trường Next.js. Chúng cho phép bạn xây dựng các ứng dụng full-stack mạnh mẽ chỉ với một framework duy nhất. Hãy thực hành và khám phá thêm các khả năng của chúng!

Comments

There are no comments at the moment.