Vite Production Configuration for TypeScript Apps Beyond the Defaults

You ran vite build, got a dist folder, deployed it, and everything seemed fine. Then a colleague opened the app on their phone and the icon looked blurry. A user tried installing it as a PWA and got a browser error. Performance scores came back lower than expected. None of these are random failures. They are the predictable result of shipping Vite’s development defaults to production without adjusting them for the real world. This article covers the configuration that actually matters once a project graduates past the prototype stage.

Production Config Checklist

  • Set build.target to specific browser versions rather than accepting the 'modules' default
  • Use manualChunks to separate vendor dependencies and maximize cache hit rates for repeat visitors
  • Name output assets with content hashes and configure the inline limit deliberately
  • Provide multiple icon sizes for browser tabs, iOS home screens, and PWA install prompts
  • Wire up vite-plugin-pwa with a complete manifest and Workbox runtime caching rules

Why Vite’s Defaults Target Development Comfort, Not Production Reality

Vite is optimized for developer experience first. The dev server starts in milliseconds, hot module replacement keeps state alive across edits, and TypeScript just compiles without manual setup. All of that is genuinely great. The tradeoff is that the default vite.config.ts generated by npm create vite@latest is intentionally minimal. It gets you to a working build, not an optimal one.

The defaults make assumptions about your browser support range, your caching strategy, and your icon setup. Those assumptions are often wrong for production applications with real users and real performance requirements. Fixing them is straightforward once you know which settings to reach for.

Setting Your Build Target Precisely

The build.target option controls which ECMAScript features esbuild transpiles. The default value is 'modules', meaning browsers that support native ES modules. That is a reasonable baseline, but it is not specific enough to let esbuild make smart decisions about which transforms to skip.

If your users are on modern browsers, you can give esbuild an exact target list. This reduces transpilation overhead and produces smaller output because esbuild skips polyfill transforms for features your target browsers already support natively.

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    target: ['chrome90', 'safari14', 'firefox90', 'edge90'],
  },
});

Features like optional chaining, nullish coalescing, and top-level await only get transpiled if the target actually requires it. With precise targets, you skip those transforms and your output stays leaner. If you do need older browser support, 'es2015' is a reasonable floor. The older the target, the more transpilation happens and the larger your final bundle grows. TypeScript types are stripped regardless of target , that part is handled upstream by the TypeScript compiler and esbuild’s type-stripping pass.

Chunk Splitting That Serves Real Users

Rollup powers Vite’s production bundler. Its default code splitting is decent, but it does not know anything specific about your caching goals. Without guidance, it tends to group dependencies in ways that hurt repeat visitors. Every deploy can invalidate cached files unnecessarily.

The build.rollupOptions.output.manualChunks function gives you precise control. A common and effective pattern separates heavy framework dependencies from application code so they cache independently across deploys.

build: {
  rollupOptions: {
    output: {
      manualChunks(id) {
        if (id.includes('node_modules')) {
          if (id.includes('react') || id.includes('react-dom')) {
            return 'react-vendor';
          }
          if (id.includes('@tanstack/react-query')) {
            return 'query-vendor';
          }
          if (id.includes('date-fns') || id.includes('lodash')) {
            return 'utils-vendor';
          }
          return 'vendor';
        }
      },
      chunkFileNames: 'assets/[name]-[hash].js',
      entryFileNames: 'assets/[name]-[hash].js',
      assetFileNames: 'assets/[name]-[hash][extname]',
    },
  },
},

The content hash in each filename is what makes this worthwhile. Browsers and CDNs cache files aggressively by name. When you update your application logic, only the chunks that actually changed get new hashes. Your React vendor chunk stays cached. Your Tanstack Query chunk stays cached. Only the application chunks get invalidated. For users returning after a deploy, this is the difference between downloading a few kilobytes and downloading hundreds of kilobytes.

Without content hashes, you are forced to either set very short cache expiry headers (hurting performance) or accept that users might get stale cached files after a deploy. Content hashes let you set aggressive long-term caching with full confidence that any changed file will always have a new URL.

Asset Optimization and Cache-Busting Strategy

Vite inlines small assets as base64 data URIs when they fall below 4KB. This avoids extra network requests for tiny images and icons. You can adjust this threshold based on your project’s asset profile, or disable it entirely by setting the limit to zero.

build: {
  assetsInlineLimit: 4096, // bytes; set to 0 to emit all assets as separate files
  sourcemap: 'hidden',
},

The sourcemap: 'hidden' setting generates source maps during the build but does not reference them in the output JavaScript. Error tracking services like Sentry can still upload and use them for readable stack traces, but public users cannot access them from browser dev tools. That is the right balance for most production apps. Setting sourcemap: true exposes them publicly, which is fine for open source projects but not for commercial applications where you do not want to expose proprietary logic.

For images, Vite does not compress them out of the box. If your project ships significant image assets, add a compression plugin to your build pipeline. The performance difference on mobile connections is real and measurable, particularly for first-time visitors who have nothing cached yet.

Why One Icon File Breaks on Modern Devices

Dropping a favicon.ico into the public folder was fine in 2012. Modern browsers, mobile operating systems, and PWA installers each request icons at different sizes and in different formats. A single ICO file handles none of them correctly.

Safari on iOS expects a 180×180 PNG linked with the apple-touch-icon rel attribute. Android Chrome pulls icons from your web manifest when installing a PWA, requiring at minimum a 192×192 and a 512×512 PNG. The browser tab icon should be a 32×32 PNG or, better yet, an SVG that stays sharp at any resolution on high-density displays. Missing any of these produces blurry icons on home screens, generic placeholders in the PWA installer, or a fuzzy favicon next to the page title.

The exact dimensions each platform and browser context expects are documented in favicon sizes , worth reviewing before you generate your icon assets, because the required set is larger than most developers expect and gaps show up in subtle, hard-to-debug ways.

Your Vite HTML template should reference each variant explicitly. Add these to your index.html:

<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />

Place all icon files in the public folder. Vite copies everything in public to your build output without transformation, so they land at the root of your deployed site exactly where browsers expect them.

Wiring Up vite-plugin-pwa with a Complete Manifest Config

PWA support in Vite comes from the community-maintained vite-plugin-pwa package, which wraps Workbox for service worker generation and handles manifest injection automatically. Install it with npm install -D vite-plugin-pwa and add it to your config’s plugins array.

The fields in a web app manifest directly control how browsers display and install your app. The web app manifest spec defines a wider field set than most tutorials cover, including categories, shortcuts, and screenshots that improve how your app appears in Android’s app discovery surfaces. Here is a complete base configuration that covers what users and platforms actually use:

import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.svg', 'favicon-32x32.png', 'apple-touch-icon.png'],
      manifest: {
        name: 'Your Application Name',
        short_name: 'AppName',
        description: 'What your application does in one sentence.',
        theme_color: '#1a1a2e',
        background_color: '#ffffff',
        display: 'standalone',
        start_url: '/',
        icons: [
          {
            src: '/icons/icon-192x192.png',
            sizes: '192x192',
            type: 'image/png',
          },
          {
            src: '/icons/icon-512x512.png',
            sizes: '512x512',
            type: 'image/png',
          },
          {
            src: '/icons/icon-512x512-maskable.png',
            sizes: '512x512',
            type: 'image/png',
            purpose: 'maskable',
          },
        ],
      },
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.yourdomain\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: {
                maxEntries: 50,
                maxAgeSeconds: 86400,
              },
            },
          },
        ],
      },
    }),
  ],
});

The purpose: 'maskable' flag on the 512×512 icon is not optional if you care about Android 12+ home screens. Without a maskable icon, Android adds white padding around your icon inside the adaptive icon frame, and it looks wrong compared to native apps. Generate the maskable variant using a tool like Maskable.app and keep your main artwork inside the safe zone, which is the central 80% of the image area.

The registerType: 'autoUpdate' setting tells the service worker to update silently when a new version is detected. That is the right default for most web apps where the page can reload safely. If your app has long-running forms or in-progress work that a silent reload would destroy, switch to 'prompt' and show a custom update banner before refreshing.

Workbox’s runtime caching strategies each serve a different purpose. Choose based on the freshness requirements of each resource type:

  • NetworkFirst: API calls and authenticated data where a stale response causes a real problem for the user
  • CacheFirst: Static assets with content hashes in their filenames , ideal for Vite’s chunked output, which already has cache-busting built in
  • StaleWhileRevalidate: Resources where slightly stale data is acceptable while the cache refreshes in the background
  • NetworkOnly: Authentication endpoints and any request that must never serve a cached response
  • CacheOnly: Rare; forces all traffic through the cache with no network fallback, useful for pre-cached offline assets

Your vite.config.ts Is Part of Your Application

Each configuration decision in this article compounds on the others. Precise build targets reduce output size. Chunk splitting multiplies the cache efficiency of that smaller output. Content-hashed filenames make CDN caching airtight. Correct icon coverage makes your app look polished everywhere users actually encounter it. The PWA manifest and service worker make it installable and functional offline.

None of this is complex. The challenge is that Vite’s defaults are good enough to ship something, and “good enough to ship” often means the configuration never gets revisited. Profile your build output regularly with rollup-plugin-visualizer to catch chunks that have grown unexpectedly large. Check your Lighthouse PWA score after major refactors. Update your browser targets as your analytics data shifts.

The gap between a Vite project that runs and one that performs well in production is almost entirely in the config file. Treat vite.config.ts as a first-class part of your codebase, not as boilerplate you scaffold once and forget.

Leave a Reply

Your email address will not be published. Required fields are marked *