From Parcel to Vite: A short story of a 100K LOC migration

From Parcel to Vite: A short story of a 100K LOC migration

We’ve migrated our three frontend projects from Parcel to Vite, and the process was… smooth.

The backstory

We have three main frontend projects at Logto: the sign-in experience, the Console, and the live preview. These projects are all in TypeScript, React, and SASS modules; in total, they have around 100K lines of code.

We loved Parcel for its simplicity and zero-config setup. I can still remember the day when I was shocked by how easy it was to set up a new project with Parcel. You can just run parcel index.html and boom, all necessary dependencies are installed and the project is running. If you are an “experienced” developer, you may feel the same way comparing it to the old days of setting up with Gulp and Webpack. Parcel is like a magic wand.
The simplicity of Parcel was the main reason we stuck with it for so long, even though it could be moody sometimes. For example:

Parcel sometimes failed to bundle the project because it couldn’t find some chunk files that were actually there.
It needed some hacky configurations to make it work with our monorepo setup.
It doesn’t support MDX 3 natively, so we had to create a custom transformer for it.
It doesn’t support manual chunks (as of the time of writing, the manual chunks feature is still in the experimental stage), which is okay for most circumstances, but sometimes you need it.

So why did we decide to migrate to something else?

We were stuck with Parcel 2.9.3, which was released in June 2023. Every time a new version was released after that, we tried to upgrade, but it always failed with build errors.
The latest version of Parcel was 2.12.0, released in February 2024. Although it has nearly daily commits, no new release has been made since then.

Someone even opened a discussion to ask if Parcel is dead. The official answer is no, Parcel is still alive, but it’s in a we-are-working-on-a-large-refactor-and-no-time-for-minor-releases state. To us, it’s like a “duck death”: The latest version we can use is from more than a year ago, and we don’t know when the next version will be released. It looks like it’s dead, it acts like it’s dead, so it’s dead to us.
Trust me, we tried.

Why Vite?

We knew Vite from Vitest. Several months ago, we were tired of Jest’s ESM support (in testing) and wanted to try something new. Vitest won our hearts with the native ESM support and the Jest compatibility. It has an amazing developer experience, and it’s powered by Vite.

The status quo

You may have different settings in your project, but usually you will find plugin replacements as the Vite ecosystem is blooming. Here are our setups at the moment of migration:

Monorepo: We use PNPM (v9) workspaces to manage our monorepo.

Module: We use ESM modules for all our projects.

TypeScript: We use TypeScript (v5.5.3) for all our projects with path aliases.

React: We use React (v18.3.1) for all our frontend projects.

Styling: We use SASS modules for styling.

SVG: We use SVGs as React components.

MDX: We have MDX with GitHub Flavored Markdown and Mermaid support.

Lazy loading: We need to lazy load some of our pages and components.

Compression: We produce compressed assets (gzip and brotli) for our production builds.

The migration

We started the migration by creating a new Vite project and playing around with it to see how it works. The process was smooth and the real migration only took a few days.

Out-of-the-box support

Vite has out-of-the-box support for monorepo, ESM, TypeScript, React, and SASS. We only needed to install the necessary plugins and configurations to make it work.

Path alias

Vite has built-in support for path aliases, for example, in our tsconfig.json:

{
“compilerOptions”: {
“baseUrl”: “.”,
“paths”: {
“@/*”: [“src/*”]
}
}
}

We only needed to add the same resolution in our vite.config.ts:

import { defineConfig } from ‘vite’;

export default defineConfig({
resolve: {
alias: [{ find: /^@//, replacement: ‘/src/’ }],
},
});

Note the replacement path should be an absolute path, while it is relative to the project root. Alternatively, you can use the vite-tsconfig-paths plugin to read the path aliases from the tsconfig.json.

React Fast Refresh and HMR

Although Vite has built-in support for HMR, it is required to install a plugin to enable React Fast Refresh. We used the @vitejs/plugin-react plugin which is provided by the Vite team and has great support for React features like Fast Refresh:

import { defineConfig } from ‘vite’;
import react from ‘@vitejs/plugin-react’;

export default defineConfig({
plugins: [react()],
});

SVG as React component

We use the vite-plugin-svgr plugin to convert SVGs to React components. It’s as simple as adding the plugin to the Vite config:

import { defineConfig } from ‘vite’;
import svgr from ‘vite-plugin-svgr’;

export default defineConfig({
plugins: [svgr()],
});

However, we didn’t specify on which condition the SVGs should be converted to React components, so all the imports were converted. The plugin offers a better default configuration: only convert the SVGs that are imported with the .svg?react extension. We updated our imports accordingly.

SASS modules

Although Vite has built-in support for SASS modules, there’s one thing we need to care about: how the class names are formatted. It may be troublesome for users and our integration tests if the class names are not formatted consistently. The one-line configuration in the vite.config.ts can solve the problem:

import { defineConfig } from ‘vite’;

export default defineConfig({
css: {
modules: {
generateScopedName: ‘[name]__[local]–[hash:base64:5]’, // Replace with your own format
},
},
});

Bt the way, Parcel and Vite have different flavors of importing SASS files:

– import * as styles from ‘./index.module.scss’;
+ import styles from ‘./index.module.scss’;

The * as syntax, however, works in Vite, but it will cause the loss of modularized class names when you use dynamic keys to access the styles object. For example:

const className = ‘button’;
// This will not work as expecteds
return <button className={styles[className]}>Click me</button>;

MDX support

Since Vite leverages Rollup under the hood, we can use the official @mdx-js/rollup plugin to support MDX as well as its plugins. The configuration looks like this:

import { defineConfig } from ‘vite’;
import mdx from ‘@mdx-js/rollup’;
import rehypeMdxCodeProps from ‘rehype-mdx-code-props’;
import remarkGfm from ‘remark-gfm’;

export default defineConfig({
plugins: [
{
// The `enforce: ‘pre’` is required to make the MDX plugin work
enforce: ‘pre’,
…mdx({
providerImportSource: ‘@mdx-js/react’,
remarkPlugins: [remarkGfm],
rehypePlugins: [[rehypeMdxCodeProps, { tagName: ‘code’ }]],
}),
},
],
});

The remarkGfm plugin is used to support GitHub Flavored Markdown, and the rehypeMdxCodeProps plugin is used to pass the props to the code blocks in the MDX files like what Docusaurus does.

Mermiad support within MDX

We would like to use Mermaid diagrams in our MDX files as other programming languages. The usage should be as simple as other code blocks:
Should be rendered as:
Since our app supports light and dark themes, we coded a little bit to make the Mermaid diagrams work with the dark theme. A React component is created:

import { useEffect } from ‘react’;
import useTheme from ‘@/hooks/use-theme’;

// Maybe defined elsewhere
enum Theme {
Dark = ‘dark’,
Light = ‘light’,
}

type Props = {
readonly children: string;
};

const themeToMermaidTheme = Object.freeze({

} satisfies Record<Theme, string>);

export default function Mermaid({ children }: Props) {
const theme = useTheme();

useEffect(() => {
(async () => {
const { default: mermaid } = await import(‘mermaid’);

mermaid.initialize({
startOnLoad: false,
theme: themeToMermaidTheme[theme],
securityLevel: ‘loose’,
});
await mermaid.run();
})();
}, [theme]);

return <div className=”mermaid”>{children}</div>;
}

useTheme is a custom hook to get the current theme from the context. The mermaid library is imported asynchronously to reduce the loading size for the initial page load.

For the code block in the MDX file, we have a unified component to do the job:

import CodeEditor from ‘@/components/CodeEditor’;
import Mermaid from ‘@/components/Mermaid’;

type Props = {
readonly children: string;
readonly className?: string;
};

export default function Code({ children, className }: Props) {
const [, language] = /language-(w+)/.exec(String(className ?? ”)) ?? [];

if (language === ‘mermaid’) {
return <Mermaid>{String(children).trimEnd()}</Mermaid>;
}

// Other code blocks go here
return <CodeEditor>{String(children).trimEnd()}</CodeEditor>;
}

Finally we define the MDX provider as follows:

import { MDXProvider } from ‘@mdx-js/react’;
import { ReactNode } from ‘react’;

type Props = {
readonly children: ReactNode;
};

export default function MdxProvider({ children }: Props) {
return (
<MDXProvider
components={{
code: Code,
// Explicitly set a `Code` component since `<code />` cannot be swapped out with a
// custom component now.
// See: https://github.com/orgs/mdx-js/discussions/2231#discussioncomment-4729474
Code,
// …other components
}}
>
{children}
</MDXProvider>
);
}

Lazy loading

This isn’t a Vite-specific thing, it’s still worth mentioning since we updated our pages to use lazy loading during the migration, and nothing broke afterward.

React has a built-in React.lazy function to lazy load components. However, it may cause some issues when you are iterating fast. We crafted a tiny library called react-safe-lazy to solve the issues. It’s a drop-in replacement for React.lazy and a detailed explanation can be found in this blog post.

Compression

There’s a neat plugin called vite-plugin-compression to produce compressed assets. It supports both gzip and brotli compression. The configuration is simple:

import { defineConfig } from ‘vite’;
import viteCompression from ‘vite-plugin-compression’;

export default defineConfig({
plugins: [
// Gzip by default
viteCompression(),
// Brotli
viteCompression({ algorithm: ‘brotliCompress’ }),
],
});

Manual chunks

One great feature of Vite (or the underlying Rollup) is the manual chunks. While React.lazy is used for lazy loading components, we can have more control over the chunks by specifying the manual chunks to decide which components or modules should be bundled together.

For example, we can first use vite-bundle-visualizer to analyze the bundle size and dependencies. Then we can write a proper function to split the chunks:

import { defineConfig } from ‘vite’;

export default defineConfig({
build: {
rollupOptions: {
manualChunks(id, { getModuleInfo }) {
// Dynamically check if the module has React-related dependencies
const hasReactDependency = (id: string): boolean => {
return (
getModuleInfo(id)?.importedIds.some(
(importedId) => importedId.includes(‘react’) || importedId.includes(‘react-dom’)
) ?? false
);
};

// Caution: React-related packages should be bundled together otherwise it may cause runtime errors
if (id.includes(‘/node_modules/’) && hasReactDependency(id)) {
return ‘react’;
}

// Add your large packages to the list
for (const largePackage of [‘mermaid’, ‘elkjs’]) {
if (id.includes(`/node_modules/${largePackage}/`)) {
return largePackage;
}
}

// All other packages in the `node_modules` folder will be bundled together
if (id.includes(‘/node_modules/’)) {
return ‘vendors’;
}

// Use the default behavior for other modules
},
},
},
});

Dev server

Unlike the production build, Vite will NOT bundle your source code in the dev mode (including linked dependencies in the same monorepo) and treat every module as a file. For us, the browser will load hundreds of modules for the first time, which looks crazy but it’s actually fine in most cases. You can see the discussion here.

If it’s a thing for you, an alternative-but-not-perfect solution is to list the linked dependencies in the optimizeDeps option of the vite.config.ts:

import { defineConfig } from ‘vite’;

export default defineConfig({
optimizeDeps: {
include: [‘@logto/phrases’, ‘@logto/schemas’],
},
});

This will “pre-bundle” the linked dependencies and make the dev server faster. The pitfall is that HMR may not work as expected for the linked dependencies.

Additionally, we use a proxy which serves the static files in production and proxies the requests to the Vitest server in development. We have some specific ports configured to avoid conflicts, and it’s also easy to set up in the vite.config.ts:

import { defineConfig } from ‘vite’;

export default defineConfig({
server: {
port: 3000,
hmr: {
port: 3001,
},
},
});

Environment variables

Unlike Parcel, Vite uses a modern approach to handle environment variables by using import.meta.env. It will automatically load the .env files and replace the variables in the code. However, it requires all the environment variables to be prefixed with VITE_ (configurable).

While we were using Parcel, it simply replaced the process.env variables without checking the prefix. So we have come up with a workaround using the define field to make the migration easier:

import { defineConfig } from ‘vite’;

export default defineConfig({
define: {
‘import.meta.env.API_URL’: JSON.stringify(process.env.API_URL),
},
});

This allows us to gradually add the prefix to the environment variables and remove the define field.

Conslusion

That’s it! We’ve successfully migrated our three frontend projects from Parcel to Vite, and hope this short story can help you with your migration. Here’s what the configuration looks like in the end:

import { defineConfig } from ‘vite’;
import react from ‘@vitejs/plugin-react’;
import svgr from ‘vite-plugin-svgr’;
import mdx from ‘@mdx-js/rollup’;
import rehypeMdxCodeProps from ‘rehype-mdx-code-props’;
import remarkGfm from ‘remark-gfm’;
import viteCompression from ‘vite-plugin-compression’;

export default defineConfig({
resolve: {
alias: [{ find: /^@//, replacement: ‘/src/’ }],
},
server: {
port: 3000,
hmr: {
port: 3001,
},
},
plugins: [
react(),
svgr(),
{
enforce: ‘pre’,
…mdx({
providerImportSource: ‘@mdx-js/react’,
remarkPlugins: [remarkGfm],
rehypePlugins: [[rehypeMdxCodeProps, { tagName: ‘code’ }]],
}),
},
viteCompression(),
viteCompression({ algorithm: ‘brotliCompress’ }),
],
css: {
modules: {
generateScopedName: ‘[name]__[local]–[hash:base64:5]’,
},
},
build: {
rollupOptions: {
manualChunks(id, { getModuleInfo }) {
// Manual chunks logic
},
},
},
});

Try Logto Cloud for free

Please follow and like us:
Pin Share