วิธีใช้ Laravel เป็น API ให้หน้าเว็บ Next.js

แยกหน้าเว็บ Next.js ออกจาก Laravel แล้วต้องตกลงกันหลายเรื่อง ตั้งแต่วิธีล็อกอิน CORS รูปแบบ JSON และ error การส่ง event realtime ไปจนถึงการ build และ deploy สองแอปให้ไปด้วยกัน

เผยแพร่
เวลาอ่าน
6 นาที
ในโพสต์นี้ 9 หัวข้อ
  1. Next.js กับ Laravel แบ่งงานกันแบบนี้
  2. เลือกวิธีล็อกอินก่อนเขียนหน้าแรก
  3. ตั้ง CORS ให้ Laravel รับ request จากโดเมนของ Next.js
  4. ให้ทุก request ผ่าน API client ตัวเดียว
  5. API Resource คือสัญญาระหว่างสองฝั่ง
  6. proxy.js กันหน้าเว็บ ส่วน Laravel กันข้อมูล
  7. ส่ง event realtime ด้วย Reverb และ Laravel Echo
  8. build แยกตาม environment แล้ว deploy สองฝั่งให้เข้ากัน
  9. เรื่องที่อาจเจอเมื่อใช้งานจริง

งาน Laravel ส่วนใหญ่ของเราใช้ Blade, Livewire หรือ Inertia ที่หน้าเว็บกับหลังบ้านอยู่ในโปรเจกต์เดียว deploy ครั้งเดียวก็จบ แต่บางงานหน้าเว็บแยกออกไปเป็นอีก repo ที่เขียนด้วย Next.js เช่น ลูกค้ามีทีม frontend ของตัวเอง หรือหน้าเว็บต้องใช้ API ชุดเดียวกับระบบอื่น ทีนี้ Laravel จะเหลือหน้าที่ส่ง JSON อย่างเดียว และสองแอปต้องตกลงกันหลายเรื่องที่ตอนอยู่โปรเจกต์เดียวเราไม่เคยต้องคิด

อย่างระบบสั่งซื้อของตัวแทนจำหน่ายที่เราดูแลฝั่ง Laravel อยู่ หน้าเว็บ Next.js เป็นอีก repo ของอีกทีม ทั้งสองฝั่งต้องตกลงกันตั้งแต่วิธีล็อกอิน หน้าตาของ JSON ไปจนถึงลำดับการ deploy โพสต์นี้รวบรวมเรื่องที่ต้องตกลงกันพวกนั้นไว้

Next.js กับ Laravel แบ่งงานกันแบบนี้

Next.js ดูแลหน้าเว็บ ส่วน Laravel ดูแลข้อมูลและสิทธิ์

เบราว์เซอร์คุยกับ Laravel ตรง ๆ ด้วย token ส่วน Next.js server ดูแลการล็อกอินและเปิดหน้าเว็บ

Next.js ส่ง React ไปให้เบราว์เซอร์ และมีส่วนที่รันบน server อยู่นิดหน่อย คือเช็ก cookie ก่อนเปิดแต่ละหน้า กับ API route สองสามตัวอย่าง /api/login และ /api/logout ส่วนกฎทางธุรกิจ การ validate ข้อมูล การเช็กสิทธิ์ และฐานข้อมูล อยู่ที่ Laravel ทั้งหมด หน้าแอดมินที่ใช้ Filament ก็ยังอยู่ในแอป Laravel ตัวเดิม ล็อกอินด้วย session ตามปกติ

ฝั่ง Laravel เริ่มจากคำสั่งเดียว

php artisan install:api

คำสั่งนี้ ติดตั้ง Sanctum และสร้าง routes/api.php ที่ทุก route ขึ้นต้นด้วย /api และไม่มี session

ถ้าทีมเดียวทำทั้งสองฝั่ง Inertia ยังง่ายกว่ามาก เพราะไม่ต้องทำ API ไม่ต้องคิดเรื่อง token หรือ CORS และยังทำ SSR ได้เหมือนกัน การแยก Next.js จะคุ้มเมื่อหน้าเว็บมีทีมและรอบ deploy ของตัวเอง ข้อดีข้อเสียของแต่ละทาง รวมถึงหลังบ้านที่ไม่ใช่ Laravel อยู่ในวิธีเลือกระหว่าง Inertia กับ Next.js ให้ระบบ Laravel

เลือกวิธีล็อกอินก่อนเขียนหน้าแรก

Sanctum ล็อกอินให้หน้าเว็บที่แยกออกไปได้สองแบบ

cookie session ของ Sanctum token ที่ Next.js เก็บใน cookie
ใครถืออะไร Laravel ออก session cookie ให้เบราว์เซอร์โดยตรง Laravel ออก token แล้ว Next.js server เก็บไว้ใน cookie ของตัวเอง
โดเมน ต้องอยู่ใต้โดเมนหลักเดียวกัน เช่น app.shop.com กับ api.shop.com คนละโดเมนก็ได้
ก่อนล็อกอิน เรียก /sanctum/csrf-cookie ก่อน เพื่อรับ CSRF token ไม่ต้อง
ระบบอื่นใช้ API ชุดเดียวกัน ต้องทำทาง token แยกอีกทาง ใช้ endpoint เดียวกันได้

เอกสารของ Sanctum แนะนำแบบแรกสำหรับหน้าเว็บของเราเอง เพราะ JavaScript ไม่เคยแตะ token เลย ถ้าโดเมนลงตัวก็เลือกแบบแรกได้เลย งานที่เราดูแลใช้แบบที่สอง เพราะระบบอื่นที่เชื่อมเข้ามาก็ล็อกอินผ่าน endpoint เดียวกันอยู่แล้ว และนักพัฒนาหน้าเว็บรัน localhost:3000 ต่อกับ API บนเซิร์ฟเวอร์ทดสอบได้ทันที

แบบที่สองฝั่ง Laravel ออก token ให้ตอนล็อกอิน พร้อมวันหมดอายุ

Route::post('/login', function (Request $request) {
    $request->validate(['email' => 'required|email', 'password' => 'required']);

    $user = User::where('email', $request->email)->first();

    if (! $user || ! Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages(['email' => 'อีเมลหรือรหัสผ่านไม่ถูกต้อง']);
    }

    $expiresAt = now()->addWeek();

    return [
        'token' => $user->createToken('web', expiresAt: $expiresAt)->plainTextToken,
        'role' => $user->role,
        'expires_at' => $expiresAt,
    ];
})->middleware('throttle:5,1');

หน้า login ส่งฟอร์มมาที่ /api/login ของ Next.js เอง แล้ว Next.js server ค่อยไปขอ token จาก Laravel แล้วเก็บไว้ใน cookie แบบ httpOnly ที่ JavaScript อ่านไม่ได้ API_URL ไม่มี NEXT_PUBLIC_ นำหน้า จึงอยู่แค่บน server

ต่อจากนี้เราต้องเลือกอีกทีว่าเบราว์เซอร์จะส่ง token ไปหา Laravel ยังไง ทางที่ง่ายคือให้ Next.js อ่าน cookie ตอนเปิดหน้าแล้วส่ง token ให้หน้าเว็บเก็บไว้ใน memory หน้าเว็บก็เรียก Laravel ตรง ๆ ได้เลย งานที่เราดูแลก็ใช้ทางนี้ แต่ต้องเข้าใจว่าถ้าโดน XSS สคริปต์ของคนร้ายก็อ่าน token จาก memory ได้เหมือนกัน ถ้าอยากให้ token ไม่ถึง JavaScript เลย ต้องให้ทุก request วิ่งผ่าน API route ของ Next.js ซึ่งแลกมากับงานที่เพิ่มขึ้น และทุก request ต้องผ่านอีกทอดหนึ่ง

ตั้ง CORS ให้ Laravel รับ request จากโดเมนของ Next.js

หน้าเว็บกับ API อยู่คนละ origin เบราว์เซอร์จึงถามก่อนทุกครั้งด้วย OPTIONS preflight Laravel ตอบให้เองจาก config/cors.php เราแค่บอกว่าอนุญาตโดเมนไหนบ้าง

'paths' => ['api/*'],
'allowed_origins' => array_values(array_filter([
    env('FRONTEND_URL'),
    'http://localhost:3000',
])),
'allowed_origins_patterns' => [
    '#^https://([a-z0-9-]+\.)?shop\.com$#i',
],
'supports_credentials' => false,

allowed_origins_patterns ช่วยตอนหน้าเว็บมี preview deploy ของแต่ละ branch ที่ได้ subdomain ใหม่ทุกครั้ง ถ้าใช้แค่ Bearer token ปิด supports_credentials ไว้ได้ ต้องเปิดเมื่อหน้าเว็บส่ง cookie ไปหา Laravel ด้วย อย่างการล็อกอินแบบ cookie session ของ Sanctum

ให้ทุก request ผ่าน API client ตัวเดียว

หน้าเว็บที่โตขึ้นจะเรียก API เป็นร้อยจุด ถ้าแต่ละจุดใส่ header และจัดการ error เอง วันที่ต้องเปลี่ยนอะไรสักอย่างจะต้องไล่แก้ทั้งโปรเจกต์ เราจึงให้ทุก request ผ่าน Axios ตัวเดียว

import axios from 'axios'
import { useToken } from '@/store/token'

const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  headers: { Accept: 'application/json' },
})

api.interceptors.request.use((config) => {
  const token = useToken.getState().token
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

api.interceptors.response.use(
  (response) => response.data,
  (error) => {
    if (error.response?.status === 401) {
      window.location.href = '/auth/login'
    }
    return {
      error: true,
      status: error.response?.status ?? 'network_error',
      message: error.response?.data?.message ?? error.message,
      errors: error.response?.data?.errors ?? {},
    }
  }
)

export default api
  • header Accept: application/json ทำให้ Laravel ตอบ validation error เป็น JSON สถานะ 422 หน้าตา { message, errors: { field: [...] } } แทนการ redirect กลับ
  • token หมดอายุหรือโดนยกเลิก Laravel ตอบ 401 แล้วหน้าเว็บก็พาไปหน้า login จากจุดเดียว
  • ถ้าจะใส่ retry ตอนเน็ตหลุด ให้ retry เฉพาะ GET อย่า retry POST เอง เพราะ request แรกอาจถึง Laravel ไปแล้ว ลูกค้าก็จะได้ออร์เดอร์ซ้ำ

API Resource คือสัญญาระหว่างสองฝั่ง

พอแยก repo หน้าตาของ JSON ที่ Laravel ส่งออกไปจาก API Resource ก็กลายเป็นสัญญาระหว่างสองทีม เรื่องที่ต้องตกลงกันมีไม่กี่ข้อ

  • เพิ่ม field ได้เลย หน้าเว็บจะเห็น field ใหม่ตอนโหลดข้อมูลรอบถัดไปโดยไม่ต้อง deploy หน้าเว็บ แล้วทีม frontend ค่อยเอาไปแสดงเมื่อพร้อม
  • เปลี่ยนชื่อหรือลบ field ต้องคุยกันก่อน หน้าเว็บที่อ่าน field เดิมจะไม่ error ให้เห็น แค่แสดงช่องว่าง หรือซ่อนปุ่มไปเงียบ ๆ
  • รูปแบบ error ต้องคงที่ API client ด้านบนอ่าน message กับ errors ถ้าวันหนึ่ง Laravel ส่ง error หน้าตาอื่น ผู้ใช้จะเห็นแจ้งเตือนว่างเปล่า

ฝั่ง Laravel เราเขียนเอกสารสั้น ๆ ไว้ใน repo ว่า field ไหนหน้าเว็บใช้ที่หน้าไหน และ field ไหนเป็นตัวบอกให้หน้าเว็บเลือกแสดงแบบอื่น ใครจะแก้ Resource ก็เปิดดูก่อนได้ ส่วนฝั่ง Next.js อ่านข้อมูลด้วย SWR แล้วสั่ง mutate() ให้ดึงใหม่หลังบันทึก หน้าจอจึงตรงกับฐานข้อมูลเสมอ

proxy.js กันหน้าเว็บ ส่วน Laravel กันข้อมูล

Next.js 16 เปลี่ยนชื่อไฟล์ middleware.js เป็น proxy.js ไฟล์นี้รันก่อนเปิดทุกหน้า เหมาะกับการอ่าน cookie แล้วพาคนที่ยังไม่ล็อกอินไปหน้า login หรือพาผู้ใช้แต่ละ role ไปหน้าแรกของตัวเอง

แต่ role ใน cookie มีไว้ให้หน้าเว็บพาไปถูกที่เท่านั้น ผู้ใช้แก้ค่าใน cookie ของเบราว์เซอร์ตัวเองได้ และยิง API ตรงโดยไม่ผ่าน Next.js ก็ได้ Laravel จึงต้องเช็กสิทธิ์ทุก request เองด้วย auth:sanctum กับ Policy ตามปกติ ซ่อนปุ่มในหน้าเว็บยังไม่นับว่ากันสิทธิ์

ส่ง event realtime ด้วย Reverb และ Laravel Echo

งานอย่างแชตหรือแจ้งเตือนสถานะออร์เดอร์ ใช้ Reverb เป็น WebSocket server ของ Laravel แล้วให้หน้าเว็บฟังด้วย Laravel Echo ได้เหมือนโปรเจกต์ Laravel ปกติ ต่างกันตรงที่ private channel ต้องขอสิทธิ์ผ่าน /broadcasting/auth ด้วย Bearer token ฝั่ง Laravel จึงต้องใส่ auth:sanctum ให้ route นี้

->withBroadcasting(
    __DIR__.'/../routes/channels.php',
    ['prefix' => 'api', 'middleware' => ['api', 'auth:sanctum']],
)

ฝั่ง Next.js ให้ Echo ขอสิทธิ์ผ่าน API client ตัวเดิม token จะได้ติดไปด้วย

import Echo from 'laravel-echo'
import Pusher from 'pusher-js'
import api from '@/lib/api'

export const createEcho = () =>
  new Echo({
    broadcaster: 'reverb',
    Pusher,
    key: process.env.NEXT_PUBLIC_REVERB_APP_KEY,
    wsHost: process.env.NEXT_PUBLIC_REVERB_HOST,
    wssPort: 443,
    forceTLS: true,
    enabledTransports: ['ws', 'wss'],
    channelAuthorization: {
      customHandler: ({ socketId, channelName }, callback) => {
        api
          .post('api/broadcasting/auth', { socket_id: socketId, channel_name: channelName })
          .then((res) => (res.error ? callback(new Error(res.message), null) : callback(null, res)))
      },
    },
  })

เรียก createEcho() เฉพาะในเบราว์เซอร์ เช่น ใน useEffect เพราะตอน Next.js render บน server ยังไม่มี WebSocket ให้ต่อ

build แยกตาม environment แล้ว deploy สองฝั่งให้เข้ากัน

ค่าที่ขึ้นต้นด้วย NEXT_PUBLIC_ อย่าง URL ของ API หรือ key ของ Reverb Next.js ฝังลงไฟล์ JavaScript ตอน build เปลี่ยนค่าทีหลังก็ไม่มีผล build ที่ใช้กับเซิร์ฟเวอร์ทดสอบจึงเอาไปใช้บน production ไม่ได้ ต้อง build แยกตาม environment เช่น มี build:uat กับ build:production คนละคำสั่ง ส่วนค่าที่อ่านแค่บน server อย่าง API_URL ในหน้า login ยังอ่านตอนรันได้ตามปกติ

สองแอปยัง deploy คนละรอบด้วย ลำดับที่ปลอดภัยคือ deploy Laravel ที่เพิ่ม field หรือ endpoint ใหม่ขึ้นไปก่อน แล้วค่อย deploy หน้าเว็บที่เริ่มใช้ของใหม่ ถ้าจะลบ field เก่าก็รอจนหน้าเว็บเลิกอ่านไปแล้วรอบหนึ่ง

เรื่องที่อาจเจอเมื่อใช้งานจริง

  • ล็อกอินหลังบ้านค้างไว้ แล้วหน้าเว็บกลายเป็นผู้ใช้อีกคน ถ้าหน้าแอดมินอยู่โดเมนเดียวกับ API และ /broadcasting/auth เช็ก session ก่อน token (auth:web,sanctum) เบราว์เซอร์ที่ยังล็อกอินหลังบ้านค้างไว้จะส่ง session cookie ไปด้วย Laravel ก็จะให้สิทธิ์ฟัง channel ในนามแอดมินแทนเจ้าของ token งานที่เราดูแลแก้ด้วยการส่ง request ขอสิทธิ์โดยไม่แนบ cookie (withCredentials: false) ทางที่ตรงกว่าคือให้ route นี้ใช้ auth:sanctum อย่างเดียว
  • cookie หายตอนล็อกอินผ่าน SSO ระหว่างพัฒนา ตอนเบราว์เซอร์เด้งจาก localhost ไปหน้า SSO แล้วกลับมา เบราว์เซอร์ถือว่าข้ามเว็บ จึงไม่ส่ง cookie ที่ตั้ง sameSite: 'strict' มาด้วย หน้าเว็บก็เห็นว่ายังไม่ได้ล็อกอิน ใช้ 'lax' แทน
  • token หมดอายุแต่ cookie ยังอยู่ ตั้งวันหมดอายุของ cookie ให้ตรงกับ expires_at ของ token และตั้ง sanctum:prune-expired ให้รันทุกวันเพื่อลบ token เก่าออกจากฐานข้อมูล

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →