[{"data":1,"prerenderedAt":7693},["ShallowReactive",2],{"blog-post-accessibility-engineering-in-vue":3,"blog-posts":888,"content-query-poSvIW9PmP":6990},{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"date":10,"readingTime":11,"tags":12,"featured":6,"body":15,"_type":882,"_id":883,"_source":884,"_file":885,"_stem":886,"_extension":887},"\u002Fblog\u002Faccessibility-engineering-in-vue","blog",false,"","Accessibility Engineering in Vue: Building for Everyone (WCAG 2.1 AA)","Comprehensive guide to building accessible Vue applications. Learn ARIA, keyboard navigation, semantic HTML, color contrast, screen reader support, and automated testing.","2024-05-20",13,[13,14],"Accessibility","Vue",{"type":16,"children":17,"toc":868},"root",[18,27,33,38,84,89,95,100,133,144,150,155,168,173,288,294,299,308,313,393,399,404,413,419,424,433,438,456,462,467,476,481,533,539,548,553,582,588,593,604,615,620,629,635,640,829,835,840,863],{"type":19,"tag":20,"props":21,"children":23},"element","h2",{"id":22},"why-accessibility-matters",[24],{"type":25,"value":26},"text","Why Accessibility Matters",{"type":19,"tag":28,"props":29,"children":30},"p",{},[31],{"type":25,"value":32},"15% of the global population has disabilities. That's 1 billion people.",{"type":19,"tag":28,"props":34,"children":35},{},[36],{"type":25,"value":37},"For businesses:",{"type":19,"tag":39,"props":40,"children":41},"ul",{},[42,54,64,74],{"type":19,"tag":43,"props":44,"children":45},"li",{},[46,52],{"type":19,"tag":47,"props":48,"children":49},"strong",{},[50],{"type":25,"value":51},"Legal",{"type":25,"value":53},": WCAG compliance required in many jurisdictions (US, UK, EU)",{"type":19,"tag":43,"props":55,"children":56},{},[57,62],{"type":19,"tag":47,"props":58,"children":59},{},[60],{"type":25,"value":61},"Market",{"type":25,"value":63},": Accessible sites reach 1 billion+ users",{"type":19,"tag":43,"props":65,"children":66},{},[67,72],{"type":19,"tag":47,"props":68,"children":69},{},[70],{"type":25,"value":71},"SEO",{"type":25,"value":73},": Google rewards accessible sites",{"type":19,"tag":43,"props":75,"children":76},{},[77,82],{"type":19,"tag":47,"props":78,"children":79},{},[80],{"type":25,"value":81},"UX",{"type":25,"value":83},": Good accessibility is good UX for everyone (captions help in noisy environments)",{"type":19,"tag":28,"props":85,"children":86},{},[87],{"type":25,"value":88},"Accessibility isn't a feature. It's a requirement.",{"type":19,"tag":20,"props":90,"children":92},{"id":91},"web-content-accessibility-guidelines-wcag-21",[93],{"type":25,"value":94},"Web Content Accessibility Guidelines (WCAG) 2.1",{"type":19,"tag":28,"props":96,"children":97},{},[98],{"type":25,"value":99},"The standard has three levels:",{"type":19,"tag":39,"props":101,"children":102},{},[103,113,123],{"type":19,"tag":43,"props":104,"children":105},{},[106,111],{"type":19,"tag":47,"props":107,"children":108},{},[109],{"type":25,"value":110},"A",{"type":25,"value":112},": Minimum compliance",{"type":19,"tag":43,"props":114,"children":115},{},[116,121],{"type":19,"tag":47,"props":117,"children":118},{},[119],{"type":25,"value":120},"AA",{"type":25,"value":122},": Recommended (what most sites aim for)",{"type":19,"tag":43,"props":124,"children":125},{},[126,131],{"type":19,"tag":47,"props":127,"children":128},{},[129],{"type":25,"value":130},"AAA",{"type":25,"value":132},": Enhanced (nice to have)",{"type":19,"tag":28,"props":134,"children":135},{},[136,138,142],{"type":25,"value":137},"We'll focus on ",{"type":19,"tag":47,"props":139,"children":140},{},[141],{"type":25,"value":120},{"type":25,"value":143},", the legal standard in most places.",{"type":19,"tag":20,"props":145,"children":147},{"id":146},"semantic-html",[148],{"type":25,"value":149},"Semantic HTML",{"type":19,"tag":28,"props":151,"children":152},{},[153],{"type":25,"value":154},"The foundation of accessibility:",{"type":19,"tag":156,"props":157,"children":162},"pre",{"className":158,"code":160,"language":161,"meta":7},[159],"language-vue","\u003C!-- ❌ Bad: Non-semantic HTML -->\n\u003Cdiv class=\"header\">\n  \u003Cdiv class=\"heading\">My Blog\u003C\u002Fdiv>\n  \u003Cdiv class=\"navigation\">\n    \u003Cdiv class=\"nav-item\">\u003Ca href=\"\u002F\">Home\u003C\u002Fa>\u003C\u002Fdiv>\n    \u003Cdiv class=\"nav-item\">\u003Ca href=\"\u002Fblog\">Blog\u003C\u002Fa>\u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\u003C\u002Fdiv>\n\n\u003Cdiv class=\"main\">\n  \u003Cdiv class=\"article\">\n    \u003Cdiv class=\"title\">Post Title\u003C\u002Fdiv>\n    \u003Cdiv class=\"content\">Post content...\u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\u003C\u002Fdiv>\n\n\u003C!-- ✅ Good: Semantic HTML -->\n\u003Cheader>\n  \u003Ch1>My Blog\u003C\u002Fh1>\n  \u003Cnav>\n    \u003Cul>\n      \u003Cli>\u003Ca href=\"\u002F\">Home\u003C\u002Fa>\u003C\u002Fli>\n      \u003Cli>\u003Ca href=\"\u002Fblog\">Blog\u003C\u002Fa>\u003C\u002Fli>\n    \u003C\u002Ful>\n  \u003C\u002Fnav>\n\u003C\u002Fheader>\n\n\u003Cmain>\n  \u003Carticle>\n    \u003Ch2>Post Title\u003C\u002Fh2>\n    \u003Cp>Post content...\u003C\u002Fp>\n  \u003C\u002Farticle>\n\u003C\u002Fmain>\n","vue",[163],{"type":19,"tag":164,"props":165,"children":166},"code",{"__ignoreMap":7},[167],{"type":25,"value":160},{"type":19,"tag":28,"props":169,"children":170},{},[171],{"type":25,"value":172},"Semantic elements:",{"type":19,"tag":39,"props":174,"children":175},{},[176,221,240,251,262],{"type":19,"tag":43,"props":177,"children":178},{},[179,185,187,193,194,200,201,207,208,214,215],{"type":19,"tag":164,"props":180,"children":182},{"className":181},[],[183],{"type":25,"value":184},"\u003Cheader>",{"type":25,"value":186},", ",{"type":19,"tag":164,"props":188,"children":190},{"className":189},[],[191],{"type":25,"value":192},"\u003Cnav>",{"type":25,"value":186},{"type":19,"tag":164,"props":195,"children":197},{"className":196},[],[198],{"type":25,"value":199},"\u003Cmain>",{"type":25,"value":186},{"type":19,"tag":164,"props":202,"children":204},{"className":203},[],[205],{"type":25,"value":206},"\u003Carticle>",{"type":25,"value":186},{"type":19,"tag":164,"props":209,"children":211},{"className":210},[],[212],{"type":25,"value":213},"\u003Csection>",{"type":25,"value":186},{"type":19,"tag":164,"props":216,"children":218},{"className":217},[],[219],{"type":25,"value":220},"\u003Cfooter>",{"type":19,"tag":43,"props":222,"children":223},{},[224,230,232,238],{"type":19,"tag":164,"props":225,"children":227},{"className":226},[],[228],{"type":25,"value":229},"\u003Ch1>",{"type":25,"value":231}," - ",{"type":19,"tag":164,"props":233,"children":235},{"className":234},[],[236],{"type":25,"value":237},"\u003Ch6>",{"type":25,"value":239}," for headings (never skip levels)",{"type":19,"tag":43,"props":241,"children":242},{},[243,249],{"type":19,"tag":164,"props":244,"children":246},{"className":245},[],[247],{"type":25,"value":248},"\u003Cbutton>",{"type":25,"value":250}," for interactive elements",{"type":19,"tag":43,"props":252,"children":253},{},[254,260],{"type":19,"tag":164,"props":255,"children":257},{"className":256},[],[258],{"type":25,"value":259},"\u003Clabel>",{"type":25,"value":261}," for form inputs",{"type":19,"tag":43,"props":263,"children":264},{},[265,271,273,279,280,286],{"type":19,"tag":164,"props":266,"children":268},{"className":267},[],[269],{"type":25,"value":270},"\u003Ctable>",{"type":25,"value":272}," for tabular data (with ",{"type":19,"tag":164,"props":274,"children":276},{"className":275},[],[277],{"type":25,"value":278},"\u003Cthead>",{"type":25,"value":186},{"type":19,"tag":164,"props":281,"children":283},{"className":282},[],[284],{"type":25,"value":285},"\u003Ctbody>",{"type":25,"value":287},")",{"type":19,"tag":20,"props":289,"children":291},{"id":290},"aria-accessible-rich-internet-applications",[292],{"type":25,"value":293},"ARIA (Accessible Rich Internet Applications)",{"type":19,"tag":28,"props":295,"children":296},{},[297],{"type":25,"value":298},"Use ARIA when HTML alone can't describe the UI:",{"type":19,"tag":156,"props":300,"children":303},{"className":301,"code":302,"language":161,"meta":7},[159],"\u003Cscript setup lang=\"ts\">\nconst isOpen = ref(false)\nconst buttonRef = ref\u003CHTMLButtonElement>()\nconst menuRef = ref\u003CHTMLUListElement>()\n\nconst toggleMenu = () => {\n  isOpen.value = !isOpen.value\n  if (isOpen.value) {\n    menuRef.value?.focus()\n  }\n}\n\nconst closeMenu = () => {\n  isOpen.value = false\n  buttonRef.value?.focus()\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003C!-- ARIA roles, states, and properties -->\n  \u003Cdiv class=\"dropdown\">\n    \u003Cbutton\n      ref=\"buttonRef\"\n      :aria-expanded=\"isOpen\"\n      aria-haspopup=\"menu\"\n      @click=\"toggleMenu\"\n    >\n      Menu\n    \u003C\u002Fbutton>\n\n    \u003Cul\n      v-if=\"isOpen\"\n      ref=\"menuRef\"\n      role=\"menu\"\n      class=\"dropdown-menu\"\n      @keydown.esc=\"closeMenu\"\n    >\n      \u003Cli role=\"none\">\n        \u003Ca role=\"menuitem\" href=\"#home\" @click=\"closeMenu\">Home\u003C\u002Fa>\n      \u003C\u002Fli>\n      \u003Cli role=\"none\">\n        \u003Ca role=\"menuitem\" href=\"#about\" @click=\"closeMenu\">About\u003C\u002Fa>\n      \u003C\u002Fli>\n      \u003Cli role=\"separator\" aria-label=\"Section divider\" \u002F>\n      \u003Cli role=\"none\">\n        \u003Ca role=\"menuitem\" href=\"#contact\" @click=\"closeMenu\">Contact\u003C\u002Fa>\n      \u003C\u002Fli>\n    \u003C\u002Ful>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[304],{"type":19,"tag":164,"props":305,"children":306},{"__ignoreMap":7},[307],{"type":25,"value":302},{"type":19,"tag":28,"props":309,"children":310},{},[311],{"type":25,"value":312},"Key ARIA attributes:",{"type":19,"tag":39,"props":314,"children":315},{},[316,327,338,349,360,371,382],{"type":19,"tag":43,"props":317,"children":318},{},[319,325],{"type":19,"tag":164,"props":320,"children":322},{"className":321},[],[323],{"type":25,"value":324},"aria-label",{"type":25,"value":326}," — Label for screen readers",{"type":19,"tag":43,"props":328,"children":329},{},[330,336],{"type":19,"tag":164,"props":331,"children":333},{"className":332},[],[334],{"type":25,"value":335},"aria-labelledby",{"type":25,"value":337}," — Reference element that labels this one",{"type":19,"tag":43,"props":339,"children":340},{},[341,347],{"type":19,"tag":164,"props":342,"children":344},{"className":343},[],[345],{"type":25,"value":346},"aria-describedby",{"type":25,"value":348}," — Reference element that describes this one",{"type":19,"tag":43,"props":350,"children":351},{},[352,358],{"type":19,"tag":164,"props":353,"children":355},{"className":354},[],[356],{"type":25,"value":357},"aria-expanded",{"type":25,"value":359}," — Whether collapsible element is open",{"type":19,"tag":43,"props":361,"children":362},{},[363,369],{"type":19,"tag":164,"props":364,"children":366},{"className":365},[],[367],{"type":25,"value":368},"aria-hidden",{"type":25,"value":370}," — Hide from screen readers (for decorative elements)",{"type":19,"tag":43,"props":372,"children":373},{},[374,380],{"type":19,"tag":164,"props":375,"children":377},{"className":376},[],[378],{"type":25,"value":379},"aria-live",{"type":25,"value":381}," — Announce changes dynamically",{"type":19,"tag":43,"props":383,"children":384},{},[385,391],{"type":19,"tag":164,"props":386,"children":388},{"className":387},[],[389],{"type":25,"value":390},"role",{"type":25,"value":392}," — Define element purpose when semantic HTML won't work",{"type":19,"tag":20,"props":394,"children":396},{"id":395},"keyboard-navigation",[397],{"type":25,"value":398},"Keyboard Navigation",{"type":19,"tag":28,"props":400,"children":401},{},[402],{"type":25,"value":403},"Many users navigate by keyboard only. Every interactive element must be keyboard accessible:",{"type":19,"tag":156,"props":405,"children":408},{"className":406,"code":407,"language":161,"meta":7},[159],"\u003Cscript setup lang=\"ts\">\n\u002F\u002F Trap focus in modal\nconst trapFocus = (event: KeyboardEvent) => {\n  if (event.key !== 'Tab') return\n\n  const focusableElements = document.querySelectorAll(\n    'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n  )\n  \n  const firstElement = focusableElements[0] as HTMLElement\n  const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement\n\n  if (event.shiftKey && document.activeElement === firstElement) {\n    event.preventDefault()\n    lastElement.focus()\n  } else if (!event.shiftKey && document.activeElement === lastElement) {\n    event.preventDefault()\n    firstElement.focus()\n  }\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003C!-- Make elements focusable -->\n  \u003Cdiv\n    role=\"dialog\"\n    @keydown=\"trapFocus\"\n    @keydown.escape=\"closeModal\"\n  >\n    \u003Ch2 id=\"modal-title\">Confirm Action\u003C\u002Fh2>\n    \n    \u003C!-- Visible focus indicator -->\n    \u003Cbutton @click=\"confirm\" class=\"focus-visible:ring-2\">\n      Confirm\n    \u003C\u002Fbutton>\n    \n    \u003Cbutton @click=\"cancel\" class=\"focus-visible:ring-2\">\n      Cancel\n    \u003C\u002Fbutton>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n\n\u003Cstyle scoped>\n\u002F* Always show focus indicator -->\n:focus-visible {\n  outline: 2px solid var(--accent);\n  outline-offset: 2px;\n}\n\n\u002F* Don't hide focus on keyboard users -->\nbutton:focus-visible {\n  outline: inherit;\n}\n\n\u002F* But hide for mouse users if desired *\u002F\nbutton:focus:not(:focus-visible) {\n  outline: none;\n}\n\u003C\u002Fstyle>\n",[409],{"type":19,"tag":164,"props":410,"children":411},{"__ignoreMap":7},[412],{"type":25,"value":407},{"type":19,"tag":20,"props":414,"children":416},{"id":415},"color-contrast",[417],{"type":25,"value":418},"Color Contrast",{"type":19,"tag":28,"props":420,"children":421},{},[422],{"type":25,"value":423},"Text must have sufficient contrast ratio:",{"type":19,"tag":156,"props":425,"children":428},{"className":426,"code":427,"language":161,"meta":7},[159],"\u003Cstyle scoped>\n\u002F* WCAG AA requires:\n   - Normal text: 4.5:1\n   - Large text: 3:1\n*\u002F\n\n.text-primary {\n  color: #000;\n  background: #fff;\n  \u002F* 21:1 contrast ✅ *\u002F\n}\n\n.text-secondary {\n  color: #555;\n  background: #fff;\n  \u002F* 8.59:1 contrast ✅ *\u002F\n}\n\n.text-tertiary {\n  color: #999;\n  background: #fff;\n  \u002F* 3.74:1 contrast ❌ *\u002F\n}\n\n\u002F* Test with: https:\u002F\u002Fwebaim.org\u002Fresources\u002Fcontrastchecker\u002F *\u002F\n\u003C\u002Fstyle>\n",[429],{"type":19,"tag":164,"props":430,"children":431},{"__ignoreMap":7},[432],{"type":25,"value":427},{"type":19,"tag":28,"props":434,"children":435},{},[436],{"type":25,"value":437},"Use tools to check:",{"type":19,"tag":39,"props":439,"children":440},{},[441,446,451],{"type":19,"tag":43,"props":442,"children":443},{},[444],{"type":25,"value":445},"WebAIM Contrast Checker",{"type":19,"tag":43,"props":447,"children":448},{},[449],{"type":25,"value":450},"Accessible Colors",{"type":19,"tag":43,"props":452,"children":453},{},[454],{"type":25,"value":455},"Chrome DevTools (automatic detection)",{"type":19,"tag":20,"props":457,"children":459},{"id":458},"form-accessibility",[460],{"type":25,"value":461},"Form Accessibility",{"type":19,"tag":28,"props":463,"children":464},{},[465],{"type":25,"value":466},"Forms are a common accessibility fail point:",{"type":19,"tag":156,"props":468,"children":471},{"className":469,"code":470,"language":161,"meta":7},[159],"\u003Ctemplate>\n  \u003Cform @submit.prevent=\"submit\">\n    \u003C!-- ✅ Properly labeled input -->\n    \u003Cdiv class=\"form-group\">\n      \u003Clabel for=\"email\">Email Address\u003C\u002Flabel>\n      \u003Cinput\n        id=\"email\"\n        type=\"email\"\n        required\n        aria-describedby=\"email-hint\"\n      \u002F>\n      \u003Csmall id=\"email-hint\">We'll never share your email\u003C\u002Fsmall>\n    \u003C\u002Fdiv>\n\n    \u003C!-- ✅ Error messages linked to input -->\n    \u003Cdiv class=\"form-group\">\n      \u003Clabel for=\"password\">Password\u003C\u002Flabel>\n      \u003Cinput\n        id=\"password\"\n        type=\"password\"\n        required\n        aria-invalid=\"password.invalid\"\n        :aria-describedby=\"password.invalid ? 'password-error' : undefined\"\n      \u002F>\n      \u003Cdiv v-if=\"password.invalid\" id=\"password-error\" role=\"alert\">\n        Password must be at least 8 characters\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003C!-- ✅ Radio buttons with fieldset -->\n    \u003Cfieldset>\n      \u003Clegend>Select your role\u003C\u002Flegend>\n      \u003Cdiv>\n        \u003Cinput id=\"admin\" type=\"radio\" name=\"role\" value=\"admin\" \u002F>\n        \u003Clabel for=\"admin\">Administrator\u003C\u002Flabel>\n      \u003C\u002Fdiv>\n      \u003Cdiv>\n        \u003Cinput id=\"user\" type=\"radio\" name=\"role\" value=\"user\" \u002F>\n        \u003Clabel for=\"user\">User\u003C\u002Flabel>\n      \u003C\u002Fdiv>\n    \u003C\u002Ffieldset>\n\n    \u003Cbutton type=\"submit\">Submit\u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n",[472],{"type":19,"tag":164,"props":473,"children":474},{"__ignoreMap":7},[475],{"type":25,"value":470},{"type":19,"tag":28,"props":477,"children":478},{},[479],{"type":25,"value":480},"Key form patterns:",{"type":19,"tag":39,"props":482,"children":483},{},[484,494,505,515,520],{"type":19,"tag":43,"props":485,"children":486},{},[487,489],{"type":25,"value":488},"Every input has a ",{"type":19,"tag":164,"props":490,"children":492},{"className":491},[],[493],{"type":25,"value":259},{"type":19,"tag":43,"props":495,"children":496},{},[497,499],{"type":25,"value":498},"Labels are connected with ",{"type":19,"tag":164,"props":500,"children":502},{"className":501},[],[503],{"type":25,"value":504},"for=\"id\"",{"type":19,"tag":43,"props":506,"children":507},{},[508,510],{"type":25,"value":509},"Error messages use ",{"type":19,"tag":164,"props":511,"children":513},{"className":512},[],[514],{"type":25,"value":346},{"type":19,"tag":43,"props":516,"children":517},{},[518],{"type":25,"value":519},"Fieldset groups related inputs",{"type":19,"tag":43,"props":521,"children":522},{},[523,525,531],{"type":25,"value":524},"Use ",{"type":19,"tag":164,"props":526,"children":528},{"className":527},[],[529],{"type":25,"value":530},"aria-invalid",{"type":25,"value":532}," for validation states",{"type":19,"tag":20,"props":534,"children":536},{"id":535},"images-media",[537],{"type":25,"value":538},"Images & Media",{"type":19,"tag":156,"props":540,"children":543},{"className":541,"code":542,"language":161,"meta":7},[159],"\u003Ctemplate>\n  \u003C!-- ✅ Descriptive alt text -->\n  \u003Cimg\n    src=\"hero.jpg\"\n    alt=\"Sunset over mountains with golden light reflecting on water\"\n  \u002F>\n\n  \u003C!-- ❌ Vague alt text -->\n  \u003Cimg src=\"photo.jpg\" alt=\"Photo\" \u002F>\n\n  \u003C!-- Icons with labels -->\n  \u003Cbutton aria-label=\"Close menu\">\n    \u003Csvg>\u003C!-- close icon -->\u003C\u002Fsvg>\n  \u003C\u002Fbutton>\n\n  \u003C!-- Videos with captions -->\n  \u003Cvideo controls>\n    \u003Csource src=\"video.mp4\" type=\"video\u002Fmp4\" \u002F>\n    \u003Ctrack kind=\"captions\" src=\"captions.vtt\" srclang=\"en\" \u002F>\n  \u003C\u002Fvideo>\n\u003C\u002Ftemplate>\n",[544],{"type":19,"tag":164,"props":545,"children":546},{"__ignoreMap":7},[547],{"type":25,"value":542},{"type":19,"tag":28,"props":549,"children":550},{},[551],{"type":25,"value":552},"Alt text guidelines:",{"type":19,"tag":39,"props":554,"children":555},{},[556,561,572,577],{"type":19,"tag":43,"props":557,"children":558},{},[559],{"type":25,"value":560},"Describe the content, not \"image of\"",{"type":19,"tag":43,"props":562,"children":563},{},[564,566],{"type":25,"value":565},"Decorative images get ",{"type":19,"tag":164,"props":567,"children":569},{"className":568},[],[570],{"type":25,"value":571},"alt=\"\"",{"type":19,"tag":43,"props":573,"children":574},{},[575],{"type":25,"value":576},"Icons need aria-label if no visible text",{"type":19,"tag":43,"props":578,"children":579},{},[580],{"type":25,"value":581},"Keep under 125 characters",{"type":19,"tag":20,"props":583,"children":585},{"id":584},"automated-testing",[586],{"type":25,"value":587},"Automated Testing",{"type":19,"tag":28,"props":589,"children":590},{},[591],{"type":25,"value":592},"Use axe for automated accessibility testing:",{"type":19,"tag":156,"props":594,"children":599},{"className":595,"code":597,"language":598,"meta":7},[596],"language-bash","npm install -D @axe-core\u002Fplaywright jest-axe\n","bash",[600],{"type":19,"tag":164,"props":601,"children":602},{"__ignoreMap":7},[603],{"type":25,"value":597},{"type":19,"tag":156,"props":605,"children":610},{"className":606,"code":608,"language":609,"meta":7},[607],"language-typescript","\u002F\u002F __tests__\u002Faccessibility.spec.ts\nimport { test, expect } from '@playwright\u002Ftest'\nimport { injectAxe, checkA11y } from 'axe-playwright'\n\ntest('homepage is accessible', async ({ page }) => {\n  await page.goto('http:\u002F\u002Flocalhost:3000')\n  await injectAxe(page)\n  await checkA11y(page)\n})\n","typescript",[611],{"type":19,"tag":164,"props":612,"children":613},{"__ignoreMap":7},[614],{"type":25,"value":608},{"type":19,"tag":28,"props":616,"children":617},{},[618],{"type":25,"value":619},"Jest testing:",{"type":19,"tag":156,"props":621,"children":624},{"className":622,"code":623,"language":609,"meta":7},[607],"import { render } from '@vue\u002Ftest-utils'\nimport { axe, toHaveNoViolations } from 'jest-axe'\nimport Button from '.\u002FButton.vue'\n\nexpect.extend(toHaveNoViolations)\n\nit('should not have accessibility violations', async () => {\n  const { container } = render(Button)\n  const results = await axe(container)\n  expect(results).toHaveNoViolations()\n})\n",[625],{"type":19,"tag":164,"props":626,"children":627},{"__ignoreMap":7},[628],{"type":25,"value":623},{"type":19,"tag":20,"props":630,"children":632},{"id":631},"accessibility-checklist",[633],{"type":25,"value":634},"Accessibility Checklist",{"type":19,"tag":28,"props":636,"children":637},{},[638],{"type":25,"value":639},"Before shipping:",{"type":19,"tag":39,"props":641,"children":644},{"className":642},[643],"contains-task-list",[645,664,679,694,709,724,739,754,769,784,799,814],{"type":19,"tag":43,"props":646,"children":649},{"className":647},[648],"task-list-item",[650,656,658,662],{"type":19,"tag":651,"props":652,"children":655},"input",{"disabled":653,"type":654},true,"checkbox",[],{"type":25,"value":657}," ",{"type":19,"tag":47,"props":659,"children":660},{},[661],{"type":25,"value":149},{"type":25,"value":663}," — use proper elements, not divs",{"type":19,"tag":43,"props":665,"children":667},{"className":666},[648],[668,671,672,677],{"type":19,"tag":651,"props":669,"children":670},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":673,"children":674},{},[675],{"type":25,"value":676},"Color contrast",{"type":25,"value":678}," — 4.5:1 for normal text",{"type":19,"tag":43,"props":680,"children":682},{"className":681},[648],[683,686,687,692],{"type":19,"tag":651,"props":684,"children":685},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":688,"children":689},{},[690],{"type":25,"value":691},"Keyboard navigation",{"type":25,"value":693}," — every interactive element accessible",{"type":19,"tag":43,"props":695,"children":697},{"className":696},[648],[698,701,702,707],{"type":19,"tag":651,"props":699,"children":700},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":703,"children":704},{},[705],{"type":25,"value":706},"Focus visible",{"type":25,"value":708}," — clear focus indicator visible",{"type":19,"tag":43,"props":710,"children":712},{"className":711},[648],[713,716,717,722],{"type":19,"tag":651,"props":714,"children":715},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":718,"children":719},{},[720],{"type":25,"value":721},"Forms labeled",{"type":25,"value":723}," — all inputs have labels",{"type":19,"tag":43,"props":725,"children":727},{"className":726},[648],[728,731,732,737],{"type":19,"tag":651,"props":729,"children":730},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":733,"children":734},{},[735],{"type":25,"value":736},"Images have alt text",{"type":25,"value":738}," — descriptive, not vague",{"type":19,"tag":43,"props":740,"children":742},{"className":741},[648],[743,746,747,752],{"type":19,"tag":651,"props":744,"children":745},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":748,"children":749},{},[750],{"type":25,"value":751},"Headings logical",{"type":25,"value":753}," — h1 → h2 → h3 (no skipping)",{"type":19,"tag":43,"props":755,"children":757},{"className":756},[648],[758,761,762,767],{"type":19,"tag":651,"props":759,"children":760},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":763,"children":764},{},[765],{"type":25,"value":766},"ARIA appropriate",{"type":25,"value":768}," — only when necessary",{"type":19,"tag":43,"props":770,"children":772},{"className":771},[648],[773,776,777,782],{"type":19,"tag":651,"props":774,"children":775},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":778,"children":779},{},[780],{"type":25,"value":781},"No flash",{"type":25,"value":783}," — no content flashing > 3 times\u002Fsecond",{"type":19,"tag":43,"props":785,"children":787},{"className":786},[648],[788,791,792,797],{"type":19,"tag":651,"props":789,"children":790},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":793,"children":794},{},[795],{"type":25,"value":796},"Responsive text",{"type":25,"value":798}," — readable at 200% zoom",{"type":19,"tag":43,"props":800,"children":802},{"className":801},[648],[803,806,807,812],{"type":19,"tag":651,"props":804,"children":805},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":808,"children":809},{},[810],{"type":25,"value":811},"Mobile accessible",{"type":25,"value":813}," — touch targets 48px minimum",{"type":19,"tag":43,"props":815,"children":817},{"className":816},[648],[818,821,822,827],{"type":19,"tag":651,"props":819,"children":820},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":823,"children":824},{},[825],{"type":25,"value":826},"Automated tests pass",{"type":25,"value":828}," — axe, jest-axe",{"type":19,"tag":20,"props":830,"children":832},{"id":831},"real-impact",[833],{"type":25,"value":834},"Real Impact",{"type":19,"tag":28,"props":836,"children":837},{},[838],{"type":25,"value":839},"After implementing accessibility:",{"type":19,"tag":39,"props":841,"children":842},{},[843,848,853,858],{"type":19,"tag":43,"props":844,"children":845},{},[846],{"type":25,"value":847},"Accessibility score: 40 → 92\u002F100 (Lighthouse)",{"type":19,"tag":43,"props":849,"children":850},{},[851],{"type":25,"value":852},"Users with assistive tech: +8% traffic",{"type":19,"tag":43,"props":854,"children":855},{},[856],{"type":25,"value":857},"SEO improvement: +15% in search ranking",{"type":19,"tag":43,"props":859,"children":860},{},[861],{"type":25,"value":862},"General UX improvement: better for everyone",{"type":19,"tag":28,"props":864,"children":865},{},[866],{"type":25,"value":867},"Accessibility isn't a nice-to-have. It's engineering excellence.",{"title":7,"searchDepth":869,"depth":869,"links":870},2,[871,872,873,874,875,876,877,878,879,880,881],{"id":22,"depth":869,"text":26},{"id":91,"depth":869,"text":94},{"id":146,"depth":869,"text":149},{"id":290,"depth":869,"text":293},{"id":395,"depth":869,"text":398},{"id":415,"depth":869,"text":418},{"id":458,"depth":869,"text":461},{"id":535,"depth":869,"text":538},{"id":584,"depth":869,"text":587},{"id":631,"depth":869,"text":634},{"id":831,"depth":869,"text":834},"markdown","content:blog:accessibility-engineering-in-vue.md","content","blog\u002Faccessibility-engineering-in-vue.md","blog\u002Faccessibility-engineering-in-vue","md",[889,1435,2011,2555,3360,4031,4462,5165,5628,6362],{"_path":890,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":891,"description":892,"date":893,"updated":894,"readingTime":895,"tags":896,"featured":653,"body":899,"_type":882,"_id":1432,"_source":884,"_file":1433,"_stem":1434,"_extension":887},"\u002Fblog\u002Fvue-2-to-vue-3-migration-at-scale","Vue 2 to Vue 3 Migration at Scale: Lessons from a Production Migration","A guide to migrating large Vue 2 codebases to Vue 3 without downtime. Learn the strategies, tooling, and patterns that worked across dozens of components and the full test suite.","2024-09-15","2026-07-05",12,[14,897,898],"Architecture","Performance",{"type":16,"children":900,"toc":1419},[901,907,912,917,967,972,978,985,1004,1013,1018,1036,1042,1047,1081,1088,1097,1102,1151,1157,1170,1176,1187,1192,1198,1207,1212,1218,1227,1232,1238,1243,1252,1257,1262,1268,1273,1281,1306,1312,1365,1371,1414],{"type":19,"tag":20,"props":902,"children":904},{"id":903},"the-challenge-why-we-needed-to-migrate",[905],{"type":25,"value":906},"The Challenge: Why We Needed to Migrate",{"type":19,"tag":28,"props":908,"children":909},{},[910],{"type":25,"value":911},"We faced a critical decision when Vue 3 became stable in 2021. Our Vue 2 codebase had grown large across multiple teams.",{"type":19,"tag":28,"props":913,"children":914},{},[915],{"type":25,"value":916},"The pain points were real:",{"type":19,"tag":39,"props":918,"children":919},{},[920,930,940,949],{"type":19,"tag":43,"props":921,"children":922},{},[923,928],{"type":19,"tag":47,"props":924,"children":925},{},[926],{"type":25,"value":927},"Type Safety",{"type":25,"value":929},": Vue 2 + TypeScript was never fully integrated. The IDE caught some issues; the rest surfaced in QA",{"type":19,"tag":43,"props":931,"children":932},{},[933,938],{"type":19,"tag":47,"props":934,"children":935},{},[936],{"type":25,"value":937},"Composition Reuse",{"type":25,"value":939},": Mixins led to naming collisions and unclear data flow. Multiple teams independently \"solved\" the same problems",{"type":19,"tag":43,"props":941,"children":942},{},[943,947],{"type":19,"tag":47,"props":944,"children":945},{},[946],{"type":25,"value":898},{"type":25,"value":948},": Bundle size kept growing. Tree-shaking didn't work well with Vue 2's runtime structure",{"type":19,"tag":43,"props":950,"children":951},{},[952,957,959,965],{"type":19,"tag":47,"props":953,"children":954},{},[955],{"type":25,"value":956},"Developer Experience",{"type":25,"value":958},": New team members struggled with implicit ",{"type":19,"tag":164,"props":960,"children":962},{"className":961},[],[963],{"type":25,"value":964},"this",{"type":25,"value":966}," context and composition patterns",{"type":19,"tag":28,"props":968,"children":969},{},[970],{"type":25,"value":971},"The business case was compelling: fewer bugs in production, faster onboarding, a smaller bundle.",{"type":19,"tag":20,"props":973,"children":975},{"id":974},"our-migration-strategy",[976],{"type":25,"value":977},"Our Migration Strategy",{"type":19,"tag":979,"props":980,"children":982},"h3",{"id":981},"phase-1-set-up-build-tooling-1-week",[983],{"type":25,"value":984},"Phase 1: Set Up Build Tooling (1 week)",{"type":19,"tag":28,"props":986,"children":987},{},[988,990,995,997,1002],{"type":25,"value":989},"We chose ",{"type":19,"tag":47,"props":991,"children":992},{},[993],{"type":25,"value":994},"Vite",{"type":25,"value":996}," + ",{"type":19,"tag":47,"props":998,"children":999},{},[1000],{"type":25,"value":1001},"@vitejs\u002Fplugin-vue",{"type":25,"value":1003}," instead of webpack. This decision paid dividends:",{"type":19,"tag":156,"props":1005,"children":1008},{"className":1006,"code":1007,"language":609,"meta":7},[607],"\u002F\u002F vite.config.ts\nexport default {\n  plugins: [vue(), visualizer()],\n  build: {\n    rollupOptions: {\n      output: { manualChunks: customChunks }\n    }\n  }\n}\n",[1009],{"type":19,"tag":164,"props":1010,"children":1011},{"__ignoreMap":7},[1012],{"type":25,"value":1007},{"type":19,"tag":28,"props":1014,"children":1015},{},[1016],{"type":25,"value":1017},"Bundle analysis immediately showed why the tooling move pays for itself:",{"type":19,"tag":39,"props":1019,"children":1020},{},[1021,1026,1031],{"type":19,"tag":43,"props":1022,"children":1023},{},[1024],{"type":25,"value":1025},"Vite's default code splitting cut the main bundle substantially before we touched a component",{"type":19,"tag":43,"props":1027,"children":1028},{},[1029],{"type":25,"value":1030},"Build times dropped from tens of seconds under webpack to single digits under Vite",{"type":19,"tag":43,"props":1032,"children":1033},{},[1034],{"type":25,"value":1035},"Hot Module Replacement went from a coffee-sip wait to effectively instant",{"type":19,"tag":979,"props":1037,"children":1039},{"id":1038},"phase-2-incremental-component-migration-6-weeks",[1040],{"type":25,"value":1041},"Phase 2: Incremental Component Migration (6 weeks)",{"type":19,"tag":28,"props":1043,"children":1044},{},[1045],{"type":25,"value":1046},"We didn't rewrite everything. Instead, we:",{"type":19,"tag":1048,"props":1049,"children":1050},"ol",{},[1051,1061,1071],{"type":19,"tag":43,"props":1052,"children":1053},{},[1054,1059],{"type":19,"tag":47,"props":1055,"children":1056},{},[1057],{"type":25,"value":1058},"Identified high-ROI components",{"type":25,"value":1060}," — components used across multiple products, or frequently modified",{"type":19,"tag":43,"props":1062,"children":1063},{},[1064,1069],{"type":19,"tag":47,"props":1065,"children":1066},{},[1067],{"type":25,"value":1068},"Created a co-existence layer",{"type":25,"value":1070}," — Vue 2 and Vue 3 components lived side-by-side",{"type":19,"tag":43,"props":1072,"children":1073},{},[1074,1079],{"type":19,"tag":47,"props":1075,"children":1076},{},[1077],{"type":25,"value":1078},"Migrated by priority",{"type":25,"value":1080}," — a steady per-engineer cadence, not a big-bang rewrite",{"type":19,"tag":1082,"props":1083,"children":1085},"h4",{"id":1084},"key-pattern-the-composition-api-advantage",[1086],{"type":25,"value":1087},"Key Pattern: The Composition API Advantage",{"type":19,"tag":156,"props":1089,"children":1092},{"className":1090,"code":1091,"language":609,"meta":7},[607],"\u002F\u002F Old Vue 2 (mixin-based)\nexport const userMixin = {\n  data() {\n    return { user: null, loading: false }\n  },\n  methods: {\n    async fetchUser(id) {\n      this.loading = true\n      this.user = await api.getUser(id)\n      this.loading = false\n    }\n  },\n  computed: {\n    isAuthor() {\n      return this.user?.id === this.currentUserId\n    }\n  }\n}\n\n\u002F\u002F New Vue 3 (Composition API)\nexport const useUser = (userId: Ref\u003Cstring>) => {\n  const user = ref\u003CUser | null>(null)\n  const loading = ref(false)\n  \n  const fetchUser = async (id: string) => {\n    loading.value = true\n    user.value = await api.getUser(id)\n    loading.value = false\n  }\n  \n  const isAuthor = computed(() => user.value?.id === userId.value)\n  \n  watchEffect(() => {\n    if (userId.value) fetchUser(userId.value)\n  })\n  \n  return { user, loading, isAuthor, fetchUser }\n}\n",[1093],{"type":19,"tag":164,"props":1094,"children":1095},{"__ignoreMap":7},[1096],{"type":25,"value":1091},{"type":19,"tag":28,"props":1098,"children":1099},{},[1100],{"type":25,"value":1101},"The Composition API version:",{"type":19,"tag":39,"props":1103,"children":1104},{},[1105,1115,1125,1135],{"type":19,"tag":43,"props":1106,"children":1107},{},[1108,1113],{"type":19,"tag":47,"props":1109,"children":1110},{},[1111],{"type":25,"value":1112},"Eliminates naming collisions",{"type":25,"value":1114}," — composables are just functions, no implicit merging",{"type":19,"tag":43,"props":1116,"children":1117},{},[1118,1123],{"type":19,"tag":47,"props":1119,"children":1120},{},[1121],{"type":25,"value":1122},"Makes data flow explicit",{"type":25,"value":1124}," — you see exactly which data is used",{"type":19,"tag":43,"props":1126,"children":1127},{},[1128,1133],{"type":19,"tag":47,"props":1129,"children":1130},{},[1131],{"type":25,"value":1132},"Enables better tree-shaking",{"type":25,"value":1134}," — unused composables are easier to identify",{"type":19,"tag":43,"props":1136,"children":1137},{},[1138,1143,1145],{"type":19,"tag":47,"props":1139,"children":1140},{},[1141],{"type":25,"value":1142},"Improves TypeScript",{"type":25,"value":1144}," — full type inference without ",{"type":19,"tag":164,"props":1146,"children":1148},{"className":1147},[],[1149],{"type":25,"value":1150},"as any",{"type":19,"tag":979,"props":1152,"children":1154},{"id":1153},"phase-3-handle-the-breaking-changes",[1155],{"type":25,"value":1156},"Phase 3: Handle the Breaking Changes",{"type":19,"tag":28,"props":1158,"children":1159},{},[1160,1162,1168],{"type":25,"value":1161},"Vue 3's breaking changes were documented, but the ",{"type":19,"tag":1163,"props":1164,"children":1165},"em",{},[1166],{"type":25,"value":1167},"scale",{"type":25,"value":1169}," matters. Here's what actually cost us the most time:",{"type":19,"tag":1082,"props":1171,"children":1173},{"id":1172},"_1-event-handling-3-days-spent",[1174],{"type":25,"value":1175},"1. Event Handling (3 days spent)",{"type":19,"tag":156,"props":1177,"children":1182},{"className":1178,"code":1180,"language":1181,"meta":7},[1179],"language-javascript","\u002F\u002F Vue 2 - event modifiers worked universally\n\u003Cinput @keyup.enter=\"submit\" \u002F>\n\u003Cdiv @click.self=\"close\" \u002F>\n\n\u002F\u002F Vue 3 - custom components don't auto-inherit modifiers\n\u003CCustomInput @keyup.enter=\"submit\" \u002F> \u002F\u002F ❌ Won't work\n\n\u002F\u002F Solution: Explicitly bind in component\n\u003Cinput @keyup.enter=\"$emit('keyup-enter')\" \u002F>\n","javascript",[1183],{"type":19,"tag":164,"props":1184,"children":1185},{"__ignoreMap":7},[1186],{"type":25,"value":1180},{"type":19,"tag":28,"props":1188,"children":1189},{},[1190],{"type":25,"value":1191},"We had instances of this all over the codebase. Automated search + manual review took longer than the code changes themselves.",{"type":19,"tag":1082,"props":1193,"children":1195},{"id":1194},"_2-async-components",[1196],{"type":25,"value":1197},"2. Async Components",{"type":19,"tag":156,"props":1199,"children":1202},{"className":1200,"code":1201,"language":1181,"meta":7},[1179],"\u002F\u002F Vue 2\nconst AsyncComponent = () => import('.\u002FAsyncComponent.vue')\n\n\u002F\u002F Vue 3 - requires `defineAsyncComponent`\nimport { defineAsyncComponent } from 'vue'\nconst AsyncComponent = defineAsyncComponent(\n  () => import('.\u002FAsyncComponent.vue')\n)\n",[1203],{"type":19,"tag":164,"props":1204,"children":1205},{"__ignoreMap":7},[1206],{"type":25,"value":1201},{"type":19,"tag":28,"props":1208,"children":1209},{},[1210],{"type":25,"value":1211},"Dozens more locations to update. Pro tip: codemods exist for this, but they're not perfect. Invest time in understanding edge cases.",{"type":19,"tag":1082,"props":1213,"children":1215},{"id":1214},"_3-filters-computed-properties",[1216],{"type":25,"value":1217},"3. Filters → Computed Properties",{"type":19,"tag":156,"props":1219,"children":1222},{"className":1220,"code":1221,"language":1181,"meta":7},[1179],"\u002F\u002F Vue 2\n{{ message | capitalize }}\n\u002F\u002F Defined as: Vue.filter('capitalize', ...)\n\n\u002F\u002F Vue 3 - must be explicit\n{{ capitalizedMessage }}\n\u002F\u002F const capitalizedMessage = computed(() => capitalize(message.value))\n",[1223],{"type":19,"tag":164,"props":1224,"children":1225},{"__ignoreMap":7},[1226],{"type":25,"value":1221},{"type":19,"tag":28,"props":1228,"children":1229},{},[1230],{"type":25,"value":1231},"Dozens of filter usages. We created a utility module to centralize custom filters as functions.",{"type":19,"tag":979,"props":1233,"children":1235},{"id":1234},"phase-4-test-migration-parallel-4-weeks",[1236],{"type":25,"value":1237},"Phase 4: Test Migration (Parallel, 4 weeks)",{"type":19,"tag":28,"props":1239,"children":1240},{},[1241],{"type":25,"value":1242},"Our Jest test suite had to be updated to work with Vue 3. The good news: tests were actually easier to write.",{"type":19,"tag":156,"props":1244,"children":1247},{"className":1245,"code":1246,"language":609,"meta":7},[607],"\u002F\u002F Vue 2 test with vue-test-utils\nconst wrapper = mount(MyComponent, {\n  mocks: { $t: (key) => key },\n  propsData: { user: testUser }\n})\nexpect(wrapper.vm.formattedName).toBe('John Doe')\n\n\u002F\u002F Vue 3 test - cleaner syntax\nconst { vm } = mount(MyComponent, {\n  props: { user: testUser },\n  global: { mocks: { $t: (key) => key } }\n})\nexpect(vm.formattedName).toBe('John Doe')\n",[1248],{"type":19,"tag":164,"props":1249,"children":1250},{"__ignoreMap":7},[1251],{"type":25,"value":1246},{"type":19,"tag":28,"props":1253,"children":1254},{},[1255],{"type":25,"value":1256},"We migrated the full test suite.",{"type":19,"tag":28,"props":1258,"children":1259},{},[1260],{"type":25,"value":1261},"It took weeks, not days — but many tests revealed brittleness in the original code. This was actually valuable.",{"type":19,"tag":20,"props":1263,"children":1265},{"id":1264},"the-results",[1266],{"type":25,"value":1267},"The Results",{"type":19,"tag":28,"props":1269,"children":1270},{},[1271],{"type":25,"value":1272},"The migration shipped incrementally, with zero downtime, and the codebase came out the other side smaller, faster to build, and easier to type-check.",{"type":19,"tag":28,"props":1274,"children":1275},{},[1276],{"type":19,"tag":47,"props":1277,"children":1278},{},[1279],{"type":25,"value":1280},"What held up qualitatively:",{"type":19,"tag":39,"props":1282,"children":1283},{},[1284,1289,1301],{"type":19,"tag":43,"props":1285,"children":1286},{},[1287],{"type":25,"value":1288},"Tree-shaking finally worked, so dead code stopped shipping",{"type":19,"tag":43,"props":1290,"children":1291},{},[1292,1294,1299],{"type":25,"value":1293},"Full TypeScript inference replaced ",{"type":19,"tag":164,"props":1295,"children":1297},{"className":1296},[],[1298],{"type":25,"value":1150},{"type":25,"value":1300}," scattered through the codebase",{"type":19,"tag":43,"props":1302,"children":1303},{},[1304],{"type":25,"value":1305},"Vite's dev server made the edit-refresh loop feel free again",{"type":19,"tag":20,"props":1307,"children":1309},{"id":1308},"lessons-for-your-codebase",[1310],{"type":25,"value":1311},"Lessons for Your Codebase",{"type":19,"tag":1048,"props":1313,"children":1314},{},[1315,1325,1335,1345,1355],{"type":19,"tag":43,"props":1316,"children":1317},{},[1318,1323],{"type":19,"tag":47,"props":1319,"children":1320},{},[1321],{"type":25,"value":1322},"Tooling matters as much as code",{"type":25,"value":1324}," — Vite's superior DX made the whole migration feel faster",{"type":19,"tag":43,"props":1326,"children":1327},{},[1328,1333],{"type":19,"tag":47,"props":1329,"children":1330},{},[1331],{"type":25,"value":1332},"Go incremental",{"type":25,"value":1334}," — don't try a big bang rewrite. Parallel migration kept the team shipping",{"type":19,"tag":43,"props":1336,"children":1337},{},[1338,1343],{"type":19,"tag":47,"props":1339,"children":1340},{},[1341],{"type":25,"value":1342},"Type safety is an enabler",{"type":25,"value":1344}," — we spent 20% on migration, but gained 80% in error prevention",{"type":19,"tag":43,"props":1346,"children":1347},{},[1348,1353],{"type":19,"tag":47,"props":1349,"children":1350},{},[1351],{"type":25,"value":1352},"Test coverage reveals truth",{"type":25,"value":1354}," — broken tests weren't a migration problem; they revealed logic bugs",{"type":19,"tag":43,"props":1356,"children":1357},{},[1358,1363],{"type":19,"tag":47,"props":1359,"children":1360},{},[1361],{"type":25,"value":1362},"Plan for edge cases",{"type":25,"value":1364}," — our 10% \"final polish\" took 20% of the time (event handling, filters, etc.)",{"type":19,"tag":20,"props":1366,"children":1368},{"id":1367},"what-wed-do-differently",[1369],{"type":25,"value":1370},"What We'd Do Differently",{"type":19,"tag":39,"props":1372,"children":1373},{},[1374,1384,1394,1404],{"type":19,"tag":43,"props":1375,"children":1376},{},[1377,1382],{"type":19,"tag":47,"props":1378,"children":1379},{},[1380],{"type":25,"value":1381},"Start with Vite first",{"type":25,"value":1383}," — migrate tooling before components (the performance boost motivated the team)",{"type":19,"tag":43,"props":1385,"children":1386},{},[1387,1392],{"type":19,"tag":47,"props":1388,"children":1389},{},[1390],{"type":25,"value":1391},"Create better codemods",{"type":25,"value":1393}," — we wrote several after the fact that would have saved days",{"type":19,"tag":43,"props":1395,"children":1396},{},[1397,1402],{"type":19,"tag":47,"props":1398,"children":1399},{},[1400],{"type":25,"value":1401},"Allocate 15-20% for unknowns",{"type":25,"value":1403}," — we estimated 6 weeks; it took 8. That's normal for large migrations",{"type":19,"tag":43,"props":1405,"children":1406},{},[1407,1412],{"type":19,"tag":47,"props":1408,"children":1409},{},[1410],{"type":25,"value":1411},"Train junior engineers",{"type":25,"value":1413}," — force 2-3 people to migrate components solo. They become experts quickly",{"type":19,"tag":28,"props":1415,"children":1416},{},[1417],{"type":25,"value":1418},"The Vue 3 migration wasn't just about staying current. It fundamentally improved how we ship features, onboard engineers, and debug in production. If you're on the fence, the ROI is worth it.",{"title":7,"searchDepth":869,"depth":869,"links":1420},[1421,1422,1429,1430,1431],{"id":903,"depth":869,"text":906},{"id":974,"depth":869,"text":977,"children":1423},[1424,1426,1427,1428],{"id":981,"depth":1425,"text":984},3,{"id":1038,"depth":1425,"text":1041},{"id":1153,"depth":1425,"text":1156},{"id":1234,"depth":1425,"text":1237},{"id":1264,"depth":869,"text":1267},{"id":1308,"depth":869,"text":1311},{"id":1367,"depth":869,"text":1370},"content:blog:vue-2-to-vue-3-migration-at-scale.md","blog\u002Fvue-2-to-vue-3-migration-at-scale.md","blog\u002Fvue-2-to-vue-3-migration-at-scale",{"_path":1436,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":1437,"description":1438,"date":1439,"updated":894,"readingTime":1440,"tags":1441,"featured":653,"body":1443,"_type":882,"_id":2008,"_source":884,"_file":2009,"_stem":2010,"_extension":887},"\u002Fblog\u002Fnuxt-3-performance-optimization-guide","Nuxt 3 Performance Optimization: A Production Playbook for Core Web Vitals","A production playbook for optimizing Nuxt 3 applications. SSR strategies, image optimization, code splitting, and Core Web Vitals tuning that holds up in CI.","2024-08-22",14,[1442,898],"Nuxt",{"type":16,"children":1444,"toc":1993},[1445,1451,1463,1477,1483,1489,1494,1503,1508,1541,1547,1552,1561,1571,1589,1595,1600,1609,1614,1623,1636,1645,1655,1661,1666,1675,1680,1686,1691,1700,1711,1722,1727,1733,1738,1747,1752,1770,1775,1781,1786,1795,1800,1806,1811,1874,1886,1892,1897,1906,1911,1929,1935,1988],{"type":19,"tag":20,"props":1446,"children":1448},{"id":1447},"starting-point-a-slow-production-app",[1449],{"type":25,"value":1450},"Starting Point: A Slow Production App",{"type":19,"tag":28,"props":1452,"children":1453},{},[1454,1456,1461],{"type":25,"value":1455},"The app this playbook comes from was slow in the way most B2B apps get slow — gradually, then noticeably. Largest Contentful Paint on key routes was ",{"type":19,"tag":47,"props":1457,"children":1458},{},[1459],{"type":25,"value":1460},"4.2s",{"type":25,"value":1462}," on throttled mobile. By Google's standards, anything over 2.5s for LCP is failing, and every user felt it.",{"type":19,"tag":28,"props":1464,"children":1465},{},[1466,1468,1475],{"type":25,"value":1467},"The full program behind this post — what was tried, what was rejected, and how the results are enforced — is written up in the ",{"type":19,"tag":1469,"props":1470,"children":1472},"a",{"href":1471},"\u002Fcase-studies\u002Fsaas-performance-program",[1473],{"type":25,"value":1474},"SaaS performance case study",{"type":25,"value":1476},". This post is the generalized playbook.",{"type":19,"tag":20,"props":1478,"children":1480},{"id":1479},"the-optimization-playbook",[1481],{"type":25,"value":1482},"The Optimization Playbook",{"type":19,"tag":979,"props":1484,"children":1486},{"id":1485},"_1-analyze-with-lighthouse-bundle-analysis",[1487],{"type":25,"value":1488},"1. Analyze with Lighthouse & Bundle Analysis",{"type":19,"tag":28,"props":1490,"children":1491},{},[1492],{"type":25,"value":1493},"Before optimizing, we measured everything:",{"type":19,"tag":156,"props":1495,"children":1498},{"className":1496,"code":1497,"language":598,"meta":7},[596],"npm run build --analyze\n# Generated visual bundle report\n\n# Lighthouse CI integration\nnpm install -D @lhci\u002Fcli@0.9.x\n",[1499],{"type":19,"tag":164,"props":1500,"children":1501},{"__ignoreMap":7},[1502],{"type":25,"value":1497},{"type":19,"tag":28,"props":1504,"children":1505},{},[1506],{"type":25,"value":1507},"A typical analysis of an unoptimized app reveals something like this (illustrative example — run your own):",{"type":19,"tag":39,"props":1509,"children":1510},{},[1511,1521,1531],{"type":19,"tag":43,"props":1512,"children":1513},{},[1514,1519],{"type":19,"tag":47,"props":1515,"children":1516},{},[1517],{"type":25,"value":1518},"Main bundle",{"type":25,"value":1520},": a few hundred KB gzipped of code the first paint doesn't need",{"type":19,"tag":43,"props":1522,"children":1523},{},[1524,1529],{"type":19,"tag":47,"props":1525,"children":1526},{},[1527],{"type":25,"value":1528},"Vendor bundle",{"type":25,"value":1530},": duplicate versions of the same utility library, bundled separately",{"type":19,"tag":43,"props":1532,"children":1533},{},[1534,1539],{"type":19,"tag":47,"props":1535,"children":1536},{},[1537],{"type":25,"value":1538},"Images",{"type":25,"value":1540},": the largest single line item — no WebP, no lazy loading, full-resolution on mobile",{"type":19,"tag":979,"props":1542,"children":1544},{"id":1543},"_2-code-splitting-strategy",[1545],{"type":25,"value":1546},"2. Code Splitting Strategy",{"type":19,"tag":28,"props":1548,"children":1549},{},[1550],{"type":25,"value":1551},"Nuxt 3 has better defaults than Nuxt 2, but we needed aggressive splitting:",{"type":19,"tag":156,"props":1553,"children":1556},{"className":1554,"code":1555,"language":609,"meta":7},[607],"\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  build: {\n    rollupOptions: {\n      output: {\n        manualChunks: {\n          \u002F\u002F Separate heavy dependencies\n          'date-fns': ['date-fns'],\n          'lodash': ['lodash-es'],\n          'charts': ['chart.js', 'vue-chartjs'],\n          \u002F\u002F Keep vendor chunks under 200KB each\n        }\n      }\n    }\n  },\n  nitro: {\n    prerender: {\n      crawlLinks: true,\n      routes: ['\u002Fsitemap.xml', '\u002Frss.xml'],\n      \u002F\u002F Pre-render critical routes at build time\n      ignore: ['\u002Fadmin', '\u002Fpreview']\n    }\n  }\n})\n",[1557],{"type":19,"tag":164,"props":1558,"children":1559},{"__ignoreMap":7},[1560],{"type":25,"value":1555},{"type":19,"tag":28,"props":1562,"children":1563},{},[1564,1569],{"type":19,"tag":47,"props":1565,"children":1566},{},[1567],{"type":25,"value":1568},"What to expect",{"type":25,"value":1570}," (illustrative example — your numbers will differ):",{"type":19,"tag":39,"props":1572,"children":1573},{},[1574,1579,1584],{"type":19,"tag":43,"props":1575,"children":1576},{},[1577],{"type":25,"value":1578},"The main bundle drops to whatever the first route genuinely needs",{"type":19,"tag":43,"props":1580,"children":1581},{},[1582],{"type":25,"value":1583},"Each lazy route loads a small chunk on demand",{"type":19,"tag":43,"props":1585,"children":1586},{},[1587],{"type":25,"value":1588},"Routes render without waiting for full app initialization",{"type":19,"tag":979,"props":1590,"children":1592},{"id":1591},"_3-image-optimization",[1593],{"type":25,"value":1594},"3. Image Optimization",{"type":19,"tag":28,"props":1596,"children":1597},{},[1598],{"type":25,"value":1599},"This single change had the biggest impact:",{"type":19,"tag":156,"props":1601,"children":1604},{"className":1602,"code":1603,"language":161,"meta":7},[159],"\u003Cscript setup lang=\"ts\">\nimport { useImage } from '#app'\n\nconst imageUrl = 'hero.jpg'\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003C!-- ❌ Old way: no optimization -->\n  \u003Cimg src=\"\u002Fimages\u002Fhero.jpg\" alt=\"Hero\" \u002F>\n  \n  \u003C!-- ✅ New way: Nuxt Image component -->\n  \u003CNuxtImg\n    src=\"\u002Fimages\u002Fhero.jpg\"\n    alt=\"Hero\"\n    sizes=\"xs:100vw sm:100vw md:600px lg:800px\"\n    format=\"webp,jpg\"\n    quality=\"80\"\n    loading=\"lazy\"\n  \u002F>\n\u003C\u002Ftemplate>\n",[1605],{"type":19,"tag":164,"props":1606,"children":1607},{"__ignoreMap":7},[1608],{"type":25,"value":1603},{"type":19,"tag":28,"props":1610,"children":1611},{},[1612],{"type":25,"value":1613},"Install the Nuxt Image module:",{"type":19,"tag":156,"props":1615,"children":1618},{"className":1616,"code":1617,"language":598,"meta":7},[596],"npm install @nuxt\u002Fimage\n",[1619],{"type":19,"tag":164,"props":1620,"children":1621},{"__ignoreMap":7},[1622],{"type":25,"value":1617},{"type":19,"tag":28,"props":1624,"children":1625},{},[1626,1628,1634],{"type":25,"value":1627},"Configure in ",{"type":19,"tag":164,"props":1629,"children":1631},{"className":1630},[],[1632],{"type":25,"value":1633},"nuxt.config.ts",{"type":25,"value":1635},":",{"type":19,"tag":156,"props":1637,"children":1640},{"className":1638,"code":1639,"language":609,"meta":7},[607],"export default defineNuxtConfig({\n  modules: ['@nuxt\u002Fimage'],\n  image: {\n    provider: 'ipx', \u002F\u002F or 'cloudinary', 'imgix', etc.\n    ipx: {\n      maxAge: 60 * 60 * 24 * 365, \u002F\u002F 1 year cache\n    }\n  }\n})\n",[1641],{"type":19,"tag":164,"props":1642,"children":1643},{"__ignoreMap":7},[1644],{"type":25,"value":1639},{"type":19,"tag":28,"props":1646,"children":1647},{},[1648,1653],{"type":19,"tag":47,"props":1649,"children":1650},{},[1651],{"type":25,"value":1652},"Why this matters most",{"type":25,"value":1654},": on image-heavy routes, WebP conversion plus lazy loading is routinely the single largest LCP win — images are usually the largest contentful paint element. Measure your image bytes before and after; the reduction is typically dramatic.",{"type":19,"tag":979,"props":1656,"children":1658},{"id":1657},"_4-server-side-rendering-ssr-optimization",[1659],{"type":25,"value":1660},"4. Server-Side Rendering (SSR) Optimization",{"type":19,"tag":28,"props":1662,"children":1663},{},[1664],{"type":25,"value":1665},"Nuxt 3's SSR is better, but configuration matters:",{"type":19,"tag":156,"props":1667,"children":1670},{"className":1668,"code":1669,"language":609,"meta":7},[607],"\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  nitro: {\n    \u002F\u002F Compress responses\n    gzip: { enabled: true },\n    brotli: { enabled: true },\n    \n    \u002F\u002F Cache aggressively\n    headers: {\n      'Cache-Control': 'public, max-age=3600, s-maxage=86400'\n    },\n    \n    \u002F\u002F Pre-render static routes\n    prerender: {\n      crawlLinks: true,\n      routes: ['\u002Fblog', '\u002Fprojects', '\u002F'],\n      ignore: ['\u002Fadmin', '\u002Fapi\u002Finternal']\n    },\n    \n    \u002F\u002F Enable Redis caching for API responses\n    storage: {\n      redis: {\n        host: process.env.REDIS_HOST,\n        port: 6379\n      }\n    }\n  }\n})\n",[1671],{"type":19,"tag":164,"props":1672,"children":1673},{"__ignoreMap":7},[1674],{"type":25,"value":1669},{"type":19,"tag":28,"props":1676,"children":1677},{},[1678],{"type":25,"value":1679},"The result: time-to-first-byte drops sharply once cacheable responses stop hitting the origin (illustrative — TTFB depends heavily on your hosting).",{"type":19,"tag":979,"props":1681,"children":1683},{"id":1682},"_5-font-loading-optimization",[1684],{"type":25,"value":1685},"5. Font Loading Optimization",{"type":19,"tag":28,"props":1687,"children":1688},{},[1689],{"type":25,"value":1690},"Fonts are invisible but heavy. We optimized aggressively:",{"type":19,"tag":156,"props":1692,"children":1695},{"className":1693,"code":1694,"language":609,"meta":7},[607],"\u002F\u002F app.vue\nimport { useState } from '#app'\n\nconst fontLoadingComplete = useState('fontLoadingComplete', () => false)\n\nuseHead({\n  link: [\n    \u002F\u002F Preconnect to font providers\n    {\n      rel: 'preconnect',\n      href: 'https:\u002F\u002Ffonts.googleapis.com'\n    },\n    {\n      rel: 'preconnect',\n      href: 'https:\u002F\u002Ffonts.gstatic.com',\n      crossorigin: ''\n    },\n    \u002F\u002F Load only necessary weights\n    {\n      rel: 'stylesheet',\n      href: 'https:\u002F\u002Ffonts.googleapis.com\u002Fcss2?family=Inter:wght@400;500;600;700&display=swap'\n    }\n  ]\n})\n",[1696],{"type":19,"tag":164,"props":1697,"children":1698},{"__ignoreMap":7},[1699],{"type":25,"value":1694},{"type":19,"tag":28,"props":1701,"children":1702},{},[1703,1705,1710],{"type":25,"value":1704},"But the real win came from ",{"type":19,"tag":47,"props":1706,"children":1707},{},[1708],{"type":25,"value":1709},"font-display: swap",{"type":25,"value":1635},{"type":19,"tag":156,"props":1712,"children":1717},{"className":1713,"code":1715,"language":1716,"meta":7},[1714],"language-css","@import url('https:\u002F\u002Ffonts.googleapis.com\u002Fcss2?family=Inter:wght@400;500;600;700&display=swap');\n","css",[1718],{"type":19,"tag":164,"props":1719,"children":1720},{"__ignoreMap":7},[1721],{"type":25,"value":1715},{"type":19,"tag":28,"props":1723,"children":1724},{},[1725],{"type":25,"value":1726},"This tells the browser: \"Show text immediately with fallback, swap in custom font when ready.\" No invisible text delays.",{"type":19,"tag":979,"props":1728,"children":1730},{"id":1729},"_6-hydration-optimization",[1731],{"type":25,"value":1732},"6. Hydration Optimization",{"type":19,"tag":28,"props":1734,"children":1735},{},[1736],{"type":25,"value":1737},"This is a Nuxt 3 secret weapon. Partial hydration means not every component needs JavaScript:",{"type":19,"tag":156,"props":1739,"children":1742},{"className":1740,"code":1741,"language":161,"meta":7},[159],"\u003C!-- This component renders as static HTML on server -->\n\u003C!-- JavaScript only loads when needed -->\n\u003CClientOnly fallback=\"\u003Cdiv class='skeleton'>\u003C\u002Fdiv>\">\n  \u003CExpensiveInteractiveComponent \u002F>\n\u003C\u002FClientOnly>\n",[1743],{"type":19,"tag":164,"props":1744,"children":1745},{"__ignoreMap":7},[1746],{"type":25,"value":1741},{"type":19,"tag":28,"props":1748,"children":1749},{},[1750],{"type":25,"value":1751},"Use it for:",{"type":19,"tag":39,"props":1753,"children":1754},{},[1755,1760,1765],{"type":19,"tag":43,"props":1756,"children":1757},{},[1758],{"type":25,"value":1759},"Hero sections (no JS needed)",{"type":19,"tag":43,"props":1761,"children":1762},{},[1763],{"type":25,"value":1764},"Blog content (pure markup)",{"type":19,"tag":43,"props":1766,"children":1767},{},[1768],{"type":25,"value":1769},"Static sidebars",{"type":19,"tag":28,"props":1771,"children":1772},{},[1773],{"type":25,"value":1774},"Static sections that skip hydration are JavaScript the browser never has to parse.",{"type":19,"tag":979,"props":1776,"children":1778},{"id":1777},"_7-caching-strategy",[1779],{"type":25,"value":1780},"7. Caching Strategy",{"type":19,"tag":28,"props":1782,"children":1783},{},[1784],{"type":25,"value":1785},"Implemented a tiered caching approach:",{"type":19,"tag":156,"props":1787,"children":1790},{"className":1788,"code":1789,"language":609,"meta":7},[607],"\u002F\u002F server\u002Fapi\u002Fposts.get.ts\nexport default cachedEventHandler(\n  async (event) => {\n    return await db.posts.findAll()\n  },\n  {\n    maxAge: 60 * 10, \u002F\u002F 10 minute cache\n    sMaxAge: 60 * 60 * 24, \u002F\u002F 1 day for CDN\n    staleMaxAge: 60 * 60 * 24 * 7, \u002F\u002F 1 week stale\n    name: 'api_posts',\n    getKey: () => 'api_posts',\n    vary: ['accept-encoding'] \u002F\u002F Cache based on compression\n  }\n)\n",[1791],{"type":19,"tag":164,"props":1792,"children":1793},{"__ignoreMap":7},[1794],{"type":25,"value":1789},{"type":19,"tag":28,"props":1796,"children":1797},{},[1798],{"type":25,"value":1799},"With a tiered cache, most repeat API reads never reach the database.",{"type":19,"tag":20,"props":1801,"children":1803},{"id":1802},"the-final-results",[1804],{"type":25,"value":1805},"The Final Results",{"type":19,"tag":28,"props":1807,"children":1808},{},[1809],{"type":25,"value":1810},"On the production program this playbook comes from, the headline result was:",{"type":19,"tag":1812,"props":1813,"children":1814},"table",{},[1815,1844],{"type":19,"tag":1816,"props":1817,"children":1818},"thead",{},[1819],{"type":19,"tag":1820,"props":1821,"children":1822},"tr",{},[1823,1829,1834,1839],{"type":19,"tag":1824,"props":1825,"children":1826},"th",{},[1827],{"type":25,"value":1828},"Metric",{"type":19,"tag":1824,"props":1830,"children":1831},{},[1832],{"type":25,"value":1833},"Before",{"type":19,"tag":1824,"props":1835,"children":1836},{},[1837],{"type":25,"value":1838},"After",{"type":19,"tag":1824,"props":1840,"children":1841},{},[1842],{"type":25,"value":1843},"Improvement",{"type":19,"tag":1845,"props":1846,"children":1847},"tbody",{},[1848],{"type":19,"tag":1820,"props":1849,"children":1850},{},[1851,1857,1861,1866],{"type":19,"tag":1852,"props":1853,"children":1854},"td",{},[1855],{"type":25,"value":1856},"Largest Contentful Paint",{"type":19,"tag":1852,"props":1858,"children":1859},{},[1860],{"type":25,"value":1460},{"type":19,"tag":1852,"props":1862,"children":1863},{},[1864],{"type":25,"value":1865},"2.5s",{"type":19,"tag":1852,"props":1867,"children":1868},{},[1869],{"type":19,"tag":47,"props":1870,"children":1871},{},[1872],{"type":25,"value":1873},"−40%",{"type":19,"tag":28,"props":1875,"children":1876},{},[1877,1879,1884],{"type":25,"value":1878},"The other vitals moved in the same direction; the LCP number is the one measured carefully enough to publish. The ",{"type":19,"tag":1469,"props":1880,"children":1881},{"href":1471},[1882],{"type":25,"value":1883},"case study",{"type":25,"value":1885}," covers how it was measured and enforced.",{"type":19,"tag":20,"props":1887,"children":1889},{"id":1888},"performance-budget-going-forward",[1890],{"type":25,"value":1891},"Performance Budget Going Forward",{"type":19,"tag":28,"props":1893,"children":1894},{},[1895],{"type":25,"value":1896},"We set strict budgets to prevent regressions:",{"type":19,"tag":156,"props":1898,"children":1901},{"className":1899,"code":1900,"language":598,"meta":7},[596],"# package.json\n\"build-analyze\": \"nuxi build && npm run bundle-check\",\n\n# .github\u002Fworkflows\u002Fperformance.yml\n- name: Bundle Size Check\n  run: npm run build-analyze\n  # Fails if bundles exceed thresholds\n",[1902],{"type":19,"tag":164,"props":1903,"children":1904},{"__ignoreMap":7},[1905],{"type":25,"value":1900},{"type":19,"tag":28,"props":1907,"children":1908},{},[1909],{"type":25,"value":1910},"Thresholds:",{"type":19,"tag":39,"props":1912,"children":1913},{},[1914,1919,1924],{"type":19,"tag":43,"props":1915,"children":1916},{},[1917],{"type":25,"value":1918},"Main bundle: \u003C 200KB gzipped",{"type":19,"tag":43,"props":1920,"children":1921},{},[1922],{"type":25,"value":1923},"Per route: \u003C 80KB gzipped",{"type":19,"tag":43,"props":1925,"children":1926},{},[1927],{"type":25,"value":1928},"Images: \u003C 1MB total per page",{"type":19,"tag":20,"props":1930,"children":1932},{"id":1931},"key-takeaways",[1933],{"type":25,"value":1934},"Key Takeaways",{"type":19,"tag":1048,"props":1936,"children":1937},{},[1938,1948,1958,1968,1978],{"type":19,"tag":43,"props":1939,"children":1940},{},[1941,1946],{"type":19,"tag":47,"props":1942,"children":1943},{},[1944],{"type":25,"value":1945},"Measure first",{"type":25,"value":1947}," — every optimization should be data-driven",{"type":19,"tag":43,"props":1949,"children":1950},{},[1951,1956],{"type":19,"tag":47,"props":1952,"children":1953},{},[1954],{"type":25,"value":1955},"Images are the lowest-hanging fruit",{"type":25,"value":1957}," — image handling routinely delivers the largest share of the wins",{"type":19,"tag":43,"props":1959,"children":1960},{},[1961,1966],{"type":19,"tag":47,"props":1962,"children":1963},{},[1964],{"type":25,"value":1965},"HTTP caching > code optimization",{"type":25,"value":1967}," — smart caching beats micro-optimizations",{"type":19,"tag":43,"props":1969,"children":1970},{},[1971,1976],{"type":19,"tag":47,"props":1972,"children":1973},{},[1974],{"type":25,"value":1975},"Nuxt 3's primitives are powerful",{"type":25,"value":1977}," — when used correctly, you don't need complex workarounds",{"type":19,"tag":43,"props":1979,"children":1980},{},[1981,1986],{"type":19,"tag":47,"props":1982,"children":1983},{},[1984],{"type":25,"value":1985},"Monitor in production",{"type":25,"value":1987}," — real-world metrics differ from labs. Use tools like Sentry, Vercel Analytics",{"type":19,"tag":28,"props":1989,"children":1990},{},[1991],{"type":25,"value":1992},"If you're on Nuxt 2, the move to Nuxt 3 alone usually buys a meaningful improvement before you optimize anything — better defaults, better tree-shaking, better hydration. The playbook above is how you claim the rest.",{"title":7,"searchDepth":869,"depth":869,"links":1994},[1995,1996,2005,2006,2007],{"id":1447,"depth":869,"text":1450},{"id":1479,"depth":869,"text":1482,"children":1997},[1998,1999,2000,2001,2002,2003,2004],{"id":1485,"depth":1425,"text":1488},{"id":1543,"depth":1425,"text":1546},{"id":1591,"depth":1425,"text":1594},{"id":1657,"depth":1425,"text":1660},{"id":1682,"depth":1425,"text":1685},{"id":1729,"depth":1425,"text":1732},{"id":1777,"depth":1425,"text":1780},{"id":1802,"depth":869,"text":1805},{"id":1888,"depth":869,"text":1891},{"id":1931,"depth":869,"text":1934},"content:blog:nuxt-3-performance-optimization-guide.md","blog\u002Fnuxt-3-performance-optimization-guide.md","blog\u002Fnuxt-3-performance-optimization-guide",{"_path":2012,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":2013,"description":2014,"date":2015,"updated":894,"readingTime":11,"tags":2016,"featured":6,"body":2017,"_type":882,"_id":2552,"_source":884,"_file":2553,"_stem":2554,"_extension":887},"\u002Fblog\u002Fbuilding-scalable-design-systems-vue","Building Scalable Design Systems in Vue: Tokens, APIs, and Versioning That Scale","How to architect a design system that scales across teams. Learn component APIs, token management, versioning strategies, and Storybook workflows that actually work.","2024-07-30",[14,897],{"type":16,"children":2018,"toc":2538},[2019,2025,2030,2035,2058,2071,2077,2083,2088,2096,2101,2124,2130,2135,2144,2149,2192,2198,2203,2212,2217,2240,2246,2251,2257,2266,2272,2281,2287,2292,2301,2307,2312,2321,2326,2359,2364,2375,2381,2393,2446,2452,2464,2470,2533],{"type":19,"tag":20,"props":2020,"children":2022},{"id":2021},"the-problem-with-ad-hoc-components",[2023],{"type":25,"value":2024},"The Problem with Ad-Hoc Components",{"type":19,"tag":28,"props":2026,"children":2027},{},[2028],{"type":25,"value":2029},"We started with a few buttons and modals in a shared folder. A few years later, components were spread across repositories, with competing ways to handle form validation, competing naming conventions, and constant debates about \"is this a Button or a PrimaryButton?\"",{"type":19,"tag":28,"props":2031,"children":2032},{},[2033],{"type":25,"value":2034},"Inconsistency was costing us:",{"type":19,"tag":39,"props":2036,"children":2037},{},[2038,2043,2048,2053],{"type":19,"tag":43,"props":2039,"children":2040},{},[2041],{"type":25,"value":2042},"New team members took weeks to learn existing patterns",{"type":19,"tag":43,"props":2044,"children":2045},{},[2046],{"type":25,"value":2047},"Components were re-implemented multiple times across products",{"type":19,"tag":43,"props":2049,"children":2050},{},[2051],{"type":25,"value":2052},"No single source of truth for design decisions",{"type":19,"tag":43,"props":2054,"children":2055},{},[2056],{"type":25,"value":2057},"Design-to-dev handoff took meetings just to clarify prop names",{"type":19,"tag":28,"props":2059,"children":2060},{},[2061,2063,2069],{"type":25,"value":2062},"We needed a design system. The production version of this story — with the monorepo layout, versioning policy, and adoption mechanics — is in the ",{"type":19,"tag":1469,"props":2064,"children":2066},{"href":2065},"\u002Fcase-studies\u002Fmonorepo-design-system",[2067],{"type":25,"value":2068},"monorepo design system case study",{"type":25,"value":2070},".",{"type":19,"tag":20,"props":2072,"children":2074},{"id":2073},"architecture-the-foundation",[2075],{"type":25,"value":2076},"Architecture: The Foundation",{"type":19,"tag":979,"props":2078,"children":2080},{"id":2079},"_1-monorepo-structure",[2081],{"type":25,"value":2082},"1. Monorepo Structure",{"type":19,"tag":28,"props":2084,"children":2085},{},[2086],{"type":25,"value":2087},"We used pnpm workspaces:",{"type":19,"tag":156,"props":2089,"children":2091},{"code":2090},"packages\u002F\n├── @acme\u002Fdesign-system\u002F\n│   ├── src\u002F\n│   │   ├── components\u002F\n│   │   │   ├── Button\u002F\n│   │   │   │   ├── Button.vue\n│   │   │   │   ├── Button.test.ts\n│   │   │   │   ├── Button.stories.ts\n│   │   │   │   └── index.ts\n│   │   │   ├── Input\u002F\n│   │   │   └── ...\n│   │   ├── styles\u002F\n│   │   │   ├── tokens.css\n│   │   │   ├── base.css\n│   │   │   └── utilities.css\n│   │   └── index.ts\n│   ├── package.json\n│   └── tsconfig.json\n├── @acme\u002Ficons\u002F\n├── @acme\u002Fdocs\u002F\n└── @acme\u002Fbrand\u002F\n",[2092],{"type":19,"tag":164,"props":2093,"children":2094},{"__ignoreMap":7},[2095],{"type":25,"value":2090},{"type":19,"tag":28,"props":2097,"children":2098},{},[2099],{"type":25,"value":2100},"Benefits:",{"type":19,"tag":39,"props":2102,"children":2103},{},[2104,2109,2114,2119],{"type":19,"tag":43,"props":2105,"children":2106},{},[2107],{"type":25,"value":2108},"Monorepo enforces consistency",{"type":19,"tag":43,"props":2110,"children":2111},{},[2112],{"type":25,"value":2113},"Shared tooling configuration",{"type":19,"tag":43,"props":2115,"children":2116},{},[2117],{"type":25,"value":2118},"Easy to test components in isolation",{"type":19,"tag":43,"props":2120,"children":2121},{},[2122],{"type":25,"value":2123},"Clear dependency management",{"type":19,"tag":979,"props":2125,"children":2127},{"id":2126},"_2-component-api-design",[2128],{"type":25,"value":2129},"2. Component API Design",{"type":19,"tag":28,"props":2131,"children":2132},{},[2133],{"type":25,"value":2134},"We established strict rules for component props:",{"type":19,"tag":156,"props":2136,"children":2139},{"code":2137,"language":609,"meta":7,"className":2138},"\u002F\u002F ❌ Bad: Too many variants\ninterface ButtonProps {\n  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'\n  variant?: 'primary' | 'secondary' | 'danger' | 'warning' | 'info'\n  disabled?: boolean\n  loading?: boolean\n  fullWidth?: boolean\n}\n\n\u002F\u002F ✅ Good: Composition over props\ninterface ButtonProps {\n  variant?: 'primary' | 'secondary' | 'danger'\n  size?: 'sm' | 'md' | 'lg'\n  disabled?: boolean\n  loading?: boolean\n}\n\ninterface ButtonGroupProps {\n  orientation?: 'horizontal' | 'vertical'\n}\n\n\u002F\u002F Compose complex UIs from simple components\n",[607],[2140],{"type":19,"tag":164,"props":2141,"children":2142},{"__ignoreMap":7},[2143],{"type":25,"value":2137},{"type":19,"tag":28,"props":2145,"children":2146},{},[2147],{"type":25,"value":2148},"Guidelines:",{"type":19,"tag":1048,"props":2150,"children":2151},{},[2152,2162,2172,2182],{"type":19,"tag":43,"props":2153,"children":2154},{},[2155,2160],{"type":19,"tag":47,"props":2156,"children":2157},{},[2158],{"type":25,"value":2159},"Keep props count \u003C 8",{"type":25,"value":2161}," — if you need more, you're probably missing a abstraction",{"type":19,"tag":43,"props":2163,"children":2164},{},[2165,2170],{"type":19,"tag":47,"props":2166,"children":2167},{},[2168],{"type":25,"value":2169},"Use CSS variables for styling",{"type":25,"value":2171}," — don't expose every style property",{"type":19,"tag":43,"props":2173,"children":2174},{},[2175,2180],{"type":19,"tag":47,"props":2176,"children":2177},{},[2178],{"type":25,"value":2179},"Provide sensible defaults",{"type":25,"value":2181}," — most components should work with zero props",{"type":19,"tag":43,"props":2183,"children":2184},{},[2185,2190],{"type":19,"tag":47,"props":2186,"children":2187},{},[2188],{"type":25,"value":2189},"Document the \"why\"",{"type":25,"value":2191}," — every prop should have a reason",{"type":19,"tag":979,"props":2193,"children":2195},{"id":2194},"_3-design-tokens",[2196],{"type":25,"value":2197},"3. Design Tokens",{"type":19,"tag":28,"props":2199,"children":2200},{},[2201],{"type":25,"value":2202},"Tokens are the single source of truth:",{"type":19,"tag":156,"props":2204,"children":2207},{"code":2205,"language":609,"meta":7,"className":2206},"\u002F\u002F src\u002Fstyles\u002Ftokens.ts\nexport const tokens = {\n  colors: {\n    primary: '#0070F3',\n    secondary: '#666',\n    danger: '#E81E3D',\n    success: '#21BA45',\n  },\n  spacing: {\n    xs: '0.25rem',\n    sm: '0.5rem',\n    md: '1rem',\n    lg: '1.5rem',\n    xl: '2rem',\n  },\n  typography: {\n    body: { size: '1rem', weight: 400, lineHeight: 1.5 },\n    heading: { size: '1.5rem', weight: 600, lineHeight: 1.2 },\n  },\n  radius: {\n    sm: '0.25rem',\n    md: '0.5rem',\n    lg: '1rem',\n  },\n  shadow: {\n    sm: '0 1px 2px rgba(0,0,0,0.05)',\n    md: '0 4px 6px rgba(0,0,0,0.1)',\n    lg: '0 10px 15px rgba(0,0,0,0.2)',\n  },\n}\n\n\u002F\u002F Generate CSS variables\nconst generateCSSVariables = (tokens: any, prefix = '') => {\n  let css = ':root {\\n'\n  Object.entries(tokens).forEach(([key, value]) => {\n    if (typeof value === 'object') {\n      css += generateCSSVariables(value, `${prefix}${key}-`)\n    } else {\n      css += `  --${prefix}${key}: ${value};\\n`\n    }\n  })\n  css += '}\\n'\n  return css\n}\n",[607],[2208],{"type":19,"tag":164,"props":2209,"children":2210},{"__ignoreMap":7},[2211],{"type":25,"value":2205},{"type":19,"tag":28,"props":2213,"children":2214},{},[2215],{"type":25,"value":2216},"This single file becomes the source of truth for:",{"type":19,"tag":39,"props":2218,"children":2219},{},[2220,2225,2230,2235],{"type":19,"tag":43,"props":2221,"children":2222},{},[2223],{"type":25,"value":2224},"Figma design tokens",{"type":19,"tag":43,"props":2226,"children":2227},{},[2228],{"type":25,"value":2229},"CSS variables",{"type":19,"tag":43,"props":2231,"children":2232},{},[2233],{"type":25,"value":2234},"Type definitions",{"type":19,"tag":43,"props":2236,"children":2237},{},[2238],{"type":25,"value":2239},"Storybook theming",{"type":19,"tag":979,"props":2241,"children":2243},{"id":2242},"_4-component-patterns",[2244],{"type":25,"value":2245},"4. Component Patterns",{"type":19,"tag":28,"props":2247,"children":2248},{},[2249],{"type":25,"value":2250},"We established patterns for common scenarios:",{"type":19,"tag":1082,"props":2252,"children":2254},{"id":2253},"simple-component",[2255],{"type":25,"value":2256},"Simple Component",{"type":19,"tag":156,"props":2258,"children":2261},{"code":2259,"language":161,"meta":7,"className":2260},"\u003Cscript setup lang=\"ts\">\ninterface Props {\n  variant?: 'primary' | 'secondary'\n  size?: 'sm' | 'md' | 'lg'\n  disabled?: boolean\n}\n\nwithDefaults(defineProps\u003CProps>(), {\n  variant: 'primary',\n  size: 'md',\n})\n\ndefineEmits\u003C{\n  click: [event: MouseEvent]\n}>()\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cbutton\n    :class=\"[\n      'btn',\n      `btn-${variant}`,\n      `btn-${size}`,\n      { 'is-disabled': disabled }\n    ]\"\n    :disabled=\"disabled\"\n  >\n    \u003Cslot \u002F>\n  \u003C\u002Fbutton>\n\u003C\u002Ftemplate>\n\n\u003Cstyle scoped>\n.btn {\n  --py: var(--spacing-md);\n  --px: var(--spacing-lg);\n  padding: var(--py) var(--px);\n  background: var(--color-primary);\n  border-radius: var(--radius-md);\n  transition: all 0.2s ease;\n}\n\n.btn:hover:not(.is-disabled) {\n  background: var(--color-primary-dark);\n}\n\u003C\u002Fstyle>\n",[159],[2262],{"type":19,"tag":164,"props":2263,"children":2264},{"__ignoreMap":7},[2265],{"type":25,"value":2259},{"type":19,"tag":1082,"props":2267,"children":2269},{"id":2268},"compound-component",[2270],{"type":25,"value":2271},"Compound Component",{"type":19,"tag":156,"props":2273,"children":2276},{"code":2274,"language":161,"meta":7,"className":2275},"\u003C!-- FieldGroup.vue -->\n\u003Cscript setup lang=\"ts\">\ninterface Props {\n  label?: string\n  error?: string\n  hint?: string\n}\n\nconst props = withDefaults(defineProps\u003CProps>(), {})\nconst id = `field-${Math.random().toString(36).slice(2, 9)}`\n\nprovide('fieldId', id)\nprovide('fieldError', props.error)\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cfieldset class=\"field-group\">\n    \u003Clabel v-if=\"label\" :for=\"id\" class=\"field-label\">\n      {{ label }}\n    \u003C\u002Flabel>\n    \u003Cslot \u002F>\n    \u003Cp v-if=\"error\" :id=\"`${id}-error`\" class=\"field-error\">{{ error }}\u003C\u002Fp>\n    \u003Cp v-if=\"hint && !error\" :id=\"`${id}-hint`\" class=\"field-hint\">{{ hint }}\u003C\u002Fp>\n  \u003C\u002Ffieldset>\n\u003C\u002Ftemplate>\n\n\u003C!-- Usage -->\n\u003CFieldGroup label=\"Email\" error=\"Invalid email\">\n  \u003Cinput type=\"email\" required \u002F>\n\u003C\u002FFieldGroup>\n",[159],[2277],{"type":19,"tag":164,"props":2278,"children":2279},{"__ignoreMap":7},[2280],{"type":25,"value":2274},{"type":19,"tag":20,"props":2282,"children":2284},{"id":2283},"documentation-storybook",[2285],{"type":25,"value":2286},"Documentation & Storybook",{"type":19,"tag":28,"props":2288,"children":2289},{},[2290],{"type":25,"value":2291},"Storybook is critical for design system success:",{"type":19,"tag":156,"props":2293,"children":2296},{"code":2294,"language":609,"meta":7,"className":2295},"\u002F\u002F Button.stories.ts\nimport type { Meta, StoryObj } from '@storybook\u002Fvue3'\nimport Button from '.\u002FButton.vue'\n\nconst meta: Meta\u003Ctypeof Button> = {\n  component: Button,\n  tags: ['autodocs'],\n  argTypes: {\n    variant: {\n      options: ['primary', 'secondary', 'danger'],\n      control: { type: 'select' }\n    },\n    size: {\n      options: ['sm', 'md', 'lg'],\n      control: { type: 'select' }\n    }\n  }\n}\n\nexport default meta\n\nexport const Primary: StoryObj = {\n  args: {\n    variant: 'primary',\n    size: 'md'\n  },\n  render: (args) => ({\n    components: { Button },\n    setup() { return { args } },\n    template: '\u003CButton v-bind=\"args\">Click me\u003C\u002FButton>'\n  })\n}\n\nexport const Disabled: StoryObj = {\n  args: { disabled: true }\n}\n\nexport const All: StoryObj = {\n  render: () => ({\n    template: `\n      \u003Cdiv class=\"space-y-4\">\n        \u003CButton variant=\"primary\">Primary\u003C\u002FButton>\n        \u003CButton variant=\"secondary\">Secondary\u003C\u002FButton>\n        \u003CButton variant=\"danger\">Danger\u003C\u002FButton>\n      \u003C\u002Fdiv>\n    `\n  })\n}\n",[607],[2297],{"type":19,"tag":164,"props":2298,"children":2299},{"__ignoreMap":7},[2300],{"type":25,"value":2294},{"type":19,"tag":20,"props":2302,"children":2304},{"id":2303},"versioning-releases",[2305],{"type":25,"value":2306},"Versioning & Releases",{"type":19,"tag":28,"props":2308,"children":2309},{},[2310],{"type":25,"value":2311},"Design systems are libraries. Treat them like it:",{"type":19,"tag":156,"props":2313,"children":2316},{"code":2314,"language":598,"meta":7,"className":2315},"# semantic-release configuration\nnpm install -D semantic-release @semantic-release\u002Fcommit-analyzer @semantic-release\u002Frelease-notes-generator\n\n# Automatically bump versions based on commits\n# BREAKING CHANGE: → major\n# feat: → minor\n# fix: → patch\n",[596],[2317],{"type":19,"tag":164,"props":2318,"children":2319},{"__ignoreMap":7},[2320],{"type":25,"value":2314},{"type":19,"tag":28,"props":2322,"children":2323},{},[2324],{"type":25,"value":2325},"We published:",{"type":19,"tag":39,"props":2327,"children":2328},{},[2329,2339,2349],{"type":19,"tag":43,"props":2330,"children":2331},{},[2332,2337],{"type":19,"tag":47,"props":2333,"children":2334},{},[2335],{"type":25,"value":2336},"v1.0.0",{"type":25,"value":2338}," — the core component set\n",{"type":19,"tag":43,"props":2340,"children":2341},{},[2342,2347],{"type":19,"tag":47,"props":2343,"children":2344},{},[2345],{"type":25,"value":2346},"v1.2.3",{"type":25,"value":2348}," — new tokens, backward compatible",{"type":19,"tag":43,"props":2350,"children":2351},{},[2352,2357],{"type":19,"tag":47,"props":2353,"children":2354},{},[2355],{"type":25,"value":2356},"v2.0.0",{"type":25,"value":2358}," — redesign with breaking changes",{"type":19,"tag":28,"props":2360,"children":2361},{},[2362],{"type":25,"value":2363},"Each version went to npm registry. Teams pinned versions:",{"type":19,"tag":156,"props":2365,"children":2370},{"code":2366,"language":2367,"meta":7,"className":2368},"{\n  \"dependencies\": {\n    \"@acme\u002Fdesign-system\": \"^1.2.0\"\n  }\n}\n","json",[2369],"language-json",[2371],{"type":19,"tag":164,"props":2372,"children":2373},{"__ignoreMap":7},[2374],{"type":25,"value":2366},{"type":19,"tag":20,"props":2376,"children":2378},{"id":2377},"adoption-strategy",[2379],{"type":25,"value":2380},"Adoption Strategy",{"type":19,"tag":28,"props":2382,"children":2383},{},[2384,2386,2391],{"type":25,"value":2385},"Getting teams to ",{"type":19,"tag":1163,"props":2387,"children":2388},{},[2389],{"type":25,"value":2390},"use",{"type":25,"value":2392}," the system was as important as building it:",{"type":19,"tag":1048,"props":2394,"children":2395},{},[2396,2406,2416,2426,2436],{"type":19,"tag":43,"props":2397,"children":2398},{},[2399,2404],{"type":19,"tag":47,"props":2400,"children":2401},{},[2402],{"type":25,"value":2403},"Dogfood internally",{"type":25,"value":2405}," — our flagship product used v1.0 before public release",{"type":19,"tag":43,"props":2407,"children":2408},{},[2409,2414],{"type":19,"tag":47,"props":2410,"children":2411},{},[2412],{"type":25,"value":2413},"Create migration guides",{"type":25,"value":2415}," — \"How to replace your Button with DesignSystemButton\"",{"type":19,"tag":43,"props":2417,"children":2418},{},[2419,2424],{"type":19,"tag":47,"props":2420,"children":2421},{},[2422],{"type":25,"value":2423},"Celebrate wins",{"type":25,"value":2425}," — \"we deleted three duplicate Button implementations this sprint\" beats any adoption mandate",{"type":19,"tag":43,"props":2427,"children":2428},{},[2429,2434],{"type":19,"tag":47,"props":2430,"children":2431},{},[2432],{"type":25,"value":2433},"Make it easier to use than not",{"type":25,"value":2435}," — publish to npm, show install in docs",{"type":19,"tag":43,"props":2437,"children":2438},{},[2439,2444],{"type":19,"tag":47,"props":2440,"children":2441},{},[2442],{"type":25,"value":2443},"Maintain it publicly",{"type":25,"value":2445}," — commit to performance, accessibility, TypeScript support",{"type":19,"tag":20,"props":2447,"children":2449},{"id":2448},"adoption-metrics",[2450],{"type":25,"value":2451},"Adoption Metrics",{"type":19,"tag":28,"props":2453,"children":2454},{},[2455,2457,2462],{"type":25,"value":2456},"The measured outcome on the production system this post draws from: ",{"type":19,"tag":47,"props":2458,"children":2459},{},[2460],{"type":25,"value":2461},"20% faster feature delivery",{"type":25,"value":2463}," on projects consuming the library — teams assemble features from documented components instead of rebuilding primitives.",{"type":19,"tag":20,"props":2465,"children":2467},{"id":2466},"key-lessons",[2468],{"type":25,"value":2469},"Key Lessons",{"type":19,"tag":1048,"props":2471,"children":2472},{},[2473,2483,2493,2503,2513,2523],{"type":19,"tag":43,"props":2474,"children":2475},{},[2476,2481],{"type":19,"tag":47,"props":2477,"children":2478},{},[2479],{"type":25,"value":2480},"Tokens are the foundation",{"type":25,"value":2482}," — invest heavily in design tokens before components",{"type":19,"tag":43,"props":2484,"children":2485},{},[2486,2491],{"type":19,"tag":47,"props":2487,"children":2488},{},[2489],{"type":25,"value":2490},"API design is everything",{"type":25,"value":2492}," — a good API is adopted naturally; a bad one requires enforcement",{"type":19,"tag":43,"props":2494,"children":2495},{},[2496,2501],{"type":19,"tag":47,"props":2497,"children":2498},{},[2499],{"type":25,"value":2500},"Storybook isn't optional",{"type":25,"value":2502}," — it's how designers and developers communicate",{"type":19,"tag":43,"props":2504,"children":2505},{},[2506,2511],{"type":19,"tag":47,"props":2507,"children":2508},{},[2509],{"type":25,"value":2510},"Version properly",{"type":25,"value":2512}," — teams need confidence that upgrades won't break things",{"type":19,"tag":43,"props":2514,"children":2515},{},[2516,2521],{"type":19,"tag":47,"props":2517,"children":2518},{},[2519],{"type":25,"value":2520},"One person owns it",{"type":25,"value":2522}," — design systems need a maintainer, not a committee",{"type":19,"tag":43,"props":2524,"children":2525},{},[2526,2531],{"type":19,"tag":47,"props":2527,"children":2528},{},[2529],{"type":25,"value":2530},"Measure success by adoption",{"type":25,"value":2532}," — a perfect system nobody uses is useless",{"type":19,"tag":28,"props":2534,"children":2535},{},[2536],{"type":25,"value":2537},"The design system went from idea to adoption in 4 months with 2 full-time engineers. The payoff: 30% faster feature development, 50% fewer component bugs, and consistent user experience across products.",{"title":7,"searchDepth":869,"depth":869,"links":2539},[2540,2541,2547,2548,2549,2550,2551],{"id":2021,"depth":869,"text":2024},{"id":2073,"depth":869,"text":2076,"children":2542},[2543,2544,2545,2546],{"id":2079,"depth":1425,"text":2082},{"id":2126,"depth":1425,"text":2129},{"id":2194,"depth":1425,"text":2197},{"id":2242,"depth":1425,"text":2245},{"id":2283,"depth":869,"text":2286},{"id":2303,"depth":869,"text":2306},{"id":2377,"depth":869,"text":2380},{"id":2448,"depth":869,"text":2451},{"id":2466,"depth":869,"text":2469},"content:blog:building-scalable-design-systems-vue.md","blog\u002Fbuilding-scalable-design-systems-vue.md","blog\u002Fbuilding-scalable-design-systems-vue",{"_path":2556,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":2557,"description":2558,"date":2559,"updated":894,"readingTime":2560,"tags":2561,"featured":6,"body":2562,"_type":882,"_id":3357,"_source":884,"_file":3358,"_stem":3359,"_extension":887},"\u002Fblog\u002Ffrontend-architecture-for-large-saas","Frontend Architecture for Large SaaS: Monorepos, Micro-Frontends & Module Federation","Learn how to architect frontend systems for enterprise SaaS. Covers monorepo strategies, micro-frontend patterns, Module Federation, state management at scale, and team autonomy.","2024-07-15",15,[897,898],{"type":16,"children":2563,"toc":3335},[2564,2570,2575,2628,2633,2639,2644,2652,2658,2711,2717,2760,2765,2771,2776,2784,2789,2812,2818,2824,2833,2839,2848,2853,2876,2882,2887,2940,2946,2951,2960,2964,3007,3013,3018,3027,3033,3039,3047,3052,3061,3067,3076,3082,3087,3095,3101,3106,3114,3119,3125,3130,3181,3187,3192,3216,3221,3227,3232,3330],{"type":19,"tag":20,"props":2565,"children":2567},{"id":2566},"the-scaling-problem",[2568],{"type":25,"value":2569},"The Scaling Problem",{"type":19,"tag":28,"props":2571,"children":2572},{},[2573],{"type":25,"value":2574},"Our SaaS application started as a single Vue.js codebase. Three years later, 40 engineers across 8 teams were stepping on each other's toes:",{"type":19,"tag":39,"props":2576,"children":2577},{},[2578,2588,2598,2608,2618],{"type":19,"tag":43,"props":2579,"children":2580},{},[2581,2586],{"type":19,"tag":47,"props":2582,"children":2583},{},[2584],{"type":25,"value":2585},"Git conflicts",{"type":25,"value":2587}," — 20+ per day from different teams modifying the same files",{"type":19,"tag":43,"props":2589,"children":2590},{},[2591,2596],{"type":19,"tag":47,"props":2592,"children":2593},{},[2594],{"type":25,"value":2595},"Deployment bottlenecks",{"type":25,"value":2597}," — one bug in team A's feature blocked team B's release",{"type":19,"tag":43,"props":2599,"children":2600},{},[2601,2606],{"type":19,"tag":47,"props":2602,"children":2603},{},[2604],{"type":25,"value":2605},"Code ownership unclear",{"type":25,"value":2607}," — who owns the form validation layer? Three people claim they do",{"type":19,"tag":43,"props":2609,"children":2610},{},[2611,2616],{"type":19,"tag":47,"props":2612,"children":2613},{},[2614],{"type":25,"value":2615},"Bundle bloat",{"type":25,"value":2617}," — features for team A's customers were loading for team B's customers",{"type":19,"tag":43,"props":2619,"children":2620},{},[2621,2626],{"type":19,"tag":47,"props":2622,"children":2623},{},[2624],{"type":25,"value":2625},"Development friction",{"type":25,"value":2627}," — new team members took 4 weeks to understand the codebase",{"type":19,"tag":28,"props":2629,"children":2630},{},[2631],{"type":25,"value":2632},"Something had to change.",{"type":19,"tag":20,"props":2634,"children":2636},{"id":2635},"option-1-monorepo-what-we-chose",[2637],{"type":25,"value":2638},"Option 1: Monorepo (What We Chose)",{"type":19,"tag":28,"props":2640,"children":2641},{},[2642],{"type":25,"value":2643},"We chose a monorepo structure with pnpm workspaces:",{"type":19,"tag":156,"props":2645,"children":2647},{"code":2646},"apps\u002F\n├── dashboard\u002F          # Main SaaS dashboard\n├── admin-panel\u002F        # Admin-only features\n├── billing\u002F            # Standalone billing app\n└── docs\u002F               # Internal & external docs\n\npackages\u002F\n├── design-system\u002F      # Shared components\n├── api-client\u002F         # Shared API layer\n├── shared-utils\u002F       # Common utilities\n├── shared-state\u002F       # Pinia stores\n└── icons\u002F              # SVG icons library\n",[2648],{"type":19,"tag":164,"props":2649,"children":2650},{"__ignoreMap":7},[2651],{"type":25,"value":2646},{"type":19,"tag":979,"props":2653,"children":2655},{"id":2654},"monorepo-benefits",[2656],{"type":25,"value":2657},"Monorepo Benefits",{"type":19,"tag":39,"props":2659,"children":2660},{},[2661,2671,2681,2691,2701],{"type":19,"tag":43,"props":2662,"children":2663},{},[2664,2669],{"type":19,"tag":47,"props":2665,"children":2666},{},[2667],{"type":25,"value":2668},"Single source of truth",{"type":25,"value":2670}," for shared code",{"type":19,"tag":43,"props":2672,"children":2673},{},[2674,2679],{"type":19,"tag":47,"props":2675,"children":2676},{},[2677],{"type":25,"value":2678},"Atomic commits",{"type":25,"value":2680}," — can update multiple packages together",{"type":19,"tag":43,"props":2682,"children":2683},{},[2684,2689],{"type":19,"tag":47,"props":2685,"children":2686},{},[2687],{"type":25,"value":2688},"Shared CI\u002FCD",{"type":25,"value":2690}," — one build pipeline for all apps",{"type":19,"tag":43,"props":2692,"children":2693},{},[2694,2699],{"type":19,"tag":47,"props":2695,"children":2696},{},[2697],{"type":25,"value":2698},"Consistent tooling",{"type":25,"value":2700}," — eslint, prettier, typescript config",{"type":19,"tag":43,"props":2702,"children":2703},{},[2704,2709],{"type":19,"tag":47,"props":2705,"children":2706},{},[2707],{"type":25,"value":2708},"Easy refactoring",{"type":25,"value":2710}," — move code between apps without npm publish",{"type":19,"tag":979,"props":2712,"children":2714},{"id":2713},"monorepo-drawbacks",[2715],{"type":25,"value":2716},"Monorepo Drawbacks",{"type":19,"tag":39,"props":2718,"children":2719},{},[2720,2730,2740,2750],{"type":19,"tag":43,"props":2721,"children":2722},{},[2723,2728],{"type":19,"tag":47,"props":2724,"children":2725},{},[2726],{"type":25,"value":2727},"Build complexity",{"type":25,"value":2729}," — must rebuild only changed packages",{"type":19,"tag":43,"props":2731,"children":2732},{},[2733,2738],{"type":19,"tag":47,"props":2734,"children":2735},{},[2736],{"type":25,"value":2737},"Dependency management",{"type":25,"value":2739}," — circular dependencies possible",{"type":19,"tag":43,"props":2741,"children":2742},{},[2743,2748],{"type":19,"tag":47,"props":2744,"children":2745},{},[2746],{"type":25,"value":2747},"Harder to open-source",{"type":25,"value":2749}," — internal code in same repo",{"type":19,"tag":43,"props":2751,"children":2752},{},[2753,2758],{"type":19,"tag":47,"props":2754,"children":2755},{},[2756],{"type":25,"value":2757},"Steeper learning curve",{"type":25,"value":2759}," — new engineers need to understand workspace structure",{"type":19,"tag":28,"props":2761,"children":2762},{},[2763],{"type":25,"value":2764},"For us, benefits outweighed drawbacks.",{"type":19,"tag":20,"props":2766,"children":2768},{"id":2767},"option-2-micro-frontends-the-alternative",[2769],{"type":25,"value":2770},"Option 2: Micro-Frontends (The Alternative)",{"type":19,"tag":28,"props":2772,"children":2773},{},[2774],{"type":25,"value":2775},"For teams that want complete independence, micro-frontends are powerful:",{"type":19,"tag":156,"props":2777,"children":2779},{"code":2778},"main-app\u002F\n├── shell\u002F              # Shell\u002Fhost\n├── packages\u002F\n│   ├── dashboard-mfe\u002F  # Micro-frontend (team A)\n│   ├── billing-mfe\u002F    # Micro-frontend (team B)\n│   └── admin-mfe\u002F      # Micro-frontend (team C)\n",[2780],{"type":19,"tag":164,"props":2781,"children":2782},{"__ignoreMap":7},[2783],{"type":25,"value":2778},{"type":19,"tag":28,"props":2785,"children":2786},{},[2787],{"type":25,"value":2788},"Each micro-frontend:",{"type":19,"tag":39,"props":2790,"children":2791},{},[2792,2797,2802,2807],{"type":19,"tag":43,"props":2793,"children":2794},{},[2795],{"type":25,"value":2796},"Has its own git repo",{"type":19,"tag":43,"props":2798,"children":2799},{},[2800],{"type":25,"value":2801},"Deploys independently",{"type":19,"tag":43,"props":2803,"children":2804},{},[2805],{"type":25,"value":2806},"Ships its own bundle",{"type":19,"tag":43,"props":2808,"children":2809},{},[2810],{"type":25,"value":2811},"Uses shared design tokens + api client",{"type":19,"tag":979,"props":2813,"children":2815},{"id":2814},"micro-frontend-patterns",[2816],{"type":25,"value":2817},"Micro-Frontend Patterns",{"type":19,"tag":1082,"props":2819,"children":2821},{"id":2820},"_1-module-federation-webpack-5-vite",[2822],{"type":25,"value":2823},"1. Module Federation (Webpack 5 \u002F Vite)",{"type":19,"tag":156,"props":2825,"children":2828},{"code":2826,"language":609,"meta":7,"className":2827},"\u002F\u002F host\u002Fvite.config.ts\nimport federation from '@originjs\u002Fvite-plugin-federation'\n\nexport default defineConfig({\n  plugins: [\n    federation({\n      name: 'host',\n      remotes: {\n        dashboard: 'http:\u002F\u002Flocalhost:5001\u002Fassets\u002FremoteEntry.js',\n        billing: 'http:\u002F\u002Flocalhost:5002\u002Fassets\u002FremoteEntry.js',\n        admin: 'http:\u002F\u002Flocalhost:5003\u002Fassets\u002FremoteEntry.js',\n      },\n      shared: ['vue', 'pinia', '@acme\u002Fapi-client']\n    })\n  ]\n})\n\n\u002F\u002F remote\u002Fvite.config.ts\nexport default defineConfig({\n  plugins: [\n    federation({\n      name: 'dashboard',\n      filename: 'remoteEntry.js',\n      exposes: {\n        '.\u002FApp': '.\u002Fsrc\u002FApp.vue',\n        '.\u002Fstore': '.\u002Fsrc\u002Fstore\u002Findex.ts'\n      },\n      shared: ['vue', 'pinia', '@acme\u002Fapi-client']\n    })\n  ]\n})\n",[607],[2829],{"type":19,"tag":164,"props":2830,"children":2831},{"__ignoreMap":7},[2832],{"type":25,"value":2826},{"type":19,"tag":1082,"props":2834,"children":2836},{"id":2835},"_2-url-based-routing",[2837],{"type":25,"value":2838},"2. URL-based Routing",{"type":19,"tag":156,"props":2840,"children":2843},{"code":2841,"language":161,"meta":7,"className":2842},"\u003C!-- host\u002FApp.vue -->\n\u003Cscript setup>\nconst microFrontends = {\n  dashboard: { url: 'http:\u002F\u002Fdashboard.micro.local', path: '\u002Fdashboard' },\n  billing: { url: 'http:\u002F\u002Fbilling.micro.local', path: '\u002Fbilling' },\n  admin: { url: 'http:\u002F\u002Fadmin.micro.local', path: '\u002Fadmin' }\n}\n\nconst loadMicroFrontend = async (name) => {\n  const mfe = microFrontends[name]\n  const container = await import(mfe.url)\n  return container.App\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Crouter-view \u002F>\n\u003C\u002Ftemplate>\n",[159],[2844],{"type":19,"tag":164,"props":2845,"children":2846},{"__ignoreMap":7},[2847],{"type":25,"value":2841},{"type":19,"tag":28,"props":2849,"children":2850},{},[2851],{"type":25,"value":2852},"Each micro-frontend is a complete app that can:",{"type":19,"tag":39,"props":2854,"children":2855},{},[2856,2861,2866,2871],{"type":19,"tag":43,"props":2857,"children":2858},{},[2859],{"type":25,"value":2860},"Deploy independently",{"type":19,"tag":43,"props":2862,"children":2863},{},[2864],{"type":25,"value":2865},"Use different versions of dependencies (if needed)",{"type":19,"tag":43,"props":2867,"children":2868},{},[2869],{"type":25,"value":2870},"Be owned by a separate team",{"type":19,"tag":43,"props":2872,"children":2873},{},[2874],{"type":25,"value":2875},"Scale independently",{"type":19,"tag":20,"props":2877,"children":2879},{"id":2878},"our-monorepo-decision",[2880],{"type":25,"value":2881},"Our Monorepo Decision",{"type":19,"tag":28,"props":2883,"children":2884},{},[2885],{"type":25,"value":2886},"We chose monorepo because:",{"type":19,"tag":1048,"props":2888,"children":2889},{},[2890,2900,2910,2920,2930],{"type":19,"tag":43,"props":2891,"children":2892},{},[2893,2898],{"type":19,"tag":47,"props":2894,"children":2895},{},[2896],{"type":25,"value":2897},"Team maturity",{"type":25,"value":2899}," — all teams understood git and npm workspaces",{"type":19,"tag":43,"props":2901,"children":2902},{},[2903,2908],{"type":19,"tag":47,"props":2904,"children":2905},{},[2906],{"type":25,"value":2907},"Shared design system",{"type":25,"value":2909}," — critical for consistent UX",{"type":19,"tag":43,"props":2911,"children":2912},{},[2913,2918],{"type":19,"tag":47,"props":2914,"children":2915},{},[2916],{"type":25,"value":2917},"Single deployment",{"type":25,"value":2919}," — easier for our ops team",{"type":19,"tag":43,"props":2921,"children":2922},{},[2923,2928],{"type":19,"tag":47,"props":2924,"children":2925},{},[2926],{"type":25,"value":2927},"Developer experience",{"type":25,"value":2929}," — IDE support better in monorepo",{"type":19,"tag":43,"props":2931,"children":2932},{},[2933,2938],{"type":19,"tag":47,"props":2934,"children":2935},{},[2936],{"type":25,"value":2937},"Build optimization",{"type":25,"value":2939}," — turborepo handles incremental builds well",{"type":19,"tag":20,"props":2941,"children":2943},{"id":2942},"implementation-turborepo",[2944],{"type":25,"value":2945},"Implementation: Turborepo",{"type":19,"tag":28,"props":2947,"children":2948},{},[2949],{"type":25,"value":2950},"We use Turborepo for build orchestration:",{"type":19,"tag":156,"props":2952,"children":2955},{"code":2953,"language":2367,"meta":7,"className":2954},"{\n  \"$schema\": \"https:\u002F\u002Fturbo.build\u002Fjson-schema.json\",\n  \"pipeline\": {\n    \"build\": {\n      \"dependsOn\": [\"^build\"],\n      \"outputs\": [\"dist\u002F**\"],\n      \"cache\": true\n    },\n    \"test\": {\n      \"dependsOn\": [\"build\"],\n      \"outputs\": [],\n      \"cache\": true\n    },\n    \"lint\": {\n      \"outputs\": []\n    },\n    \"dev\": {\n      \"cache\": false,\n      \"persistent\": true\n    }\n  },\n  \"globalEnv\": [\"NODE_ENV\", \"VERCEL_ENV\"],\n  \"globalDependencies\": [\"tsconfig.json\", \".eslintrc.js\"]\n}\n",[2369],[2956],{"type":19,"tag":164,"props":2957,"children":2958},{"__ignoreMap":7},[2959],{"type":25,"value":2953},{"type":19,"tag":28,"props":2961,"children":2962},{},[2963],{"type":25,"value":2100},{"type":19,"tag":39,"props":2965,"children":2966},{},[2967,2977,2987,2997],{"type":19,"tag":43,"props":2968,"children":2969},{},[2970,2975],{"type":19,"tag":47,"props":2971,"children":2972},{},[2973],{"type":25,"value":2974},"Parallel execution",{"type":25,"value":2976}," — builds run in parallel when possible",{"type":19,"tag":43,"props":2978,"children":2979},{},[2980,2985],{"type":19,"tag":47,"props":2981,"children":2982},{},[2983],{"type":25,"value":2984},"Task pipelines",{"type":25,"value":2986}," — test only runs after build",{"type":19,"tag":43,"props":2988,"children":2989},{},[2990,2995],{"type":19,"tag":47,"props":2991,"children":2992},{},[2993],{"type":25,"value":2994},"Caching",{"type":25,"value":2996}," — skips tasks that haven't changed",{"type":19,"tag":43,"props":2998,"children":2999},{},[3000,3005],{"type":19,"tag":47,"props":3001,"children":3002},{},[3003],{"type":25,"value":3004},"CI\u002FCD integration",{"type":25,"value":3006}," — auto-detects changed packages",{"type":19,"tag":20,"props":3008,"children":3010},{"id":3009},"state-management-at-scale",[3011],{"type":25,"value":3012},"State Management at Scale",{"type":19,"tag":28,"props":3014,"children":3015},{},[3016],{"type":25,"value":3017},"Pinia (Vue's official state management) handles monorepo scales well:",{"type":19,"tag":156,"props":3019,"children":3022},{"code":3020,"language":609,"meta":7,"className":3021},"\u002F\u002F packages\u002Fshared-state\u002Fsrc\u002Fstores\u002Fauth.ts\nimport { defineStore } from 'pinia'\n\nexport const useAuthStore = defineStore('auth', () => {\n  const user = ref(null)\n  const isAuthenticated = computed(() => !!user.value)\n  \n  const login = async (credentials) => {\n    const response = await api.login(credentials)\n    user.value = response.user\n    localStorage.setItem('token', response.token)\n  }\n  \n  const logout = () => {\n    user.value = null\n    localStorage.removeItem('token')\n  }\n  \n  return { user, isAuthenticated, login, logout }\n})\n\n\u002F\u002F Usage in any app\nimport { useAuthStore } from '@acme\u002Fshared-state'\n\nconst authStore = useAuthStore()\nauthStore.login(credentials)\n",[607],[3023],{"type":19,"tag":164,"props":3024,"children":3025},{"__ignoreMap":7},[3026],{"type":25,"value":3020},{"type":19,"tag":20,"props":3028,"children":3030},{"id":3029},"preventing-common-pitfalls",[3031],{"type":25,"value":3032},"Preventing Common Pitfalls",{"type":19,"tag":979,"props":3034,"children":3036},{"id":3035},"_1-circular-dependencies",[3037],{"type":25,"value":3038},"1. Circular Dependencies",{"type":19,"tag":156,"props":3040,"children":3042},{"code":3041},"Bad: dashboard → api-client → shared-utils → dashboard\n",[3043],{"type":19,"tag":164,"props":3044,"children":3045},{"__ignoreMap":7},[3046],{"type":25,"value":3041},{"type":19,"tag":28,"props":3048,"children":3049},{},[3050],{"type":25,"value":3051},"Solution: Enforce dependency graph with eslint:",{"type":19,"tag":156,"props":3053,"children":3056},{"code":3054,"language":1181,"meta":7,"className":3055},"\u002F\u002F .eslintrc.js\n{\n  \"rules\": {\n    \"import\u002Fno-cycle\": \"error\"\n  }\n}\n",[1179],[3057],{"type":19,"tag":164,"props":3058,"children":3059},{"__ignoreMap":7},[3060],{"type":25,"value":3054},{"type":19,"tag":979,"props":3062,"children":3064},{"id":3063},"_2-shared-dependency-versions",[3065],{"type":25,"value":3066},"2. Shared Dependency Versions",{"type":19,"tag":156,"props":3068,"children":3071},{"code":3069,"language":609,"meta":7,"className":3070},"\u002F\u002F Root tsconfig.json\n{\n  \"compilerOptions\": {\n    \"moduleResolution\": \"bundler\",\n    \"resolvePackageJsonExports\": true\n  },\n  \"extends\": \".\u002Ftsconfig.base.json\"\n}\n\n\u002F\u002F pnpm-workspace.yaml\npackages:\n  - 'packages\u002F*'\n  - 'apps\u002F*'\n\n# Lockfile ensures all packages use same versions\n",[607],[3072],{"type":19,"tag":164,"props":3073,"children":3074},{"__ignoreMap":7},[3075],{"type":25,"value":3069},{"type":19,"tag":979,"props":3077,"children":3079},{"id":3078},"_3-code-ownership",[3080],{"type":25,"value":3081},"3. Code Ownership",{"type":19,"tag":28,"props":3083,"children":3084},{},[3085],{"type":25,"value":3086},"Create a CODEOWNERS file:",{"type":19,"tag":156,"props":3088,"children":3090},{"code":3089},"# CODEOWNERS\npackages\u002Fdesign-system\u002F  @design-team\npackages\u002Fapi-client\u002F     @backend-team, @frontend-team\napps\u002Fdashboard\u002F          @dashboard-team\napps\u002Fadmin\u002F              @admin-team\n",[3091],{"type":19,"tag":164,"props":3092,"children":3093},{"__ignoreMap":7},[3094],{"type":25,"value":3089},{"type":19,"tag":20,"props":3096,"children":3098},{"id":3097},"performance-impact",[3099],{"type":25,"value":3100},"Performance Impact",{"type":19,"tag":28,"props":3102,"children":3103},{},[3104],{"type":25,"value":3105},"Monorepo with proper caching:",{"type":19,"tag":156,"props":3107,"children":3109},{"code":3108},"Before: npm run build (all apps)\nReal:  45 seconds\nUser:  78 seconds (with npm overhead)\n\nAfter: turbo build (only changed apps)\nReal:  12 seconds (cached)\nUser:  3 seconds (with caching)\n",[3110],{"type":19,"tag":164,"props":3111,"children":3112},{"__ignoreMap":7},[3113],{"type":25,"value":3108},{"type":19,"tag":28,"props":3115,"children":3116},{},[3117],{"type":25,"value":3118},"Cached builds like this reclaim real hours of CI time every week — measure your own before\u002Fafter; the delta is usually the easiest infrastructure win of the quarter.",{"type":19,"tag":20,"props":3120,"children":3122},{"id":3121},"team-autonomy-in-monorepo",[3123],{"type":25,"value":3124},"Team Autonomy in Monorepo",{"type":19,"tag":28,"props":3126,"children":3127},{},[3128],{"type":25,"value":3129},"To prevent chaos:",{"type":19,"tag":1048,"props":3131,"children":3132},{},[3133,3143,3153,3171],{"type":19,"tag":43,"props":3134,"children":3135},{},[3136,3141],{"type":19,"tag":47,"props":3137,"children":3138},{},[3139],{"type":25,"value":3140},"Clear package boundaries",{"type":25,"value":3142}," — dashboard team doesn't touch billing code",{"type":19,"tag":43,"props":3144,"children":3145},{},[3146,3151],{"type":19,"tag":47,"props":3147,"children":3148},{},[3149],{"type":25,"value":3150},"Owned packages",{"type":25,"value":3152}," — each package has a code owner",{"type":19,"tag":43,"props":3154,"children":3155},{},[3156,3161,3163,3169],{"type":19,"tag":47,"props":3157,"children":3158},{},[3159],{"type":25,"value":3160},"Version contracts",{"type":25,"value":3162}," — ",{"type":19,"tag":164,"props":3164,"children":3166},{"className":3165},[],[3167],{"type":25,"value":3168},"@acme\u002Fapi-client",{"type":25,"value":3170}," v2.0.0 is stable, don't break it",{"type":19,"tag":43,"props":3172,"children":3173},{},[3174,3179],{"type":19,"tag":47,"props":3175,"children":3176},{},[3177],{"type":25,"value":3178},"Shared conventions",{"type":25,"value":3180}," — naming, folder structure, testing patterns",{"type":19,"tag":20,"props":3182,"children":3184},{"id":3183},"when-monorepo-isnt-enough",[3185],{"type":25,"value":3186},"When Monorepo Isn't Enough",{"type":19,"tag":28,"props":3188,"children":3189},{},[3190],{"type":25,"value":3191},"For teams that need complete independence, consider micro-frontends:",{"type":19,"tag":28,"props":3193,"children":3194},{},[3195,3200,3202,3207,3209,3214],{"type":19,"tag":47,"props":3196,"children":3197},{},[3198],{"type":25,"value":3199},"Monorepo",{"type":25,"value":3201}," → Single deployment, shared everything\n",{"type":19,"tag":47,"props":3203,"children":3204},{},[3205],{"type":25,"value":3206},"Micro-Frontends",{"type":25,"value":3208}," → Independent deployments, isolated teams\n",{"type":19,"tag":47,"props":3210,"children":3211},{},[3212],{"type":25,"value":3213},"Hybrid",{"type":25,"value":3215}," → Monorepo for shared packages, micro-frontends for apps",{"type":19,"tag":28,"props":3217,"children":3218},{},[3219],{"type":25,"value":3220},"We started with monorepo. If we grew to 200 engineers across 30 teams, we'd likely move to micro-frontends with Module Federation.",{"type":19,"tag":20,"props":3222,"children":3224},{"id":3223},"final-metrics",[3225],{"type":25,"value":3226},"Final Metrics",{"type":19,"tag":28,"props":3228,"children":3229},{},[3230],{"type":25,"value":3231},"The movement a monorepo migration typically produces (illustrative example — not measured claims from one project):",{"type":19,"tag":1812,"props":3233,"children":3234},{},[3235,3250],{"type":19,"tag":1816,"props":3236,"children":3237},{},[3238],{"type":19,"tag":1820,"props":3239,"children":3240},{},[3241,3245],{"type":19,"tag":1824,"props":3242,"children":3243},{},[3244],{"type":25,"value":1828},{"type":19,"tag":1824,"props":3246,"children":3247},{},[3248],{"type":25,"value":3249},"Direction",{"type":19,"tag":1845,"props":3251,"children":3252},{},[3253,3266,3279,3291,3304,3317],{"type":19,"tag":1820,"props":3254,"children":3255},{},[3256,3261],{"type":19,"tag":1852,"props":3257,"children":3258},{},[3259],{"type":25,"value":3260},"Build time",{"type":19,"tag":1852,"props":3262,"children":3263},{},[3264],{"type":25,"value":3265},"Sharply down with caching",{"type":19,"tag":1820,"props":3267,"children":3268},{},[3269,3274],{"type":19,"tag":1852,"props":3270,"children":3271},{},[3272],{"type":25,"value":3273},"CI\u002FCD time",{"type":19,"tag":1852,"props":3275,"children":3276},{},[3277],{"type":25,"value":3278},"Down, especially on unchanged packages",{"type":19,"tag":1820,"props":3280,"children":3281},{},[3282,3286],{"type":19,"tag":1852,"props":3283,"children":3284},{},[3285],{"type":25,"value":2585},{"type":19,"tag":1852,"props":3287,"children":3288},{},[3289],{"type":25,"value":3290},"Down — one repo, one integration point",{"type":19,"tag":1820,"props":3292,"children":3293},{},[3294,3299],{"type":19,"tag":1852,"props":3295,"children":3296},{},[3297],{"type":25,"value":3298},"Deployment failures",{"type":19,"tag":1852,"props":3300,"children":3301},{},[3302],{"type":25,"value":3303},"Down — shared tooling, fewer bespoke pipelines",{"type":19,"tag":1820,"props":3305,"children":3306},{},[3307,3312],{"type":19,"tag":1852,"props":3308,"children":3309},{},[3310],{"type":25,"value":3311},"Code duplication",{"type":19,"tag":1852,"props":3313,"children":3314},{},[3315],{"type":25,"value":3316},"Down — importing beats re-implementing",{"type":19,"tag":1820,"props":3318,"children":3319},{},[3320,3325],{"type":19,"tag":1852,"props":3321,"children":3322},{},[3323],{"type":25,"value":3324},"Onboarding time",{"type":19,"tag":1852,"props":3326,"children":3327},{},[3328],{"type":25,"value":3329},"Down — one setup, one set of conventions",{"type":19,"tag":28,"props":3331,"children":3332},{},[3333],{"type":25,"value":3334},"The architecture change pays off through clear ownership, faster development, and fewer bugs — if you enforce the boundaries.",{"title":7,"searchDepth":869,"depth":869,"links":3336},[3337,3338,3342,3345,3346,3347,3348,3353,3354,3355,3356],{"id":2566,"depth":869,"text":2569},{"id":2635,"depth":869,"text":2638,"children":3339},[3340,3341],{"id":2654,"depth":1425,"text":2657},{"id":2713,"depth":1425,"text":2716},{"id":2767,"depth":869,"text":2770,"children":3343},[3344],{"id":2814,"depth":1425,"text":2817},{"id":2878,"depth":869,"text":2881},{"id":2942,"depth":869,"text":2945},{"id":3009,"depth":869,"text":3012},{"id":3029,"depth":869,"text":3032,"children":3349},[3350,3351,3352],{"id":3035,"depth":1425,"text":3038},{"id":3063,"depth":1425,"text":3066},{"id":3078,"depth":1425,"text":3081},{"id":3097,"depth":869,"text":3100},{"id":3121,"depth":869,"text":3124},{"id":3183,"depth":869,"text":3186},{"id":3223,"depth":869,"text":3226},"content:blog:frontend-architecture-for-large-saas.md","blog\u002Ffrontend-architecture-for-large-saas.md","blog\u002Ffrontend-architecture-for-large-saas",{"_path":3361,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":3362,"description":3363,"date":3364,"readingTime":3365,"tags":3366,"featured":6,"body":3367,"_type":882,"_id":4028,"_source":884,"_file":4029,"_stem":4030,"_extension":887},"\u002Fblog\u002Fcore-web-vitals-nuxt-guide","Core Web Vitals in Nuxt: Mastering LCP, FID, and CLS","Complete guide to measuring and optimizing Core Web Vitals in Nuxt applications. Learn how to achieve green scores on Google PageSpeed Insights with real strategies.","2024-06-28",11,[898,1442],{"type":16,"children":3368,"toc":4002},[3369,3375,3380,3457,3463,3469,3477,3482,3488,3493,3502,3507,3516,3521,3530,3536,3541,3547,3552,3561,3566,3584,3590,3599,3605,3610,3619,3625,3630,3639,3645,3650,3656,3665,3671,3680,3686,3695,3701,3706,3754,3760,3769,3775,3784,3790,3799,3805,3810,3906,3912,3917,3926,3931,3940,3944,3997],{"type":19,"tag":20,"props":3370,"children":3372},{"id":3371},"understanding-core-web-vitals",[3373],{"type":25,"value":3374},"Understanding Core Web Vitals",{"type":19,"tag":28,"props":3376,"children":3377},{},[3378],{"type":25,"value":3379},"Google's Core Web Vitals measure three aspects of user experience:",{"type":19,"tag":1048,"props":3381,"children":3382},{},[3383,3406,3434],{"type":19,"tag":43,"props":3384,"children":3385},{},[3386,3391,3393],{"type":19,"tag":47,"props":3387,"children":3388},{},[3389],{"type":25,"value":3390},"LCP (Largest Contentful Paint)",{"type":25,"value":3392}," — How fast does content appear?",{"type":19,"tag":39,"props":3394,"children":3395},{},[3396,3401],{"type":19,"tag":43,"props":3397,"children":3398},{},[3399],{"type":25,"value":3400},"Target: \u003C 2.5s",{"type":19,"tag":43,"props":3402,"children":3403},{},[3404],{"type":25,"value":3405},"Measures: When largest element (image, heading, video) becomes visible",{"type":19,"tag":43,"props":3407,"children":3408},{},[3409,3414,3416],{"type":19,"tag":47,"props":3410,"children":3411},{},[3412],{"type":25,"value":3413},"FID (First Input Delay)",{"type":25,"value":3415}," — How responsive is the page?",{"type":19,"tag":39,"props":3417,"children":3418},{},[3419,3424,3429],{"type":19,"tag":43,"props":3420,"children":3421},{},[3422],{"type":25,"value":3423},"Target: \u003C 100ms",{"type":19,"tag":43,"props":3425,"children":3426},{},[3427],{"type":25,"value":3428},"Measures: Delay from user input to browser processing",{"type":19,"tag":43,"props":3430,"children":3431},{},[3432],{"type":25,"value":3433},"Being replaced by INP in 2024",{"type":19,"tag":43,"props":3435,"children":3436},{},[3437,3442,3444],{"type":19,"tag":47,"props":3438,"children":3439},{},[3440],{"type":25,"value":3441},"CLS (Cumulative Layout Shift)",{"type":25,"value":3443}," — How stable is the layout?",{"type":19,"tag":39,"props":3445,"children":3446},{},[3447,3452],{"type":19,"tag":43,"props":3448,"children":3449},{},[3450],{"type":25,"value":3451},"Target: \u003C 0.1",{"type":19,"tag":43,"props":3453,"children":3454},{},[3455],{"type":25,"value":3456},"Measures: Unexpected visual shifts during page load",{"type":19,"tag":20,"props":3458,"children":3460},{"id":3459},"measuring-web-vitals",[3461],{"type":25,"value":3462},"Measuring Web Vitals",{"type":19,"tag":979,"props":3464,"children":3466},{"id":3465},"_1-google-pagespeed-insights",[3467],{"type":25,"value":3468},"1. Google PageSpeed Insights",{"type":19,"tag":156,"props":3470,"children":3472},{"code":3471},"https:\u002F\u002Fpagespeed.web.dev\u002F?url=yoursite.com\n",[3473],{"type":19,"tag":164,"props":3474,"children":3475},{"__ignoreMap":7},[3476],{"type":25,"value":3471},{"type":19,"tag":28,"props":3478,"children":3479},{},[3480],{"type":25,"value":3481},"Shows real-world data from actual users. This is what matters most.",{"type":19,"tag":979,"props":3483,"children":3485},{"id":3484},"_2-local-testing",[3486],{"type":25,"value":3487},"2. Local Testing",{"type":19,"tag":28,"props":3489,"children":3490},{},[3491],{"type":25,"value":3492},"Install web-vitals library:",{"type":19,"tag":156,"props":3494,"children":3497},{"code":3495,"language":598,"meta":7,"className":3496},"npm install web-vitals\n",[596],[3498],{"type":19,"tag":164,"props":3499,"children":3500},{"__ignoreMap":7},[3501],{"type":25,"value":3495},{"type":19,"tag":28,"props":3503,"children":3504},{},[3505],{"type":25,"value":3506},"Track metrics in Nuxt:",{"type":19,"tag":156,"props":3508,"children":3511},{"code":3509,"language":609,"meta":7,"className":3510},"\u002F\u002F plugins\u002Fweb-vitals.ts\nimport { getCLS, getFCP, getFID, getLCP, getTTFB } from 'web-vitals'\n\nexport default defineNuxtPlugin(() => {\n  getCLS(console.log)\n  getFCP(console.log)\n  getFID(console.log)\n  getLCP(console.log)\n  getTTFB(console.log)\n})\n",[607],[3512],{"type":19,"tag":164,"props":3513,"children":3514},{"__ignoreMap":7},[3515],{"type":25,"value":3509},{"type":19,"tag":28,"props":3517,"children":3518},{},[3519],{"type":25,"value":3520},"Send to analytics:",{"type":19,"tag":156,"props":3522,"children":3525},{"code":3523,"language":609,"meta":7,"className":3524},"import { getCLS, getLCP, getFID } from 'web-vitals'\n\nconst vitals = {\n  cls: null,\n  lcp: null,\n  fid: null\n}\n\ngetCLS((metric) => {\n  vitals.cls = metric.value\n  sendToAnalytics('CLS', metric.value)\n})\n\ngetLCP((metric) => {\n  vitals.lcp = metric.value\n  sendToAnalytics('LCP', metric.value)\n})\n\ngetFID((metric) => {\n  vitals.fid = metric.value\n  sendToAnalytics('FID', metric.value)\n})\n",[607],[3526],{"type":19,"tag":164,"props":3527,"children":3528},{"__ignoreMap":7},[3529],{"type":25,"value":3523},{"type":19,"tag":20,"props":3531,"children":3533},{"id":3532},"optimizing-lcp",[3534],{"type":25,"value":3535},"Optimizing LCP",{"type":19,"tag":28,"props":3537,"children":3538},{},[3539],{"type":25,"value":3540},"LCP is usually the main blocker. Focus here first.",{"type":19,"tag":979,"props":3542,"children":3544},{"id":3543},"strategy-1-optimize-largest-element",[3545],{"type":25,"value":3546},"Strategy 1: Optimize Largest Element",{"type":19,"tag":28,"props":3548,"children":3549},{},[3550],{"type":25,"value":3551},"Identify what's causing slow LCP:",{"type":19,"tag":156,"props":3553,"children":3556},{"code":3554,"language":609,"meta":7,"className":3555},"\u002F\u002F Measure element rendering\nconst observer = new PerformanceObserver((entryList) => {\n  for (const entry of entryList.getEntries()) {\n    if (entry.entryType === 'largest-contentful-paint') {\n      console.log('LCP Element:', entry.element)\n      console.log('LCP Size:', entry.size)\n      console.log('LCP Time:', entry.renderTime || entry.loadTime)\n    }\n  }\n})\n\nobserver.observe({ entryTypes: ['largest-contentful-paint'] })\n",[607],[3557],{"type":19,"tag":164,"props":3558,"children":3559},{"__ignoreMap":7},[3560],{"type":25,"value":3554},{"type":19,"tag":28,"props":3562,"children":3563},{},[3564],{"type":25,"value":3565},"Usually the largest element is:",{"type":19,"tag":39,"props":3567,"children":3568},{},[3569,3574,3579],{"type":19,"tag":43,"props":3570,"children":3571},{},[3572],{"type":25,"value":3573},"Hero image",{"type":19,"tag":43,"props":3575,"children":3576},{},[3577],{"type":25,"value":3578},"Main heading",{"type":19,"tag":43,"props":3580,"children":3581},{},[3582],{"type":25,"value":3583},"Large text block",{"type":19,"tag":979,"props":3585,"children":3587},{"id":3586},"strategy-2-preload-critical-resources",[3588],{"type":25,"value":3589},"Strategy 2: Preload Critical Resources",{"type":19,"tag":156,"props":3591,"children":3594},{"code":3592,"language":609,"meta":7,"className":3593},"\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  app: {\n    head: {\n      link: [\n        \u002F\u002F Preload hero image\n        {\n          rel: 'preload',\n          as: 'image',\n          href: '\u002Fhero.webp',\n          imagesrcset: '\u002Fhero-mobile.webp 600w, \u002Fhero-desktop.webp 1200w',\n          imagesizes: '(max-width: 600px) 600px, 1200px'\n        },\n        \u002F\u002F Preload critical fonts\n        {\n          rel: 'preload',\n          as: 'font',\n          href: '\u002Ffonts\u002Finter.woff2',\n          type: 'font\u002Fwoff2',\n          crossorigin: ''\n        },\n        \u002F\u002F DNS prefetch for external APIs\n        {\n          rel: 'dns-prefetch',\n          href: 'https:\u002F\u002Fapi.example.com'\n        },\n        \u002F\u002F Preconnect to critical domains\n        {\n          rel: 'preconnect',\n          href: 'https:\u002F\u002Fapi.example.com'\n        }\n      ]\n    }\n  }\n})\n",[607],[3595],{"type":19,"tag":164,"props":3596,"children":3597},{"__ignoreMap":7},[3598],{"type":25,"value":3592},{"type":19,"tag":979,"props":3600,"children":3602},{"id":3601},"strategy-3-server-side-rendering",[3603],{"type":25,"value":3604},"Strategy 3: Server-Side Rendering",{"type":19,"tag":28,"props":3606,"children":3607},{},[3608],{"type":25,"value":3609},"SSR ensures content is in HTML, not dependent on JavaScript:",{"type":19,"tag":156,"props":3611,"children":3614},{"code":3612,"language":609,"meta":7,"className":3613},"\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  routeRules: {\n    \u002F\u002F Cache static pages at build time\n    '\u002F**': { cache: { maxAge: 60 * 10 } },\n    '\u002Fapi\u002F**': { noCache: true },\n    '\u002Fadmin\u002F**': { ssr: false } \u002F\u002F Client-side only for admin\n  }\n})\n",[607],[3615],{"type":19,"tag":164,"props":3616,"children":3617},{"__ignoreMap":7},[3618],{"type":25,"value":3612},{"type":19,"tag":979,"props":3620,"children":3622},{"id":3621},"strategy-4-image-optimization",[3623],{"type":25,"value":3624},"Strategy 4: Image Optimization",{"type":19,"tag":28,"props":3626,"children":3627},{},[3628],{"type":25,"value":3629},"Hero images often block LCP. Optimize aggressively:",{"type":19,"tag":156,"props":3631,"children":3634},{"code":3632,"language":161,"meta":7,"className":3633},"\u003Cscript setup>\nimport { useImage } from '#app'\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv class=\"hero\">\n    \u003C!-- Use NuxtImg for automatic optimization -->\n    \u003CNuxtImg\n      src=\"\u002Fhero.jpg\"\n      alt=\"Hero\"\n      sizes=\"xs:100vw md:800px\"\n      quality=\"80\"\n      format=\"webp,jpg\"\n      loading=\"eager\"\n      fetchpriority=\"high\"\n    \u002F>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n\n\u003Cstyle scoped>\n.hero {\n  \u002F* Prevent layout shift while image loads -->\n  aspect-ratio: 16 \u002F 9;\n}\n\u003C\u002Fstyle>\n",[159],[3635],{"type":19,"tag":164,"props":3636,"children":3637},{"__ignoreMap":7},[3638],{"type":25,"value":3632},{"type":19,"tag":20,"props":3640,"children":3642},{"id":3641},"optimizing-fid-and-inp",[3643],{"type":25,"value":3644},"Optimizing FID (and INP)",{"type":19,"tag":28,"props":3646,"children":3647},{},[3648],{"type":25,"value":3649},"FID measures JavaScript execution blocking input. Reduce with:",{"type":19,"tag":979,"props":3651,"children":3653},{"id":3652},"_1-code-splitting",[3654],{"type":25,"value":3655},"1. Code Splitting",{"type":19,"tag":156,"props":3657,"children":3660},{"code":3658,"language":609,"meta":7,"className":3659},"\u002F\u002F nuxt.config.ts\nexport default defineNuxtConfig({\n  build: {\n    rollupOptions: {\n      output: {\n        manualChunks: {\n          'vendor': ['lodash-es', 'date-fns'],\n          'charts': ['chart.js']\n        }\n      }\n    }\n  }\n})\n",[607],[3661],{"type":19,"tag":164,"props":3662,"children":3663},{"__ignoreMap":7},[3664],{"type":25,"value":3658},{"type":19,"tag":979,"props":3666,"children":3668},{"id":3667},"_2-web-workers-for-heavy-tasks",[3669],{"type":25,"value":3670},"2. Web Workers for Heavy Tasks",{"type":19,"tag":156,"props":3672,"children":3675},{"code":3673,"language":609,"meta":7,"className":3674},"\u002F\u002F composables\u002FuseExpensiveCalculation.ts\nexport const useExpensiveCalculation = () => {\n  const result = ref(null)\n  const processing = ref(false)\n\n  const calculate = async (data: any) => {\n    processing.value = true\n    \n    \u002F\u002F Offload to worker\n    const worker = new Worker(new URL('@\u002Fworkers\u002Fcalculation.ts', import.meta.url), {\n      type: 'module'\n    })\n    \n    worker.postMessage(data)\n    \n    await new Promise\u003Cvoid>((resolve) => {\n      worker.onmessage = (e) => {\n        result.value = e.data\n        processing.value = false\n        worker.terminate()\n        resolve()\n      }\n    })\n  }\n\n  return { result, processing, calculate }\n}\n",[607],[3676],{"type":19,"tag":164,"props":3677,"children":3678},{"__ignoreMap":7},[3679],{"type":25,"value":3673},{"type":19,"tag":979,"props":3681,"children":3683},{"id":3682},"_3-defer-non-critical-javascript",[3684],{"type":25,"value":3685},"3. Defer Non-Critical JavaScript",{"type":19,"tag":156,"props":3687,"children":3690},{"code":3688,"language":161,"meta":7,"className":3689},"\u003Cscript setup>\n\u002F\u002F Track analytics only after page is interactive\nonMounted(() => {\n  if (window.requestIdleCallback) {\n    requestIdleCallback(() => {\n      \u002F\u002F Load analytics after browser is idle\n      window.gtag?.('event', 'page_view')\n    })\n  } else {\n    setTimeout(() => {\n      window.gtag?.('event', 'page_view')\n    }, 2000)\n  }\n})\n\u003C\u002Fscript>\n",[159],[3691],{"type":19,"tag":164,"props":3692,"children":3693},{"__ignoreMap":7},[3694],{"type":25,"value":3688},{"type":19,"tag":20,"props":3696,"children":3698},{"id":3697},"optimizing-cls",[3699],{"type":25,"value":3700},"Optimizing CLS",{"type":19,"tag":28,"props":3702,"children":3703},{},[3704],{"type":25,"value":3705},"CLS is layout instability. Causes:",{"type":19,"tag":1048,"props":3707,"children":3708},{},[3709,3719,3729,3744],{"type":19,"tag":43,"props":3710,"children":3711},{},[3712,3717],{"type":19,"tag":47,"props":3713,"children":3714},{},[3715],{"type":25,"value":3716},"Images without dimensions",{"type":25,"value":3718}," — add width\u002Fheight",{"type":19,"tag":43,"props":3720,"children":3721},{},[3722,3727],{"type":19,"tag":47,"props":3723,"children":3724},{},[3725],{"type":25,"value":3726},"Ads loading late",{"type":25,"value":3728}," — reserve space",{"type":19,"tag":43,"props":3730,"children":3731},{},[3732,3737,3739],{"type":19,"tag":47,"props":3733,"children":3734},{},[3735],{"type":25,"value":3736},"Fonts loading",{"type":25,"value":3738}," — use ",{"type":19,"tag":164,"props":3740,"children":3742},{"className":3741},[],[3743],{"type":25,"value":1709},{"type":19,"tag":43,"props":3745,"children":3746},{},[3747,3752],{"type":19,"tag":47,"props":3748,"children":3749},{},[3750],{"type":25,"value":3751},"Dynamic content",{"type":25,"value":3753}," — skeleton screens",{"type":19,"tag":979,"props":3755,"children":3757},{"id":3756},"fix-1-image-dimensions",[3758],{"type":25,"value":3759},"Fix 1: Image Dimensions",{"type":19,"tag":156,"props":3761,"children":3764},{"code":3762,"language":161,"meta":7,"className":3763},"\u003C!-- ❌ Causes CLS -->\n\u003Cimg src=\"photo.jpg\" alt=\"Photo\" \u002F>\n\n\u003C!-- ✅ No CLS -->\n\u003Cimg src=\"photo.jpg\" alt=\"Photo\" width=\"800\" height=\"600\" \u002F>\n\n\u003C!-- Or use aspect-ratio -->\n\u003Cdiv class=\"image-container\">\n  \u003Cimg src=\"photo.jpg\" alt=\"Photo\" \u002F>\n\u003C\u002Fdiv>\n\n\u003Cstyle>\n.image-container {\n  aspect-ratio: 800 \u002F 600;\n}\n\u003C\u002Fstyle>\n",[159],[3765],{"type":19,"tag":164,"props":3766,"children":3767},{"__ignoreMap":7},[3768],{"type":25,"value":3762},{"type":19,"tag":979,"props":3770,"children":3772},{"id":3771},"fix-2-font-loading",[3773],{"type":25,"value":3774},"Fix 2: Font Loading",{"type":19,"tag":156,"props":3776,"children":3779},{"code":3777,"language":609,"meta":7,"className":3778},"\u002F\u002F Use font-display: swap to show text immediately\nexport default defineNuxtConfig({\n  app: {\n    head: {\n      link: [\n        {\n          rel: 'stylesheet',\n          href: 'https:\u002F\u002Ffonts.googleapis.com\u002Fcss2?family=Inter:wght@400;500;600;700&display=swap'\n        }\n      ]\n    }\n  }\n})\n",[607],[3780],{"type":19,"tag":164,"props":3781,"children":3782},{"__ignoreMap":7},[3783],{"type":25,"value":3777},{"type":19,"tag":979,"props":3785,"children":3787},{"id":3786},"fix-3-skeleton-screens",[3788],{"type":25,"value":3789},"Fix 3: Skeleton Screens",{"type":19,"tag":156,"props":3791,"children":3794},{"code":3792,"language":161,"meta":7,"className":3793},"\u003Cscript setup>\nconst post = ref(null)\nconst loading = ref(true)\n\nonMounted(async () => {\n  post.value = await fetchPost()\n  loading.value = false\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Carticle v-if=\"loading\" class=\"post-skeleton\">\n    \u003Cdiv class=\"skeleton-title\" \u002F>\n    \u003Cdiv class=\"skeleton-text\" \u002F>\n    \u003Cdiv class=\"skeleton-text\" \u002F>\n  \u003C\u002Farticle>\n  \n  \u003Carticle v-else>\n    \u003Ch1>{{ post.title }}\u003C\u002Fh1>\n    \u003Cp>{{ post.content }}\u003C\u002Fp>\n  \u003C\u002Farticle>\n\u003C\u002Ftemplate>\n\n\u003Cstyle scoped>\n.skeleton-title {\n  width: 80%;\n  height: 2rem;\n  background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);\n  background-size: 200% 100%;\n  animation: loading 1.5s infinite;\n  border-radius: 0.25rem;\n  margin-bottom: 1rem;\n}\n\n@keyframes loading {\n  0% { background-position: 200% 0; }\n  100% { background-position: -200% 0; }\n}\n\u003C\u002Fstyle>\n",[159],[3795],{"type":19,"tag":164,"props":3796,"children":3797},{"__ignoreMap":7},[3798],{"type":25,"value":3792},{"type":19,"tag":20,"props":3800,"children":3802},{"id":3801},"real-world-results",[3803],{"type":25,"value":3804},"Real-World Results",{"type":19,"tag":28,"props":3806,"children":3807},{},[3808],{"type":25,"value":3809},"After implementing these optimizations:",{"type":19,"tag":1812,"props":3811,"children":3812},{},[3813,3831],{"type":19,"tag":1816,"props":3814,"children":3815},{},[3816],{"type":19,"tag":1820,"props":3817,"children":3818},{},[3819,3823,3827],{"type":19,"tag":1824,"props":3820,"children":3821},{},[3822],{"type":25,"value":1828},{"type":19,"tag":1824,"props":3824,"children":3825},{},[3826],{"type":25,"value":1833},{"type":19,"tag":1824,"props":3828,"children":3829},{},[3830],{"type":25,"value":1838},{"type":19,"tag":1845,"props":3832,"children":3833},{},[3834,3852,3870,3888],{"type":19,"tag":1820,"props":3835,"children":3836},{},[3837,3842,3847],{"type":19,"tag":1852,"props":3838,"children":3839},{},[3840],{"type":25,"value":3841},"LCP",{"type":19,"tag":1852,"props":3843,"children":3844},{},[3845],{"type":25,"value":3846},"3.8s",{"type":19,"tag":1852,"props":3848,"children":3849},{},[3850],{"type":25,"value":3851},"1.6s",{"type":19,"tag":1820,"props":3853,"children":3854},{},[3855,3860,3865],{"type":19,"tag":1852,"props":3856,"children":3857},{},[3858],{"type":25,"value":3859},"FID",{"type":19,"tag":1852,"props":3861,"children":3862},{},[3863],{"type":25,"value":3864},"180ms",{"type":19,"tag":1852,"props":3866,"children":3867},{},[3868],{"type":25,"value":3869},"45ms",{"type":19,"tag":1820,"props":3871,"children":3872},{},[3873,3878,3883],{"type":19,"tag":1852,"props":3874,"children":3875},{},[3876],{"type":25,"value":3877},"CLS",{"type":19,"tag":1852,"props":3879,"children":3880},{},[3881],{"type":25,"value":3882},"0.22",{"type":19,"tag":1852,"props":3884,"children":3885},{},[3886],{"type":25,"value":3887},"0.04",{"type":19,"tag":1820,"props":3889,"children":3890},{},[3891,3896,3901],{"type":19,"tag":1852,"props":3892,"children":3893},{},[3894],{"type":25,"value":3895},"PageSpeed Score",{"type":19,"tag":1852,"props":3897,"children":3898},{},[3899],{"type":25,"value":3900},"42",{"type":19,"tag":1852,"props":3902,"children":3903},{},[3904],{"type":25,"value":3905},"92",{"type":19,"tag":20,"props":3907,"children":3909},{"id":3908},"continuous-monitoring",[3910],{"type":25,"value":3911},"Continuous Monitoring",{"type":19,"tag":28,"props":3913,"children":3914},{},[3915],{"type":25,"value":3916},"Set up alerts for regressions:",{"type":19,"tag":156,"props":3918,"children":3921},{"code":3919,"language":609,"meta":7,"className":3920},"\u002F\u002F Lighthouse CI\nnpm install -D @lhci\u002Fcli\n\n# lhciconfig.json\n{\n  \"ci\": {\n    \"collect\": {\n      \"url\": [\"https:\u002F\u002Fyoursite.com\"],\n      \"numberOfRuns\": 3\n    },\n    \"assert\": {\n      \"preset\": \"lighthouse:recommended\",\n      \"assertions\": {\n        \"largest-contentful-paint\": [\"error\", { \"maxNumericValue\": 2500 }],\n        \"first-input-delay\": [\"error\", { \"maxNumericValue\": 100 }],\n        \"cumulative-layout-shift\": [\"error\", { \"maxNumericValue\": 0.1 }]\n      }\n    }\n  }\n}\n",[607],[3922],{"type":19,"tag":164,"props":3923,"children":3924},{"__ignoreMap":7},[3925],{"type":25,"value":3919},{"type":19,"tag":28,"props":3927,"children":3928},{},[3929],{"type":25,"value":3930},"Run in CI:",{"type":19,"tag":156,"props":3932,"children":3935},{"code":3933,"language":598,"meta":7,"className":3934},"# .github\u002Fworkflows\u002Flighthouse.yml\n- name: Run Lighthouse CI\n  run: lhci autorun\n",[596],[3936],{"type":19,"tag":164,"props":3937,"children":3938},{"__ignoreMap":7},[3939],{"type":25,"value":3933},{"type":19,"tag":20,"props":3941,"children":3942},{"id":1931},[3943],{"type":25,"value":1934},{"type":19,"tag":1048,"props":3945,"children":3946},{},[3947,3957,3967,3977,3987],{"type":19,"tag":43,"props":3948,"children":3949},{},[3950,3955],{"type":19,"tag":47,"props":3951,"children":3952},{},[3953],{"type":25,"value":3954},"Measure real user data",{"type":25,"value":3956}," — lab data differs from production",{"type":19,"tag":43,"props":3958,"children":3959},{},[3960,3965],{"type":19,"tag":47,"props":3961,"children":3962},{},[3963],{"type":25,"value":3964},"LCP is usually the bottleneck",{"type":25,"value":3966}," — focus optimization efforts there",{"type":19,"tag":43,"props":3968,"children":3969},{},[3970,3975],{"type":19,"tag":47,"props":3971,"children":3972},{},[3973],{"type":25,"value":3974},"Images matter most",{"type":25,"value":3976}," — proper optimization yields biggest gains",{"type":19,"tag":43,"props":3978,"children":3979},{},[3980,3985],{"type":19,"tag":47,"props":3981,"children":3982},{},[3983],{"type":25,"value":3984},"Preload critical resources",{"type":25,"value":3986}," — hero images and fonts",{"type":19,"tag":43,"props":3988,"children":3989},{},[3990,3995],{"type":19,"tag":47,"props":3991,"children":3992},{},[3993],{"type":25,"value":3994},"Monitor continuously",{"type":25,"value":3996}," — regressions happen. Catch them early",{"type":19,"tag":28,"props":3998,"children":3999},{},[4000],{"type":25,"value":4001},"Core Web Vitals are no longer just SEO metrics—they're user experience fundamentals. Invest in getting them right.",{"title":7,"searchDepth":869,"depth":869,"links":4003},[4004,4005,4009,4015,4020,4025,4026,4027],{"id":3371,"depth":869,"text":3374},{"id":3459,"depth":869,"text":3462,"children":4006},[4007,4008],{"id":3465,"depth":1425,"text":3468},{"id":3484,"depth":1425,"text":3487},{"id":3532,"depth":869,"text":3535,"children":4010},[4011,4012,4013,4014],{"id":3543,"depth":1425,"text":3546},{"id":3586,"depth":1425,"text":3589},{"id":3601,"depth":1425,"text":3604},{"id":3621,"depth":1425,"text":3624},{"id":3641,"depth":869,"text":3644,"children":4016},[4017,4018,4019],{"id":3652,"depth":1425,"text":3655},{"id":3667,"depth":1425,"text":3670},{"id":3682,"depth":1425,"text":3685},{"id":3697,"depth":869,"text":3700,"children":4021},[4022,4023,4024],{"id":3756,"depth":1425,"text":3759},{"id":3771,"depth":1425,"text":3774},{"id":3786,"depth":1425,"text":3789},{"id":3801,"depth":869,"text":3804},{"id":3908,"depth":869,"text":3911},{"id":1931,"depth":869,"text":1934},"content:blog:core-web-vitals-nuxt-guide.md","blog\u002Fcore-web-vitals-nuxt-guide.md","blog\u002Fcore-web-vitals-nuxt-guide",{"_path":4032,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":4033,"description":4034,"date":4035,"updated":894,"readingTime":895,"tags":4036,"featured":6,"body":4039,"_type":882,"_id":4459,"_source":884,"_file":4460,"_stem":4461,"_extension":887},"\u002Fblog\u002Fadvanced-frontend-ci-cd-workflows","Advanced Frontend CI\u002FCD Workflows: From Code to Production in 5 Minutes","Build automated CI\u002FCD pipelines for frontend apps. Learn GitHub Actions, parallel testing, performance monitoring, semantic versioning, and production deployments.","2024-06-10",[4037,4038],"CI\u002FCD","DevOps",{"type":16,"children":4040,"toc":4443},[4041,4047,4052,4057,4070,4075,4080,4133,4139,4147,4153,4164,4170,4176,4181,4190,4195,4201,4210,4223,4229,4238,4243,4249,4258,4263,4291,4296,4302,4311,4316,4322,4331,4337,4342,4353,4359,4364,4369,4374,4379,4385,4438],{"type":19,"tag":20,"props":4042,"children":4044},{"id":4043},"the-state-of-frontend-cicd",[4045],{"type":25,"value":4046},"The State of Frontend CI\u002FCD",{"type":19,"tag":28,"props":4048,"children":4049},{},[4050],{"type":25,"value":4051},"The \"5 minutes\" in the title is the target this guide builds toward — a design goal for the pipeline, not a production stat I'm claiming. My own verified result is more modest and stated at the end.",{"type":19,"tag":28,"props":4053,"children":4054},{},[4055],{"type":25,"value":4056},"Many teams still use basic CI pipelines:",{"type":19,"tag":1048,"props":4058,"children":4059},{},[4060,4065],{"type":19,"tag":43,"props":4061,"children":4062},{},[4063],{"type":25,"value":4064},"Run tests",{"type":19,"tag":43,"props":4066,"children":4067},{},[4068],{"type":25,"value":4069},"If pass, deploy",{"type":19,"tag":28,"props":4071,"children":4072},{},[4073],{"type":25,"value":4074},"Result: slow feedback, missed bugs, brittle deployments.",{"type":19,"tag":28,"props":4076,"children":4077},{},[4078],{"type":25,"value":4079},"Modern CI\u002FCD is sophisticated:",{"type":19,"tag":1048,"props":4081,"children":4082},{},[4083,4093,4103,4113,4123],{"type":19,"tag":43,"props":4084,"children":4085},{},[4086,4091],{"type":19,"tag":47,"props":4087,"children":4088},{},[4089],{"type":25,"value":4090},"Parallel testing",{"type":25,"value":4092}," — unit, integration, e2e at the same time",{"type":19,"tag":43,"props":4094,"children":4095},{},[4096,4101],{"type":19,"tag":47,"props":4097,"children":4098},{},[4099],{"type":25,"value":4100},"Performance monitoring",{"type":25,"value":4102}," — reject PRs that slow down the app",{"type":19,"tag":43,"props":4104,"children":4105},{},[4106,4111],{"type":19,"tag":47,"props":4107,"children":4108},{},[4109],{"type":25,"value":4110},"Visual regression testing",{"type":25,"value":4112}," — catch unintended design changes",{"type":19,"tag":43,"props":4114,"children":4115},{},[4116,4121],{"type":19,"tag":47,"props":4117,"children":4118},{},[4119],{"type":25,"value":4120},"Semantic versioning",{"type":25,"value":4122}," — automate version bumps and releases",{"type":19,"tag":43,"props":4124,"children":4125},{},[4126,4131],{"type":19,"tag":47,"props":4127,"children":4128},{},[4129],{"type":25,"value":4130},"Multi-stage deployments",{"type":25,"value":4132}," — preview → staging → production",{"type":19,"tag":20,"props":4134,"children":4136},{"id":4135},"the-full-pipeline",[4137],{"type":25,"value":4138},"The Full Pipeline",{"type":19,"tag":156,"props":4140,"children":4142},{"code":4141},"Code Push\n  ↓\n├─→ Lint & Format\n├─→ Unit Tests (parallel)\n├─→ Integration Tests (parallel)\n├─→ Build Artifact\n├─→ Visual Regression Tests\n├─→ Lighthouse Performance Check\n├─→ Security Scanning\n├─→ Deploy Preview (for PRs)\n└─→ Deploy Production (on main)\n",[4143],{"type":19,"tag":164,"props":4144,"children":4145},{"__ignoreMap":7},[4146],{"type":25,"value":4141},{"type":19,"tag":20,"props":4148,"children":4150},{"id":4149},"github-actions-workflow",[4151],{"type":25,"value":4152},"GitHub Actions Workflow",{"type":19,"tag":156,"props":4154,"children":4159},{"code":4155,"language":4156,"meta":7,"className":4157},"name: Frontend CI\u002FCD\n\non:\n  push:\n    branches: [main, develop]\n  pull_request:\n    branches: [main, develop]\n\nenv:\n  NODE_VERSION: '18'\n  PNPM_VERSION: '8'\n\njobs:\n  # Setup\n  setup:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v3\n      \n      - uses: pnpm\u002Faction-setup@v2\n        with:\n          version: ${{ env.PNPM_VERSION }}\n      \n      - uses: actions\u002Fsetup-node@v3\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: 'pnpm'\n      \n      - run: pnpm install --frozen-lockfile\n  \n  # Lint\n  lint:\n    needs: setup\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v3\n      - uses: pnpm\u002Faction-setup@v2\n      - uses: actions\u002Fsetup-node@v3\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: 'pnpm'\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm lint\n  \n  # Unit Tests (Parallel)\n  test-unit:\n    needs: setup\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        shard: [1, 2, 3, 4]\n    steps:\n      - uses: actions\u002Fcheckout@v3\n      - uses: pnpm\u002Faction-setup@v2\n      - uses: actions\u002Fsetup-node@v3\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n          cache: 'pnpm'\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm test:unit -- --shard=${{ matrix.shard }}\u002F4\n  \n  # Integration Tests\n  test-integration:\n    needs: setup\n    runs-on: ubuntu-latest\n    services:\n      postgres:\n        image: postgres:15\n        env:\n          POSTGRES_PASSWORD: postgres\n        options: >-\n          --health-cmd pg_isready\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n        ports:\n          - 5432:5432\n    steps:\n      - uses: actions\u002Fcheckout@v3\n      - uses: pnpm\u002Faction-setup@v2\n      - uses: actions\u002Fsetup-node@v3\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm test:integration\n        env:\n          DATABASE_URL: postgresql:\u002F\u002Fpostgres:postgres@localhost:5432\u002Ftest\n  \n  # Build\n  build:\n    needs: setup\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v3\n      - uses: pnpm\u002Faction-setup@v2\n      - uses: actions\u002Fsetup-node@v3\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm build\n      - uses: actions\u002Fupload-artifact@v3\n        with:\n          name: build-artifact\n          path: dist\n  \n  # Visual Regression Tests\n  visual-regression:\n    needs: build\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v3\n      - uses: pnpm\u002Faction-setup@v2\n      - uses: actions\u002Fsetup-node@v3\n      - run: pnpm install --frozen-lockfile\n      \n      - uses: actions\u002Fdownload-artifact@v3\n        with:\n          name: build-artifact\n          path: dist\n      \n      - run: pnpm dlx playwright install --with-deps\n      - run: pnpm test:visual\n      \n      - uses: actions\u002Fupload-artifact@v3\n        if: failure()\n        with:\n          name: visual-regression-report\n          path: test-results\u002F\n  \n  # Performance Check\n  lighthouse:\n    needs: build\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v3\n      - uses: pnpm\u002Faction-setup@v2\n      - uses: actions\u002Fsetup-node@v3\n      - run: pnpm install --frozen-lockfile\n      \n      - uses: actions\u002Fdownload-artifact@v3\n        with:\n          name: build-artifact\n          path: dist\n      \n      - uses: treosh\u002Flighthouse-ci-action@v9\n        with:\n          uploadArtifacts: true\n          configPath: '.\u002Flighthouserc.json'\n  \n  # Security Scanning\n  security:\n    needs: setup\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v3\n      \n      # npm audit\n      - run: npm audit --audit-level=moderate\n      \n      # Snyk scanning\n      - uses: snyk\u002Factions\u002Fsetup@master\n      - run: snyk test --severity-threshold=high\n        env:\n          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}\n      \n      # OWASP dependency check\n      - uses: dependency-check\u002FDependency-Check_Action@main\n        with:\n          project: 'Frontend'\n          path: '.'\n          format: 'SARIF'\n          args: >\n            --enableExperimental\n            --enableRetired\n  \n  # Deploy Preview (on PR)\n  deploy-preview:\n    needs: [lint, test-unit, build, lighthouse]\n    if: github.event_name == 'pull_request'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v3\n      - uses: actions\u002Fdownload-artifact@v3\n        with:\n          name: build-artifact\n          path: dist\n      \n      - uses: netlify\u002Factions\u002Fcli@master\n        with:\n          args: deploy --dir=dist\n        env:\n          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}\n          NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}\n      \n      - uses: actions\u002Fgithub-script@v6\n        with:\n          script: |\n            github.rest.issues.createComment({\n              issue_number: context.issue.number,\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              body: `Preview deployed: [View](https:\u002F\u002Fdeploy-preview-${{ github.event.number }}.netlify.app)`\n            })\n  \n  # Deploy Production (on main)\n  deploy-production:\n    needs: [lint, test-unit, build, lighthouse, security]\n    if: github.ref == 'refs\u002Fheads\u002Fmain' && github.event_name == 'push'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v3\n        with:\n          fetch-depth: 0\n      \n      - uses: actions\u002Fdownload-artifact@v3\n        with:\n          name: build-artifact\n          path: dist\n      \n      # Semantic release\n      - uses: pnpm\u002Faction-setup@v2\n      - uses: actions\u002Fsetup-node@v3\n      - run: pnpm install --frozen-lockfile\n      \n      - uses: cycjimmy\u002Fsemantic-release-action@v3\n        id: semantic\n        with:\n          branches: |\n            [\n              '+([0-9])?(.{+([0-9]),x}).x',\n              'main',\n              'develop',\n              {\n                name: 'next',\n                prerelease: true\n              }\n            ]\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n      \n      # Deploy to Vercel\n      - uses: amondnet\u002Fvercel-action@v20\n        if: steps.semantic.outputs.new_release_published == 'true'\n        with:\n          vercel-token: ${{ secrets.VERCEL_TOKEN }}\n          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}\n          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}\n          working-directory: .\u002Fdist\n      \n      # Notify Slack\n      - uses: slackapi\u002Fslack-github-action@v1.24.0\n        with:\n          payload: |\n            {\n              \"text\": \"✅ Production deployment successful\",\n              \"blocks\": [\n                {\n                  \"type\": \"section\",\n                  \"text\": {\n                    \"type\": \"mrkdwn\",\n                    \"text\": \"*Production Deployed*\\n*Version:* ${{ steps.semantic.outputs.new_release_version }}\\n*Commit:* ${{ github.sha }}\"\n                  }\n                }\n              ]\n            }\n        env:\n          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}\n","yaml",[4158],"language-yaml",[4160],{"type":19,"tag":164,"props":4161,"children":4162},{"__ignoreMap":7},[4163],{"type":25,"value":4155},{"type":19,"tag":20,"props":4165,"children":4167},{"id":4166},"key-features-explained",[4168],{"type":25,"value":4169},"Key Features Explained",{"type":19,"tag":979,"props":4171,"children":4173},{"id":4172},"_1-parallel-job-execution",[4174],{"type":25,"value":4175},"1. Parallel Job Execution",{"type":19,"tag":28,"props":4177,"children":4178},{},[4179],{"type":25,"value":4180},"Tests run simultaneously instead of sequentially:",{"type":19,"tag":156,"props":4182,"children":4185},{"code":4183,"language":4156,"meta":7,"className":4184},"strategy:\n  matrix:\n    shard: [1, 2, 3, 4]\nrun: pnpm test -- --shard=${{ matrix.shard }}\u002F4\n",[4158],[4186],{"type":19,"tag":164,"props":4187,"children":4188},{"__ignoreMap":7},[4189],{"type":25,"value":4183},{"type":19,"tag":28,"props":4191,"children":4192},{},[4193],{"type":25,"value":4194},"Reduces CI time from 12 minutes → 4 minutes.",{"type":19,"tag":979,"props":4196,"children":4198},{"id":4197},"_2-dependency-caching",[4199],{"type":25,"value":4200},"2. Dependency Caching",{"type":19,"tag":156,"props":4202,"children":4205},{"code":4203,"language":4156,"meta":7,"className":4204},"- uses: actions\u002Fsetup-node@v3\n  with:\n    node-version: ${{ env.NODE_VERSION }}\n    cache: 'pnpm'\n",[4158],[4206],{"type":19,"tag":164,"props":4207,"children":4208},{"__ignoreMap":7},[4209],{"type":25,"value":4203},{"type":19,"tag":28,"props":4211,"children":4212},{},[4213,4215,4221],{"type":25,"value":4214},"Skips ",{"type":19,"tag":164,"props":4216,"children":4218},{"className":4217},[],[4219],{"type":25,"value":4220},"pnpm install",{"type":25,"value":4222}," if dependencies haven't changed. Saves 1-2 minutes per run.",{"type":19,"tag":979,"props":4224,"children":4226},{"id":4225},"_3-conditional-deployment",[4227],{"type":25,"value":4228},"3. Conditional Deployment",{"type":19,"tag":156,"props":4230,"children":4233},{"code":4231,"language":4156,"meta":7,"className":4232},"if: github.ref == 'refs\u002Fheads\u002Fmain' && github.event_name == 'push'\n",[4158],[4234],{"type":19,"tag":164,"props":4235,"children":4236},{"__ignoreMap":7},[4237],{"type":25,"value":4231},{"type":19,"tag":28,"props":4239,"children":4240},{},[4241],{"type":25,"value":4242},"Only deploy to production on main branch pushes. PRs get preview deployments.",{"type":19,"tag":979,"props":4244,"children":4246},{"id":4245},"_4-semantic-versioning",[4247],{"type":25,"value":4248},"4. Semantic Versioning",{"type":19,"tag":156,"props":4250,"children":4253},{"code":4251,"language":4156,"meta":7,"className":4252},"- uses: cycjimmy\u002Fsemantic-release-action@v3\n",[4158],[4254],{"type":19,"tag":164,"props":4255,"children":4256},{"__ignoreMap":7},[4257],{"type":25,"value":4251},{"type":19,"tag":28,"props":4259,"children":4260},{},[4261],{"type":25,"value":4262},"Automatically:",{"type":19,"tag":39,"props":4264,"children":4265},{},[4266,4271,4276,4281,4286],{"type":19,"tag":43,"props":4267,"children":4268},{},[4269],{"type":25,"value":4270},"Analyzes commits (feat → minor, fix → patch)",{"type":19,"tag":43,"props":4272,"children":4273},{},[4274],{"type":25,"value":4275},"Bumps version in package.json",{"type":19,"tag":43,"props":4277,"children":4278},{},[4279],{"type":25,"value":4280},"Creates GitHub release",{"type":19,"tag":43,"props":4282,"children":4283},{},[4284],{"type":25,"value":4285},"Publishes to npm",{"type":19,"tag":43,"props":4287,"children":4288},{},[4289],{"type":25,"value":4290},"Generates changelog",{"type":19,"tag":28,"props":4292,"children":4293},{},[4294],{"type":25,"value":4295},"One less manual step.",{"type":19,"tag":979,"props":4297,"children":4299},{"id":4298},"_5-performance-monitoring",[4300],{"type":25,"value":4301},"5. Performance Monitoring",{"type":19,"tag":156,"props":4303,"children":4306},{"code":4304,"language":4156,"meta":7,"className":4305},"- uses: treosh\u002Flighthouse-ci-action@v9\n",[4158],[4307],{"type":19,"tag":164,"props":4308,"children":4309},{"__ignoreMap":7},[4310],{"type":25,"value":4304},{"type":19,"tag":28,"props":4312,"children":4313},{},[4314],{"type":25,"value":4315},"Blocks deployment if performance degrades. Ensures Lighthouse score stays above threshold.",{"type":19,"tag":20,"props":4317,"children":4319},{"id":4318},"caching-strategy",[4320],{"type":25,"value":4321},"Caching Strategy",{"type":19,"tag":156,"props":4323,"children":4326},{"code":4324,"language":4156,"meta":7,"className":4325},"# .github\u002Fworkflows\u002Fci.yml\ncache-strategy:\n  - cache: node_modules\n    key: ${{ runner.os }}-pnpm-${{ hashFiles('**\u002Fpnpm-lock.yaml') }}\n    restore-keys: ${{ runner.os }}-pnpm-\n  \n  - cache: .next\n    key: ${{ runner.os }}-next-${{ hashFiles('**\u002Fpackage-lock.json') }}\n    restore-keys: ${{ runner.os }}-next-\n  \n  - cache: .turbo\n    key: ${{ runner.os }}-turbo-${{ hashFiles('**\u002Fpnpm-lock.yaml') }}\n    restore-keys: ${{ runner.os }}-turbo-\n",[4158],[4327],{"type":19,"tag":164,"props":4328,"children":4329},{"__ignoreMap":7},[4330],{"type":25,"value":4324},{"type":19,"tag":20,"props":4332,"children":4334},{"id":4333},"results",[4335],{"type":25,"value":4336},"Results",{"type":19,"tag":28,"props":4338,"children":4339},{},[4340],{"type":25,"value":4341},"What a pipeline like this typically changes (illustrative, not measured claims): CI time drops from tens of minutes to single digits, deploys go from a weekly event to a routine one, and security scanning stops depending on someone remembering to run it.",{"type":19,"tag":28,"props":4343,"children":4344},{},[4345,4347,4352],{"type":25,"value":4346},"My verified production result from building this kind of pipeline at Ordant: ",{"type":19,"tag":47,"props":4348,"children":4349},{},[4350],{"type":25,"value":4351},"merge-to-deploy time down 40%, with 99.9% uptime",{"type":25,"value":2070},{"type":19,"tag":20,"props":4354,"children":4356},{"id":4355},"cost-optimization",[4357],{"type":25,"value":4358},"Cost Optimization",{"type":19,"tag":28,"props":4360,"children":4361},{},[4362],{"type":25,"value":4363},"Worked example at GitHub Actions list pricing ($0.008\u002Fminute per job):",{"type":19,"tag":28,"props":4365,"children":4366},{},[4367],{"type":25,"value":4368},"A 5-minute pipeline with 6 parallel jobs = 30 job-minutes = $0.24\u002Frun.",{"type":19,"tag":28,"props":4370,"children":4371},{},[4372],{"type":25,"value":4373},"At 30 runs\u002Fday, that's $7.20\u002Fday ≈ $216\u002Fmonth.",{"type":19,"tag":28,"props":4375,"children":4376},{},[4377],{"type":25,"value":4378},"Worth it for the bugs prevented and deployment speed gained.",{"type":19,"tag":20,"props":4380,"children":4382},{"id":4381},"pro-tips",[4383],{"type":25,"value":4384},"Pro Tips",{"type":19,"tag":1048,"props":4386,"children":4387},{},[4388,4398,4408,4418,4428],{"type":19,"tag":43,"props":4389,"children":4390},{},[4391,4396],{"type":19,"tag":47,"props":4392,"children":4393},{},[4394],{"type":25,"value":4395},"Cache aggressively",{"type":25,"value":4397}," — saves 1-3 minutes per run",{"type":19,"tag":43,"props":4399,"children":4400},{},[4401,4406],{"type":19,"tag":47,"props":4402,"children":4403},{},[4404],{"type":25,"value":4405},"Run tests in parallel",{"type":25,"value":4407}," — hardware is cheaper than time",{"type":19,"tag":43,"props":4409,"children":4410},{},[4411,4416],{"type":19,"tag":47,"props":4412,"children":4413},{},[4414],{"type":25,"value":4415},"Fail fast",{"type":25,"value":4417}," — lint before tests, tests before build",{"type":19,"tag":43,"props":4419,"children":4420},{},[4421,4426],{"type":19,"tag":47,"props":4422,"children":4423},{},[4424],{"type":25,"value":4425},"Monitor performance",{"type":25,"value":4427}," — performance regression is a deployment blocker",{"type":19,"tag":43,"props":4429,"children":4430},{},[4431,4436],{"type":19,"tag":47,"props":4432,"children":4433},{},[4434],{"type":25,"value":4435},"Automate versioning",{"type":25,"value":4437}," — semantic-release removes guesswork",{"type":19,"tag":28,"props":4439,"children":4440},{},[4441],{"type":25,"value":4442},"Good CI\u002FCD is a force multiplier. Invest in it early.",{"title":7,"searchDepth":869,"depth":869,"links":4444},[4445,4446,4447,4448,4455,4456,4457,4458],{"id":4043,"depth":869,"text":4046},{"id":4135,"depth":869,"text":4138},{"id":4149,"depth":869,"text":4152},{"id":4166,"depth":869,"text":4169,"children":4449},[4450,4451,4452,4453,4454],{"id":4172,"depth":1425,"text":4175},{"id":4197,"depth":1425,"text":4200},{"id":4225,"depth":1425,"text":4228},{"id":4245,"depth":1425,"text":4248},{"id":4298,"depth":1425,"text":4301},{"id":4318,"depth":869,"text":4321},{"id":4333,"depth":869,"text":4336},{"id":4355,"depth":869,"text":4358},{"id":4381,"depth":869,"text":4384},"content:blog:advanced-frontend-ci-cd-workflows.md","blog\u002Fadvanced-frontend-ci-cd-workflows.md","blog\u002Fadvanced-frontend-ci-cd-workflows",{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"date":10,"readingTime":11,"tags":4463,"featured":6,"body":4464,"_type":882,"_id":883,"_source":884,"_file":885,"_stem":886,"_extension":887},[13,14],{"type":16,"children":4465,"toc":5152},[4466,4470,4474,4478,4513,4517,4521,4525,4552,4561,4565,4569,4577,4581,4676,4680,4684,4692,4696,4762,4766,4770,4778,4782,4786,4794,4798,4813,4817,4821,4829,4833,4877,4881,4889,4893,4917,4921,4925,4933,4941,4945,4953,4957,4961,5121,5125,5129,5148],{"type":19,"tag":20,"props":4467,"children":4468},{"id":22},[4469],{"type":25,"value":26},{"type":19,"tag":28,"props":4471,"children":4472},{},[4473],{"type":25,"value":32},{"type":19,"tag":28,"props":4475,"children":4476},{},[4477],{"type":25,"value":37},{"type":19,"tag":39,"props":4479,"children":4480},{},[4481,4489,4497,4505],{"type":19,"tag":43,"props":4482,"children":4483},{},[4484,4488],{"type":19,"tag":47,"props":4485,"children":4486},{},[4487],{"type":25,"value":51},{"type":25,"value":53},{"type":19,"tag":43,"props":4490,"children":4491},{},[4492,4496],{"type":19,"tag":47,"props":4493,"children":4494},{},[4495],{"type":25,"value":61},{"type":25,"value":63},{"type":19,"tag":43,"props":4498,"children":4499},{},[4500,4504],{"type":19,"tag":47,"props":4501,"children":4502},{},[4503],{"type":25,"value":71},{"type":25,"value":73},{"type":19,"tag":43,"props":4506,"children":4507},{},[4508,4512],{"type":19,"tag":47,"props":4509,"children":4510},{},[4511],{"type":25,"value":81},{"type":25,"value":83},{"type":19,"tag":28,"props":4514,"children":4515},{},[4516],{"type":25,"value":88},{"type":19,"tag":20,"props":4518,"children":4519},{"id":91},[4520],{"type":25,"value":94},{"type":19,"tag":28,"props":4522,"children":4523},{},[4524],{"type":25,"value":99},{"type":19,"tag":39,"props":4526,"children":4527},{},[4528,4536,4544],{"type":19,"tag":43,"props":4529,"children":4530},{},[4531,4535],{"type":19,"tag":47,"props":4532,"children":4533},{},[4534],{"type":25,"value":110},{"type":25,"value":112},{"type":19,"tag":43,"props":4537,"children":4538},{},[4539,4543],{"type":19,"tag":47,"props":4540,"children":4541},{},[4542],{"type":25,"value":120},{"type":25,"value":122},{"type":19,"tag":43,"props":4545,"children":4546},{},[4547,4551],{"type":19,"tag":47,"props":4548,"children":4549},{},[4550],{"type":25,"value":130},{"type":25,"value":132},{"type":19,"tag":28,"props":4553,"children":4554},{},[4555,4556,4560],{"type":25,"value":137},{"type":19,"tag":47,"props":4557,"children":4558},{},[4559],{"type":25,"value":120},{"type":25,"value":143},{"type":19,"tag":20,"props":4562,"children":4563},{"id":146},[4564],{"type":25,"value":149},{"type":19,"tag":28,"props":4566,"children":4567},{},[4568],{"type":25,"value":154},{"type":19,"tag":156,"props":4570,"children":4572},{"className":4571,"code":160,"language":161,"meta":7},[159],[4573],{"type":19,"tag":164,"props":4574,"children":4575},{"__ignoreMap":7},[4576],{"type":25,"value":160},{"type":19,"tag":28,"props":4578,"children":4579},{},[4580],{"type":25,"value":172},{"type":19,"tag":39,"props":4582,"children":4583},{},[4584,4622,4637,4646,4655],{"type":19,"tag":43,"props":4585,"children":4586},{},[4587,4592,4593,4598,4599,4604,4605,4610,4611,4616,4617],{"type":19,"tag":164,"props":4588,"children":4590},{"className":4589},[],[4591],{"type":25,"value":184},{"type":25,"value":186},{"type":19,"tag":164,"props":4594,"children":4596},{"className":4595},[],[4597],{"type":25,"value":192},{"type":25,"value":186},{"type":19,"tag":164,"props":4600,"children":4602},{"className":4601},[],[4603],{"type":25,"value":199},{"type":25,"value":186},{"type":19,"tag":164,"props":4606,"children":4608},{"className":4607},[],[4609],{"type":25,"value":206},{"type":25,"value":186},{"type":19,"tag":164,"props":4612,"children":4614},{"className":4613},[],[4615],{"type":25,"value":213},{"type":25,"value":186},{"type":19,"tag":164,"props":4618,"children":4620},{"className":4619},[],[4621],{"type":25,"value":220},{"type":19,"tag":43,"props":4623,"children":4624},{},[4625,4630,4631,4636],{"type":19,"tag":164,"props":4626,"children":4628},{"className":4627},[],[4629],{"type":25,"value":229},{"type":25,"value":231},{"type":19,"tag":164,"props":4632,"children":4634},{"className":4633},[],[4635],{"type":25,"value":237},{"type":25,"value":239},{"type":19,"tag":43,"props":4638,"children":4639},{},[4640,4645],{"type":19,"tag":164,"props":4641,"children":4643},{"className":4642},[],[4644],{"type":25,"value":248},{"type":25,"value":250},{"type":19,"tag":43,"props":4647,"children":4648},{},[4649,4654],{"type":19,"tag":164,"props":4650,"children":4652},{"className":4651},[],[4653],{"type":25,"value":259},{"type":25,"value":261},{"type":19,"tag":43,"props":4656,"children":4657},{},[4658,4663,4664,4669,4670,4675],{"type":19,"tag":164,"props":4659,"children":4661},{"className":4660},[],[4662],{"type":25,"value":270},{"type":25,"value":272},{"type":19,"tag":164,"props":4665,"children":4667},{"className":4666},[],[4668],{"type":25,"value":278},{"type":25,"value":186},{"type":19,"tag":164,"props":4671,"children":4673},{"className":4672},[],[4674],{"type":25,"value":285},{"type":25,"value":287},{"type":19,"tag":20,"props":4677,"children":4678},{"id":290},[4679],{"type":25,"value":293},{"type":19,"tag":28,"props":4681,"children":4682},{},[4683],{"type":25,"value":298},{"type":19,"tag":156,"props":4685,"children":4687},{"className":4686,"code":302,"language":161,"meta":7},[159],[4688],{"type":19,"tag":164,"props":4689,"children":4690},{"__ignoreMap":7},[4691],{"type":25,"value":302},{"type":19,"tag":28,"props":4693,"children":4694},{},[4695],{"type":25,"value":312},{"type":19,"tag":39,"props":4697,"children":4698},{},[4699,4708,4717,4726,4735,4744,4753],{"type":19,"tag":43,"props":4700,"children":4701},{},[4702,4707],{"type":19,"tag":164,"props":4703,"children":4705},{"className":4704},[],[4706],{"type":25,"value":324},{"type":25,"value":326},{"type":19,"tag":43,"props":4709,"children":4710},{},[4711,4716],{"type":19,"tag":164,"props":4712,"children":4714},{"className":4713},[],[4715],{"type":25,"value":335},{"type":25,"value":337},{"type":19,"tag":43,"props":4718,"children":4719},{},[4720,4725],{"type":19,"tag":164,"props":4721,"children":4723},{"className":4722},[],[4724],{"type":25,"value":346},{"type":25,"value":348},{"type":19,"tag":43,"props":4727,"children":4728},{},[4729,4734],{"type":19,"tag":164,"props":4730,"children":4732},{"className":4731},[],[4733],{"type":25,"value":357},{"type":25,"value":359},{"type":19,"tag":43,"props":4736,"children":4737},{},[4738,4743],{"type":19,"tag":164,"props":4739,"children":4741},{"className":4740},[],[4742],{"type":25,"value":368},{"type":25,"value":370},{"type":19,"tag":43,"props":4745,"children":4746},{},[4747,4752],{"type":19,"tag":164,"props":4748,"children":4750},{"className":4749},[],[4751],{"type":25,"value":379},{"type":25,"value":381},{"type":19,"tag":43,"props":4754,"children":4755},{},[4756,4761],{"type":19,"tag":164,"props":4757,"children":4759},{"className":4758},[],[4760],{"type":25,"value":390},{"type":25,"value":392},{"type":19,"tag":20,"props":4763,"children":4764},{"id":395},[4765],{"type":25,"value":398},{"type":19,"tag":28,"props":4767,"children":4768},{},[4769],{"type":25,"value":403},{"type":19,"tag":156,"props":4771,"children":4773},{"className":4772,"code":407,"language":161,"meta":7},[159],[4774],{"type":19,"tag":164,"props":4775,"children":4776},{"__ignoreMap":7},[4777],{"type":25,"value":407},{"type":19,"tag":20,"props":4779,"children":4780},{"id":415},[4781],{"type":25,"value":418},{"type":19,"tag":28,"props":4783,"children":4784},{},[4785],{"type":25,"value":423},{"type":19,"tag":156,"props":4787,"children":4789},{"className":4788,"code":427,"language":161,"meta":7},[159],[4790],{"type":19,"tag":164,"props":4791,"children":4792},{"__ignoreMap":7},[4793],{"type":25,"value":427},{"type":19,"tag":28,"props":4795,"children":4796},{},[4797],{"type":25,"value":437},{"type":19,"tag":39,"props":4799,"children":4800},{},[4801,4805,4809],{"type":19,"tag":43,"props":4802,"children":4803},{},[4804],{"type":25,"value":445},{"type":19,"tag":43,"props":4806,"children":4807},{},[4808],{"type":25,"value":450},{"type":19,"tag":43,"props":4810,"children":4811},{},[4812],{"type":25,"value":455},{"type":19,"tag":20,"props":4814,"children":4815},{"id":458},[4816],{"type":25,"value":461},{"type":19,"tag":28,"props":4818,"children":4819},{},[4820],{"type":25,"value":466},{"type":19,"tag":156,"props":4822,"children":4824},{"className":4823,"code":470,"language":161,"meta":7},[159],[4825],{"type":19,"tag":164,"props":4826,"children":4827},{"__ignoreMap":7},[4828],{"type":25,"value":470},{"type":19,"tag":28,"props":4830,"children":4831},{},[4832],{"type":25,"value":480},{"type":19,"tag":39,"props":4834,"children":4835},{},[4836,4845,4854,4863,4867],{"type":19,"tag":43,"props":4837,"children":4838},{},[4839,4840],{"type":25,"value":488},{"type":19,"tag":164,"props":4841,"children":4843},{"className":4842},[],[4844],{"type":25,"value":259},{"type":19,"tag":43,"props":4846,"children":4847},{},[4848,4849],{"type":25,"value":498},{"type":19,"tag":164,"props":4850,"children":4852},{"className":4851},[],[4853],{"type":25,"value":504},{"type":19,"tag":43,"props":4855,"children":4856},{},[4857,4858],{"type":25,"value":509},{"type":19,"tag":164,"props":4859,"children":4861},{"className":4860},[],[4862],{"type":25,"value":346},{"type":19,"tag":43,"props":4864,"children":4865},{},[4866],{"type":25,"value":519},{"type":19,"tag":43,"props":4868,"children":4869},{},[4870,4871,4876],{"type":25,"value":524},{"type":19,"tag":164,"props":4872,"children":4874},{"className":4873},[],[4875],{"type":25,"value":530},{"type":25,"value":532},{"type":19,"tag":20,"props":4878,"children":4879},{"id":535},[4880],{"type":25,"value":538},{"type":19,"tag":156,"props":4882,"children":4884},{"className":4883,"code":542,"language":161,"meta":7},[159],[4885],{"type":19,"tag":164,"props":4886,"children":4887},{"__ignoreMap":7},[4888],{"type":25,"value":542},{"type":19,"tag":28,"props":4890,"children":4891},{},[4892],{"type":25,"value":552},{"type":19,"tag":39,"props":4894,"children":4895},{},[4896,4900,4909,4913],{"type":19,"tag":43,"props":4897,"children":4898},{},[4899],{"type":25,"value":560},{"type":19,"tag":43,"props":4901,"children":4902},{},[4903,4904],{"type":25,"value":565},{"type":19,"tag":164,"props":4905,"children":4907},{"className":4906},[],[4908],{"type":25,"value":571},{"type":19,"tag":43,"props":4910,"children":4911},{},[4912],{"type":25,"value":576},{"type":19,"tag":43,"props":4914,"children":4915},{},[4916],{"type":25,"value":581},{"type":19,"tag":20,"props":4918,"children":4919},{"id":584},[4920],{"type":25,"value":587},{"type":19,"tag":28,"props":4922,"children":4923},{},[4924],{"type":25,"value":592},{"type":19,"tag":156,"props":4926,"children":4928},{"className":4927,"code":597,"language":598,"meta":7},[596],[4929],{"type":19,"tag":164,"props":4930,"children":4931},{"__ignoreMap":7},[4932],{"type":25,"value":597},{"type":19,"tag":156,"props":4934,"children":4936},{"className":4935,"code":608,"language":609,"meta":7},[607],[4937],{"type":19,"tag":164,"props":4938,"children":4939},{"__ignoreMap":7},[4940],{"type":25,"value":608},{"type":19,"tag":28,"props":4942,"children":4943},{},[4944],{"type":25,"value":619},{"type":19,"tag":156,"props":4946,"children":4948},{"className":4947,"code":623,"language":609,"meta":7},[607],[4949],{"type":19,"tag":164,"props":4950,"children":4951},{"__ignoreMap":7},[4952],{"type":25,"value":623},{"type":19,"tag":20,"props":4954,"children":4955},{"id":631},[4956],{"type":25,"value":634},{"type":19,"tag":28,"props":4958,"children":4959},{},[4960],{"type":25,"value":639},{"type":19,"tag":39,"props":4962,"children":4964},{"className":4963},[643],[4965,4978,4991,5004,5017,5030,5043,5056,5069,5082,5095,5108],{"type":19,"tag":43,"props":4966,"children":4968},{"className":4967},[648],[4969,4972,4973,4977],{"type":19,"tag":651,"props":4970,"children":4971},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":4974,"children":4975},{},[4976],{"type":25,"value":149},{"type":25,"value":663},{"type":19,"tag":43,"props":4979,"children":4981},{"className":4980},[648],[4982,4985,4986,4990],{"type":19,"tag":651,"props":4983,"children":4984},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":4987,"children":4988},{},[4989],{"type":25,"value":676},{"type":25,"value":678},{"type":19,"tag":43,"props":4992,"children":4994},{"className":4993},[648],[4995,4998,4999,5003],{"type":19,"tag":651,"props":4996,"children":4997},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":5000,"children":5001},{},[5002],{"type":25,"value":691},{"type":25,"value":693},{"type":19,"tag":43,"props":5005,"children":5007},{"className":5006},[648],[5008,5011,5012,5016],{"type":19,"tag":651,"props":5009,"children":5010},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":5013,"children":5014},{},[5015],{"type":25,"value":706},{"type":25,"value":708},{"type":19,"tag":43,"props":5018,"children":5020},{"className":5019},[648],[5021,5024,5025,5029],{"type":19,"tag":651,"props":5022,"children":5023},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":5026,"children":5027},{},[5028],{"type":25,"value":721},{"type":25,"value":723},{"type":19,"tag":43,"props":5031,"children":5033},{"className":5032},[648],[5034,5037,5038,5042],{"type":19,"tag":651,"props":5035,"children":5036},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":5039,"children":5040},{},[5041],{"type":25,"value":736},{"type":25,"value":738},{"type":19,"tag":43,"props":5044,"children":5046},{"className":5045},[648],[5047,5050,5051,5055],{"type":19,"tag":651,"props":5048,"children":5049},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":5052,"children":5053},{},[5054],{"type":25,"value":751},{"type":25,"value":753},{"type":19,"tag":43,"props":5057,"children":5059},{"className":5058},[648],[5060,5063,5064,5068],{"type":19,"tag":651,"props":5061,"children":5062},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":5065,"children":5066},{},[5067],{"type":25,"value":766},{"type":25,"value":768},{"type":19,"tag":43,"props":5070,"children":5072},{"className":5071},[648],[5073,5076,5077,5081],{"type":19,"tag":651,"props":5074,"children":5075},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":5078,"children":5079},{},[5080],{"type":25,"value":781},{"type":25,"value":783},{"type":19,"tag":43,"props":5083,"children":5085},{"className":5084},[648],[5086,5089,5090,5094],{"type":19,"tag":651,"props":5087,"children":5088},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":5091,"children":5092},{},[5093],{"type":25,"value":796},{"type":25,"value":798},{"type":19,"tag":43,"props":5096,"children":5098},{"className":5097},[648],[5099,5102,5103,5107],{"type":19,"tag":651,"props":5100,"children":5101},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":5104,"children":5105},{},[5106],{"type":25,"value":811},{"type":25,"value":813},{"type":19,"tag":43,"props":5109,"children":5111},{"className":5110},[648],[5112,5115,5116,5120],{"type":19,"tag":651,"props":5113,"children":5114},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":5117,"children":5118},{},[5119],{"type":25,"value":826},{"type":25,"value":828},{"type":19,"tag":20,"props":5122,"children":5123},{"id":831},[5124],{"type":25,"value":834},{"type":19,"tag":28,"props":5126,"children":5127},{},[5128],{"type":25,"value":839},{"type":19,"tag":39,"props":5130,"children":5131},{},[5132,5136,5140,5144],{"type":19,"tag":43,"props":5133,"children":5134},{},[5135],{"type":25,"value":847},{"type":19,"tag":43,"props":5137,"children":5138},{},[5139],{"type":25,"value":852},{"type":19,"tag":43,"props":5141,"children":5142},{},[5143],{"type":25,"value":857},{"type":19,"tag":43,"props":5145,"children":5146},{},[5147],{"type":25,"value":862},{"type":19,"tag":28,"props":5149,"children":5150},{},[5151],{"type":25,"value":867},{"title":7,"searchDepth":869,"depth":869,"links":5153},[5154,5155,5156,5157,5158,5159,5160,5161,5162,5163,5164],{"id":22,"depth":869,"text":26},{"id":91,"depth":869,"text":94},{"id":146,"depth":869,"text":149},{"id":290,"depth":869,"text":293},{"id":395,"depth":869,"text":398},{"id":415,"depth":869,"text":418},{"id":458,"depth":869,"text":461},{"id":535,"depth":869,"text":538},{"id":584,"depth":869,"text":587},{"id":631,"depth":869,"text":634},{"id":831,"depth":869,"text":834},{"_path":5166,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":5167,"description":5168,"date":5169,"readingTime":3365,"tags":5170,"featured":6,"body":5171,"_type":882,"_id":5625,"_source":884,"_file":5626,"_stem":5627,"_extension":887},"\u002Fblog\u002Fhow-to-structure-enterprise-vue-applications","How to Structure Enterprise Vue Applications: Scalable Folder Architecture","Architecture guide for large Vue applications. Learn folder structure, dependency injection, composables patterns, naming conventions, and scaling to 100K+ lines of code.","2024-05-05",[897,14],{"type":16,"children":5172,"toc":5613},[5173,5179,5184,5194,5202,5207,5217,5225,5230,5240,5248,5253,5259,5271,5279,5283,5340,5346,5351,5360,5365,5374,5379,5388,5394,5399,5408,5413,5422,5428,5433,5442,5446,5455,5461,5466,5475,5480,5486,5495,5501,5506,5514,5520,5525,5533,5539,5608],{"type":19,"tag":20,"props":5174,"children":5176},{"id":5175},"the-folder-structure-problem",[5177],{"type":25,"value":5178},"The Folder Structure Problem",{"type":19,"tag":28,"props":5180,"children":5181},{},[5182],{"type":25,"value":5183},"I've seen three patterns:",{"type":19,"tag":28,"props":5185,"children":5186},{},[5187,5192],{"type":19,"tag":47,"props":5188,"children":5189},{},[5190],{"type":25,"value":5191},"Chaos Model",{"type":25,"value":5193}," (what startups do):",{"type":19,"tag":156,"props":5195,"children":5197},{"code":5196},"src\u002F\n├── components\u002F\n│   ├── Button.vue\n│   ├── Modal.vue\n│   ├── UserCard.vue\n│   └── SomeRandomComponent.vue\n├── views\u002F\n│   ├── Home.vue\n│   └── Dashboard.vue\n└── utils.js\n",[5198],{"type":19,"tag":164,"props":5199,"children":5200},{"__ignoreMap":7},[5201],{"type":25,"value":5196},{"type":19,"tag":28,"props":5203,"children":5204},{},[5205],{"type":25,"value":5206},"After 6 months, nobody remembers where anything is.",{"type":19,"tag":28,"props":5208,"children":5209},{},[5210,5215],{"type":19,"tag":47,"props":5211,"children":5212},{},[5213],{"type":25,"value":5214},"Rails Model",{"type":25,"value":5216}," (what some teams copy):",{"type":19,"tag":156,"props":5218,"children":5220},{"code":5219},"src\u002F\n├── views\u002F\n├── components\u002F\n├── composables\u002F\n├── stores\u002F\n├── utils\u002F\n└── services\u002F\n",[5221],{"type":19,"tag":164,"props":5222,"children":5223},{"__ignoreMap":7},[5224],{"type":25,"value":5219},{"type":19,"tag":28,"props":5226,"children":5227},{},[5228],{"type":25,"value":5229},"Better, but still unclear what belongs where.",{"type":19,"tag":28,"props":5231,"children":5232},{},[5233,5238],{"type":19,"tag":47,"props":5234,"children":5235},{},[5236],{"type":25,"value":5237},"Domain-Driven Model",{"type":25,"value":5239}," (what scales):",{"type":19,"tag":156,"props":5241,"children":5243},{"code":5242},"src\u002F\n├── domains\u002F\n│   ├── users\u002F\n│   │   ├── components\u002F\n│   │   ├── composables\u002F\n│   │   ├── stores\u002F\n│   │   ├── types\u002F\n│   │   └── api.ts\n│   ├── billing\u002F\n│   └── products\u002F\n├── shared\u002F\n│   ├── components\u002F\n│   ├── composables\u002F\n│   ├── types\u002F\n│   └── utils\u002F\n└── app.vue\n",[5244],{"type":19,"tag":164,"props":5245,"children":5246},{"__ignoreMap":7},[5247],{"type":25,"value":5242},{"type":19,"tag":28,"props":5249,"children":5250},{},[5251],{"type":25,"value":5252},"We'll focus on the domain-driven model.",{"type":19,"tag":20,"props":5254,"children":5256},{"id":5255},"the-domain-driven-structure",[5257],{"type":25,"value":5258},"The Domain-Driven Structure",{"type":19,"tag":28,"props":5260,"children":5261},{},[5262,5264,5269],{"type":25,"value":5263},"Organize by ",{"type":19,"tag":47,"props":5265,"children":5266},{},[5267],{"type":25,"value":5268},"business domain",{"type":25,"value":5270},", not technical layer:",{"type":19,"tag":156,"props":5272,"children":5274},{"code":5273},"src\u002F\n├── domains\u002F\n│   │\n│   ├── users\u002F\n│   │   ├── api.ts              # API calls for users\n│   │   ├── types.ts            # User types (User, CreateUserDTO)\n│   │   ├── composables\u002F\n│   │   │   ├── useUser.ts      # Single user fetch\u002Fupdate\n│   │   │   ├── useUserList.ts  # List with pagination\n│   │   │   └── useAuth.ts      # Authentication\n│   │   ├── stores\u002F\n│   │   │   └── userStore.ts    # Pinia store\n│   │   ├── components\u002F\n│   │   │   ├── UserCard.vue\n│   │   │   ├── UserForm.vue\n│   │   │   └── UserAvatar.vue\n│   │   ├── pages\u002F\n│   │   │   ├── UserDetail.vue\n│   │   │   └── UserList.vue\n│   │   └── index.ts            # Public API\n│   │\n│   ├── billing\u002F\n│   │   ├── api.ts\n│   │   ├── types.ts            # Invoice, Subscription types\n│   │   ├── composables\u002F\n│   │   ├── stores\u002F\n│   │   ├── components\u002F\n│   │   ├── pages\u002F\n│   │   └── index.ts\n│   │\n│   └── products\u002F\n│       ├── api.ts\n│       ├── types.ts\n│       ├── composables\u002F\n│       ├── stores\u002F\n│       ├── components\u002F\n│       ├── pages\u002F\n│       └── index.ts\n│\n├── shared\u002F\n│   ├── components\u002F             # Reusable UI (Button, Modal, etc.)\n│   │   ├── Button.vue\n│   │   ├── Modal.vue\n│   │   └── Pagination.vue\n│   ├── composables\u002F            # Shared logic (useLocalStorage, etc.)\n│   │   ├── useFetch.ts\n│   │   ├── useLocalStorage.ts\n│   │   └── useDebounce.ts\n│   ├── types\u002F\n│   │   ├── index.ts\n│   │   └── api.ts\n│   ├── utils\u002F\n│   │   ├── formatters.ts\n│   │   ├── validators.ts\n│   │   └── helpers.ts\n│   ├── stores\u002F\n│   │   └── appStore.ts         # Global app state\n│   ├── middleware\u002F\n│   │   ├── auth.ts\n│   │   └── logging.ts\n│   └── index.ts                # Barrel export\n│\n├── router\u002F\n│   ├── index.ts\n│   ├── guards.ts\n│   └── routes.ts\n│\n├── styles\u002F\n│   ├── main.css\n│   ├── variables.css\n│   └── utilities.css\n│\n├── plugins\u002F\n│   └── index.ts\n│\n└── app.vue\n",[5275],{"type":19,"tag":164,"props":5276,"children":5277},{"__ignoreMap":7},[5278],{"type":25,"value":5273},{"type":19,"tag":28,"props":5280,"children":5281},{},[5282],{"type":25,"value":2100},{"type":19,"tag":39,"props":5284,"children":5285},{},[5286,5296,5320,5330],{"type":19,"tag":43,"props":5287,"children":5288},{},[5289,5294],{"type":19,"tag":47,"props":5290,"children":5291},{},[5292],{"type":25,"value":5293},"Feature cohesion",{"type":25,"value":5295}," — all code for \"users\" is in one place",{"type":19,"tag":43,"props":5297,"children":5298},{},[5299,5304,5306,5312,5314],{"type":19,"tag":47,"props":5300,"children":5301},{},[5302],{"type":25,"value":5303},"Independent shipping",{"type":25,"value":5305}," — one team owns ",{"type":19,"tag":164,"props":5307,"children":5309},{"className":5308},[],[5310],{"type":25,"value":5311},"domains\u002Fusers",{"type":25,"value":5313},", another owns ",{"type":19,"tag":164,"props":5315,"children":5317},{"className":5316},[],[5318],{"type":25,"value":5319},"domains\u002Fbilling",{"type":19,"tag":43,"props":5321,"children":5322},{},[5323,5328],{"type":19,"tag":47,"props":5324,"children":5325},{},[5326],{"type":25,"value":5327},"Clear dependencies",{"type":25,"value":5329}," — easy to see what each domain needs",{"type":19,"tag":43,"props":5331,"children":5332},{},[5333,5338],{"type":19,"tag":47,"props":5334,"children":5335},{},[5336],{"type":25,"value":5337},"Scaling",{"type":25,"value":5339}," — add new domains without touching existing ones",{"type":19,"tag":20,"props":5341,"children":5343},{"id":5342},"the-barrel-export-pattern",[5344],{"type":25,"value":5345},"The Barrel Export Pattern",{"type":19,"tag":28,"props":5347,"children":5348},{},[5349],{"type":25,"value":5350},"Each domain exports a public API:",{"type":19,"tag":156,"props":5352,"children":5355},{"code":5353,"language":609,"meta":7,"className":5354},"\u002F\u002F domains\u002Fusers\u002Findex.ts\nexport * from '.\u002Ftypes'\nexport { default as useUser } from '.\u002Fcomposables\u002FuseUser'\nexport { default as useUserList } from '.\u002Fcomposables\u002FuseUserList'\nexport { default as UserCard } from '.\u002Fcomponents\u002FUserCard.vue'\nexport { default as UserForm } from '.\u002Fcomponents\u002FUserForm.vue'\nexport { useUserStore } from '.\u002Fstores\u002FuserStore'\nexport * as userApi from '.\u002Fapi'\n",[607],[5356],{"type":19,"tag":164,"props":5357,"children":5358},{"__ignoreMap":7},[5359],{"type":25,"value":5353},{"type":19,"tag":28,"props":5361,"children":5362},{},[5363],{"type":25,"value":5364},"Usage:",{"type":19,"tag":156,"props":5366,"children":5369},{"code":5367,"language":609,"meta":7,"className":5368},"\u002F\u002F Importing from domain is clean\nimport { useUser, UserCard, userApi } from '@\u002Fdomains\u002Fusers'\n\n\u002F\u002F Instead of scattered imports\nimport useUser from '@\u002Fdomains\u002Fusers\u002Fcomposables\u002FuseUser'\nimport UserCard from '@\u002Fdomains\u002Fusers\u002Fcomponents\u002FUserCard.vue'\nimport * as userApi from '@\u002Fdomains\u002Fusers\u002Fapi'\n",[607],[5370],{"type":19,"tag":164,"props":5371,"children":5372},{"__ignoreMap":7},[5373],{"type":25,"value":5367},{"type":19,"tag":28,"props":5375,"children":5376},{},[5377],{"type":25,"value":5378},"Enforce this with eslint:",{"type":19,"tag":156,"props":5380,"children":5383},{"code":5381,"language":1181,"meta":7,"className":5382},"\u002F\u002F .eslintrc.js\n{\n  rules: {\n    'import\u002Fno-restricted-paths': [\n      'error',\n      {\n        zones: [\n          {\n            target: '.\u002Fsrc\u002Fdomains\u002Fusers',\n            from: '.\u002Fsrc\u002Fdomains\u002Fbilling',\n            message: 'Users domain cannot import from Billing domain'\n          }\n        ]\n      }\n    ]\n  }\n}\n",[1179],[5384],{"type":19,"tag":164,"props":5385,"children":5386},{"__ignoreMap":7},[5387],{"type":25,"value":5381},{"type":19,"tag":20,"props":5389,"children":5391},{"id":5390},"composables-the-shared-logic",[5392],{"type":25,"value":5393},"Composables: The Shared Logic",{"type":19,"tag":28,"props":5395,"children":5396},{},[5397],{"type":25,"value":5398},"Composables are where reusable logic lives:",{"type":19,"tag":156,"props":5400,"children":5403},{"code":5401,"language":609,"meta":7,"className":5402},"\u002F\u002F domains\u002Fusers\u002Fcomposables\u002FuseUser.ts\nimport { ref, computed } from 'vue'\nimport type { User } from '..\u002Ftypes'\nimport * as userApi from '..\u002Fapi'\n\nexport const useUser = (userId: string) => {\n  const user = ref\u003CUser | null>(null)\n  const loading = ref(false)\n  const error = ref\u003Cstring | null>(null)\n\n  const fetchUser = async () => {\n    loading.value = true\n    try {\n      user.value = await userApi.getUser(userId)\n      error.value = null\n    } catch (err) {\n      error.value = (err as Error).message\n      user.value = null\n    } finally {\n      loading.value = false\n    }\n  }\n\n  const updateUser = async (updates: Partial\u003CUser>) => {\n    try {\n      user.value = await userApi.updateUser(userId, updates)\n      error.value = null\n    } catch (err) {\n      error.value = (err as Error).message\n    }\n  }\n\n  onMounted(() => fetchUser())\n\n  return {\n    user: readonly(user),\n    loading: readonly(loading),\n    error: readonly(error),\n    fetchUser,\n    updateUser\n  }\n}\n",[607],[5404],{"type":19,"tag":164,"props":5405,"children":5406},{"__ignoreMap":7},[5407],{"type":25,"value":5401},{"type":19,"tag":28,"props":5409,"children":5410},{},[5411],{"type":25,"value":5412},"Use it:",{"type":19,"tag":156,"props":5414,"children":5417},{"code":5415,"language":161,"meta":7,"className":5416},"\u003Cscript setup lang=\"ts\">\nimport { useUser } from '@\u002Fdomains\u002Fusers'\n\nconst route = useRoute()\nconst { user, loading, error } = useUser(route.params.id)\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv v-if=\"loading\">Loading...\u003C\u002Fdiv>\n  \u003Cdiv v-else-if=\"error\">{{ error }}\u003C\u002Fdiv>\n  \u003Cdiv v-else>{{ user?.name }}\u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[159],[5418],{"type":19,"tag":164,"props":5419,"children":5420},{"__ignoreMap":7},[5421],{"type":25,"value":5415},{"type":19,"tag":20,"props":5423,"children":5425},{"id":5424},"dependency-injection",[5426],{"type":25,"value":5427},"Dependency Injection",{"type":19,"tag":28,"props":5429,"children":5430},{},[5431],{"type":25,"value":5432},"For large apps, inject dependencies instead of hardcoding:",{"type":19,"tag":156,"props":5434,"children":5437},{"code":5435,"language":609,"meta":7,"className":5436},"\u002F\u002F shared\u002Fservices\u002Fapi.ts\nclass ApiService {\n  private baseUrl: string\n\n  constructor(baseUrl: string) {\n    this.baseUrl = baseUrl\n  }\n\n  async get(path: string) {\n    return fetch(`${this.baseUrl}${path}`).then(r => r.json())\n  }\n}\n\n\u002F\u002F main.ts\nimport { createApp } from 'vue'\nimport ApiService from '.\u002Fshared\u002Fservices\u002Fapi'\n\nconst app = createApp(App)\n\napp.provide('api', new ApiService(import.meta.env.VITE_API_URL))\n\napp.mount('#app')\n",[607],[5438],{"type":19,"tag":164,"props":5439,"children":5440},{"__ignoreMap":7},[5441],{"type":25,"value":5435},{"type":19,"tag":28,"props":5443,"children":5444},{},[5445],{"type":25,"value":5412},{"type":19,"tag":156,"props":5447,"children":5450},{"code":5448,"language":609,"meta":7,"className":5449},"\u002F\u002F domains\u002Fusers\u002Fapi.ts\nimport { inject } from 'vue'\n\nexport const useUserApi = () => {\n  const api = inject\u003CApiService>('api')!\n  \n  return {\n    getUser: (id: string) => api.get(`\u002Fusers\u002F${id}`),\n    listUsers: () => api.get('\u002Fusers'),\n    createUser: (data: any) => api.post('\u002Fusers', data)\n  }\n}\n",[607],[5451],{"type":19,"tag":164,"props":5452,"children":5453},{"__ignoreMap":7},[5454],{"type":25,"value":5448},{"type":19,"tag":20,"props":5456,"children":5458},{"id":5457},"types-organization",[5459],{"type":25,"value":5460},"Types Organization",{"type":19,"tag":28,"props":5462,"children":5463},{},[5464],{"type":25,"value":5465},"Centralize types:",{"type":19,"tag":156,"props":5467,"children":5470},{"code":5468,"language":609,"meta":7,"className":5469},"\u002F\u002F domains\u002Fusers\u002Ftypes.ts\nexport interface User {\n  id: string\n  name: string\n  email: string\n  avatar?: string\n  role: 'user' | 'admin'\n  createdAt: string\n  updatedAt: string\n}\n\nexport interface CreateUserDTO {\n  name: string\n  email: string\n  password: string\n  role?: 'user' | 'admin'\n}\n\nexport interface UpdateUserDTO {\n  name?: string\n  email?: string\n  avatar?: string\n}\n\nexport interface UserListResponse {\n  data: User[]\n  total: number\n  page: number\n  pageSize: number\n}\n",[607],[5471],{"type":19,"tag":164,"props":5472,"children":5473},{"__ignoreMap":7},[5474],{"type":25,"value":5468},{"type":19,"tag":28,"props":5476,"children":5477},{},[5478],{"type":25,"value":5479},"Never use inline interfaces.",{"type":19,"tag":20,"props":5481,"children":5483},{"id":5482},"naming-conventions",[5484],{"type":25,"value":5485},"Naming Conventions",{"type":19,"tag":156,"props":5487,"children":5490},{"code":5488,"language":609,"meta":7,"className":5489},"\u002F\u002F Components: PascalCase\nUserCard.vue\nUserForm.vue\nCreateUserModal.vue\n\n\u002F\u002F Composables: useCamelCase\nuseUser.ts\nuseUserList.ts\nuseUserForm.ts\nuseDebounce.ts\n\n\u002F\u002F Stores: Store suffix\nuserStore.ts\nauthStore.ts\nappStore.ts\n\n\u002F\u002F API files: lowercase\napi.ts (or users-api.ts)\n\n\u002F\u002F Types: PascalCase\nUser.ts\nCreateUserDTO.ts\n\n\u002F\u002F Utils: camelCase, descriptive\nformatDate.ts\nvalidateEmail.ts\ncalculateDiscount.ts\n",[607],[5491],{"type":19,"tag":164,"props":5492,"children":5493},{"__ignoreMap":7},[5494],{"type":25,"value":5488},{"type":19,"tag":20,"props":5496,"children":5498},{"id":5497},"growing-the-structure",[5499],{"type":25,"value":5500},"Growing the Structure",{"type":19,"tag":28,"props":5502,"children":5503},{},[5504],{"type":25,"value":5505},"As you add more code, follow these patterns:",{"type":19,"tag":156,"props":5507,"children":5509},{"code":5508},"domains\u002Fusers\u002F (20 KB)\n  ├── api.ts\n  ├── types.ts\n  ├── composables\u002F (3 files)\n  ├── components\u002F (5 files)\n  └── pages\u002F (2 files)\n\n# When it gets too big (50+ KB), break it down:\n\ndomains\u002F\n├── users\u002F\n│   ├── list\u002F            # User listing feature\n│   ├── detail\u002F          # User detail\u002Fedit feature\n│   ├── auth\u002F            # Auth-specific logic\n│   └── shared\u002F          # Shared user types\u002Fapi\n",[5510],{"type":19,"tag":164,"props":5511,"children":5512},{"__ignoreMap":7},[5513],{"type":25,"value":5508},{"type":19,"tag":20,"props":5515,"children":5517},{"id":5516},"scaling-to-100k-lines",[5518],{"type":25,"value":5519},"Scaling to 100K+ Lines",{"type":19,"tag":28,"props":5521,"children":5522},{},[5523],{"type":25,"value":5524},"At scale, add:",{"type":19,"tag":156,"props":5526,"children":5528},{"code":5527},"src\u002F\n├── domains\u002F\n├── features\u002F            # Cross-domain workflows\n│   ├── onboarding\u002F      # Onboarding flow uses Users + Billing\n│   ├── migration\u002F       # Data migration uses multiple domains\n│   └── reporting\u002F\n├── shared\u002F\n├── router\u002F\n└── layouts\u002F             # Layout components\n",[5529],{"type":19,"tag":164,"props":5530,"children":5531},{"__ignoreMap":7},[5532],{"type":25,"value":5527},{"type":19,"tag":20,"props":5534,"children":5536},{"id":5535},"key-rules",[5537],{"type":25,"value":5538},"Key Rules",{"type":19,"tag":1048,"props":5540,"children":5541},{},[5542,5552,5562,5572,5582,5598],{"type":19,"tag":43,"props":5543,"children":5544},{},[5545,5550],{"type":19,"tag":47,"props":5546,"children":5547},{},[5548],{"type":25,"value":5549},"Keep domains independent",{"type":25,"value":5551}," — Users domain can't import from Billing",{"type":19,"tag":43,"props":5553,"children":5554},{},[5555,5560],{"type":19,"tag":47,"props":5556,"children":5557},{},[5558],{"type":25,"value":5559},"Shared is truly shared",{"type":25,"value":5561}," — only reusable across domains",{"type":19,"tag":43,"props":5563,"children":5564},{},[5565,5570],{"type":19,"tag":47,"props":5566,"children":5567},{},[5568],{"type":25,"value":5569},"Use barrel exports",{"type":25,"value":5571}," — clean public APIs",{"type":19,"tag":43,"props":5573,"children":5574},{},[5575,5580],{"type":19,"tag":47,"props":5576,"children":5577},{},[5578],{"type":25,"value":5579},"Type everything",{"type":25,"value":5581}," — centralize types by domain",{"type":19,"tag":43,"props":5583,"children":5584},{},[5585,5590,5592],{"type":19,"tag":47,"props":5586,"children":5587},{},[5588],{"type":25,"value":5589},"One entry point",{"type":25,"value":5591}," — each domain exports from ",{"type":19,"tag":164,"props":5593,"children":5595},{"className":5594},[],[5596],{"type":25,"value":5597},"index.ts",{"type":19,"tag":43,"props":5599,"children":5600},{},[5601,5606],{"type":19,"tag":47,"props":5602,"children":5603},{},[5604],{"type":25,"value":5605},"Enforce with linting",{"type":25,"value":5607}," — catch violations early",{"type":19,"tag":28,"props":5609,"children":5610},{},[5611],{"type":25,"value":5612},"This structure scales from 10 engineers to 100. Your codebase will thank you.",{"title":7,"searchDepth":869,"depth":869,"links":5614},[5615,5616,5617,5618,5619,5620,5621,5622,5623,5624],{"id":5175,"depth":869,"text":5178},{"id":5255,"depth":869,"text":5258},{"id":5342,"depth":869,"text":5345},{"id":5390,"depth":869,"text":5393},{"id":5424,"depth":869,"text":5427},{"id":5457,"depth":869,"text":5460},{"id":5482,"depth":869,"text":5485},{"id":5497,"depth":869,"text":5500},{"id":5516,"depth":869,"text":5519},{"id":5535,"depth":869,"text":5538},"content:blog:how-to-structure-enterprise-vue-applications.md","blog\u002Fhow-to-structure-enterprise-vue-applications.md","blog\u002Fhow-to-structure-enterprise-vue-applications",{"_path":5629,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":5630,"description":5631,"date":5632,"updated":894,"readingTime":5633,"tags":5634,"featured":6,"body":5635,"_type":882,"_id":6359,"_source":884,"_file":6360,"_stem":6361,"_extension":887},"\u002Fblog\u002Freducing-bundle-size-in-large-vue-apps","Reducing Bundle Size in Large Vue Apps: A Strategic Guide","Strategic guide to bundle optimization. Learn tree-shaking, dynamic imports, dependency analysis, code splitting, and continuous monitoring.","2024-04-18",10,[898],{"type":16,"children":5636,"toc":6337},[5637,5643,5655,5667,5698,5703,5709,5714,5723,5728,5746,5751,5807,5813,5819,5828,5834,5843,5849,5858,5864,5873,5878,5896,5906,5912,5917,5926,5932,5941,5947,5956,5969,5974,5992,5998,6003,6012,6021,6026,6035,6040,6046,6052,6061,6067,6076,6082,6092,6187,6199,6217,6223,6276,6280,6332],{"type":19,"tag":20,"props":5638,"children":5640},{"id":5639},"the-bundle-problem",[5641],{"type":25,"value":5642},"The Bundle Problem",{"type":19,"tag":28,"props":5644,"children":5645},{},[5646,5648,5653],{"type":25,"value":5647},"Vue apps grow organically, and nobody watches the bundle until it hurts. Working on a clinical data platform, I shipped a bundle reduction of ",{"type":19,"tag":47,"props":5649,"children":5650},{},[5651],{"type":25,"value":5652},"35%",{"type":25,"value":5654}," using the approach in this guide — the strategy below is the repeatable part.",{"type":19,"tag":28,"props":5656,"children":5657},{},[5658,5660,5665],{"type":25,"value":5659},"To make the mechanics concrete, this guide walks through a representative large Vue app. ",{"type":19,"tag":47,"props":5661,"children":5662},{},[5663],{"type":25,"value":5664},"All specific sizes below are an illustrative example",{"type":25,"value":5666},", not measurements from one project:",{"type":19,"tag":39,"props":5668,"children":5669},{},[5670,5679,5688],{"type":19,"tag":43,"props":5671,"children":5672},{},[5673,5677],{"type":19,"tag":47,"props":5674,"children":5675},{},[5676],{"type":25,"value":1518},{"type":25,"value":5678},": 850 KB (gzipped: 280 KB)",{"type":19,"tag":43,"props":5680,"children":5681},{},[5682,5686],{"type":19,"tag":47,"props":5683,"children":5684},{},[5685],{"type":25,"value":1528},{"type":25,"value":5687},": 450 KB (gzipped: 150 KB)",{"type":19,"tag":43,"props":5689,"children":5690},{},[5691,5696],{"type":19,"tag":47,"props":5692,"children":5693},{},[5694],{"type":25,"value":5695},"Total",{"type":25,"value":5697},": 1.2 MB gzipped",{"type":19,"tag":28,"props":5699,"children":5700},{},[5701],{"type":25,"value":5702},"At 3G speeds (1 Mbps), that's 10+ seconds just for JavaScript.",{"type":19,"tag":20,"props":5704,"children":5706},{"id":5705},"step-1-analyze-the-damage",[5707],{"type":25,"value":5708},"Step 1: Analyze the Damage",{"type":19,"tag":28,"props":5710,"children":5711},{},[5712],{"type":25,"value":5713},"First, visualize where bytes are going:",{"type":19,"tag":156,"props":5715,"children":5718},{"className":5716,"code":5717,"language":598,"meta":7},[596],"npm install -D rollup-plugin-visualizer\n\n# vite.config.ts\nimport { visualizer } from 'rollup-plugin-visualizer'\n\nexport default {\n  plugins: [vue(), visualizer()]\n}\n\nnpm run build\n# Opens dist\u002Fstats.html — interactive bundle visualization\n",[5719],{"type":19,"tag":164,"props":5720,"children":5721},{"__ignoreMap":7},[5722],{"type":25,"value":5717},{"type":19,"tag":28,"props":5724,"children":5725},{},[5726],{"type":25,"value":5727},"You'll see:",{"type":19,"tag":39,"props":5729,"children":5730},{},[5731,5736,5741],{"type":19,"tag":43,"props":5732,"children":5733},{},[5734],{"type":25,"value":5735},"What packages are large",{"type":19,"tag":43,"props":5737,"children":5738},{},[5739],{"type":25,"value":5740},"What's imported but unused",{"type":19,"tag":43,"props":5742,"children":5743},{},[5744],{"type":25,"value":5745},"Duplicate dependencies at different versions",{"type":19,"tag":28,"props":5747,"children":5748},{},[5749],{"type":25,"value":5750},"A typical analysis reveals (illustrative example):",{"type":19,"tag":39,"props":5752,"children":5753},{},[5754,5765,5783,5794],{"type":19,"tag":43,"props":5755,"children":5756},{},[5757,5763],{"type":19,"tag":164,"props":5758,"children":5760},{"className":5759},[],[5761],{"type":25,"value":5762},"lodash-es",{"type":25,"value":5764},": 71 KB (for 3 functions actually used)",{"type":19,"tag":43,"props":5766,"children":5767},{},[5768,5774,5776,5782],{"type":19,"tag":164,"props":5769,"children":5771},{"className":5770},[],[5772],{"type":25,"value":5773},"moment",{"type":25,"value":5775},": 65 KB (replaced with ",{"type":19,"tag":164,"props":5777,"children":5779},{"className":5778},[],[5780],{"type":25,"value":5781},"date-fns",{"type":25,"value":287},{"type":19,"tag":43,"props":5784,"children":5785},{},[5786,5792],{"type":19,"tag":164,"props":5787,"children":5789},{"className":5788},[],[5790],{"type":25,"value":5791},"chart.js",{"type":25,"value":5793},": 120 KB (only used on one page)",{"type":19,"tag":43,"props":5795,"children":5796},{},[5797,5799,5805],{"type":25,"value":5798},"Duplicate ",{"type":19,"tag":164,"props":5800,"children":5802},{"className":5801},[],[5803],{"type":25,"value":5804},"axios",{"type":25,"value":5806},": two versions imported by different packages",{"type":19,"tag":20,"props":5808,"children":5810},{"id":5809},"step-2-tree-shake-aggressively",[5811],{"type":25,"value":5812},"Step 2: Tree-Shake Aggressively",{"type":19,"tag":979,"props":5814,"children":5816},{"id":5815},"remove-unused-packages",[5817],{"type":25,"value":5818},"Remove Unused Packages",{"type":19,"tag":156,"props":5820,"children":5823},{"className":5821,"code":5822,"language":598,"meta":7},[596],"# Find unused dependencies\nnpm install -g depcheck\ndepcheck\n\n# Removes 50+ KB of dead code\nnpm uninstall unused-package\n",[5824],{"type":19,"tag":164,"props":5825,"children":5826},{"__ignoreMap":7},[5827],{"type":25,"value":5822},{"type":19,"tag":979,"props":5829,"children":5831},{"id":5830},"replace-heavy-packages",[5832],{"type":25,"value":5833},"Replace Heavy Packages",{"type":19,"tag":156,"props":5835,"children":5838},{"className":5836,"code":5837,"language":609,"meta":7},[607],"\u002F\u002F ❌ lodash-es (71 KB)\nimport { debounce, throttle, cloneDeep } from 'lodash-es'\n\n\u002F\u002F ✅ Use native JavaScript + small libraries\n\u002F\u002F Debounce (use native setTimeout)\nconst debounce = (fn: Function, delay: number) => {\n  let timeout: NodeJS.Timeout\n  return (...args: any[]) => {\n    clearTimeout(timeout)\n    timeout = setTimeout(() => fn(...args), delay)\n  }\n}\n\n\u002F\u002F Or use a lighter library\nimport { debounce } from 'radash' \u002F\u002F 8 KB vs 71 KB\n",[5839],{"type":19,"tag":164,"props":5840,"children":5841},{"__ignoreMap":7},[5842],{"type":25,"value":5837},{"type":19,"tag":979,"props":5844,"children":5846},{"id":5845},"dependency-consolidation",[5847],{"type":25,"value":5848},"Dependency Consolidation",{"type":19,"tag":156,"props":5850,"children":5853},{"className":5851,"code":5852,"language":609,"meta":7},[607],"\u002F\u002F ❌ Multiple date libraries\nimport { format } from 'date-fns'\nimport moment from 'moment' \u002F\u002F Someone else's code\nimport dayjs from 'dayjs' \u002F\u002F Yet another\n\n\u002F\u002F ✅ Choose one\nimport { format } from 'date-fns'\n\u002F\u002F date-fns: 30 KB (tree-shakeable) vs moment: 65 KB (not tree-shakeable)\n",[5854],{"type":19,"tag":164,"props":5855,"children":5856},{"__ignoreMap":7},[5857],{"type":25,"value":5852},{"type":19,"tag":979,"props":5859,"children":5861},{"id":5860},"build-config-for-tree-shaking",[5862],{"type":25,"value":5863},"Build Config for Tree-Shaking",{"type":19,"tag":156,"props":5865,"children":5868},{"className":5866,"code":5867,"language":609,"meta":7},[607],"\u002F\u002F vite.config.ts\nexport default defineConfig({\n  build: {\n    minify: 'terser',\n    terserOptions: {\n      compress: {\n        drop_console: true,\n        drop_debugger: true\n      }\n    },\n    rollupOptions: {\n      output: {\n        \u002F\u002F Manual code splitting\n        manualChunks: {\n          \u002F\u002F Separate large dependencies\n          'charts': ['chart.js', 'vue-chartjs'],\n          'markdown': ['markdown-it', 'highlight.js'],\n          'utils': ['lodash', 'date-fns'],\n          'vendor': ['vue', 'vue-router', 'pinia']\n        }\n      }\n    }\n  }\n})\n",[5869],{"type":19,"tag":164,"props":5870,"children":5871},{"__ignoreMap":7},[5872],{"type":25,"value":5867},{"type":19,"tag":28,"props":5874,"children":5875},{},[5876],{"type":25,"value":5877},"Results for the illustrative example app:",{"type":19,"tag":39,"props":5879,"children":5880},{},[5881,5886,5891],{"type":19,"tag":43,"props":5882,"children":5883},{},[5884],{"type":25,"value":5885},"Removed unused: -150 KB",{"type":19,"tag":43,"props":5887,"children":5888},{},[5889],{"type":25,"value":5890},"Replaced heavy packages: -120 KB",{"type":19,"tag":43,"props":5892,"children":5893},{},[5894],{"type":25,"value":5895},"Tree-shaking optimized: -40 KB",{"type":19,"tag":28,"props":5897,"children":5898},{},[5899,5904],{"type":19,"tag":47,"props":5900,"children":5901},{},[5902],{"type":25,"value":5903},"Total: -310 KB (27% reduction)",{"type":25,"value":5905}," — again, illustrative; the ratio between the three buckets is what generalizes.",{"type":19,"tag":20,"props":5907,"children":5909},{"id":5908},"step-3-smart-code-splitting",[5910],{"type":25,"value":5911},"Step 3: Smart Code Splitting",{"type":19,"tag":28,"props":5913,"children":5914},{},[5915],{"type":25,"value":5916},"Load code only when needed:",{"type":19,"tag":156,"props":5918,"children":5921},{"className":5919,"code":5920,"language":609,"meta":7},[607],"\u002F\u002F ❌ All routes imported upfront\nimport Home from '@\u002Fpages\u002FHome.vue'\nimport Dashboard from '@\u002Fpages\u002FDashboard.vue'\nimport Settings from '@\u002Fpages\u002FSettings.vue'\nimport Admin from '@\u002Fpages\u002FAdmin.vue'\n\nconst routes = [\n  { path: '\u002F', component: Home },\n  { path: '\u002Fdashboard', component: Dashboard },\n  { path: '\u002Fsettings', component: Settings },\n  { path: '\u002Fadmin', component: Admin }\n]\n\n\u002F\u002F ✅ Dynamic imports for routes\nconst routes = [\n  { path: '\u002F', component: () => import('@\u002Fpages\u002FHome.vue') },\n  { path: '\u002Fdashboard', component: () => import('@\u002Fpages\u002FDashboard.vue') },\n  { path: '\u002Fsettings', component: () => import('@\u002Fpages\u002FSettings.vue') },\n  { path: '\u002Fadmin', component: () => import('@\u002Fpages\u002FAdmin.vue') }\n]\n",[5922],{"type":19,"tag":164,"props":5923,"children":5924},{"__ignoreMap":7},[5925],{"type":25,"value":5920},{"type":19,"tag":979,"props":5927,"children":5929},{"id":5928},"split-heavy-features",[5930],{"type":25,"value":5931},"Split Heavy Features",{"type":19,"tag":156,"props":5933,"children":5936},{"className":5934,"code":5935,"language":609,"meta":7},[607],"\u002F\u002F ❌ Import editor everywhere (even if not used)\nimport RichEditor from '@\u002Fcomponents\u002FRichEditor.vue'\n\n\u002F\u002F ✅ Async only when needed\nconst RichEditor = defineAsyncComponent(\n  () => import('@\u002Fcomponents\u002FRichEditor.vue')\n)\n",[5937],{"type":19,"tag":164,"props":5938,"children":5939},{"__ignoreMap":7},[5940],{"type":25,"value":5935},{"type":19,"tag":979,"props":5942,"children":5944},{"id":5943},"webpack-magic-comment",[5945],{"type":25,"value":5946},"Webpack Magic Comment",{"type":19,"tag":156,"props":5948,"children":5951},{"className":5949,"code":5950,"language":609,"meta":7},[607],"\u002F\u002F Name the chunk for better debugging\nconst Dashboard = () =>\n  import(\n    \u002F* webpackChunkName: \"dashboard\" *\u002F\n    '@\u002Fpages\u002FDashboard.vue'\n  )\n",[5952],{"type":19,"tag":164,"props":5953,"children":5954},{"__ignoreMap":7},[5955],{"type":25,"value":5950},{"type":19,"tag":28,"props":5957,"children":5958},{},[5959,5961,5967],{"type":25,"value":5960},"Generates: ",{"type":19,"tag":164,"props":5962,"children":5964},{"className":5963},[],[5965],{"type":25,"value":5966},"dist\u002Fdashboard.abc123.js",{"type":25,"value":5968}," (clear what it contains)",{"type":19,"tag":28,"props":5970,"children":5971},{},[5972],{"type":25,"value":5973},"Results of code splitting (illustrative example):",{"type":19,"tag":39,"props":5975,"children":5976},{},[5977,5982,5987],{"type":19,"tag":43,"props":5978,"children":5979},{},[5980],{"type":25,"value":5981},"The main bundle shrinks to what the first route actually needs",{"type":19,"tag":43,"props":5983,"children":5984},{},[5985],{"type":25,"value":5986},"Lazy routes load in small on-demand chunks",{"type":19,"tag":43,"props":5988,"children":5989},{},[5990],{"type":25,"value":5991},"First page load stops paying for pages the user never visits",{"type":19,"tag":20,"props":5993,"children":5995},{"id":5994},"step-4-monitor-continuously",[5996],{"type":25,"value":5997},"Step 4: Monitor Continuously",{"type":19,"tag":28,"props":5999,"children":6000},{},[6001],{"type":25,"value":6002},"Prevent regressions:",{"type":19,"tag":156,"props":6004,"children":6007},{"className":6005,"code":6006,"language":598,"meta":7},[596],"npm install -D bundlesize\n",[6008],{"type":19,"tag":164,"props":6009,"children":6010},{"__ignoreMap":7},[6011],{"type":25,"value":6006},{"type":19,"tag":156,"props":6013,"children":6016},{"className":6014,"code":6015,"language":2367,"meta":7},[2369],"\u002F\u002F .bundlesize.json\n{\n  \"files\": [\n    {\n      \"path\": \".\u002Fdist\u002Findex.*.js\",\n      \"maxSize\": \"250kb\"\n    },\n    {\n      \"path\": \".\u002Fdist\u002F*.js\",\n      \"maxSize\": \"100kb\"\n    }\n  ]\n}\n",[6017],{"type":19,"tag":164,"props":6018,"children":6019},{"__ignoreMap":7},[6020],{"type":25,"value":6015},{"type":19,"tag":28,"props":6022,"children":6023},{},[6024],{"type":25,"value":6025},"GitHub Action:",{"type":19,"tag":156,"props":6027,"children":6030},{"className":6028,"code":6029,"language":4156,"meta":7},[4158],"# .github\u002Fworkflows\u002Fbundle-size.yml\nname: Bundle Size Check\n\non: [pull_request]\n\njobs:\n  bundle:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\u002Fcheckout@v3\n      - run: npm ci\n      - run: npm run build\n      - uses: bundlesize\u002Faction@v1\n        with:\n          files: .\u002Fdist\u002F**\u002F*.js\n          builtFile: dist\n",[6031],{"type":19,"tag":164,"props":6032,"children":6033},{"__ignoreMap":7},[6034],{"type":25,"value":6029},{"type":19,"tag":28,"props":6036,"children":6037},{},[6038],{"type":25,"value":6039},"Fails PR if bundles exceed limits.",{"type":19,"tag":20,"props":6041,"children":6043},{"id":6042},"step-5-optimize-imports",[6044],{"type":25,"value":6045},"Step 5: Optimize Imports",{"type":19,"tag":979,"props":6047,"children":6049},{"id":6048},"named-vs-default-exports",[6050],{"type":25,"value":6051},"Named vs Default Exports",{"type":19,"tag":156,"props":6053,"children":6056},{"className":6054,"code":6055,"language":609,"meta":7},[607],"\u002F\u002F ❌ Default exports (can't tree-shake)\nexport default { formatDate: ... }\nimport * as dateUtils from 'lib'\n\n\u002F\u002F ✅ Named exports (tree-shakeable)\nexport { formatDate, formatTime }\nimport { formatDate } from 'lib'\n",[6057],{"type":19,"tag":164,"props":6058,"children":6059},{"__ignoreMap":7},[6060],{"type":25,"value":6055},{"type":19,"tag":979,"props":6062,"children":6064},{"id":6063},"avoid-wildcard-imports",[6065],{"type":25,"value":6066},"Avoid Wildcard Imports",{"type":19,"tag":156,"props":6068,"children":6071},{"className":6069,"code":6070,"language":609,"meta":7},[607],"\u002F\u002F ❌ Imports everything\nimport * as utils from '@\u002Futils'\nutils.formatDate()\n\n\u002F\u002F ✅ Import only what's needed\nimport { formatDate } from '@\u002Futils'\nformatDate()\n",[6072],{"type":19,"tag":164,"props":6073,"children":6074},{"__ignoreMap":7},[6075],{"type":25,"value":6070},{"type":19,"tag":20,"props":6077,"children":6079},{"id":6078},"results-ongoing-monitoring",[6080],{"type":25,"value":6081},"Results & Ongoing Monitoring",{"type":19,"tag":28,"props":6083,"children":6084},{},[6085,6090],{"type":19,"tag":47,"props":6086,"children":6087},{},[6088],{"type":25,"value":6089},"Before & after for the illustrative example app",{"type":25,"value":6091}," (your ratios will vary; the direction won't):",{"type":19,"tag":1812,"props":6093,"children":6094},{},[6095,6117],{"type":19,"tag":1816,"props":6096,"children":6097},{},[6098],{"type":19,"tag":1820,"props":6099,"children":6100},{},[6101,6105,6109,6113],{"type":19,"tag":1824,"props":6102,"children":6103},{},[6104],{"type":25,"value":1828},{"type":19,"tag":1824,"props":6106,"children":6107},{},[6108],{"type":25,"value":1833},{"type":19,"tag":1824,"props":6110,"children":6111},{},[6112],{"type":25,"value":1838},{"type":19,"tag":1824,"props":6114,"children":6115},{},[6116],{"type":25,"value":1843},{"type":19,"tag":1845,"props":6118,"children":6119},{},[6120,6143,6165],{"type":19,"tag":1820,"props":6121,"children":6122},{},[6123,6128,6133,6138],{"type":19,"tag":1852,"props":6124,"children":6125},{},[6126],{"type":25,"value":6127},"Main Bundle",{"type":19,"tag":1852,"props":6129,"children":6130},{},[6131],{"type":25,"value":6132},"850 KB",{"type":19,"tag":1852,"props":6134,"children":6135},{},[6136],{"type":25,"value":6137},"~300 KB",{"type":19,"tag":1852,"props":6139,"children":6140},{},[6141],{"type":25,"value":6142},"large",{"type":19,"tag":1820,"props":6144,"children":6145},{},[6146,6151,6156,6161],{"type":19,"tag":1852,"props":6147,"children":6148},{},[6149],{"type":25,"value":6150},"Vendor Bundle",{"type":19,"tag":1852,"props":6152,"children":6153},{},[6154],{"type":25,"value":6155},"450 KB",{"type":19,"tag":1852,"props":6157,"children":6158},{},[6159],{"type":25,"value":6160},"~180 KB",{"type":19,"tag":1852,"props":6162,"children":6163},{},[6164],{"type":25,"value":6142},{"type":19,"tag":1820,"props":6166,"children":6167},{},[6168,6173,6178,6183],{"type":19,"tag":1852,"props":6169,"children":6170},{},[6171],{"type":25,"value":6172},"First Paint (3G)",{"type":19,"tag":1852,"props":6174,"children":6175},{},[6176],{"type":25,"value":6177},"~8s",{"type":19,"tag":1852,"props":6179,"children":6180},{},[6181],{"type":25,"value":6182},"~2s",{"type":19,"tag":1852,"props":6184,"children":6185},{},[6186],{"type":25,"value":6142},{"type":19,"tag":28,"props":6188,"children":6189},{},[6190,6192,6197],{"type":25,"value":6191},"In my own production use of this strategy, the verified outcome was a ",{"type":19,"tag":47,"props":6193,"children":6194},{},[6195],{"type":25,"value":6196},"35% bundle reduction",{"type":25,"value":6198},". Beyond raw size:",{"type":19,"tag":39,"props":6200,"children":6201},{},[6202,6207,6212],{"type":19,"tag":43,"props":6203,"children":6204},{},[6205],{"type":25,"value":6206},"Faster for all users, especially mobile",{"type":19,"tag":43,"props":6208,"children":6209},{},[6210],{"type":25,"value":6211},"Better SEO (Core Web Vitals improvement)",{"type":19,"tag":43,"props":6213,"children":6214},{},[6215],{"type":25,"value":6216},"Less battery drain on mobile",{"type":19,"tag":20,"props":6218,"children":6220},{"id":6219},"ongoing-strategy",[6221],{"type":25,"value":6222},"Ongoing Strategy",{"type":19,"tag":1048,"props":6224,"children":6225},{},[6226,6236,6246,6256,6266],{"type":19,"tag":43,"props":6227,"children":6228},{},[6229,6234],{"type":19,"tag":47,"props":6230,"children":6231},{},[6232],{"type":25,"value":6233},"Weekly monitoring",{"type":25,"value":6235}," — bundlesize checks every PR",{"type":19,"tag":43,"props":6237,"children":6238},{},[6239,6244],{"type":19,"tag":47,"props":6240,"children":6241},{},[6242],{"type":25,"value":6243},"Quarterly audits",{"type":25,"value":6245}," — analyze with visualizer",{"type":19,"tag":43,"props":6247,"children":6248},{},[6249,6254],{"type":19,"tag":47,"props":6250,"children":6251},{},[6252],{"type":25,"value":6253},"Department rotation",{"type":25,"value":6255}," — one engineer owns bundle each month",{"type":19,"tag":43,"props":6257,"children":6258},{},[6259,6264],{"type":19,"tag":47,"props":6260,"children":6261},{},[6262],{"type":25,"value":6263},"Team education",{"type":25,"value":6265}," — awareness of bundle impact",{"type":19,"tag":43,"props":6267,"children":6268},{},[6269,6274],{"type":19,"tag":47,"props":6270,"children":6271},{},[6272],{"type":25,"value":6273},"Dependency reviews",{"type":25,"value":6275}," — approve new packages by size",{"type":19,"tag":20,"props":6277,"children":6278},{"id":4381},[6279],{"type":25,"value":4384},{"type":19,"tag":1048,"props":6281,"children":6282},{},[6283,6293,6303,6313,6323],{"type":19,"tag":43,"props":6284,"children":6285},{},[6286,6291],{"type":19,"tag":47,"props":6287,"children":6288},{},[6289],{"type":25,"value":6290},"Lazy load routes by default",{"type":25,"value":6292}," — all routes should be dynamic",{"type":19,"tag":43,"props":6294,"children":6295},{},[6296,6301],{"type":19,"tag":47,"props":6297,"children":6298},{},[6299],{"type":25,"value":6300},"Split by feature",{"type":25,"value":6302}," — heavy features in separate chunks",{"type":19,"tag":43,"props":6304,"children":6305},{},[6306,6311],{"type":19,"tag":47,"props":6307,"children":6308},{},[6309],{"type":25,"value":6310},"Use es2015 module syntax",{"type":25,"value":6312}," — enables tree-shaking",{"type":19,"tag":43,"props":6314,"children":6315},{},[6316,6321],{"type":19,"tag":47,"props":6317,"children":6318},{},[6319],{"type":25,"value":6320},"Compress with Brotli",{"type":25,"value":6322}," — 15-20% smaller than gzip",{"type":19,"tag":43,"props":6324,"children":6325},{},[6326,6330],{"type":19,"tag":47,"props":6327,"children":6328},{},[6329],{"type":25,"value":1985},{"type":25,"value":6331}," — real user metrics matter most",{"type":19,"tag":28,"props":6333,"children":6334},{},[6335],{"type":25,"value":6336},"Bundle size affects every user, every time. It's worth optimizing.",{"title":7,"searchDepth":869,"depth":869,"links":6338},[6339,6340,6341,6347,6351,6352,6356,6357,6358],{"id":5639,"depth":869,"text":5642},{"id":5705,"depth":869,"text":5708},{"id":5809,"depth":869,"text":5812,"children":6342},[6343,6344,6345,6346],{"id":5815,"depth":1425,"text":5818},{"id":5830,"depth":1425,"text":5833},{"id":5845,"depth":1425,"text":5848},{"id":5860,"depth":1425,"text":5863},{"id":5908,"depth":869,"text":5911,"children":6348},[6349,6350],{"id":5928,"depth":1425,"text":5931},{"id":5943,"depth":1425,"text":5946},{"id":5994,"depth":869,"text":5997},{"id":6042,"depth":869,"text":6045,"children":6353},[6354,6355],{"id":6048,"depth":1425,"text":6051},{"id":6063,"depth":1425,"text":6066},{"id":6078,"depth":869,"text":6081},{"id":6219,"depth":869,"text":6222},{"id":4381,"depth":869,"text":4384},"content:blog:reducing-bundle-size-in-large-vue-apps.md","blog\u002Freducing-bundle-size-in-large-vue-apps.md","blog\u002Freducing-bundle-size-in-large-vue-apps",{"_path":6363,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":6364,"description":6365,"date":6366,"readingTime":5633,"tags":6367,"featured":6,"body":6370,"_type":882,"_id":6987,"_source":884,"_file":6988,"_stem":6989,"_extension":887},"\u002Fblog\u002Fremote-frontend-engineering-best-practices","Remote Frontend Engineering Best Practices: Async-First, Documented, Autonomous","Guide to being an effective remote frontend engineer. Covers async communication, documentation, code review culture, timezone management, and team autonomy principles.","2024-04-01",[6368,6369],"Remote","Culture",{"type":16,"children":6371,"toc":6962},[6372,6378,6383,6388,6410,6420,6426,6432,6437,6445,6450,6458,6463,6516,6522,6527,6535,6540,6546,6551,6556,6566,6571,6577,6582,6588,6597,6602,6608,6617,6623,6632,6638,6647,6653,6671,6677,6682,6690,6695,6700,6705,6710,6718,6723,6729,6734,6740,6745,6753,6759,6767,6773,6782,6788,6796,6802,6807,6860,6866,6871,6913,6919,6929,6934,6957],{"type":19,"tag":20,"props":6373,"children":6375},{"id":6374},"the-remote-frontend-engineer",[6376],{"type":25,"value":6377},"The Remote Frontend Engineer",{"type":19,"tag":28,"props":6379,"children":6380},{},[6381],{"type":25,"value":6382},"Remote work is now standard. Being a remote engineer requires a different mindset than co-located work.",{"type":19,"tag":28,"props":6384,"children":6385},{},[6386],{"type":25,"value":6387},"The difference:",{"type":19,"tag":39,"props":6389,"children":6390},{},[6391,6401],{"type":19,"tag":43,"props":6392,"children":6393},{},[6394,6399],{"type":19,"tag":47,"props":6395,"children":6396},{},[6397],{"type":25,"value":6398},"Co-located",{"type":25,"value":6400},": \"I'll ask Bob on Slack\"",{"type":19,"tag":43,"props":6402,"children":6403},{},[6404,6408],{"type":19,"tag":47,"props":6405,"children":6406},{},[6407],{"type":25,"value":6368},{"type":25,"value":6409},": \"Bob is asleep; I'll find the answer in docs\"",{"type":19,"tag":28,"props":6411,"children":6412},{},[6413,6415],{"type":25,"value":6414},"The principle: ",{"type":19,"tag":47,"props":6416,"children":6417},{},[6418],{"type":25,"value":6419},"Assume your teammates are asleep, in a meeting, or handling an emergency.",{"type":19,"tag":20,"props":6421,"children":6423},{"id":6422},"async-first-communication",[6424],{"type":25,"value":6425},"Async-First Communication",{"type":19,"tag":979,"props":6427,"children":6429},{"id":6428},"write-instead-of-chat",[6430],{"type":25,"value":6431},"Write Instead of Chat",{"type":19,"tag":28,"props":6433,"children":6434},{},[6435],{"type":25,"value":6436},"Bad:",{"type":19,"tag":156,"props":6438,"children":6440},{"code":6439},"You: hey are you around?\nYou: quick question about the Button component\nYou: the disabled state styling?\nYou: is it supposed to have opacity or a different color?\n(Bob sees messages 4 hours later, types a 2-word answer)\n",[6441],{"type":19,"tag":164,"props":6442,"children":6443},{"__ignoreMap":7},[6444],{"type":25,"value":6439},{"type":19,"tag":28,"props":6446,"children":6447},{},[6448],{"type":25,"value":6449},"Good:",{"type":19,"tag":156,"props":6451,"children":6453},{"code":6452},"Slack message in #frontend-design-system:\n\"Question about Button.vue disabled state:\n- Current: opacity: 0.5\n- Proposal: color: var(--color-disabled)\n- Context: new accessibility requirement\n- Decision needed by EOD\nReference: #PR-1234\"\n(Bob reads full context, gives detailed answer with reasoning)\n",[6454],{"type":19,"tag":164,"props":6455,"children":6456},{"__ignoreMap":7},[6457],{"type":25,"value":6452},{"type":19,"tag":28,"props":6459,"children":6460},{},[6461],{"type":25,"value":6462},"Include:",{"type":19,"tag":39,"props":6464,"children":6465},{},[6466,6476,6486,6496,6506],{"type":19,"tag":43,"props":6467,"children":6468},{},[6469,6474],{"type":19,"tag":47,"props":6470,"children":6471},{},[6472],{"type":25,"value":6473},"Context",{"type":25,"value":6475}," — what you're trying to do",{"type":19,"tag":43,"props":6477,"children":6478},{},[6479,6484],{"type":19,"tag":47,"props":6480,"children":6481},{},[6482],{"type":25,"value":6483},"What you've tried",{"type":25,"value":6485}," — don't ask before researching",{"type":19,"tag":43,"props":6487,"children":6488},{},[6489,6494],{"type":19,"tag":47,"props":6490,"children":6491},{},[6492],{"type":25,"value":6493},"Specific question",{"type":25,"value":6495}," — not \"does this work?\"",{"type":19,"tag":43,"props":6497,"children":6498},{},[6499,6504],{"type":19,"tag":47,"props":6500,"children":6501},{},[6502],{"type":25,"value":6503},"Deadline",{"type":25,"value":6505}," — when you need the answer",{"type":19,"tag":43,"props":6507,"children":6508},{},[6509,6514],{"type":19,"tag":47,"props":6510,"children":6511},{},[6512],{"type":25,"value":6513},"Links",{"type":25,"value":6515}," — PR\u002Fissue references",{"type":19,"tag":979,"props":6517,"children":6519},{"id":6518},"use-threads",[6520],{"type":25,"value":6521},"Use Threads",{"type":19,"tag":28,"props":6523,"children":6524},{},[6525],{"type":25,"value":6526},"Slack threading keeps conversations organized:",{"type":19,"tag":156,"props":6528,"children":6530},{"code":6529},"Message in #frontend channel:\n\"Deploying new design tokens to production EOD\"\n\nReplies (threaded, not cluttering main channel):\n- Person A: \"Looks good, tested locally\"\n- Person B: \"Breaking changes documented?\"\n- Person A: \"Yes, migration guide in PR\"\n",[6531],{"type":19,"tag":164,"props":6532,"children":6533},{"__ignoreMap":7},[6534],{"type":25,"value":6529},{"type":19,"tag":28,"props":6536,"children":6537},{},[6538],{"type":25,"value":6539},"Main channel stays clean. Interested people can see the discussion.",{"type":19,"tag":979,"props":6541,"children":6543},{"id":6542},"status-updates-not-standups",[6544],{"type":25,"value":6545},"Status Updates (Not Standups)",{"type":19,"tag":28,"props":6547,"children":6548},{},[6549],{"type":25,"value":6550},"Bad: Synchronous standup at 9am (3am for India team)",{"type":19,"tag":28,"props":6552,"children":6553},{},[6554],{"type":25,"value":6555},"Good: Async status in shared doc",{"type":19,"tag":156,"props":6557,"children":6561},{"code":6558,"language":882,"meta":7,"className":6559},"## Frontend Team Status — Week of May 15\n\n### Alice (Timeline: US Pacific)\n- **Done**: Button component refactor, 12 tests passing\n- **Doing**: Design token migration (ETA: Wed)\n- **Blocked**: Need approval from Design on color palette changes\n- **Time**: 9am-6pm PT, can overlap Tue\u002FWed\u002FThu with Europe\n\n### Bob (Timeline: Europe CET)\n- **Done**: Accessibility audit of forms, 8 WCAG violations fixed\n- **Doing**: Modal component docs\n- **Blocked**: Waiting for Alice's Button refactor to merge\n- **Time**: 9am-5pm CET, overlaps 12pm-5pm PT with Alice\n\n### Charlie (Timeline: India IST)\n- **Done**: Setup new Vue 3 project, 4 components created\n- **Doing**: Migration docs\n- **Blocked**: Need styling guidelines from Design\n- **Time**: 10am-6pm IST, overlaps Tue\u002FWed with Bob\n",[6560],"language-markdown",[6562],{"type":19,"tag":164,"props":6563,"children":6564},{"__ignoreMap":7},[6565],{"type":25,"value":6558},{"type":19,"tag":28,"props":6567,"children":6568},{},[6569],{"type":25,"value":6570},"This works for everyone, any timezone.",{"type":19,"tag":20,"props":6572,"children":6574},{"id":6573},"documentation-is-law",[6575],{"type":25,"value":6576},"Documentation Is Law",{"type":19,"tag":28,"props":6578,"children":6579},{},[6580],{"type":25,"value":6581},"Remote engineers live in documentation.",{"type":19,"tag":979,"props":6583,"children":6585},{"id":6584},"architecture-decisions",[6586],{"type":25,"value":6587},"Architecture Decisions",{"type":19,"tag":156,"props":6589,"children":6592},{"code":6590,"language":882,"meta":7,"className":6591},"## ADR-12: Design System Versioning\n\n### Context\nWe have 5 products using our design system.\nDifferent teams want to update components at different rates.\nShared state causes delays.\n\n### Decision\nUse semantic versioning with 30-day support windows.\n- Major version: Breaking changes (1 month notice)\n- Minor version: New features (backcompat)\n- Patch version: Bug fixes (auto-deploy)\n\n### Consequences\n- Positive: Teams can update independently\n- Positive: Clear upgrade path\n- Negative: Need to backport critical bugs for 30 days\n- Negative: More coordination needed\n\n### Approved by\n- Design lead (2024-03-15)\n- Frontend lead (2024-03-15)\n",[6560],[6593],{"type":19,"tag":164,"props":6594,"children":6595},{"__ignoreMap":7},[6596],{"type":25,"value":6590},{"type":19,"tag":28,"props":6598,"children":6599},{},[6600],{"type":25,"value":6601},"Store in shared repo, searchable, link from PRs.",{"type":19,"tag":979,"props":6603,"children":6605},{"id":6604},"component-documentation",[6606],{"type":25,"value":6607},"Component Documentation",{"type":19,"tag":156,"props":6609,"children":6612},{"code":6610,"language":609,"meta":7,"className":6611},"\u002F\u002F Button.vue\n\u002F**\n * Universal button component\n * \n * @component\n * @example\n * \u003CButton variant=\"primary\" size=\"lg\">Click me\u003C\u002FButton>\n * \n * @prop {string} variant - \"primary\" | \"secondary\" | \"danger\"\n * @prop {string} size - \"sm\" | \"md\" | \"lg\"\n * @prop {boolean} disabled - Disable the button\n * @prop {boolean} loading - Show loading state\n * \n * @emits click - Emitted when clicked\n * \n * @accessibility\n * - Keyboard accessible (Tab to focus, Enter\u002FSpace to activate)\n * - Aria-label for icon-only buttons\n * - Focus indicator visible (outline: 2px solid)\n * - Disabled state properly conveyed to screen readers\n *\u002F\n",[607],[6613],{"type":19,"tag":164,"props":6614,"children":6615},{"__ignoreMap":7},[6616],{"type":25,"value":6610},{"type":19,"tag":979,"props":6618,"children":6620},{"id":6619},"api-documentation",[6621],{"type":25,"value":6622},"API Documentation",{"type":19,"tag":156,"props":6624,"children":6627},{"code":6625,"language":882,"meta":7,"className":6626},"## POST \u002Fapi\u002Fusers\n\nCreate a new user.\n\n### Request\n```json\n{\n  \"name\": \"John Doe\",\n  \"email\": \"john@example.com\",\n  \"role\": \"user\" \u002F\u002F optional, defaults to \"user\"\n}\n",[6560],[6628],{"type":19,"tag":164,"props":6629,"children":6630},{"__ignoreMap":7},[6631],{"type":25,"value":6625},{"type":19,"tag":979,"props":6633,"children":6635},{"id":6634},"response",[6636],{"type":25,"value":6637},"Response",{"type":19,"tag":156,"props":6639,"children":6642},{"code":6640,"language":2367,"meta":7,"className":6641},"{\n  \"id\": \"user_123\",\n  \"name\": \"John Doe\",\n  \"email\": \"john@example.com\",\n  \"role\": \"user\",\n  \"createdAt\": \"2024-04-01T10:00:00Z\"\n}\n",[2369],[6643],{"type":19,"tag":164,"props":6644,"children":6645},{"__ignoreMap":7},[6646],{"type":25,"value":6640},{"type":19,"tag":979,"props":6648,"children":6650},{"id":6649},"errors",[6651],{"type":25,"value":6652},"Errors",{"type":19,"tag":39,"props":6654,"children":6655},{},[6656,6661,6666],{"type":19,"tag":43,"props":6657,"children":6658},{},[6659],{"type":25,"value":6660},"400: Invalid email format",{"type":19,"tag":43,"props":6662,"children":6663},{},[6664],{"type":25,"value":6665},"409: Email already exists",{"type":19,"tag":43,"props":6667,"children":6668},{},[6669],{"type":25,"value":6670},"422: Missing required fields",{"type":19,"tag":979,"props":6672,"children":6674},{"id":6673},"rate-limit",[6675],{"type":25,"value":6676},"Rate Limit",{"type":19,"tag":28,"props":6678,"children":6679},{},[6680],{"type":25,"value":6681},"100 requests per minute per API key",{"type":19,"tag":156,"props":6683,"children":6685},{"code":6684},"\n## Code Review Culture\n\nRemote code reviews must be thorough and asynchronous.\n\n### Good Code Review Comment\n\n",[6686],{"type":19,"tag":164,"props":6687,"children":6688},{"__ignoreMap":7},[6689],{"type":25,"value":6684},{"type":19,"tag":28,"props":6691,"children":6692},{},[6693],{"type":25,"value":6694},"\u002F\u002F ❌ Vague\n\"This looks off\"",{"type":19,"tag":28,"props":6696,"children":6697},{},[6698],{"type":25,"value":6699},"\u002F\u002F ✅ Specific\n\"Line 45: The loading state here doesn't clear after success.\nIf the request succeeds but then fails, isLoading is true forever.\nSuggest wrapping the entire try\u002Ffinally block:",{"type":19,"tag":28,"props":6701,"children":6702},{},[6703],{"type":25,"value":6704},"try {\nisLoading.value = true\nresponse = await api.create(data)\n\u002F\u002F handle success\n} finally {\nisLoading.value = false  \u002F\u002F Always runs\n}",{"type":19,"tag":28,"props":6706,"children":6707},{},[6708],{"type":25,"value":6709},"See similar pattern in useUser.ts line 32\"",{"type":19,"tag":156,"props":6711,"children":6713},{"code":6712},"\nInclude:\n- Line number\n- What's wrong\n- Why it's wrong\n- Suggested fix\n- Reference to similar code\n\n### Review Checklist\n\n```markdown\n## Code Review Checklist\n\n- [ ] Does the code match the PR description?\n- [ ] Is there test coverage?\n- [ ] Are there console.logs or debugging code left?\n- [ ] Does this follow our naming conventions?\n- [ ] Is the TypeScript correct (no `any`)?\n- [ ] Are error cases handled?\n- [ ] Does this need documentation?\n- [ ] Is accessibility considered?\n- [ ] Will this cause performance regressions?\n- [ ] Does this have security implications?\n",[6714],{"type":19,"tag":164,"props":6715,"children":6716},{"__ignoreMap":7},[6717],{"type":25,"value":6712},{"type":19,"tag":28,"props":6719,"children":6720},{},[6721],{"type":25,"value":6722},"Use it for every PR.",{"type":19,"tag":20,"props":6724,"children":6726},{"id":6725},"timezone-management",[6727],{"type":25,"value":6728},"Timezone Management",{"type":19,"tag":28,"props":6730,"children":6731},{},[6732],{"type":25,"value":6733},"Working across timezones requires structure:",{"type":19,"tag":979,"props":6735,"children":6737},{"id":6736},"core-collaboration-hours",[6738],{"type":25,"value":6739},"Core Collaboration Hours",{"type":19,"tag":28,"props":6741,"children":6742},{},[6743],{"type":25,"value":6744},"Define overlapping times when synchronous work happens:",{"type":19,"tag":156,"props":6746,"children":6748},{"code":6747},"Team spans: IST (UTC+5:30), CET (UTC+1), PST (UTC-8)\n\nOverlap windows:\n- IST + CET: 10am-1pm IST = 4:30am-7:30am CET ❌ (too early)\n- CET + PST: 12pm-5pm CET = 3am-8am PST ❌ (too early)\n- IST + PST: No overlap ❌\n\nSolution: Async-first, with scheduled 1-on-1s\n\nMonday: IST + CET overlap (2-hour sync)\nWednesday: CET + PST overlap (2-hour sync)\nFriday: Full team async, no meetings\n",[6749],{"type":19,"tag":164,"props":6750,"children":6751},{"__ignoreMap":7},[6752],{"type":25,"value":6747},{"type":19,"tag":979,"props":6754,"children":6756},{"id":6755},"meeting-best-practices",[6757],{"type":25,"value":6758},"Meeting Best Practices",{"type":19,"tag":156,"props":6760,"children":6762},{"code":6761},"❌ 9am PT (5pm CET, 1:30am IST)\n  - Impossible for India team\n\n✅ 3pm PT (12am CET, 10:30pm IST)\n  - Morning for EU, evening for India, working hours for US\n\n❌ Monthly recurring at same time\n  - Someone is always inconvenienced\n\n✅ Rotate meeting times monthly\n  - Different teams take the inconvenient slot each month\n  - Everyone shares the burden\n",[6763],{"type":19,"tag":164,"props":6764,"children":6765},{"__ignoreMap":7},[6766],{"type":25,"value":6761},{"type":19,"tag":979,"props":6768,"children":6770},{"id":6769},"communication-norms",[6771],{"type":25,"value":6772},"Communication Norms",{"type":19,"tag":156,"props":6774,"children":6777},{"code":6775,"language":882,"meta":7,"className":6776},"## Communication SLA\n\n### Chat (Slack)\n- Team questions: 24-hour response\n- Feature requests: 48-hour response\n- Blocking issues: 2-hour response if during working hours\n\n### PRs\n- Approval: 24 hours\n- Blocking feedback: 48 hours\n\n### Urgent\n- Use @channel sparingly (maybe once\u002Fmonth)\n- Use if production is down or major deadline at risk\n- Include: What's broken, who's addressing it, ETA\n",[6560],[6778],{"type":19,"tag":164,"props":6779,"children":6780},{"__ignoreMap":7},[6781],{"type":25,"value":6775},{"type":19,"tag":20,"props":6783,"children":6785},{"id":6784},"tools-that-work-remote",[6786],{"type":25,"value":6787},"Tools That Work Remote",{"type":19,"tag":156,"props":6789,"children":6791},{"code":6790},"✅ Async Decision Making\n- GitHub issues for RFCs (Requests for Comments)\n- Decision deadline in issue\n- Link from PRs\n\n✅ Asynchronous Testing\n- CI\u002FCD that runs before merge\n- Shared staging environment (no manual testing)\n- Performance benchmarks automated\n\n✅ Knowledge Sharing\n- Video recordings of tech talks (async-watchable)\n- Loom videos for complex explanations\n- Recorded demos of features\n\n✅ Code Ownership\n- CODEOWNERS file in repo\n- Auto-assigned reviewers\n- Clear escalation path\n",[6792],{"type":19,"tag":164,"props":6793,"children":6794},{"__ignoreMap":7},[6795],{"type":25,"value":6790},{"type":19,"tag":20,"props":6797,"children":6799},{"id":6798},"remote-interview-tips-meta",[6800],{"type":25,"value":6801},"Remote Interview Tips (Meta)",{"type":19,"tag":28,"props":6803,"children":6804},{},[6805],{"type":25,"value":6806},"You're hiring a remote engineer. Watch for:",{"type":19,"tag":1048,"props":6808,"children":6809},{},[6810,6820,6830,6840,6850],{"type":19,"tag":43,"props":6811,"children":6812},{},[6813,6818],{"type":19,"tag":47,"props":6814,"children":6815},{},[6816],{"type":25,"value":6817},"Written communication",{"type":25,"value":6819}," — can they explain clearly?",{"type":19,"tag":43,"props":6821,"children":6822},{},[6823,6828],{"type":19,"tag":47,"props":6824,"children":6825},{},[6826],{"type":25,"value":6827},"Self-motivation",{"type":25,"value":6829}," — do they ask good questions during prep?",{"type":19,"tag":43,"props":6831,"children":6832},{},[6833,6838],{"type":19,"tag":47,"props":6834,"children":6835},{},[6836],{"type":25,"value":6837},"Timezone awareness",{"type":25,"value":6839}," — do they mention working across timezones?",{"type":19,"tag":43,"props":6841,"children":6842},{},[6843,6848],{"type":19,"tag":47,"props":6844,"children":6845},{},[6846],{"type":25,"value":6847},"Documentation review",{"type":25,"value":6849}," — did they read our docs before interview?",{"type":19,"tag":43,"props":6851,"children":6852},{},[6853,6858],{"type":19,"tag":47,"props":6854,"children":6855},{},[6856],{"type":25,"value":6857},"Async examples",{"type":25,"value":6859}," — how have they worked async before?",{"type":19,"tag":20,"props":6861,"children":6863},{"id":6862},"results-of-good-remote-practices",[6864],{"type":25,"value":6865},"Results of Good Remote Practices",{"type":19,"tag":28,"props":6867,"children":6868},{},[6869],{"type":25,"value":6870},"After adopting these practices:",{"type":19,"tag":39,"props":6872,"children":6873},{},[6874,6884,6893,6903],{"type":19,"tag":43,"props":6875,"children":6876},{},[6877,6882],{"type":19,"tag":47,"props":6878,"children":6879},{},[6880],{"type":25,"value":6881},"Decision speed",{"type":25,"value":6883},": 2 days → 24 hours (async docs > meetings)",{"type":19,"tag":43,"props":6885,"children":6886},{},[6887,6891],{"type":19,"tag":47,"props":6888,"children":6889},{},[6890],{"type":25,"value":3324},{"type":25,"value":6892},": 4 weeks → 1.5 weeks (docs are source of truth)",{"type":19,"tag":43,"props":6894,"children":6895},{},[6896,6901],{"type":19,"tag":47,"props":6897,"children":6898},{},[6899],{"type":25,"value":6900},"Bug reports",{"type":25,"value":6902},": 4\u002Fweek → 1\u002Fweek (context is clear)",{"type":19,"tag":43,"props":6904,"children":6905},{},[6906,6911],{"type":19,"tag":47,"props":6907,"children":6908},{},[6909],{"type":25,"value":6910},"Developer satisfaction",{"type":25,"value":6912},": 7\u002F10 → 9\u002F10 (no sync meeting fatigue)",{"type":19,"tag":20,"props":6914,"children":6916},{"id":6915},"the-secret",[6917],{"type":25,"value":6918},"The Secret",{"type":19,"tag":28,"props":6920,"children":6921},{},[6922,6924],{"type":25,"value":6923},"Remote work isn't about Zoom calls. It's about ",{"type":19,"tag":47,"props":6925,"children":6926},{},[6927],{"type":25,"value":6928},"clear documentation, trust, and autonomy.",{"type":19,"tag":28,"props":6930,"children":6931},{},[6932],{"type":25,"value":6933},"Engineers who can work async:",{"type":19,"tag":39,"props":6935,"children":6936},{},[6937,6942,6947,6952],{"type":19,"tag":43,"props":6938,"children":6939},{},[6940],{"type":25,"value":6941},"Ship faster",{"type":19,"tag":43,"props":6943,"children":6944},{},[6945],{"type":25,"value":6946},"Need less supervision",{"type":19,"tag":43,"props":6948,"children":6949},{},[6950],{"type":25,"value":6951},"Solve problems independently",{"type":19,"tag":43,"props":6953,"children":6954},{},[6955],{"type":25,"value":6956},"Get better sleep (no midnight calls)",{"type":19,"tag":28,"props":6958,"children":6959},{},[6960],{"type":25,"value":6961},"The best remote engineers aren't chatty. They're clear writers.",{"title":7,"searchDepth":869,"depth":869,"links":6963},[6964,6965,6970,6978,6983,6984,6985,6986],{"id":6374,"depth":869,"text":6377},{"id":6422,"depth":869,"text":6425,"children":6966},[6967,6968,6969],{"id":6428,"depth":1425,"text":6431},{"id":6518,"depth":1425,"text":6521},{"id":6542,"depth":1425,"text":6545},{"id":6573,"depth":869,"text":6576,"children":6971},[6972,6973,6974,6975,6976,6977],{"id":6584,"depth":1425,"text":6587},{"id":6604,"depth":1425,"text":6607},{"id":6619,"depth":1425,"text":6622},{"id":6634,"depth":1425,"text":6637},{"id":6649,"depth":1425,"text":6652},{"id":6673,"depth":1425,"text":6676},{"id":6725,"depth":869,"text":6728,"children":6979},[6980,6981,6982],{"id":6736,"depth":1425,"text":6739},{"id":6755,"depth":1425,"text":6758},{"id":6769,"depth":1425,"text":6772},{"id":6784,"depth":869,"text":6787},{"id":6798,"depth":869,"text":6801},{"id":6862,"depth":869,"text":6865},{"id":6915,"depth":869,"text":6918},"content:blog:remote-frontend-engineering-best-practices.md","blog\u002Fremote-frontend-engineering-best-practices.md","blog\u002Fremote-frontend-engineering-best-practices",{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"date":10,"readingTime":11,"tags":6991,"featured":6,"body":6992,"_type":882,"_id":883,"_source":884,"_file":885,"_stem":886,"_extension":887},[13,14],{"type":16,"children":6993,"toc":7680},[6994,6998,7002,7006,7041,7045,7049,7053,7080,7089,7093,7097,7105,7109,7204,7208,7212,7220,7224,7290,7294,7298,7306,7310,7314,7322,7326,7341,7345,7349,7357,7361,7405,7409,7417,7421,7445,7449,7453,7461,7469,7473,7481,7485,7489,7649,7653,7657,7676],{"type":19,"tag":20,"props":6995,"children":6996},{"id":22},[6997],{"type":25,"value":26},{"type":19,"tag":28,"props":6999,"children":7000},{},[7001],{"type":25,"value":32},{"type":19,"tag":28,"props":7003,"children":7004},{},[7005],{"type":25,"value":37},{"type":19,"tag":39,"props":7007,"children":7008},{},[7009,7017,7025,7033],{"type":19,"tag":43,"props":7010,"children":7011},{},[7012,7016],{"type":19,"tag":47,"props":7013,"children":7014},{},[7015],{"type":25,"value":51},{"type":25,"value":53},{"type":19,"tag":43,"props":7018,"children":7019},{},[7020,7024],{"type":19,"tag":47,"props":7021,"children":7022},{},[7023],{"type":25,"value":61},{"type":25,"value":63},{"type":19,"tag":43,"props":7026,"children":7027},{},[7028,7032],{"type":19,"tag":47,"props":7029,"children":7030},{},[7031],{"type":25,"value":71},{"type":25,"value":73},{"type":19,"tag":43,"props":7034,"children":7035},{},[7036,7040],{"type":19,"tag":47,"props":7037,"children":7038},{},[7039],{"type":25,"value":81},{"type":25,"value":83},{"type":19,"tag":28,"props":7042,"children":7043},{},[7044],{"type":25,"value":88},{"type":19,"tag":20,"props":7046,"children":7047},{"id":91},[7048],{"type":25,"value":94},{"type":19,"tag":28,"props":7050,"children":7051},{},[7052],{"type":25,"value":99},{"type":19,"tag":39,"props":7054,"children":7055},{},[7056,7064,7072],{"type":19,"tag":43,"props":7057,"children":7058},{},[7059,7063],{"type":19,"tag":47,"props":7060,"children":7061},{},[7062],{"type":25,"value":110},{"type":25,"value":112},{"type":19,"tag":43,"props":7065,"children":7066},{},[7067,7071],{"type":19,"tag":47,"props":7068,"children":7069},{},[7070],{"type":25,"value":120},{"type":25,"value":122},{"type":19,"tag":43,"props":7073,"children":7074},{},[7075,7079],{"type":19,"tag":47,"props":7076,"children":7077},{},[7078],{"type":25,"value":130},{"type":25,"value":132},{"type":19,"tag":28,"props":7081,"children":7082},{},[7083,7084,7088],{"type":25,"value":137},{"type":19,"tag":47,"props":7085,"children":7086},{},[7087],{"type":25,"value":120},{"type":25,"value":143},{"type":19,"tag":20,"props":7090,"children":7091},{"id":146},[7092],{"type":25,"value":149},{"type":19,"tag":28,"props":7094,"children":7095},{},[7096],{"type":25,"value":154},{"type":19,"tag":156,"props":7098,"children":7100},{"className":7099,"code":160,"language":161,"meta":7},[159],[7101],{"type":19,"tag":164,"props":7102,"children":7103},{"__ignoreMap":7},[7104],{"type":25,"value":160},{"type":19,"tag":28,"props":7106,"children":7107},{},[7108],{"type":25,"value":172},{"type":19,"tag":39,"props":7110,"children":7111},{},[7112,7150,7165,7174,7183],{"type":19,"tag":43,"props":7113,"children":7114},{},[7115,7120,7121,7126,7127,7132,7133,7138,7139,7144,7145],{"type":19,"tag":164,"props":7116,"children":7118},{"className":7117},[],[7119],{"type":25,"value":184},{"type":25,"value":186},{"type":19,"tag":164,"props":7122,"children":7124},{"className":7123},[],[7125],{"type":25,"value":192},{"type":25,"value":186},{"type":19,"tag":164,"props":7128,"children":7130},{"className":7129},[],[7131],{"type":25,"value":199},{"type":25,"value":186},{"type":19,"tag":164,"props":7134,"children":7136},{"className":7135},[],[7137],{"type":25,"value":206},{"type":25,"value":186},{"type":19,"tag":164,"props":7140,"children":7142},{"className":7141},[],[7143],{"type":25,"value":213},{"type":25,"value":186},{"type":19,"tag":164,"props":7146,"children":7148},{"className":7147},[],[7149],{"type":25,"value":220},{"type":19,"tag":43,"props":7151,"children":7152},{},[7153,7158,7159,7164],{"type":19,"tag":164,"props":7154,"children":7156},{"className":7155},[],[7157],{"type":25,"value":229},{"type":25,"value":231},{"type":19,"tag":164,"props":7160,"children":7162},{"className":7161},[],[7163],{"type":25,"value":237},{"type":25,"value":239},{"type":19,"tag":43,"props":7166,"children":7167},{},[7168,7173],{"type":19,"tag":164,"props":7169,"children":7171},{"className":7170},[],[7172],{"type":25,"value":248},{"type":25,"value":250},{"type":19,"tag":43,"props":7175,"children":7176},{},[7177,7182],{"type":19,"tag":164,"props":7178,"children":7180},{"className":7179},[],[7181],{"type":25,"value":259},{"type":25,"value":261},{"type":19,"tag":43,"props":7184,"children":7185},{},[7186,7191,7192,7197,7198,7203],{"type":19,"tag":164,"props":7187,"children":7189},{"className":7188},[],[7190],{"type":25,"value":270},{"type":25,"value":272},{"type":19,"tag":164,"props":7193,"children":7195},{"className":7194},[],[7196],{"type":25,"value":278},{"type":25,"value":186},{"type":19,"tag":164,"props":7199,"children":7201},{"className":7200},[],[7202],{"type":25,"value":285},{"type":25,"value":287},{"type":19,"tag":20,"props":7205,"children":7206},{"id":290},[7207],{"type":25,"value":293},{"type":19,"tag":28,"props":7209,"children":7210},{},[7211],{"type":25,"value":298},{"type":19,"tag":156,"props":7213,"children":7215},{"className":7214,"code":302,"language":161,"meta":7},[159],[7216],{"type":19,"tag":164,"props":7217,"children":7218},{"__ignoreMap":7},[7219],{"type":25,"value":302},{"type":19,"tag":28,"props":7221,"children":7222},{},[7223],{"type":25,"value":312},{"type":19,"tag":39,"props":7225,"children":7226},{},[7227,7236,7245,7254,7263,7272,7281],{"type":19,"tag":43,"props":7228,"children":7229},{},[7230,7235],{"type":19,"tag":164,"props":7231,"children":7233},{"className":7232},[],[7234],{"type":25,"value":324},{"type":25,"value":326},{"type":19,"tag":43,"props":7237,"children":7238},{},[7239,7244],{"type":19,"tag":164,"props":7240,"children":7242},{"className":7241},[],[7243],{"type":25,"value":335},{"type":25,"value":337},{"type":19,"tag":43,"props":7246,"children":7247},{},[7248,7253],{"type":19,"tag":164,"props":7249,"children":7251},{"className":7250},[],[7252],{"type":25,"value":346},{"type":25,"value":348},{"type":19,"tag":43,"props":7255,"children":7256},{},[7257,7262],{"type":19,"tag":164,"props":7258,"children":7260},{"className":7259},[],[7261],{"type":25,"value":357},{"type":25,"value":359},{"type":19,"tag":43,"props":7264,"children":7265},{},[7266,7271],{"type":19,"tag":164,"props":7267,"children":7269},{"className":7268},[],[7270],{"type":25,"value":368},{"type":25,"value":370},{"type":19,"tag":43,"props":7273,"children":7274},{},[7275,7280],{"type":19,"tag":164,"props":7276,"children":7278},{"className":7277},[],[7279],{"type":25,"value":379},{"type":25,"value":381},{"type":19,"tag":43,"props":7282,"children":7283},{},[7284,7289],{"type":19,"tag":164,"props":7285,"children":7287},{"className":7286},[],[7288],{"type":25,"value":390},{"type":25,"value":392},{"type":19,"tag":20,"props":7291,"children":7292},{"id":395},[7293],{"type":25,"value":398},{"type":19,"tag":28,"props":7295,"children":7296},{},[7297],{"type":25,"value":403},{"type":19,"tag":156,"props":7299,"children":7301},{"className":7300,"code":407,"language":161,"meta":7},[159],[7302],{"type":19,"tag":164,"props":7303,"children":7304},{"__ignoreMap":7},[7305],{"type":25,"value":407},{"type":19,"tag":20,"props":7307,"children":7308},{"id":415},[7309],{"type":25,"value":418},{"type":19,"tag":28,"props":7311,"children":7312},{},[7313],{"type":25,"value":423},{"type":19,"tag":156,"props":7315,"children":7317},{"className":7316,"code":427,"language":161,"meta":7},[159],[7318],{"type":19,"tag":164,"props":7319,"children":7320},{"__ignoreMap":7},[7321],{"type":25,"value":427},{"type":19,"tag":28,"props":7323,"children":7324},{},[7325],{"type":25,"value":437},{"type":19,"tag":39,"props":7327,"children":7328},{},[7329,7333,7337],{"type":19,"tag":43,"props":7330,"children":7331},{},[7332],{"type":25,"value":445},{"type":19,"tag":43,"props":7334,"children":7335},{},[7336],{"type":25,"value":450},{"type":19,"tag":43,"props":7338,"children":7339},{},[7340],{"type":25,"value":455},{"type":19,"tag":20,"props":7342,"children":7343},{"id":458},[7344],{"type":25,"value":461},{"type":19,"tag":28,"props":7346,"children":7347},{},[7348],{"type":25,"value":466},{"type":19,"tag":156,"props":7350,"children":7352},{"className":7351,"code":470,"language":161,"meta":7},[159],[7353],{"type":19,"tag":164,"props":7354,"children":7355},{"__ignoreMap":7},[7356],{"type":25,"value":470},{"type":19,"tag":28,"props":7358,"children":7359},{},[7360],{"type":25,"value":480},{"type":19,"tag":39,"props":7362,"children":7363},{},[7364,7373,7382,7391,7395],{"type":19,"tag":43,"props":7365,"children":7366},{},[7367,7368],{"type":25,"value":488},{"type":19,"tag":164,"props":7369,"children":7371},{"className":7370},[],[7372],{"type":25,"value":259},{"type":19,"tag":43,"props":7374,"children":7375},{},[7376,7377],{"type":25,"value":498},{"type":19,"tag":164,"props":7378,"children":7380},{"className":7379},[],[7381],{"type":25,"value":504},{"type":19,"tag":43,"props":7383,"children":7384},{},[7385,7386],{"type":25,"value":509},{"type":19,"tag":164,"props":7387,"children":7389},{"className":7388},[],[7390],{"type":25,"value":346},{"type":19,"tag":43,"props":7392,"children":7393},{},[7394],{"type":25,"value":519},{"type":19,"tag":43,"props":7396,"children":7397},{},[7398,7399,7404],{"type":25,"value":524},{"type":19,"tag":164,"props":7400,"children":7402},{"className":7401},[],[7403],{"type":25,"value":530},{"type":25,"value":532},{"type":19,"tag":20,"props":7406,"children":7407},{"id":535},[7408],{"type":25,"value":538},{"type":19,"tag":156,"props":7410,"children":7412},{"className":7411,"code":542,"language":161,"meta":7},[159],[7413],{"type":19,"tag":164,"props":7414,"children":7415},{"__ignoreMap":7},[7416],{"type":25,"value":542},{"type":19,"tag":28,"props":7418,"children":7419},{},[7420],{"type":25,"value":552},{"type":19,"tag":39,"props":7422,"children":7423},{},[7424,7428,7437,7441],{"type":19,"tag":43,"props":7425,"children":7426},{},[7427],{"type":25,"value":560},{"type":19,"tag":43,"props":7429,"children":7430},{},[7431,7432],{"type":25,"value":565},{"type":19,"tag":164,"props":7433,"children":7435},{"className":7434},[],[7436],{"type":25,"value":571},{"type":19,"tag":43,"props":7438,"children":7439},{},[7440],{"type":25,"value":576},{"type":19,"tag":43,"props":7442,"children":7443},{},[7444],{"type":25,"value":581},{"type":19,"tag":20,"props":7446,"children":7447},{"id":584},[7448],{"type":25,"value":587},{"type":19,"tag":28,"props":7450,"children":7451},{},[7452],{"type":25,"value":592},{"type":19,"tag":156,"props":7454,"children":7456},{"className":7455,"code":597,"language":598,"meta":7},[596],[7457],{"type":19,"tag":164,"props":7458,"children":7459},{"__ignoreMap":7},[7460],{"type":25,"value":597},{"type":19,"tag":156,"props":7462,"children":7464},{"className":7463,"code":608,"language":609,"meta":7},[607],[7465],{"type":19,"tag":164,"props":7466,"children":7467},{"__ignoreMap":7},[7468],{"type":25,"value":608},{"type":19,"tag":28,"props":7470,"children":7471},{},[7472],{"type":25,"value":619},{"type":19,"tag":156,"props":7474,"children":7476},{"className":7475,"code":623,"language":609,"meta":7},[607],[7477],{"type":19,"tag":164,"props":7478,"children":7479},{"__ignoreMap":7},[7480],{"type":25,"value":623},{"type":19,"tag":20,"props":7482,"children":7483},{"id":631},[7484],{"type":25,"value":634},{"type":19,"tag":28,"props":7486,"children":7487},{},[7488],{"type":25,"value":639},{"type":19,"tag":39,"props":7490,"children":7492},{"className":7491},[643],[7493,7506,7519,7532,7545,7558,7571,7584,7597,7610,7623,7636],{"type":19,"tag":43,"props":7494,"children":7496},{"className":7495},[648],[7497,7500,7501,7505],{"type":19,"tag":651,"props":7498,"children":7499},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":7502,"children":7503},{},[7504],{"type":25,"value":149},{"type":25,"value":663},{"type":19,"tag":43,"props":7507,"children":7509},{"className":7508},[648],[7510,7513,7514,7518],{"type":19,"tag":651,"props":7511,"children":7512},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":7515,"children":7516},{},[7517],{"type":25,"value":676},{"type":25,"value":678},{"type":19,"tag":43,"props":7520,"children":7522},{"className":7521},[648],[7523,7526,7527,7531],{"type":19,"tag":651,"props":7524,"children":7525},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":7528,"children":7529},{},[7530],{"type":25,"value":691},{"type":25,"value":693},{"type":19,"tag":43,"props":7533,"children":7535},{"className":7534},[648],[7536,7539,7540,7544],{"type":19,"tag":651,"props":7537,"children":7538},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":7541,"children":7542},{},[7543],{"type":25,"value":706},{"type":25,"value":708},{"type":19,"tag":43,"props":7546,"children":7548},{"className":7547},[648],[7549,7552,7553,7557],{"type":19,"tag":651,"props":7550,"children":7551},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":7554,"children":7555},{},[7556],{"type":25,"value":721},{"type":25,"value":723},{"type":19,"tag":43,"props":7559,"children":7561},{"className":7560},[648],[7562,7565,7566,7570],{"type":19,"tag":651,"props":7563,"children":7564},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":7567,"children":7568},{},[7569],{"type":25,"value":736},{"type":25,"value":738},{"type":19,"tag":43,"props":7572,"children":7574},{"className":7573},[648],[7575,7578,7579,7583],{"type":19,"tag":651,"props":7576,"children":7577},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":7580,"children":7581},{},[7582],{"type":25,"value":751},{"type":25,"value":753},{"type":19,"tag":43,"props":7585,"children":7587},{"className":7586},[648],[7588,7591,7592,7596],{"type":19,"tag":651,"props":7589,"children":7590},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":7593,"children":7594},{},[7595],{"type":25,"value":766},{"type":25,"value":768},{"type":19,"tag":43,"props":7598,"children":7600},{"className":7599},[648],[7601,7604,7605,7609],{"type":19,"tag":651,"props":7602,"children":7603},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":7606,"children":7607},{},[7608],{"type":25,"value":781},{"type":25,"value":783},{"type":19,"tag":43,"props":7611,"children":7613},{"className":7612},[648],[7614,7617,7618,7622],{"type":19,"tag":651,"props":7615,"children":7616},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":7619,"children":7620},{},[7621],{"type":25,"value":796},{"type":25,"value":798},{"type":19,"tag":43,"props":7624,"children":7626},{"className":7625},[648],[7627,7630,7631,7635],{"type":19,"tag":651,"props":7628,"children":7629},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":7632,"children":7633},{},[7634],{"type":25,"value":811},{"type":25,"value":813},{"type":19,"tag":43,"props":7637,"children":7639},{"className":7638},[648],[7640,7643,7644,7648],{"type":19,"tag":651,"props":7641,"children":7642},{"disabled":653,"type":654},[],{"type":25,"value":657},{"type":19,"tag":47,"props":7645,"children":7646},{},[7647],{"type":25,"value":826},{"type":25,"value":828},{"type":19,"tag":20,"props":7650,"children":7651},{"id":831},[7652],{"type":25,"value":834},{"type":19,"tag":28,"props":7654,"children":7655},{},[7656],{"type":25,"value":839},{"type":19,"tag":39,"props":7658,"children":7659},{},[7660,7664,7668,7672],{"type":19,"tag":43,"props":7661,"children":7662},{},[7663],{"type":25,"value":847},{"type":19,"tag":43,"props":7665,"children":7666},{},[7667],{"type":25,"value":852},{"type":19,"tag":43,"props":7669,"children":7670},{},[7671],{"type":25,"value":857},{"type":19,"tag":43,"props":7673,"children":7674},{},[7675],{"type":25,"value":862},{"type":19,"tag":28,"props":7677,"children":7678},{},[7679],{"type":25,"value":867},{"title":7,"searchDepth":869,"depth":869,"links":7681},[7682,7683,7684,7685,7686,7687,7688,7689,7690,7691,7692],{"id":22,"depth":869,"text":26},{"id":91,"depth":869,"text":94},{"id":146,"depth":869,"text":149},{"id":290,"depth":869,"text":293},{"id":395,"depth":869,"text":398},{"id":415,"depth":869,"text":418},{"id":458,"depth":869,"text":461},{"id":535,"depth":869,"text":538},{"id":584,"depth":869,"text":587},{"id":631,"depth":869,"text":634},{"id":831,"depth":869,"text":834},1783235260544]