← Back to Blog
React2026-05-09·74 min read

I shipped a video player to npm — twice. What I learned about scoped CSS, "use client", and Nuxt modules.

By Abdullah Al Fahad

Engineering Cross-Framework Component Libraries: Isolation, Bundling, and Integration Patterns

Current Situation Analysis

Modern frontend development relies heavily on shared component libraries to maintain consistency across applications. However, publishing a UI library that functions reliably across multiple frameworks—specifically React and Vue—introduces a complex matrix of isolation, bundling, and runtime integration challenges.

The industry standard for "drop-in" components often fails in production environments due to three pervasive issues:

  1. CSS Namespace Pollution: Many libraries bundle global resets, theme tokens, or wildcard selectors. When integrated into a mature design system, these styles cause visual regressions that are difficult to debug. The problem is exacerbated by utility-first frameworks like Tailwind CSS, which generate extensive preflight resets that can bloat library bundles and leak into consumer applications.
  2. Framework Boundary Violations: Bundlers like Vite and Rollup optimize code aggressively, often stripping framework-specific directives (e.g., "use client" in Next.js) that are critical for correct runtime behavior. Additionally, framework-specific integrations (like Nuxt modules) often introduce Node.js dependencies that crash browser bundles if not properly isolated.
  3. Lifecycle Divergence: React and Vue handle component mounting and reactivity differently. Patterns that work in one framework, such as immediate watchers in Vue's Composition API, can cause race conditions where DOM references are accessed before they are bound, leading to silent failures in media initialization.

Data from production audits of popular UI packages reveals that libraries shipping unscoped CSS average 15–20 KB of unused styles in consumer apps, while those failing to preserve build directives result in immediate runtime errors for server-side rendering consumers.

WOW Moment: Key Findings

The difference between a naive library implementation and an engineered cross-framework solution is measurable in bundle size, isolation guarantees, and integration friction.

Strategy CSS Footprint Global Leakage Next.js/Nuxt Compatibility Bundle Integrity
Naive Tailwind Lib ~17 KB High (:root, *) Broken (Directives stripped) Node deps leak to browser
Scoped Hand-Crafted ~2.8 KB Zero (Single root class) Native (Directives preserved) Clean browser bundle
Subpath Isolation N/A N/A Framework-specific entry Zero cross-contamination

Why this matters: Adopting scoped CSS and subpath exports reduces the consumer's bundle size by over 80% while eliminating CSS specificity wars. Preserving build directives and isolating Node dependencies ensures the library works out-of-the-box in modern meta-frameworks without requiring consumers to write wrappers or configure webpack aliases.

Core Solution

Building a robust cross-framework library requires deliberate architectural decisions at the CSS, build, and integration layers.

1. Strict CSS Isolation

Treat your stylesheet as a public API. Every selector you publish is a contract. Avoid utility frameworks in library code; they generate too much boilerplate and risk leaking resets. Instead, use a single root class to scope all styles.

Implementation: Define a root namespace (e.g., .mc-player) and nest all component styles under it. Avoid :root variable definitions and global element selectors.

/* src/styles/player.css */
.mc-player {
  position: relative;
  display: inline-flex;
  border-radius: 0.5rem;
  overflow: hidden;
  background-color: #111;
  font-family: system-ui, sans-serif;
}

.mc-player__video {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.mc-player__overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.4);
  transition: opacity 0.2s ease;
}

.mc-player__overlay.is-hidden {
  opacity: 0;
  pointer-events: none;
}

/* Consumer override example */
.mc-player {
  border-radius: 1rem;
}

This approach reduces CSS size significantly and allows consumers to override styles using predictable specificity without !important hacks.

2. Preserving Framework Directives

When building for React ecosystems like Next.js App Router, components using hooks must be marked with "use client". However, Rollup (used by Vite) strips top-of-file comments during the build process. If the directive is removed, server components will crash when importing the library.

Implementation: Create a custom Rollup plugin that re-injects the directive after bundling.

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

function preserveClientDirective() {
  return {
    name: 'preserve-client-directive',
    renderChunk(code: string) {
      // Check if the chunk already has the directive
      if (code.startsWith('"use client"') || code.startsWith("'use client'")) {
        return null;
      }
      // Re-prepend directive if missing
      return {
        code: `"use client";\n${code}`,
        map: null,
      };
    },
  };
}

export default defineConfig({
  plugins: [react()],
  build: {
    lib: {
      entry: 'src/index.ts',
      formats: ['es', 'cjs'],
    },
    rollupOptions: {
      output: {
        plugins: [preserveClientDirective()],
      },
    },
  },
});

This ensures that dist/index.mjs and dist/index.cjs both begin with the directive, allowing seamless imports in server components.

3. Subpath Exports for Framework Integration

Nuxt modules require @nuxt/kit, which depends on Node.js APIs. If you bundle the module code with your main library entry, browser consumers will encounter ReferenceError: require is not defined or similar Node-related crashes.

Implementation: Use package.json subpath exports to separate the framework-specific integration from the core library. Configure Vite to emit a separate entry point for the Nuxt module and mark Node dependencies as external.

// package.json
{
  "name": "@acme/media-player",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./style.css": "./dist/style.css",
    "./nuxt": {
      "types": "./dist/nuxt.d.ts",
      "import": "./dist/nuxt.mjs",
      "require": "./dist/nuxt.cjs"
    }
  }
}
// vite.config.ts
export default defineConfig({
  build: {
    lib: {
      entry: {
        index: resolve(__dirname, 'src/react/index.ts'),
        nuxt: resolve(__dirname, 'src/vue/nuxt-module.ts'),
      },
      formats: ['es', 'cjs'],
    },
    rollupOptions: {
      external: ['vue', 'react', '@nuxt/kit', '#app', /^node:.*/],
    },
  },
});

The Nuxt module entry can safely import @nuxt/kit and register plugins without affecting the browser bundle.

4. Handling Lifecycle Race Conditions

Vue's Composition API watch with immediate: true executes synchronously during setup, before the template renders. If your component relies on a template ref (e.g., a <video> element), the ref will be null when the watcher fires, causing initialization failures. React's useEffect runs after commit, so this race condition does not occur there.

Implementation: Separate the initial mount logic from reactive updates.

// src/vue/VideoPlayer.vue
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue';

const videoRef = ref<HTMLVideoElement | null>(null);
const props = defineProps<{ src: string }>();

function initializePlayer(source: string) {
  if (!videoRef.value) return;
  // Initialize HLS or media source
  console.log('Initializing player with:', source);
}

// Use onMounted for the first initialization when refs are bound
onMounted(() => {
  if (props.src) {
    initializePlayer(props.src);
  }
});

// Use a standard watcher for subsequent prop changes
watch(
  () => props.src,
  (newSrc) => {
    if (newSrc) {
      initializePlayer(newSrc);
    }
  }
);
</script>

This pattern ensures the DOM element exists before initialization, preventing silent failures in media loading.

Pitfall Guide

Pitfall Explanation Fix
Tailwind Preflight Bleed Bundling Tailwind generates :root variables and * resets that leak into consumer apps, breaking layouts. Avoid Tailwind in libraries. Use scoped CSS with a single root class. If Tailwind is required, use @layer and strict scoping, but manual CSS is safer.
Directive Stripping Rollup removes "use client" during minification, causing Next.js App Router to treat client components as server components. Implement a Rollup renderChunk plugin to re-inject directives post-build. Verify dist output manually.
Node Dependency Leak Importing framework SDKs (e.g., @nuxt/kit) in the main entry bundles Node APIs into the browser build. Use subpath exports (./nuxt) for framework integrations. Mark Node packages as external in Rollup config.
Immediate Watch Race Vue watch(..., { immediate: true }) fires before template refs are bound, causing null reference errors. Use onMounted for initial setup and a non-immediate watch for updates.
Specificity Wars Consumers cannot override library styles due to high specificity or !important usage in the library. Use a single root class with predictable naming. Avoid !important. Allow consumers to override via CSS variables or class extension.
Ignoring Framework Timing Assuming React and Vue lifecycle behaviors are identical leads to bugs in media initialization or event binding. Test components in both frameworks. Account for Vue's synchronous setup vs. React's post-commit effects.
Missing Type Exports Consumers lack TypeScript support, leading to poor DX and runtime errors. Ensure types fields in package.json exports point to generated .d.ts files. Use tsc or vite-plugin-dts.

Production Bundle

Action Checklist

  • Audit CSS Scope: Open dist/style.css and verify no :root, body, or * selectors exist. Ensure all styles are nested under a single root class.
  • Verify Directives: Check dist/index.mjs and dist/index.cjs. Line 1 must be "use client"; for React packages.
  • Test Subpath Exports: Import the library in a vanilla Vue app and a Nuxt app. Ensure the Nuxt import does not crash the vanilla app with Node errors.
  • Validate Lifecycle: In Vue, verify that onMounted is used for initial DOM access. Ensure watch does not use immediate: true for ref-dependent logic.
  • Check Bundle Size: Run size-limit or bundlephobia to ensure CSS and JS remain under target thresholds (e.g., <5 KB gzipped).
  • Test Overrides: Create a consumer app and attempt to override library styles using CSS variables or class extension. Verify specificity works as expected.
  • Publish Dry Run: Use npm pack to inspect the published tarball. Ensure only dist, package.json, and README are included.

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Library targets Next.js + Nuxt Subpath exports (./nuxt) Isolates Node dependencies; prevents browser crashes. Low (Build config complexity)
Design system requires strict isolation Scoped CSS (Single root class) Eliminates CSS bleed; predictable overrides. Low (Manual CSS authoring)
Library uses Tailwind Avoid or strict isolation Tailwind preflight causes massive bloat and leakage. Medium (Refactoring effort)
Vue component uses DOM refs onMounted + watch Prevents race conditions; ensures refs are bound. Low (Code structure change)
React component uses hooks Rollup directive plugin Preserves "use client" for App Router compatibility. Low (Plugin implementation)

Configuration Template

vite.config.ts

import { resolve } from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';

function preserveClientDirective() {
  return {
    name: 'preserve-client-directive',
    renderChunk(code: string) {
      if (code.startsWith('"use client"') || code.startsWith("'use client'")) {
        return null;
      }
      return { code: `"use client";\n${code}`, map: null };
    },
  };
}

export default defineConfig({
  plugins: [
    react(),
    dts({ insertTypesEntry: true }),
  ],
  build: {
    lib: {
      entry: {
        index: resolve(__dirname, 'src/index.ts'),
        nuxt: resolve(__dirname, 'src/integrations/nuxt.ts'),
      },
      formats: ['es', 'cjs'],
    },
    rollupOptions: {
      external: ['react', 'react-dom', 'vue', '@nuxt/kit', '#app', /^node:.*/],
      output: {
        plugins: [preserveClientDirective()],
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM',
          vue: 'Vue',
        },
      },
    },
    cssCodeSplit: false,
  },
});

package.json Exports

{
  "name": "@acme/media-player",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./style.css": "./dist/style.css",
    "./nuxt": {
      "types": "./dist/nuxt.d.ts",
      "import": "./dist/nuxt.mjs",
      "require": "./dist/nuxt.cjs"
    }
  },
  "files": [
    "dist"
  ]
}

Quick Start Guide

  1. Initialize Project: Create a Vite library project with TypeScript. Install vite-plugin-dts for type generation.
  2. Implement Scoped CSS: Write styles using a single root class (e.g., .mc-player). Avoid global selectors. Import CSS in your entry file or export separately.
  3. Configure Build: Set up vite.config.ts with lib mode, subpath exports, and the directive preservation plugin. Mark framework SDKs as external.
  4. Handle Lifecycle: In Vue components, use onMounted for initial DOM access. In React, ensure "use client" is present in source files.
  5. Build and Verify: Run vite build. Inspect dist output for CSS scope, directive presence, and bundle size. Test imports in a Next.js and Nuxt sandbox application.