Back to blog

25 Micro-Apps in One Nuxt Shell

the shell problem

so you've got 25 independent micro-apps and they all need to feel like one product. users don't care about your architecture — they care that clicking "Messages" takes them somewhere that looks and feels like the same app they were just in. that's the whole challenge with smart surfaces at Upwork. each surface is its own SPA bundle, its own build, its own deploy. but to the user it's just... Upwork.

the thing that holds it all together is the App Shell. it's a thin Nuxt layer that handles everything shared: navigation, authentication, theming, and most importantly — routing between surfaces.

how the shell works

the shell itself is surprisingly minimal. it renders the global nav, manages auth state, and dynamically loads whichever surface the current route maps to:

<!-- App Shell -->
<template>
  <div id="app-shell">
    <GlobalNav />
    <Suspense>
      <component :is="currentSurface" v-bind="surfaceProps" />
    </Suspense>
  </div>
</template>
 
<script setup>
const route = useRoute()
 
const currentSurface = computed(() =>
  defineAsyncComponent(() => import(`./surfaces/${route.meta.surface}.vue`))
)
 
const surfaceProps = computed(() => ({
  auth: useAuth(),
  params: route.params,
}))
</script>

that defineAsyncComponent call is doing the heavy lifting. when you navigate to /jobs/123/applicants, the shell looks up which surface owns that route, lazy-loads the bundle, and mounts it. the surface gets auth context and route params as props. it doesn't need to know about any other surface. it doesn't even need to know it's running inside a shell.

shared auth, isolated everything else

authentication was the first thing we pulled into the shell. every surface needs to make authenticated API calls, and having 25 separate auth implementations would be insane. the shell handles the OAuth flow, manages token refresh, and passes the auth context down to whatever surface is mounted.

// shell/composables/useShellAuth.ts
export function useShellAuth() {
  const token = useCookie("upwork_token")
 
  async function refreshToken() {
    const { data } = await $fetch("/api/auth/refresh", {
      headers: { Authorization: `Bearer ${token.value}` },
    })
    token.value = data.token
  }
 
  return { token, refreshToken, isAuthenticated: computed(() => !!token.value) }
}

surfaces receive this through props. they never touch cookies directly, never manage their own token lifecycle. if we change how auth works tomorrow — switch providers, add MFA, whatever — we change it in one place and every surface gets it for free.

the routing challenge

this is the part that took the most iteration. with a monolithic Nuxt app, routing is simple — you have a pages directory and Nuxt generates routes from the file structure. with 25 independent surfaces, each with their own internal routes, things get complicated fast.

our approach: the shell owns the top-level route map. it knows that /messages/* belongs to the messaging surface, /jobs/*/applicants belongs to the application review surface, and so on. within a surface, routing is handled internally — the surface can have its own sub-routes, its own navigation state, whatever it needs.

the tricky part is cross-surface navigation. when a user clicks a link that goes from the messaging surface to a job posting, the shell has to intercept that navigation, unmount the current surface, and mount the new one. we handle this with a navigation guard:

// shell/plugins/surfaceRouter.ts
export default defineNuxtPlugin((nuxtApp) => {
  const router = useRouter()
 
  router.beforeEach((to, from) => {
    const toSurface = to.meta.surface
    const fromSurface = from.meta.surface
 
    if (toSurface !== fromSurface) {
      // crossing surface boundary — let the shell handle it
      return navigateTo(to.fullPath, { external: false })
    }
  })
})

staging and independence

one of the best things about this architecture is how it affects development workflow. each surface can be run in isolation with a lightweight dev shell that mocks the auth context and nav. you don't need to boot the entire platform to work on the payment settings surface. just run it standalone, point it at a staging API, and go.

we also get independent deploys. ship a fix to the messaging surface without touching anything else. no coordinated release trains, no "wait for the next deploy window." if your surface passes its tests, it ships.

what i'm still figuring out

the edges are where it gets messy. surfaces that need to share state — like when the messaging surface needs to know about a job's status from the jobs surface — don't have a clean answer yet. we're experimenting with a shared event bus in the shell, but it feels like it could become the new monolith if we're not careful.

there's also the bundle size question. 25 lazy-loaded bundles means 25 potential loading spinners as users navigate around. we're pre-fetching likely next surfaces based on navigation patterns, but it's still not as instant as a monolith where everything is already loaded.

the architecture is working though. teams move faster, deploys are safer, and the product feels cohesive even though it's 25 independent apps under the hood. sometimes the boring solution — a thin shell that loads fat surfaces — is the right one.