[{"data":1,"prerenderedAt":420},["ShallowReactive",2],{"blog-\u002Fblog\u002Fnuxt-3-migration":3},{"id":4,"title":5,"body":6,"comments":406,"cover":407,"date":408,"description":409,"draft":406,"extension":410,"meta":411,"navigation":412,"path":413,"seo":414,"stem":415,"tags":416,"__hash__":419},"blog\u002Fblog\u002Fnuxt-3-migration.md","Migrating from Nuxt 2 to Nuxt 3",{"type":7,"value":8,"toc":400},"minimark",[9,13,23,28,31,58,65,69,80,87,91,94,132,147,151,162,242,245,393,396],[10,11,12],"p",{},"This website used to run on Nuxt 2. Single page, no blog, no components — just text and a few logos. Simple, but the framework hit end-of-life at the end of 2024. Time for a proper rebuild.",[14,15,16],"blockquote",{},[10,17,18,22],{},[19,20,21],"strong",{},"Update:"," the site now runs on Nuxt 4, but the rest of this post is intentionally left as a historical write-up of the Nuxt 2 → 3 rebuild.",[24,25,27],"h2",{"id":26},"what-changed","What changed",[10,29,30],{},"Three things motivated the move:",[32,33,34,41,47],"ol",{},[35,36,37,40],"li",{},[19,38,39],{},"Get onto a maintained framework"," — Nuxt 2 is no longer receiving security updates",[35,42,43,46],{},[19,44,45],{},"Add a blog without a CMS"," — I wanted to write posts as Markdown files, not manage a database",[35,48,49,52,53,57],{},[19,50,51],{},"Refresh the design"," — the old site had no visual hierarchy, minimal accessibility, and a broken SEO setup (it was a client-side-only SPA with ",[54,55,56],"code",{},"ssr: false",")",[10,59,60,61,64],{},"Nuxt 3 with ",[54,62,63],{},"@nuxt\u002Fcontent"," ticked all three boxes at the time.",[24,66,68],{"id":67},"the-seo-problem-with-the-old-site","The SEO problem with the old site",[10,70,71,72,74,75,79],{},"The old config had ",[54,73,56],{},", which means Nuxt rendered a blank HTML shell on the server and populated it in the browser. Google ",[76,77,78],"em",{},"can"," crawl SPAs, but it's unreliable and slow — the crawler needs to execute JavaScript before it can index any content.",[10,81,82,83,86],{},"With ",[54,84,85],{},"nuxt generate"," in Nuxt 3 (SSR enabled at build time), every page ships as fully pre-rendered HTML. Google reads it instantly, no JavaScript execution needed.",[24,88,90],{"id":89},"the-migration","The migration",[10,92,93],{},"For a site this small, the migration was straightforward:",[95,96,97,107,120,129],"ul",{},[35,98,99,102,103,106],{},[54,100,101],{},"nuxt.config.js"," → ",[54,104,105],{},"nuxt.config.ts"," — typed config, new module API",[35,108,109,102,112,115,116,119],{},[54,110,111],{},"\u003CNuxt \u002F>",[54,113,114],{},"\u003Cslot \u002F>"," in layouts (no more ",[54,117,118],{},"\u003CNuxtPage \u002F>"," needed in layouts)",[35,121,122,102,125,128],{},[54,123,124],{},"asyncData",[54,126,127],{},"useAsyncData"," composable",[35,130,131],{},"Global styles from SASS to plain CSS with Tailwind directives",[10,133,134,135,138,139,142,143,146],{},"The trickiest part was getting ",[54,136,137],{},"@tailwindcss\u002Ftypography"," right for dark mode. The ",[54,140,141],{},"prose-invert"," modifier handles most of it, but code block colours needed manual ",[54,144,145],{},"--tw-prose-*"," CSS variable overrides.",[24,148,150],{"id":149},"nuxtcontent-is-excellent","@nuxt\u002Fcontent is excellent",[10,152,153,154,157,158,161],{},"Writing posts as ",[54,155,156],{},".md"," files in a ",[54,159,160],{},"content\u002F"," folder is the right workflow for a developer blog. Frontmatter handles metadata (title, description, date, tags) cleanly. Shiki handles syntax highlighting out of the box:",[163,164,169],"pre",{"className":165,"code":166,"language":167,"meta":168,"style":168},"language-typescript shiki shiki-themes github-dark","\u002F\u002F This is automatically highlighted\nconst greeting = (name: string): string => {\n  return `Hello, ${name}!`\n}\n","typescript","",[54,170,171,180,221,236],{"__ignoreMap":168},[172,173,176],"span",{"class":174,"line":175},"line",1,[172,177,179],{"class":178},"sAwPA","\u002F\u002F This is automatically highlighted\n",[172,181,183,187,191,194,198,202,205,209,211,213,215,218],{"class":174,"line":182},2,[172,184,186],{"class":185},"snl16","const",[172,188,190],{"class":189},"svObZ"," greeting",[172,192,193],{"class":185}," =",[172,195,197],{"class":196},"s95oV"," (",[172,199,201],{"class":200},"s9osk","name",[172,203,204],{"class":185},":",[172,206,208],{"class":207},"sDLfK"," string",[172,210,57],{"class":196},[172,212,204],{"class":185},[172,214,208],{"class":207},[172,216,217],{"class":185}," =>",[172,219,220],{"class":196}," {\n",[172,222,224,227,231,233],{"class":174,"line":223},3,[172,225,226],{"class":185},"  return",[172,228,230],{"class":229},"sU2Wk"," `Hello, ${",[172,232,201],{"class":196},[172,234,235],{"class":229},"}!`\n",[172,237,239],{"class":174,"line":238},4,[172,240,241],{"class":196},"}\n",[10,243,244],{},"The query API was also clean and composable for that version:",[163,246,250],{"className":247,"code":248,"language":249,"meta":168,"style":168},"language-javascript shiki shiki-themes github-dark","\u002F\u002F Fetch 3 latest posts, pick only the fields you need\nconst { data } = await useAsyncData('posts', () =>\n  queryContent('\u002Fblog')\n    .sort({ date: -1 })\n    .limit(3)\n    .only(['title', 'description', 'date', 'tags', '_path'])\n    .find(),\n)\n","javascript",[54,251,252,257,291,304,324,339,377,388],{"__ignoreMap":168},[172,253,254],{"class":174,"line":175},[172,255,256],{"class":178},"\u002F\u002F Fetch 3 latest posts, pick only the fields you need\n",[172,258,259,261,264,267,270,273,276,279,282,285,288],{"class":174,"line":182},[172,260,186],{"class":185},[172,262,263],{"class":196}," { ",[172,265,266],{"class":207},"data",[172,268,269],{"class":196}," } ",[172,271,272],{"class":185},"=",[172,274,275],{"class":185}," await",[172,277,278],{"class":189}," useAsyncData",[172,280,281],{"class":196},"(",[172,283,284],{"class":229},"'posts'",[172,286,287],{"class":196},", () ",[172,289,290],{"class":185},"=>\n",[172,292,293,296,298,301],{"class":174,"line":223},[172,294,295],{"class":189},"  queryContent",[172,297,281],{"class":196},[172,299,300],{"class":229},"'\u002Fblog'",[172,302,303],{"class":196},")\n",[172,305,306,309,312,315,318,321],{"class":174,"line":238},[172,307,308],{"class":196},"    .",[172,310,311],{"class":189},"sort",[172,313,314],{"class":196},"({ date: ",[172,316,317],{"class":185},"-",[172,319,320],{"class":207},"1",[172,322,323],{"class":196}," })\n",[172,325,327,329,332,334,337],{"class":174,"line":326},5,[172,328,308],{"class":196},[172,330,331],{"class":189},"limit",[172,333,281],{"class":196},[172,335,336],{"class":207},"3",[172,338,303],{"class":196},[172,340,342,344,347,350,353,356,359,361,364,366,369,371,374],{"class":174,"line":341},6,[172,343,308],{"class":196},[172,345,346],{"class":189},"only",[172,348,349],{"class":196},"([",[172,351,352],{"class":229},"'title'",[172,354,355],{"class":196},", ",[172,357,358],{"class":229},"'description'",[172,360,355],{"class":196},[172,362,363],{"class":229},"'date'",[172,365,355],{"class":196},[172,367,368],{"class":229},"'tags'",[172,370,355],{"class":196},[172,372,373],{"class":229},"'_path'",[172,375,376],{"class":196},"])\n",[172,378,380,382,385],{"class":174,"line":379},7,[172,381,308],{"class":196},[172,383,384],{"class":189},"find",[172,386,387],{"class":196},"(),\n",[172,389,391],{"class":174,"line":390},8,[172,392,303],{"class":196},[10,394,395],{},"Reading time is calculated automatically. Strongly recommended.",[397,398,399],"style",{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#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);}",{"title":168,"searchDepth":182,"depth":182,"links":401},[402,403,404,405],{"id":26,"depth":182,"text":27},{"id":67,"depth":182,"text":68},{"id":89,"depth":182,"text":90},{"id":149,"depth":182,"text":150},false,"\u002Fimages\u002Fcovers\u002Fnuxt-3-migration.svg","2025-03-10","What I learned rebuilding this site from scratch — and why the move was worth it.","md",{},true,"\u002Fblog\u002Fnuxt-3-migration",{"title":5,"description":409},"blog\u002Fnuxt-3-migration",[417,418],"nuxt","web development","DvW68CZIhxNWcJHWdSJCFeDjTlRfmfhEEggvk643k3w",1779028492699]