### SSR 讓你造成的誤解 #### 前情提要: 在 next 中說的 client component 指的是 CSR component + SSR page,要區別是否是 client component 或是 server component 在於 pre-render + hydrate 是否都在 server 端去執行,所以 SSR(getServerSideProps) 的 component 屬於 client component 範疇。 **實作 getServerSideProps 在next 中會遇到的狀況:** 1. app : 因為 next 對於 server component的解析會分辨 app 中的 component 是 client component 或是 server component,差別在於: **server component**: 所有的 html css js 甚至是 hydrate 都在 server 完成 client 端只是負責接收 chunk response 去 render,所以你無法在 server component 中用 getServerSideProps。 **client component**: next 不會去執行或是去渲染給出 inition html,所以在 app folder 中使用 getServerSideProps 是很詭異的事情,因為不會去 trigger getServerSideProps 這個 function,同時你這個頁面也不會有原本 ssr效果,會是白頁。 ** 小節:** 你不能在 app folder 中使用 getServerSideProps 2. page : 回傳 inition html 、 json payload 、css,讓client端負責下載以上內容後,在進行 hydrate, 補充:不管你是在 page 或是 app 所有 web 內容都是 serverSide,差別在於他們解析模塊的方式不同。 #### 模塊解釋: app: chunk response,所有內容都完成瀏覽器只是負責渲染。 page: json payload ,會有inition html 、 json data、bundle js 、 css,client 端負責 hydrate。 ```typescript // chunk response module.exports = { tag: 'Server Root', props: {...}, children: [ { tag: "Client Component1", props: {...}: children: [] }, { tag: "Server Component1", props: {...}: children: [ { tag: "Server Component2", props: {...}: children: [] }, { tag: "Server Component3", props: {...}: children: [] }, ]} ] } ``` 總結: 1. hydrate 時機點,app大部分比page都在server完成,所以這大大減少 ttl 時間,page 可能會有等待 ttl 問題( 儘管 UI 渲染好但你的 js 目前無法 interactive) ### redirect not use in try cache 因為 redirect 本身會 throw 整個 error 出來讓 next 做出 redirect 功能,這樣導致redirect 在try cache中無法引用,因為如果包起來沒有 `throw error` `next` 在 `global` 中知道要轉頁,解決方式如 `handleSubmitBeforeAfter` 用 `isRedirectError` 判斷是不是 `redirect error` 然後 `throw error` 出去 : ![](https://hackmd.io/_uploads/SJYdrGA8n.png) https://github.com/vercel/next.js/issues/49298#issuecomment-1537433377 或是在 `try catch` 中再次 `redirect` ```typescript export async function createCourse(form: FormData) { try { const data = { id: CourseId.random().value, name: form.get("name") as string, duration: form.get("duration") as string, } const url = "mongodb://localhost:27017/next13-fullstack" const client = MongoClientFactory.createClient("mooc", { url }) const repository = new MongoCourseRepository(client) const course = Course.fromPrimitives(data) await repository.save(course); redirect("/", RedirectType.push) } catch (error) { let message= "Something went wrong" console.log(error) if (error instanceof Error) { message = error.message } redirect(`/?error=${message}`, RedirectType.push) } } ``` ## slot react 會用 slot 方式處理 server component render 的原因,有一部分是設計規則。 ```typescript 'use client' // This pattern will **not** work! // You cannot import a Server Component into a Client Component. import ExampleServerComponent from './example-server-component' export default function ExampleClientComponent({ children, }: { children: React.ReactNode }) { const [count, setCount] = useState(0) return ( <> <button onClick={() => setCount(count + 1)}>{count}</button> <ExampleServerComponent /> </> ) } ``` 你不能直接在 client component 中直接引用 server component,這樣會造成 server 無數次往返,導致 react 無法正確在 client 端 render ExampleServerComponent,以上的寫法目前 server component 不能這樣做。 但 如果你需要在client component 的 children 中,加入 server component的話改成這樣寫法就ok。 ```typescript 'use client' import { useState } from 'react' export default function ExampleClientComponent({ children, }: { children: React.ReactNode }) { const [count, setCount] = useState(0) return ( <> <button onClick={() => setCount(count + 1)}>{count}</button> {children} </> ) } // some page <ExampleClientComponent> <ExampleServerComponent/> </ExampleClientComponent> ``` ## Layout vs template ### Layout 只會渲染一次不會重新 `render` 好處是可以保留靜態資源 #### Layouts do not receive searchParams 因為 `layout` 永遠不會 `render` 所以他會在導頁時候會保留 `searchParams` 的狀態,例如底下的 `/dashboard/settings` 跟 `/dashboard/analytics` ,如果你在 `layout` 中有 `searchParams` 的 `common UI` 永遠都不會 `render` ![image](https://hackmd.io/_uploads/rkEzNN8kll.png) 所以如果你有關於 `common searchParams UI` 你需要包一個 `ClientComponent` 如此這個 `ClientComponent` 就不會被`layout` 的 `render` 受限 ```typescript import { ClientComponent } from '@/app/ui/ClientComponent' export default function Layout({ children }: { children: React.ReactNode }) { return ( <> <ClientComponent /> {/* Other Layout UI */} <main>{children}</main> </> ) } ``` `layout` 還有一個建議是不要在這邊做 `auth` 驗證,這會讓整個頁面失去 `PPR` 的優勢,因為如果在 `layout` 做驗證,因為會呼叫 `get cookie` 的 `function` 而這個 `function` 就會被 `next` 當作是 `dynamic function` 變相告訴 `next` ### template 跟 `layout` 相反他會在每次 `render page` 的時候都會觸發,所以很適合做一些需要 `reset` 的共用 `UI` 例如 `page` 的 `transition` 或是 `breadcrumb` ```typescript "use client" import { usePathname } from "next/navigation" export default function Template({ children }: { children: React.ReactNode }) { const pathname = usePathname() return ( <div className="animate-in slide-in-from-bottom-30 duration-500 p-4 "> <pre className="inline-block bg-muted px-2 py-1 rounded-lg">{pathname}</pre> {children} </div> ) } ``` ### Layout approach 1. 基於 layout 做 auth 的前提是如果只需要做單次的驗證就可以的話就可以使用這個 `pattern` 在頂層確認 `user` 的 `access token` 存在與否有就可以進入到 `page` 反之就到 `sign in` ,但這樣有一個風險會是可能在 `next` 傳送 `json payload` 給 `browser render` 的 `chunk` 會有入要燈入後才能看到的內容被包起來。 ```typescript export default function Layout({ children, }: Readonly<{ children: React.ReactNode; }>) { const isAuthenticated = false if (!isAuthenticated) { redirect('/login') } return ( <div> {children} </div> ); } ``` ![截圖 2025-04-24 下午2.15.45](https://hackmd.io/_uploads/SJiIvUvJll.png) ### Next page rendering flow 1. `browser` 向 `nextjs` 請求 `render page` 跟 `layout` 2. `layout` 跟 `page` 的 `render` 彼此是平行的,且 `layout` 只會 `render` 一次最終 `next` 會做最後的組裝 `page` + `layout` 3. 如果 `user` 拜訪另外一個 `page` 是基於同一個 `layout` 底下的話同樣的 `layout` 不會再重新渲染,而是直接請求新的 `page` 然後再組裝離這個 `page` 的 `layout` ![截圖 2025-04-24 下午2.17.27](https://hackmd.io/_uploads/BkKtDLDklx.png) ## 每個頁面去驗證 另一個則是在 `page` 頁面每次都重新發送請求,這樣也不會有內容外洩的疑慮,同時也可以 `dynamic` 的確保 `user` 的 `auth` 情況來決定 `context` 要是什麼。 ```typescript import React from 'react' const geyPaidContent = (token: string) => { if (token !== 'correct') return 'Login to view this page' return 'subscribe to me!' } function PaidPage() { const token = 'exampletoken' const paid = geyPaidContent(token) return ( <div>{paid}</div> ) } export default PaidPage ``` ![截圖 2025-04-24 下午2.29.51](https://hackmd.io/_uploads/H1UtcLPylx.png) ## Middleware 另外一個最安全且保有彈性的寫法是基於 `middleware` 去做身份驗證,透過 `middleware` 去驗證 `user token` 存在與否,同時也不像在 `layout` 只會 `render` 一次,也不會有 `senstive data` 外流的狀況 ![截圖 2025-04-24 下午2.36.56](https://hackmd.io/_uploads/SkZ7nLPkeg.png) ```typescript import { unauthorized } from 'next/navigation' import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' // This function can be marked `async` if using `await` inside export function middleware(request: NextRequest) { const sessionToken = request.cookies.get('authToken')?.value const isPaid = false if (!isPaid && sessionToken) { unauthorized() } if (!isPaid && !sessionToken) { return NextResponse.redirect(new URL('/login', request.url)) } return NextResponse.next() } // See "Matching Paths" below to learn more export const config = { matcher: '/middleware', } ``` ## Implement Incremental Static Regeneration 1. 在 `app router` 中可以採用底下的方式去 `static render ` 概念是我可以先 `pre-build` `10` 頁的 `blog page` 減少 `build time` 時間 2. 透過 `export const dynamic = 'force-static'` 告 `next` 如果 `user` 訪問第 `11` 頁的時候再產出第 `11` 頁的內容。 ```typescript // app/blog/[id]/page.tsx export const revalidate = 60 export const dynamicParams = true export const dynamic = 'force-static' export async function generateStaticParams() { const posts = await fetch('https://api.vercel.app/blog').then((res) => res.json() ) as Post[] return posts.slice(0, 10).map((post) => ({ id: String(post.id), })) } ``` 所以當你 `next build` 好後會在 `.next` 中預先有前 `10` 頁的內容 ![截圖 2025-09-12 上午10.52.11](https://hackmd.io/_uploads/SJHgj-Wsll.png) 等到在 `request time` 需要第 `11` 頁的東西後 `next` 才會產出對應的 `html` ![截圖 2025-09-12 上午10.52.20](https://hackmd.io/_uploads/SkSeo--sxg.png) ## Partial Prerendering 在這邊 `Toaster` 使用 `Suspense` 原因是 `async` 的 `component` ,另一個原因是在 `PPR` 中 `component` 使用 `dynamic` 的 `API` 會強制讓頁面變成`dynamic` ,`Suspense` 可以讓頁面保留靜態的頁面。 ```typescript import { cookies } from "next/headers"; export async function Toaster() { const cookieStore = await cookies(); const toasts = cookieStore .getAll() .filter((cookie) => cookie.name.startsWith("toast-") && cookie.value) .map((cookie) => ({ id: cookie.name, message: cookie.value, })); return ( <div> {toasts.map((toast) => ( <div key={toast.id} className="mt-4"> <p>{toast.message}</p> </div> ))} </div> ); } ``` ```typescript import { Toaster } from "./toaster"; export default function RootLayout({ children, }: Readonly<{ children: ReactNode; }>) { return ( <html lang="en"> <body className="mx-auto max-w-2xl bg-slate-100 antialiased"> {children} <Suspense> <Toaster /> </Suspense> </body> </html> ); } ``` ## Server Type Component https://conform.guide/