ES Modules Logo ESModules.com

Frameworks & ESM

Every major JavaScript framework is built on ES Modules. React, Vue, Svelte, Angular, and Astro all use ESM for component architecture, tree-shaking, server rendering, and development tooling.

React & Next.js

React is distributed as ES Modules on npm. Next.js, the most popular React framework, uses Turbopack (a Rust-based ESM-native bundler) for development and Webpack or Turbopack for production builds. The App Router architecture is built entirely around ESM conventions.

Server & Client Components

React Server Components (RSC) use directives at the top of ES Module files to define server/client boundaries. The bundler reads these directives to split the module graph:

// app/page.jsx - Server Component (default in App Router)
// No directive needed - server components are the default
import { db } from '@/lib/database';
import { ClientCounter } from './counter';

export default async function Page() {
  // This runs on the server - direct database access
  const posts = await db.query('SELECT * FROM posts');

  return (
    <main>
      <h1>{posts.length} Posts</h1>
      {/* Client component hydrates on the browser */}
      <ClientCounter initialCount={posts.length} />
    </main>
  );
}
// app/counter.jsx - Client Component
'use client'; // This directive marks the ESM boundary

import { useState } from 'react';

export function ClientCounter({ initialCount }) {
  const [count, setCount] = useState(initialCount);
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}

How RSC Uses the Module Graph:

The 'use client' directive tells the bundler where to split the ESM graph. Server modules (and their imports) never ship to the browser. Only client modules and their dependency trees are included in the client bundle. This is a fundamentally ESM-driven architecture.

Server Actions

Server Actions use the 'use server' directive to mark functions that run on the server but can be called from client components:

// app/actions.js
'use server'; // Every export from this module is a server action

import { db } from '@/lib/database';
import { revalidatePath } from 'next/cache';

export async function createPost(formData) {
  const title = formData.get('title');
  await db.insert('posts', { title });
  revalidatePath('/posts');
}

export async function deletePost(id) {
  await db.delete('posts', { id });
  revalidatePath('/posts');
}
// app/new-post.jsx
'use client';
import { createPost } from './actions'; // Import server action into client

export function NewPostForm() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  );
}

Turbopack & ESM

Next.js uses Turbopack in development, which is an ESM-native bundler written in Rust. It treats every file as an ES Module and computes incremental updates at the module level:

// next.config.js - Turbopack is the default dev bundler in Next.js 15+
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Turbopack is enabled by default for `next dev`
  // For production, Webpack is still used (Turbopack prod coming soon)
  experimental: {
    // Turbopack-specific options
    turbo: {
      rules: {
        '*.svg': {
          loaders: ['@svgr/webpack'],
          as: '*.js',
        },
      },
    },
  },
};

export default nextConfig;

Performance Impact:

Turbopack performs incremental computation at the ESM level. When you edit a file, only that module and its direct dependents are recomputed. For large apps with thousands of modules, this means updates in milliseconds instead of seconds.

Vue & Nuxt

Vue 3 is distributed as ES Modules and designed for tree-shaking from the ground up. Nuxt 3 is built on top of Vite, which serves native ESM to the browser during development with no bundling step required.

Composition API & ESM

The Composition API is designed around ES Module exports. Composables are plain functions exported from ESM files, making them naturally tree-shakeable:

// composables/useCounter.js - Composable as an ES Module
import { ref, computed } from 'vue';

export function useCounter(initial = 0) {
  const count = ref(initial);
  const doubled = computed(() => count.value * 2);

  function increment() { count.value++; }
  function decrement() { count.value--; }

  return { count, doubled, increment, decrement };
}
// composables/useFetch.js - Async composable
import { ref, watchEffect } from 'vue';

export function useFetch(url) {
  const data = ref(null);
  const error = ref(null);
  const loading = ref(true);

  watchEffect(async () => {
    loading.value = true;
    try {
      const res = await fetch(url.value || url);
      data.value = await res.json();
    } catch (e) {
      error.value = e;
    } finally {
      loading.value = false;
    }
  });

  return { data, error, loading };
}
<!-- components/PostList.vue - Using composables -->
<script setup>
import { useCounter } from '@/composables/useCounter';
import { useFetch } from '@/composables/useFetch';

const { count, increment } = useCounter(0);
const { data: posts, loading } = useFetch('/api/posts');
</script>

<template>
  <div v-if="loading">Loading...</div>
  <ul v-else>
    <li v-for="post in posts" :key="post.id">{{ post.title }}</li>
  </ul>
  <button @click="increment">Clicked {{ count }} times</button>
</template>

Nuxt Auto-Imports

Nuxt 3 auto-imports composables, components, and utilities. Under the hood, it scans your ESM files and generates import statements at build time so you never write boilerplate imports:

<!-- pages/index.vue - No manual imports needed -->
<script setup>
// useCounter is auto-imported from composables/
const { count, increment } = useCounter();

// ref, computed, watch are auto-imported from vue
const message = ref('Hello');
const upper = computed(() => message.value.toUpperCase());

// useFetch is auto-imported from Nuxt
const { data: posts } = await useFetch('/api/posts');
</script>

<template>
  <div>
    <p>{{ upper }}</p>
    <NuxtLink to="/about">About</NuxtLink>
  </div>
</template>

How Auto-Imports Work:

Nuxt scans the composables/, utils/, and components/ directories and generates a virtual ESM file with all the imports. This file is prepended to your modules by the Vite plugin. The result is fully tree-shakeable because unused auto-imports are removed during the production build.

Vue 3 Tree-Shaking

Vue 3 exports everything as named ESM exports, so unused features are eliminated during the production build:

// Only the APIs you actually use are included in the bundle
import { createApp, ref, computed, watch } from 'vue';

// These are NOT included if you do not import them:
// - Transition, KeepAlive, Teleport, Suspense
// - v-model, v-show compiler transforms
// - Server-side rendering utilities
// Vue 2 included everything (~23KB min+gz)
// Vue 3 with tree-shaking: as low as ~10KB min+gz

Svelte & SvelteKit

Svelte takes a compile-time approach to ES Modules. The compiler transforms .svelte files into optimized JavaScript ES Modules with no runtime framework code. SvelteKit is built on Vite and uses the file system for routing.

Compile-Time ESM

<!-- src/routes/+page.svelte -->
<script>
  // Standard ES Module imports work in Svelte components
  import { fade } from 'svelte/transition';
  import { writable } from 'svelte/store';
  import { formatDate } from '$lib/utils';

  let count = $state(0); // Svelte 5 runes
  let doubled = $derived(count * 2);

  function increment() { count++; }
</script>

<button onclick={increment} transition:fade>
  {count} x 2 = {doubled}
</button>

The Svelte compiler transforms this into a plain ES Module with fine-grained DOM updates. There is no virtual DOM or runtime diffing - the compiler generates the exact JavaScript needed:

// Simplified compiler output (conceptual)
import { fade } from 'svelte/transition';
import { formatDate } from '$lib/utils';

// Direct DOM manipulation - no virtual DOM overhead
export default function Page(target) {
  let count = 0;
  const button = document.createElement('button');

  function update() {
    button.textContent = `${count} x 2 = ${count * 2}`;
  }

  button.addEventListener('click', () => { count++; update(); });
  target.appendChild(button);
  update();
}

SvelteKit Routing & Loading

SvelteKit uses a file-based routing system where each route is an ES Module. Load functions run on the server and pass data to the page component:

// src/routes/posts/[slug]/+page.server.js
// This ESM file runs ONLY on the server
import { db } from '$lib/server/database';
import { error } from '@sveltejs/kit';

export async function load({ params }) {
  const post = await db.getPost(params.slug);
  if (!post) throw error(404, 'Not found');

  return { post };
}
<!-- src/routes/posts/[slug]/+page.svelte -->
<script>
  // Data from the server load function
  let { data } = $props();
</script>

<article>
  <h1>{data.post.title}</h1>
  <div>{@html data.post.content}</div>
</article>
// src/routes/posts/[slug]/+page.js
// This ESM file runs on BOTH server and client
// Use for data that does not need server-only access

export async function load({ fetch, params }) {
  const res = await fetch(`/api/posts/${params.slug}`);
  return { post: await res.json() };
}

The $lib Alias:

SvelteKit provides the $lib import alias that maps to src/lib/. This is resolved at build time by Vite. The $app/ alias provides framework utilities like $app/navigation, $app/environment, and $app/stores. These are all standard ESM imports resolved by the bundler.

Angular

Angular has fully embraced ES Modules with standalone components (no more NgModules required), an esbuild-based build system, and signals for reactive state. Since Angular 17, the new build system uses esbuild and Vite for dramatically faster builds.

Standalone Components

Standalone components are self-contained ES Modules that declare their own dependencies, eliminating the need for NgModule boilerplate:

// counter.component.ts - Standalone component
import { Component, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-counter',
  standalone: true,
  imports: [CommonModule], // Dependencies declared per-component
  template: `
    <button (click)="increment()">
      Count: {{ count() }} (doubled: {{ doubled() }})
    </button>
  `
})
export class CounterComponent {
  count = signal(0);
  doubled = computed(() => this.count() * 2);

  increment() {
    this.count.update(n => n + 1);
  }
}
// app.routes.ts - Lazy-loaded routes using dynamic ESM import()
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '',
    loadComponent: () =>
      import('./home/home.component')
        .then(m => m.HomeComponent)
  },
  {
    path: 'dashboard',
    loadComponent: () =>
      import('./dashboard/dashboard.component')
        .then(m => m.DashboardComponent)
  },
  {
    path: 'admin',
    loadChildren: () =>
      import('./admin/admin.routes')
        .then(m => m.ADMIN_ROUTES)
  }
];

The NgModule to ESM Shift:

Angular's move from NgModules to standalone components is a shift toward standard ES Module patterns. Instead of a proprietary module system (NgModule), components now declare their imports directly - just like any other ES Module. This makes Angular code more portable and easier to tree-shake.

esbuild-Based Build System

Angular 17+ uses esbuild for compilation and Vite for the dev server. This replaces the older Webpack-based build and is significantly faster:

// angular.json - esbuild builder (default in Angular 17+)
{
  "architect": {
    "build": {
      "builder": "@angular-devkit/build-angular:application",
      "options": {
        "outputPath": "dist/my-app",
        "index": "src/index.html",
        "browser": "src/main.ts",
        "tsConfig": "tsconfig.app.json"
      }
    },
    "serve": {
      "builder": "@angular-devkit/build-angular:dev-server"
    }
  }
}
// main.ts - Bootstrapping with ESM
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

bootstrapApplication(AppComponent, appConfig)
  .catch(err => console.error(err));

Astro

Astro is an ESM-native framework built on Vite that uses an island architecture for partial hydration. Pages are rendered as static HTML by default, and interactive components from any framework (React, Vue, Svelte) are hydrated independently as ES Module islands.

Island Architecture

---
// src/pages/index.astro - Server-rendered by default
import Layout from '../layouts/Layout.astro';
import Header from '../components/Header.astro'; // Zero JS
import ReactCounter from '../components/Counter.jsx'; // Interactive island
import VueCarousel from '../components/Carousel.vue'; // Another island

const posts = await fetch('https://api.example.com/posts')
  .then(r => r.json());
---

<Layout title="Home">
  <Header /> <!-- Static HTML, no JS shipped -->

  <!-- Load immediately - ESM island -->
  <ReactCounter client:load count={posts.length} />

  <!-- Load when visible - lazy ESM island -->
  <VueCarousel client:visible items={posts} />

  <!-- Load on idle - lowest priority ESM island -->
  <ReactCounter client:idle count={0} />

  <!-- Static list - renders to HTML, ships zero JS -->
  <ul>
    {posts.map(post => <li>{post.title}</li>)}
  </ul>
</Layout>

Hydration Directives:

  • client:load - Hydrate the ES Module immediately on page load
  • client:idle - Hydrate when the browser is idle (requestIdleCallback)
  • client:visible - Hydrate when the component scrolls into view (IntersectionObserver)
  • client:media - Hydrate when a CSS media query matches
  • client:only - Skip SSR, render only on the client

Multi-Framework Support

Astro can use components from multiple frameworks in the same project. Each component is loaded as an independent ES Module with its own framework runtime:

// astro.config.mjs - Mix frameworks via ESM integrations
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import vue from '@astrojs/vue';
import svelte from '@astrojs/svelte';

export default defineConfig({
  integrations: [react(), vue(), svelte()],
  output: 'hybrid', // Static by default, opt-in server routes
});
---
// Use React, Vue, and Svelte on the same page
import ReactChart from '../components/Chart.jsx';
import VueForm from '../components/Form.vue';
import SvelteToggle from '../components/Toggle.svelte';
---

<!-- Each framework's runtime is loaded only for its islands -->
<ReactChart client:visible data={chartData} />
<VueForm client:load />
<SvelteToggle client:idle />

Why Islands Win:

A traditional SPA ships the entire framework runtime plus all component code upfront. With Astro's island architecture, most of the page is static HTML with zero JavaScript. Only interactive components ship their ES Module code, and each island hydrates independently. A typical Astro site ships 90% less JavaScript than an equivalent SPA.

Framework Comparison

Feature React / Next.js Vue / Nuxt Svelte / SvelteKit Angular Astro
Dev Bundler Turbopack Vite Vite esbuild + Vite Vite
Prod Bundler Webpack / Turbopack Rollup (via Vite) Rollup (via Vite) esbuild Rollup (via Vite)
SSR Support Full (RSC + SSR) Full (Nitro) Full (Adapter) Full (Angular SSR) Full (Hybrid)
ESM-Native Yes Yes Yes Yes Yes
Tree-Shaking Yes Yes (API-level) Yes (compile-time) Yes Yes (zero JS default)
Lazy Routes dynamic import() defineAsyncComponent File-based loadComponent() File-based
Config Format next.config.mjs nuxt.config.ts svelte.config.js angular.json astro.config.mjs
Partial Hydration RSC (server/client) No (full hydration) No (full hydration) Deferrable views Yes (islands)

Key Takeaway:

Every major framework now uses ESM as its foundation. The main differences are in how they handle the server/client boundary and how much JavaScript ships to the browser. React uses RSC directives, Astro uses island directives, and Vue/Svelte hydrate the full component tree. All of them rely on ESM's static structure for tree-shaking and code splitting.

Frequently Asked Questions