[{"data":1,"prerenderedAt":738},["ShallowReactive",2],{"layout-sidebar-\u002Fblog\u002Fwhere-to-fetch-server-or-client":3,"blog-where-to-fetch-server-or-client":22},{"type":4,"author":5,"date":6,"status":7,"toc":8},"blog-detail","Fabian Kirchhoff","2026-08-15","published",[9,13,16,19],{"id":10,"text":11,"depth":12},"one-table-needs-both","One table needs both",2,{"id":14,"text":15,"depth":12},"writing-it-down","Writing it down",{"id":17,"text":18,"depth":12},"when-to-break-it","When to break it",{"id":20,"text":21,"depth":12},"resources","Resources",{"id":23,"title":24,"author":5,"body":25,"date":6,"description":723,"extension":724,"featured":725,"meta":726,"navigation":337,"order":727,"path":728,"seo":729,"specs":730,"status":7,"stem":735,"tag":736,"__hash__":737},"blog\u002Fblog\u002Fwhere-to-fetch-server-or-client.md","What to Fetch on the Server, What to Fetch on the Client",{"type":26,"value":27,"toc":717},"minimark",[28,32,36,43,46,49,52,55,58,242,245,248,251,254,257,259,265,470,479,489,492,665,671,673,676,678,713],[29,30,24],"h1",{"id":31},"what-to-fetch-on-the-server-what-to-fetch-on-the-client",[33,34,35],"p",{},"Nuxt gives you two places to fetch. During SSR, where the result ships inside the HTML document. Or on the client after hydration, where it arrives as a second request. The docs explain how to do both. They don't tell you which to pick.",[33,37,38,39],{},"I use one question: ",[40,41,42],"strong",{},"does the response decide the layout, or does it fill a layout that is already decided?",[44,45],"layout-test-rule",{},[33,47,48],{},"Whatever decides the layout has to be in the HTML, because the browser sizes the page from that HTML. If it arrives later and changes a size, it moves content the user is already looking at.",[50,51,11],"h2",{"id":10},[33,53,54],{},"Admin tables are the clearest example I have, because one component fetches both kinds.",[33,56,57],{},"The columns aren't knowable at build time — each tenant defines their own custom fields, so the column list is per-tenant data that doesn't exist in the bundle. It comes from an endpoint:",[59,60,65],"pre",{"className":61,"code":62,"language":63,"meta":64,"style":64},"language-json shiki shiki-themes github-dark github-dark","\u002F\u002F GET \u002Fapi\u002Ftable-config\u002Fbookings\n{\n  \"entity\": \"bookings\",\n  \"columns\": [\n    { \"key\": \"customer\", \"header\": \"Customer\", \"type\": \"text\", \"width\": 200 },\n    { \"key\": \"total\", \"header\": \"Umsatz\", \"type\": \"currency\", \"width\": 110 },\n    { \"key\": \"customFields.cost_center\", \"header\": \"Kostenstelle\", \"type\": \"text\" }\n  ]\n}\n","json","",[66,67,68,77,83,100,109,157,198,230,236],"code",{"__ignoreMap":64},[69,70,73],"span",{"class":71,"line":72},"line",1,[69,74,76],{"class":75},"sJ8bj","\u002F\u002F GET \u002Fapi\u002Ftable-config\u002Fbookings\n",[69,78,79],{"class":71,"line":12},[69,80,82],{"class":81},"suv1-","{\n",[69,84,86,90,93,97],{"class":71,"line":85},3,[69,87,89],{"class":88},"s8ozJ","  \"entity\"",[69,91,92],{"class":81},": ",[69,94,96],{"class":95},"s4wv1","\"bookings\"",[69,98,99],{"class":81},",\n",[69,101,103,106],{"class":71,"line":102},4,[69,104,105],{"class":88},"  \"columns\"",[69,107,108],{"class":81},": [\n",[69,110,112,115,118,120,123,126,129,131,134,136,139,141,144,146,149,151,154],{"class":71,"line":111},5,[69,113,114],{"class":81},"    { ",[69,116,117],{"class":88},"\"key\"",[69,119,92],{"class":81},[69,121,122],{"class":95},"\"customer\"",[69,124,125],{"class":81},", ",[69,127,128],{"class":88},"\"header\"",[69,130,92],{"class":81},[69,132,133],{"class":95},"\"Customer\"",[69,135,125],{"class":81},[69,137,138],{"class":88},"\"type\"",[69,140,92],{"class":81},[69,142,143],{"class":95},"\"text\"",[69,145,125],{"class":81},[69,147,148],{"class":88},"\"width\"",[69,150,92],{"class":81},[69,152,153],{"class":88},"200",[69,155,156],{"class":81}," },\n",[69,158,160,162,164,166,169,171,173,175,178,180,182,184,187,189,191,193,196],{"class":71,"line":159},6,[69,161,114],{"class":81},[69,163,117],{"class":88},[69,165,92],{"class":81},[69,167,168],{"class":95},"\"total\"",[69,170,125],{"class":81},[69,172,128],{"class":88},[69,174,92],{"class":81},[69,176,177],{"class":95},"\"Umsatz\"",[69,179,125],{"class":81},[69,181,138],{"class":88},[69,183,92],{"class":81},[69,185,186],{"class":95},"\"currency\"",[69,188,125],{"class":81},[69,190,148],{"class":88},[69,192,92],{"class":81},[69,194,195],{"class":88},"110",[69,197,156],{"class":81},[69,199,201,203,205,207,210,212,214,216,219,221,223,225,227],{"class":71,"line":200},7,[69,202,114],{"class":81},[69,204,117],{"class":88},[69,206,92],{"class":81},[69,208,209],{"class":95},"\"customFields.cost_center\"",[69,211,125],{"class":81},[69,213,128],{"class":88},[69,215,92],{"class":81},[69,217,218],{"class":95},"\"Kostenstelle\"",[69,220,125],{"class":81},[69,222,138],{"class":88},[69,224,92],{"class":81},[69,226,143],{"class":95},[69,228,229],{"class":81}," }\n",[69,231,233],{"class":71,"line":232},8,[69,234,235],{"class":81},"  ]\n",[69,237,239],{"class":71,"line":238},9,[69,240,241],{"class":81},"}\n",[33,243,244],{},"The config decides the layout: how many columns exist, how wide each one is, what order they're in.",[33,246,247],{},"The rows don't. A cell can render anything — a badge, a truncated name, an avatar — but it renders inside a column the config already sized. The rows change what's in the table, not its shape.",[33,249,250],{},"So the config goes in the SSR pass and the rows are fetched from the client. The rows still arrive late and the skeletons are still there, but they're already sitting in the real columns:",[252,253],"table-hydration-demo",{},[33,255,256],{},"Fetch the config on the client instead and the bundle has to render something first. The real config then corrects it in two independent moves: which columns exist, and how wide each one is. That's two layout shifts before a single row exists, and two is the floor — a tenant whose column set is further from the default pays more.",[50,258,15],{"id":14},[33,260,261,264],{},[66,262,263],{},"useAsyncData"," runs during SSR, serializes the result into the payload, and the client reads it from there instead of making a second request:",[59,266,270],{"className":267,"code":268,"language":269,"meta":64,"style":64},"language-typescript shiki shiki-themes github-dark github-dark","export async function useTableConfig(entity: MaybeRefOrGetter\u003Cstring>) {\n  const { locale } = useI18n()\n\n  const { data } = await useAsyncData(\n    () => `table-config:${toValue(entity)}:${locale.value}`,\n    () => $fetch\u003CTableConfig>(`\u002Fapi\u002Ftable-config\u002F${toValue(entity)}`)\n  )\n\n  return { columns: computed(() => data.value?.columns ?? []) }\n}\n","typescript",[66,271,272,310,333,339,361,398,431,436,440,465],{"__ignoreMap":64},[69,273,274,278,281,284,288,291,295,298,301,304,307],{"class":71,"line":72},[69,275,277],{"class":276},"sOPea","export",[69,279,280],{"class":276}," async",[69,282,283],{"class":276}," function",[69,285,287],{"class":286},"sFR8T"," useTableConfig",[69,289,290],{"class":81},"(",[69,292,294],{"class":293},"s-3mD","entity",[69,296,297],{"class":276},":",[69,299,300],{"class":286}," MaybeRefOrGetter",[69,302,303],{"class":81},"\u003C",[69,305,306],{"class":88},"string",[69,308,309],{"class":81},">) {\n",[69,311,312,315,318,321,324,327,330],{"class":71,"line":12},[69,313,314],{"class":276},"  const",[69,316,317],{"class":81}," { ",[69,319,320],{"class":88},"locale",[69,322,323],{"class":81}," } ",[69,325,326],{"class":276},"=",[69,328,329],{"class":286}," useI18n",[69,331,332],{"class":81},"()\n",[69,334,335],{"class":71,"line":85},[69,336,338],{"emptyLinePlaceholder":337},true,"\n",[69,340,341,343,345,348,350,352,355,358],{"class":71,"line":102},[69,342,314],{"class":276},[69,344,317],{"class":81},[69,346,347],{"class":88},"data",[69,349,323],{"class":81},[69,351,326],{"class":276},[69,353,354],{"class":276}," await",[69,356,357],{"class":286}," useAsyncData",[69,359,360],{"class":81},"(\n",[69,362,363,366,369,372,375,377,379,382,385,387,390,393,396],{"class":71,"line":111},[69,364,365],{"class":81},"    () ",[69,367,368],{"class":276},"=>",[69,370,371],{"class":95}," `table-config:${",[69,373,374],{"class":286},"toValue",[69,376,290],{"class":95},[69,378,294],{"class":81},[69,380,381],{"class":95},")",[69,383,384],{"class":95},"}:${",[69,386,320],{"class":81},[69,388,389],{"class":95},".",[69,391,392],{"class":81},"value",[69,394,395],{"class":95},"}`",[69,397,99],{"class":81},[69,399,400,402,404,407,409,412,415,418,420,422,424,426,428],{"class":71,"line":159},[69,401,365],{"class":81},[69,403,368],{"class":276},[69,405,406],{"class":286}," $fetch",[69,408,303],{"class":81},[69,410,411],{"class":286},"TableConfig",[69,413,414],{"class":81},">(",[69,416,417],{"class":95},"`\u002Fapi\u002Ftable-config\u002F${",[69,419,374],{"class":286},[69,421,290],{"class":95},[69,423,294],{"class":81},[69,425,381],{"class":95},[69,427,395],{"class":95},[69,429,430],{"class":81},")\n",[69,432,433],{"class":71,"line":200},[69,434,435],{"class":81},"  )\n",[69,437,438],{"class":71,"line":232},[69,439,338],{"emptyLinePlaceholder":337},[69,441,442,445,448,451,454,456,459,462],{"class":71,"line":238},[69,443,444],{"class":276},"  return",[69,446,447],{"class":81}," { columns: ",[69,449,450],{"class":286},"computed",[69,452,453],{"class":81},"(() ",[69,455,368],{"class":276},[69,457,458],{"class":81}," data.value?.columns ",[69,460,461],{"class":276},"??",[69,463,464],{"class":81}," []) }\n",[69,466,468],{"class":71,"line":467},10,[69,469,241],{"class":81},[33,471,472,473,475,476,478],{},"The key is a function, not a string. ",[66,474,294],{}," and ",[66,477,320],{}," are reactive, and a static key freezes whatever they were on the first call, so every table after that reads the wrong cache entry.",[33,480,481,482,485,486,389],{},"The ",[66,483,484],{},"await"," is load-bearing. It suspends the component during SSR until the config resolves, which is what puts the finished markup in the server response. That also means you can only call it from a setup Nuxt can suspend — a page component, or something inside ",[66,487,488],{},"\u003CSuspense>",[33,490,491],{},"The rows are the same request with one option flipped:",[59,493,497],{"className":494,"code":495,"language":496,"meta":64,"style":64},"language-vue shiki shiki-themes github-dark github-dark","\u003Cscript setup lang=\"ts\">\nconst { columns } = await useTableConfig('bookings')\n\nconst { data: rows, pending } = useFetch('\u002Fapi\u002Fbookings', { server: false })\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CDataTable\n    :columns=\"columns\"\n    :rows=\"rows ?? []\"\n    :loading=\"pending\"\n  \u002F>\n\u003C\u002Ftemplate>\n","vue",[66,498,499,521,546,550,589,598,602,611,619,629,639,650,656],{"__ignoreMap":64},[69,500,501,503,507,510,513,515,518],{"class":71,"line":72},[69,502,303],{"class":81},[69,504,506],{"class":505},"sxg3X","script",[69,508,509],{"class":286}," setup",[69,511,512],{"class":286}," lang",[69,514,326],{"class":81},[69,516,517],{"class":95},"\"ts\"",[69,519,520],{"class":81},">\n",[69,522,523,526,528,531,533,535,537,539,541,544],{"class":71,"line":12},[69,524,525],{"class":276},"const",[69,527,317],{"class":81},[69,529,530],{"class":88},"columns",[69,532,323],{"class":81},[69,534,326],{"class":276},[69,536,354],{"class":276},[69,538,287],{"class":286},[69,540,290],{"class":81},[69,542,543],{"class":95},"'bookings'",[69,545,430],{"class":81},[69,547,548],{"class":71,"line":85},[69,549,338],{"emptyLinePlaceholder":337},[69,551,552,554,556,558,560,563,565,568,570,572,575,577,580,583,586],{"class":71,"line":102},[69,553,525],{"class":276},[69,555,317],{"class":81},[69,557,347],{"class":293},[69,559,92],{"class":81},[69,561,562],{"class":88},"rows",[69,564,125],{"class":81},[69,566,567],{"class":88},"pending",[69,569,323],{"class":81},[69,571,326],{"class":276},[69,573,574],{"class":286}," useFetch",[69,576,290],{"class":81},[69,578,579],{"class":95},"'\u002Fapi\u002Fbookings'",[69,581,582],{"class":81},", { server: ",[69,584,585],{"class":88},"false",[69,587,588],{"class":81}," })\n",[69,590,591,594,596],{"class":71,"line":111},[69,592,593],{"class":81},"\u003C\u002F",[69,595,506],{"class":505},[69,597,520],{"class":81},[69,599,600],{"class":71,"line":159},[69,601,338],{"emptyLinePlaceholder":337},[69,603,604,606,609],{"class":71,"line":200},[69,605,303],{"class":81},[69,607,608],{"class":505},"template",[69,610,520],{"class":81},[69,612,613,616],{"class":71,"line":232},[69,614,615],{"class":81},"  \u003C",[69,617,618],{"class":505},"DataTable\n",[69,620,621,624,626],{"class":71,"line":238},[69,622,623],{"class":286},"    :columns",[69,625,326],{"class":81},[69,627,628],{"class":95},"\"columns\"\n",[69,630,631,634,636],{"class":71,"line":467},[69,632,633],{"class":286},"    :rows",[69,635,326],{"class":81},[69,637,638],{"class":95},"\"rows ?? []\"\n",[69,640,642,645,647],{"class":71,"line":641},11,[69,643,644],{"class":286},"    :loading",[69,646,326],{"class":81},[69,648,649],{"class":95},"\"pending\"\n",[69,651,653],{"class":71,"line":652},12,[69,654,655],{"class":81},"  \u002F>\n",[69,657,659,661,663],{"class":71,"line":658},13,[69,660,593],{"class":81},[69,662,608],{"class":505},[69,664,520],{"class":81},[33,666,667,670],{},[66,668,669],{},"server: false"," is the whole split in one option.",[50,672,18],{"id":17},[33,674,675],{},"The rule assumes what decides layout is cheap. If that endpoint takes 800ms, putting it in the SSR pass delays the whole document to fix a shift the user would have seen for a fraction of that. Cache it, or accept the shift. Ours answers in single-digit milliseconds; without that cache I wouldn't do it.",[50,677,21],{"id":20},[679,680,681,694,705],"ul",{},[682,683,684,693],"li",{},[685,686,690,691],"a",{"href":687,"rel":688},"https:\u002F\u002Fnuxt.com\u002Fdocs\u002Fapi\u002Fcomposables\u002Fuse-async-data",[689],"nofollow","Nuxt ",[66,692,263],{}," — payload serialization and cache keys",[682,695,696,701,702,704],{},[685,697,700],{"href":698,"rel":699},"https:\u002F\u002Fnuxt.com\u002Fdocs\u002Fgetting-started\u002Fdata-fetching",[689],"Nuxt data fetching"," — ",[66,703,669],{}," and where requests run",[682,706,707,712],{},[685,708,711],{"href":709,"rel":710},"https:\u002F\u002Fweb.dev\u002Farticles\u002Fcls",[689],"Cumulative Layout Shift"," — what the shifts this avoids actually cost",[714,715,716],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .suv1-, html code.shiki .suv1-{--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sOPea, html code.shiki .sOPea{--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .s-3mD, html code.shiki .s-3mD{--shiki-default:#FFAB70;--shiki-dark:#FFAB70}html pre.shiki code .sxg3X, html code.shiki .sxg3X{--shiki-default:#85E89D;--shiki-dark:#85E89D}",{"title":64,"searchDepth":12,"depth":12,"links":718},[719,720,721,722],{"id":10,"depth":12,"text":11},{"id":14,"depth":12,"text":15},{"id":17,"depth":12,"text":18},{"id":20,"depth":12,"text":21},"One question decides where every fetch in a Nuxt app belongs — does the response determine the layout, or does it fill a layout that is already decided?","md",false,{},null,"\u002Fblog\u002Fwhere-to-fetch-server-or-client",{"title":24,"description":723},[731,732,733,734],"NUXT","VUE","SSR","TYPESCRIPT","blog\u002Fwhere-to-fetch-server-or-client","ARCHITECTURE","pAbgtwkRU5Oh6RAGm4GKXOD-E3NYK59cS4NJ6FqLCWQ",1788177591001]