25 Micro-Apps in One Nuxt Shell
Back to blog

25 Micro-Apps in One Nuxt Shell

·Dan Castrillo

the shell problem

so you've got 25 independent micro-apps and they all need to feel like one product. clicking "Messages" should take you somewhere that looks and feels like the same app you 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 App Shell holds it all together. it's a thin Nuxt layer that handles everything shared. navigation, authentication, theming, and routing between surfaces.

how the shell works

the shell itself is minimal. it renders the global nav, manages auth state, and 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 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

routing took the most iteration. with a monolithic Nuxt app, 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 someone clicks a link that goes from the messaging surface to a job posting, the shell intercepts that navigation, unmounts the current surface, and mounts 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

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

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 works. teams move faster, deploys are safer, and the product feels cohesive even though it's 25 independent apps under the hood.

Related Posts