Repository: remix-run/remix
Pull request #5854 (2023-03-20 20:01:25)

refactor(dev): separate subcompilers for brower js, server js, and css
The Remix compiler coordinates 3 separate `esbuild` compilations:
1. The JS assets for the browser (`browserjs`)
2. The CSS assets (`css`)
3. The JS for the server (`serverjs`)

This PR refactors the compiler code to isolate each of these compilations (and the plugins specific to each compilation) to their own namespaces.

Decoupling the `browserjs` and `css` leads to reduced responsibilities for both of those compilations.
Namely, manifest creation can be extricated from `browserjs` and handled by the top-level Remix compiler instead, eliminating the need for asynchronous coordination for the manifest.

This PR intentionally leaves both `browserjs/compiler.ts` and `css/compiler.ts` modules as copies of the old `compileBrowser.ts` module. Now each of these modules can be simplified and optimized independently.

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 16c29178bcae7d0b218bb215d37a4357300bac5f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/remix-run/remix/new/pedro/subcompilers?filename=.changeset/orange-laws-fly.md&value=---%0A%22remix%22%3A%20patch%0A%22%40remix-run%2Fdev%22%3A%20patch%0A---%0A%0Arefactor(dev)%3A%20isolate%20subcompilers%0A)



Patch:
diff --git a/packages/remix-dev/compiler/compileBrowser.ts b/packages/remix-dev/compiler/browserjs/compiler.ts
similarity index 51%
rename from packages/remix-dev/compiler/compileBrowser.ts
rename to packages/remix-dev/compiler/browserjs/compiler.ts
index a0d9df206..d84004bf0 100644
--- a/packages/remix-dev/compiler/compileBrowser.ts
+++ b/packages/remix-dev/compiler/browserjs/compiler.ts
@@ -1,47 +1,57 @@
 import * as path from "path";
-import * as fse from "fs-extra";
 import { builtinModules as nodeBuiltins } from "module";
 import * as esbuild from "esbuild";
 import { NodeModulesPolyfillPlugin } from "@esbuild-plugins/node-modules-polyfill";
-import postcss from "postcss";
-import postcssDiscardDuplicates from "postcss-discard-duplicates";
 
-import type { Channel, WriteChannel } from "../channel";
-import { createChannel } from "../channel";
-import type { RemixConfig } from "../config";
-import type { AssetsManifest } from "./assets";
-import { createAssetsManifest } from "./assets";
-import { getAppDependencies } from "./dependencies";
-import { loaders } from "./loaders";
-import type { CompileOptions } from "./options";
-import { browserRouteModulesPlugin } from "./plugins/browserRouteModulesPlugin";
-import { browserRouteModulesPlugin as browserRouteModulesPlugin_v2 } from "./plugins/browserRouteModulesPlugin_v2";
-import { cssFilePlugin } from "./plugins/cssFilePlugin";
-import { deprecatedRemixPackagePlugin } from "./plugins/deprecatedRemixPackagePlugin";
-import { emptyModulesPlugin } from "./plugins/emptyModulesPlugin";
-import { mdxPlugin } from "./plugins/mdx";
-import { urlImportsPlugin } from "./plugins/urlImportsPlugin";
-import { cssBundleUpdatePlugin } from "./plugins/cssBundleUpdatePlugin";
-import { cssModulesPlugin } from "./plugins/cssModulesPlugin";
-import { cssSideEffectImportsPlugin } from "./plugins/cssSideEffectImportsPlugin";
-import { vanillaExtractPlugin } from "./plugins/vanillaExtractPlugin";
-import {
-  cssBundleEntryModulePlugin,
-  cssBundleEntryModuleId,
-} from "./plugins/cssBundleEntryModulePlugin";
-import { writeFileSafe } from "./utils/fs";
-import invariant from "../invariant";
-import { hmrPlugin } from "./plugins/hmrPlugin";
-import { NodeProtocolExternalPlugin } from "./plugins/nodeProtocolExternalPlugin";
-
-export type BrowserCompiler = {
+import type { RemixConfig } from "../../config";
+import type * as Manifest from "../manifest";
+import { getAppDependencies } from "../../dependencies";
+import { loaders } from "../loaders";
+import type { CompileOptions } from "../options";
+import { browserRouteModulesPlugin } from "./plugins/routes";
+import { browserRouteModulesPlugin as browserRouteModulesPlugin_v2 } from "./plugins/routes_unstable";
+import { cssFilePlugin } from "../plugins/cssImports";
+import { deprecatedRemixPackagePlugin } from "../plugins/deprecatedRemixPackage";
+import { emptyModulesPlugin } from "../plugins/emptyModules";
+import { mdxPlugin } from "../plugins/mdx";
+import { externalPlugin } from "../plugins/external";
+import { cssBundleUpdatePlugin } from "./plugins/cssBundleUpdate";
+import { cssModulesPlugin } from "../plugins/cssModuleImports";
+import { cssSideEffectImportsPlugin } from "../plugins/cssSideEffectImports";
+import { vanillaExtractPlugin } from "../plugins/vanillaExtract";
+// import {
+//   cssBundleEntryModulePlugin,
+//   cssBundleEntryModuleId,
+// } from "../plugins/cssBundleEntryModulePlugin";
+import invariant from "../../invariant";
+import { hmrPlugin } from "./plugins/hmr";
+import { createMatchPath } from "../utils/tsconfig";
+import { getPreferredPackageManager } from "../../cli/getPreferredPackageManager";
+
+type Compiler = {
   // produce ./public/build/
-  compile: (
-    manifestChannel: WriteChannel<AssetsManifest>
-  ) => Promise<esbuild.Metafile>;
+  compile: () => Promise<{
+    metafile: esbuild.Metafile;
+    hmr?: Manifest.Type["hmr"];
+  }>;
   dispose: () => void;
 };
 
+function getNpmPackageName(id: string): string {
+  let split = id.split("/");
+  let packageName = split[0];
+  if (packageName.startsWith("@")) packageName += `/${split[1]}`;
+  return packageName;
+}
+
+function isBareModuleId(id: string): boolean {
+  return !id.startsWith("node:") && !id.startsWith(".") && !path.isAbsolute(id);
+}
+
+function isNodeBuiltIn(packageName: string) {
+  return nodeBuiltins.includes(packageName);
+}
+
 const getExternals = (remixConfig: RemixConfig): string[] => {
   // For the browser build, exclude node built-ins that don't have a
   // browser-safe alternative installed in node_modules. Nothing should
@@ -61,20 +71,6 @@ const getExternals = (remixConfig: RemixConfig): string[] => {
   return nodeBuiltins.filter((mod) => !dependencies.includes(mod));
 };
 
-const writeAssetsManifest = async (
-  config: RemixConfig,
-  assetsManifest: AssetsManifest
-) => {
-  let filename = `manifest-${assetsManifest.version.toUpperCase()}.js`;
-
-  assetsManifest.url = config.publicPath + filename;
-
-  await writeFileSafe(
-    path.join(config.assetsBuildDirectory, filename),
-    `window.__remixManifest=${JSON.stringify(assetsManifest)};`
-  );
-};
-
 const isCssBundlingEnabled = (config: RemixConfig): boolean =>
   Boolean(
     config.future.unstable_cssModules ||
@@ -82,23 +78,19 @@ const isCssBundlingEnabled = (config: RemixConfig): boolean =>
       config.future.unstable_vanillaExtract
   );
 
-let cssBundleHrefChannel: Channel<string | undefined>;
-
-// This function gives esbuild access to the latest channel value on rebuilds
-let getCssBundleHref = () => cssBundleHrefChannel.read();
-
 const createEsbuildConfig = (
   build: "app" | "css",
   config: RemixConfig,
   options: CompileOptions,
-  onLoader: (filename: string, code: string) => void
+  onLoader: (filename: string, code: string) => void,
+  readCssBundleHref: () => Promise<string | undefined>
 ): esbuild.BuildOptions | esbuild.BuildIncremental => {
   let isCssBuild = build === "css";
   let entryPoints: Record<string, string>;
 
   if (isCssBuild) {
     entryPoints = {
-      "css-bundle": cssBundleEntryModuleId,
+      // "css-bundle": cssBundleEntryModuleId,
     };
   } else {
     entryPoints = {
@@ -116,11 +108,23 @@ const createEsbuildConfig = (
   let { mode } = options;
   let outputCss = isCssBuild;
 
+  let matchPath = config.tsconfigPath
+    ? createMatchPath(config.tsconfigPath)
+    : undefined;
+  function resolvePath(id: string) {
+    if (!matchPath) {
+      return id;
+    }
+    return (
+      matchPath(id, undefined, undefined, [".ts", ".tsx", ".js", ".jsx"]) || id
+    );
+  }
+
   let plugins: esbuild.Plugin[] = [
     deprecatedRemixPackagePlugin(options.onWarning),
-    isCssBundlingEnabled(config) && isCssBuild
-      ? cssBundleEntryModulePlugin(config)
-      : null,
+    // isCssBundlingEnabled(config) && isCssBuild
+    //   ? cssBundleEntryModulePlugin(config)
+    //   : null,
     config.future.unstable_cssModules
       ? cssModulesPlugin({ config, mode, outputCss })
       : null,
@@ -131,14 +135,55 @@ const createEsbuildConfig = (
       ? cssSideEffectImportsPlugin({ config, options })
       : null,
     cssFilePlugin({ config, options }),
-    urlImportsPlugin(),
+    externalPlugin(/^https?:\/\//, { sideEffects: false }),
     mdxPlugin(config),
     config.future.unstable_dev
       ? browserRouteModulesPlugin_v2(config, /\?browser$/, onLoader, mode)
       : browserRouteModulesPlugin(config, /\?browser$/),
     emptyModulesPlugin(config, /\.server(\.[jt]sx?)?$/),
     NodeModulesPolyfillPlugin(),
-    NodeProtocolExternalPlugin(),
+    externalPlugin(/^node:.*/, { sideEffects: false }),
+    {
+      // TODO: should be removed when error handling for compiler is improved
+      name: "warn-on-unresolved-imports",
+      setup: (build) => {
+        build.onResolve({ filter: /.*/ }, (args) => {
+          if (!isBareModuleId(resolvePath(args.path))) {
+            return undefined;
+          }
+
+          if (args.path === "remix:hmr") {
+            return undefined;
+          }
+
+          let packageName = getNpmPackageName(args.path);
+          let pkgManager = getPreferredPackageManager();
+          if (
+            options.onWarning &&
+            !isNodeBuiltIn(packageName) &&
+            !/\bnode_modules\b/.test(args.importer) &&
+            // Silence spurious warnings when using Yarn PnP. Yarn PnP doesn’t use
+            // a `node_modules` folder to keep its dependencies, so the above check
+            // will always fail.
+            (pkgManager === "npm" ||
+              (pkgManager === "yarn" && process.versions.pnp == null))
+          ) {
+            try {
+              require.resolve(args.path);
+            } catch (error: unknown) {
+              options.onWarning(
+                `The path "${args.path}" is imported in ` +
+                  `${path.relative(process.cwd(), args.importer)} but ` +
+                  `"${args.path}" was not found in your node_modules. ` +
+                  `Did you forget to install it?`,
+                args.path
+              );
+            }
+          }
+          return undefined;
+        });
+      },
+    } as esbuild.Plugin,
   ].filter(isNotNull);
 
   if (build === "app" && mode === "development" && config.future.unstable_dev) {
@@ -161,7 +206,9 @@ const createEsbuildConfig = (
     plugins.push(hmrPlugin({ remixConfig: config }));
 
     if (isCssBundlingEnabled(config)) {
-      plugins.push(cssBundleUpdatePlugin({ getCssBundleHref }));
+      plugins.push(
+        cssBundleUpdatePlugin({ getCssBundleHref: readCssBundleHref })
+      );
     }
   }
 
@@ -212,12 +259,12 @@ const createEsbuildConfig = (
   };
 };
 
-export const createBrowserCompiler = (
+export const create = (
   remixConfig: RemixConfig,
-  options: CompileOptions
-): BrowserCompiler => {
+  options: CompileOptions,
+  readCssBundleHref: () => Promise<string | undefined>
+): Compiler => {
   let appCompiler: esbuild.BuildIncremental;
-  let cssCompiler: esbuild.BuildIncremental;
 
   let hmrRoutes: Record<string, { loaderHash: string }> = {};
   let onLoader = (filename: string, code: string) => {
@@ -225,12 +272,18 @@ export const createBrowserCompiler = (
     hmrRoutes[key] = { loaderHash: code };
   };
 
-  let compile = async (manifestChannel: WriteChannel<AssetsManifest>) => {
+  let compile = async () => {
     hmrRoutes = {};
     let appBuildTask = async () => {
       appCompiler = await (!appCompiler
         ? esbuild.build({
-            ...createEsbuildConfig("app", remixConfig, options, onLoader),
+            ...createEsbuildConfig(
+              "app",
+              remixConfig,
+              options,
+              onLoader,
+              readCssBundleHref
+            ),
             metafile: true,
             incremental: true,
           })
@@ -243,112 +296,9 @@ export const createBrowserCompiler = (
       return appCompiler.metafile;
     };
 
-    let cssBuildTask = async () => {
-      if (!isCssBundlingEnabled(remixConfig)) {
-        return;
-      }
-
-      try {
-        // The types aren't great when combining write: false and incremental: true
-        //  so we need to assert that it's an incremental build
-        cssCompiler = (await (!cssCompiler
-          ? esbuild.build({
-              ...createEsbuildConfig("css", remixConfig, options, onLoader),
-              metafile: true,
-              incremental: true,
-              write: false,
-            })
-          : cssCompiler.rebuild())) as esbuild.BuildIncremental;
-
-        invariant(
-          cssCompiler.metafile,
-          "Expected CSS compiler metafile to be defined. This is likely a bug in Remix. Please open an issue at https://github.com/remix-run/remix/issues/new"
-        );
-
-        let outputFiles = cssCompiler.outputFiles || [];
-
-        let isCssBundleFile = (
-          outputFile: esbuild.OutputFile,
-          extension: ".css" | ".css.map"
-        ): boolean => {
-          return (
-            path.dirname(outputFile.path) ===
-              remixConfig.assetsBuildDirectory &&
-            path.basename(outputFile.path).startsWith("css-bundle") &&
-            outputFile.path.endsWith(extension)
-          );
-        };
-
-        let cssBundleFile = outputFiles.find((outputFile) =>
-          isCssBundleFile(outputFile, ".css")
-        );
-
-        if (!cssBundleFile) {
-          cssBundleHrefChannel.write(undefined);
-          return;
-        }
-
-        let cssBundlePath = cssBundleFile.path;
-
-        let cssBundleHref =
-          remixConfig.publicPath +
-          path.relative(
-            remixConfig.assetsBuildDirectory,
-            path.resolve(cssBundlePath)
-          );
-
-        cssBundleHrefChannel.write(cssBundleHref);
-
-        let { css, map } = await postcss([
-          // We need to discard duplicate rules since "composes"
-          // in CSS Modules can result in duplicate styles
-          postcssDiscardDuplicates(),
-        ]).process(cssBundleFile.text, {
-          from: cssBundlePath,
-          to: cssBundlePath,
-          map: options.sourcemap && {
-            prev: outputFiles.find((outputFile) =>
-              isCssBundleFile(outputFile, ".css.map")
-            )?.text,
-            inline: false,
-            annotation: false,
-            sourcesContent: true,
-          },
-        });
-
-        await fse.ensureDir(path.dirname(cssBundlePath));
-
-        await Promise.all([
-          fse.writeFile(cssBundlePath, css),
-          options.mode !== "production" && map
-            ? fse.writeFile(`${cssBundlePath}.map`, map.toString()) // Write our updated source map rather than esbuild's
-            : null,
-          ...outputFiles
-            .filter((outputFile) => !/\.(css|js|map)$/.test(outputFile.path))
-            .map(async (asset) => {
-              await fse.ensureDir(path.dirname(asset.path));
-              await fse.writeFile(asset.path, asset.contents);
-            }),
-        ]);
-
-        return cssBundleHref;
-      } catch (error) {
-        cssBundleHrefChannel.write(undefined);
-        throw error;
-      }
-    };
-
-    // Reset the channel to co-ordinate the CSS and app builds
-    if (isCssBundlingEnabled(remixConfig)) {
-      cssBundleHrefChannel = createChannel();
-    }
+    let metafile = await appBuildTask();
 
-    let [cssBundleHref, metafile] = await Promise.all([
-      cssBuildTask(),
-      appBuildTask(),
-    ]);
-
-    let hmr: AssetsManifest["hmr"] | undefined = undefined;
+    let hmr: Manifest.Type["hmr"] | undefined = undefined;
     if (options.mode === "development" && remixConfig.future.unstable_dev) {
       let hmrRuntimeOutput = Object.entries(metafile.outputs).find(
         ([_, output]) => output.inputs["hmr-runtime:remix:hmr"]
@@ -367,23 +317,12 @@ export const createBrowserCompiler = (
       };
     }
 
-    let manifest = await createAssetsManifest({
-      config: remixConfig,
-      metafile: appCompiler.metafile!,
-      cssBundleHref,
-      hmr,
-    });
-    await writeAssetsManifest(remixConfig, manifest);
-    manifestChannel.write(manifest);
-    return metafile;
+    return { metafile, hmr };
   };
 
   return {
     compile,
-    dispose: () => {
-      appCompiler?.rebuild.dispose();
-      cssCompiler?.rebuild.dispose();
-    },
+    dispose: () => appCompiler?.rebuild.dispose(),
   };
 };
 
diff --git a/packages/remix-dev/compiler/browserjs/index.ts b/packages/remix-dev/compiler/browserjs/index.ts
new file mode 100644
index 000000000..6b608d454
--- /dev/null
+++ b/packages/remix-dev/compiler/browserjs/index.ts
@@ -0,0 +1,1 @@
+export * as compiler from "./compiler";
diff --git a/packages/remix-dev/compiler/plugins/cssBundleUpdatePlugin.ts b/packages/remix-dev/compiler/browserjs/plugins/cssBundleUpdate.ts
similarity index 100%
rename from packages/remix-dev/compiler/plugins/cssBundleUpdatePlugin.ts
rename to packages/remix-dev/compiler/browserjs/plugins/cssBundleUpdate.ts
diff --git a/packages/remix-dev/compiler/plugins/hmrPlugin.ts b/packages/remix-dev/compiler/browserjs/plugins/hmr.ts
similarity index 99%
rename from packages/remix-dev/compiler/plugins/hmrPlugin.ts
rename to packages/remix-dev/compiler/browserjs/plugins/hmr.ts
index b3c383881..35bef40fc 100644
--- a/packages/remix-dev/compiler/plugins/hmrPlugin.ts
+++ b/packages/remix-dev/compiler/browserjs/plugins/hmr.ts
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
 import * as path from "node:path";
 import * as esbuild from "esbuild";
 
-import type { RemixConfig } from "../../config";
+import type { RemixConfig } from "../../../config";
 
 export let hmrPlugin = ({
   remixConfig,
diff --git a/packages/remix-dev/compiler/plugins/browserRouteModulesPlugin.ts b/packages/remix-dev/compiler/browserjs/plugins/routes.ts
similarity index 93%
rename from packages/remix-dev/compiler/plugins/browserRouteModulesPlugin.ts
rename to packages/remix-dev/compiler/browserjs/plugins/routes.ts
index b8fdd1c88..ad8af5532 100644
--- a/packages/remix-dev/compiler/plugins/browserRouteModulesPlugin.ts
+++ b/packages/remix-dev/compiler/browserjs/plugins/routes.ts
@@ -1,8 +1,8 @@
 import type esbuild from "esbuild";
 
-import type { RemixConfig } from "../../config";
-import { getRouteModuleExports } from "../routeExports";
-import invariant from "../../invariant";
+import type { RemixConfig } from "../../../config";
+import { getRouteModuleExports } from "../../routeExports";
+import invariant from "../../../invariant";
 
 type Route = RemixConfig["routes"][string];
 
diff --git a/packages/remix-dev/compiler/plugins/browserRouteModulesPlugin_v2.ts b/packages/remix-dev/compiler/browserjs/plugins/routes_unstable.ts
similarity index 93%
rename from packages/remix-dev/compiler/plugins/browserRouteModulesPlugin_v2.ts
rename to packages/remix-dev/compiler/browserjs/plugins/routes_unstable.ts
index 5dd00f519..5d595ded8 100644
--- a/packages/remix-dev/compiler/plugins/browserRouteModulesPlugin_v2.ts
+++ b/packages/remix-dev/compiler/browserjs/plugins/routes_unstable.ts
@@ -3,13 +3,13 @@ import * as path from "node:path";
 import type esbuild from "esbuild";
 import generate from "@babel/generator";
 
-import type { RemixConfig } from "../../config";
-import invariant from "../../invariant";
-import * as Transform from "../../transform";
-import type { CompileOptions } from "../options";
-import { getLoaderForFile } from "../loaders";
-import { getRouteModuleExports } from "../routeExports";
-import { applyHMR } from "./hmrPlugin";
+import type { RemixConfig } from "../../../config";
+import invariant from "../../../invariant";
+import * as Transform from "../../../transform";
+import type { CompileOptions } from "../../options";
+import { getLoaderForFile } from "../../loaders";
+import { getRouteModuleExports } from "../../routeExports";
+import { applyHMR } from "./hmr";
 
 const serverOnlyExports = new Set(["action", "loader"]);
 
diff --git a/packages/remix-dev/compiler/build.ts b/packages/remix-dev/compiler/build.ts
index 225d56aa5..71f773683 100644
--- a/packages/remix-dev/compiler/build.ts
+++ b/packages/remix-dev/compiler/build.ts
@@ -1,8 +1,8 @@
 import type { RemixConfig } from "../config";
-import { warnOnce } from "./warnings";
+import { warnOnce } from "../warnOnce";
 import { logCompileFailure } from "./onCompileFailure";
 import type { CompileOptions } from "./options";
-import { compile, createRemixCompiler } from "./remixCompiler";
+import * as Compiler from "./compiler";
 
 export async function build(
   config: RemixConfig,
@@ -14,12 +14,12 @@ export async function build(
     onCompileFailure = logCompileFailure,
   }: Partial<CompileOptions> = {}
 ): Promise<void> {
-  let compiler = createRemixCompiler(config, {
+  let compiler = Compiler.create(config, {
     mode,
     target,
     sourcemap,
     onWarning,
     onCompileFailure,
   });
-  await compile(compiler, { onCompileFailure });
+  await compiler.compile();
 }
diff --git a/packages/remix-dev/compiler/compiler.ts b/packages/remix-dev/compiler/compiler.ts
new file mode 100644
index 000000000..8d353760b
--- /dev/null
+++ b/packages/remix-dev/compiler/compiler.ts
@@ -0,0 +1,97 @@
+import * as path from "path";
+import type esbuild from "esbuild";
+import { promises as fsp } from "fs";
+
+import type { RemixConfig } from "../config";
+import * as Manifest from "./manifest";
+import * as BrowserJS from "./browserjs";
+import * as ServerJS from "./serverjs";
+import type { CompileOptions } from "./options";
+import type { Channel } from "../channel";
+import { createChannel } from "../channel";
+import * as CSS from "./css";
+
+export type CompileResult = {
+  assetsManifest: Manifest.Type;
+  metafile: {
+    browser: esbuild.Metafile;
+    server: esbuild.Metafile;
+  };
+};
+
+type Compiler = {
+  compile: () => Promise<CompileResult | undefined>;
+  dispose: () => void;
+};
+
+export let create = (
+  config: RemixConfig,
+  options: CompileOptions
+): Compiler => {
+  let cssBundleHrefChannel: Channel<string | undefined>;
+  let writeCssBundleHref = (cssBundleHref?: string) =>
+    cssBundleHrefChannel.write(cssBundleHref);
+  let readCssBundleHref = () => cssBundleHrefChannel.read();
+  let css = CSS.compiler.create(config, options, writeCssBundleHref);
+  let browser = BrowserJS.compiler.create(config, options, readCssBundleHref);
+  let server = ServerJS.compiler.create(config, options);
+  return {
+    compile: async () => {
+      // TODO: only reset cssBundleHrefChannel if css bundling is enabled?
+      // otherwise we could just write `undefined` to the channel immediately
+      cssBundleHrefChannel = createChannel();
+      try {
+        let [cssBundleHref, { metafile, hmr }] = await Promise.all([
+          css.compile(),
+          browser.compile(),
+        ]);
+        let manifest = await Manifest.create({
+          config,
+          metafile: metafile,
+          cssBundleHref,
+          hmr,
+        });
+        let [serverMetafile] = await Promise.all([
+          server.compile(manifest),
+          writeAssetsManifest(config, manifest),
+        ]);
+
+        return {
+          assetsManifest: manifest,
+          metafile: {
+            browser: metafile,
+            server: serverMetafile,
+          },
+        };
+      } catch (error: unknown) {
+        options.onCompileFailure?.(error as Error);
+        return undefined;
+      }
+    },
+    dispose: () => {
+      css.dispose();
+      browser.dispose();
+      server.dispose();
+    },
+  };
+};
+
+const writeAssetsManifest = async (
+  config: RemixConfig,
+  assetsManifest: Manifest.Type
+) => {
+  let filename = `manifest-${assetsManifest.version.toUpperCase()}.js`;
+
+  assetsManifest.url = config.publicPath + filename;
+
+  await writeFileSafe(
+    path.join(config.assetsBuildDirectory, filename),
+    `window.__remixManifest=${JSON.stringify(assetsManifest)};`
+  );
+};
+
+async function writeFileSafe(file: string, contents: string): Promise<string> {
+  await fsp.mkdir(path.dirname(file), { recursive: true });
+  await fsp.writeFile(file, contents);
+  return file;
+}
diff --git a/packages/remix-dev/compiler/css/compiler.ts b/packages/remix-dev/compiler/css/compiler.ts
new file mode 100644
index 000000000..8f07a9744
--- /dev/null
+++ b/packages/remix-dev/compiler/css/compiler.ts
@@ -0,0 +1,290 @@
+import * as path from "path";
+import * as fse from "fs-extra";
+import { builtinModules as nodeBuiltins } from "module";
+import * as esbuild from "esbuild";
+import { NodeModulesPolyfillPlugin } from "@esbuild-plugins/node-modules-polyfill";
+import postcss from "postcss";
+import postcssDiscardDuplicates from "postcss-discard-duplicates";
+
+import type { RemixConfig } from "../../config";
+import { getAppDependencies } from "../../dependencies";
+import { loaders } from "../loaders";
+import type { CompileOptions } from "../options";
+// import { browserRouteModulesPlugin } from "../plugins/browserRouteModulesPlugin";
+// import { browserRouteModulesPlugin as browserRouteModulesPlugin_v2 } from "../plugins/browserRouteModulesPlugin_v2";
+import { cssFilePlugin } from "../plugins/cssImports";
+import { deprecatedRemixPackagePlugin } from "../plugins/deprecatedRemixPackage";
+import { emptyModulesPlugin } from "../plugins/emptyModules";
+import { mdxPlugin } from "../plugins/mdx";
+import { externalPlugin } from "../plugins/external";
+// import { cssBundleUpdatePlugin } from "../plugins/cssBundleUpdatePlugin";
+import { cssModulesPlugin } from "../plugins/cssModuleImports";
+import { cssSideEffectImportsPlugin } from "../plugins/cssSideEffectImports";
+import { vanillaExtractPlugin } from "../plugins/vanillaExtract";
+import {
+  cssBundleEntryModulePlugin,
+  cssBundleEntryModuleId,
+} from "./plugins/bundleEntry";
+import invariant from "../../invariant";
+// import { hmrPlugin } from "../plugins/hmrPlugin";
+
+function isNotNull<Value>(value: Value): value is Exclude<Value, null> {
+  return value !== null;
+}
+
+const isCssBundlingEnabled = (config: RemixConfig): boolean =>
+  Boolean(
+    config.future.unstable_cssModules ||
+      config.future.unstable_cssSideEffectImports ||
+      config.future.unstable_vanillaExtract
+  );
+
+const getExternals = (remixConfig: RemixConfig): string[] => {
+  // For the browser build, exclude node built-ins that don't have a
+  // browser-safe alternative installed in node_modules. Nothing should
+  // *actually* be external in the browser build (we want to bundle all deps) so
+  // this is really just making sure we don't accidentally have any dependencies
+  // on node built-ins in browser bundles.
+  let dependencies = Object.keys(getAppDependencies(remixConfig));
+  let fakeBuiltins = nodeBuiltins.filter((mod) => dependencies.includes(mod));
+
+  if (fakeBuiltins.length > 0) {
+    throw new Error(
+      `It appears you're using a module that is built in to node, but you installed it as a dependency which could cause problems. Please remove ${fakeBuiltins.join(
+        ", "
+      )} before continuing.`
+    );
+  }
+  return nodeBuiltins.filter((mod) => !dependencies.includes(mod));
+};
+
+const createEsbuildConfig = (
+  build: "app" | "css",
+  config: RemixConfig,
+  options: CompileOptions
+  // onLoader: (filename: string, code: string) => void
+): esbuild.BuildOptions | esbuild.BuildIncremental => {
+  let isCssBuild = build === "css";
+  let entryPoints: Record<string, string>;
+
+  if (isCssBuild) {
+    entryPoints = {
+      "css-bundle": cssBundleEntryModuleId,
+    };
+  } else {
+    entryPoints = {
+      "entry.client": config.entryClientFilePath,
+    };
+
+    for (let id of Object.keys(config.routes)) {
+      // All route entry points are virtual modules that will be loaded by the
+      // browserEntryPointsPlugin. This allows us to tree-shake server-only code
+      // that we don't want to run in the browser (i.e. action & loader).
+      entryPoints[id] = config.routes[id].file + "?browser";
+    }
+  }
+
+  let { mode } = options;
+  let outputCss = isCssBuild;
+
+  let plugins: esbuild.Plugin[] = [
+    deprecatedRemixPackagePlugin(options.onWarning),
+    isCssBundlingEnabled(config) && isCssBuild
+      ? cssBundleEntryModulePlugin(config)
+      : null,
+    config.future.unstable_cssModules
+      ? cssModulesPlugin({ config, mode, outputCss })
+      : null,
+    config.future.unstable_vanillaExtract
+      ? vanillaExtractPlugin({ config, mode, outputCss })
+      : null,
+    config.future.unstable_cssSideEffectImports
+      ? cssSideEffectImportsPlugin({ config, options })
+      : null,
+    cssFilePlugin({ config, options }),
+    externalPlugin(/^https?:\/\//, { sideEffects: false }),
+    mdxPlugin(config),
+    // config.future.unstable_dev
+    //   ? browserRouteModulesPlugin_v2(config, /\?browser$/, onLoader, mode)
+    //   : browserRouteModulesPlugin(config, /\?browser$/),
+    emptyModulesPlugin(config, /\.server(\.[jt]sx?)?$/),
+    NodeModulesPolyfillPlugin(),
+    externalPlugin(/^node:.*/, { sideEffects: false }),
+  ].filter(isNotNull);
+
+  if (build === "app" && mode === "development" && config.future.unstable_dev) {
+    // TODO prebundle deps instead of chunking just these ones
+    let isolateChunks = [
+      require.resolve("react"),
+      require.resolve("react/jsx-dev-runtime"),
+      require.resolve("react/jsx-runtime"),
+      require.resolve("react-dom"),
+      require.resolve("react-dom/client"),
+      require.resolve("react-refresh/runtime"),
+      require.resolve("@remix-run/react"),
+      "remix:hmr",
+    ];
+    entryPoints = {
+      ...entryPoints,
+      ...Object.fromEntries(isolateChunks.map((imprt) => [imprt, imprt])),
+    };
+
+    // plugins.push(hmrPlugin({ remixConfig: config }));
+
+    if (isCssBundlingEnabled(config)) {
+      // plugins.push(cssBundleUpdatePlugin({ getCssBundleHref }));
+    }
+  }
+
+  return {
+    entryPoints,
+    outdir: config.assetsBuildDirectory,
+    platform: "browser",
+    format: "esm",
+    external: [
+      // This allows Vanilla Extract to bundle asset imports, e.g. `import href
+      // from './image.svg'` resolves to a string like "/build/_assets/XXXX.svg"
+      // which will then appear in the compiled CSS, e.g. `background:
+      // url("/build/_assets/XXXX.svg")`. If we don't mark this path as
+      // external, esbuild will try to bundle it again but won't find it.
+      config.future.unstable_vanillaExtract
+        ? `${config.publicPath}_assets/*`
+        : null,
+      ...getExternals(config),
+    ].filter(isNotNull),
+    loader: loaders,
+    bundle: true,
+    logLevel: "silent",
+    splitting: !isCssBuild,
+    sourcemap: options.sourcemap,
+    // As pointed out by https://github.com/evanw/esbuild/issues/2440, when tsconfig is set to
+    // `undefined`, esbuild will keep looking for a tsconfig.json recursively up. This unwanted
+    // behavior can only be avoided by creating an empty tsconfig file in the root directory.
+    tsconfig: config.tsconfigPath,
+    mainFields: ["browser", "module", "main"],
+    treeShaking: true,
+    minify: options.mode === "production",
+    entryNames: "[dir]/[name]-[hash]",
+    chunkNames: "_shared/[name]-[hash]",
+    assetNames: "_assets/[name]-[hash]",
+    publicPath: config.publicPath,
+    define: {
+      "process.env.NODE_ENV": JSON.stringify(options.mode),
+      "process.env.REMIX_DEV_SERVER_WS_PORT": JSON.stringify(
+        config.devServerPort
+      ),
+    },
+    jsx: "automatic",
+    jsxDev: options.mode !== "production",
+    plugins,
+    supported: {
+      "import-meta": true,
+    },
+  };
+};
+
+export let create = (
+  remixConfig: RemixConfig,
+  options: CompileOptions,
+  writeCssBundleHref: (cssBundleHref?: string) => void
+) => {
+  let cssCompiler: esbuild.BuildIncremental;
+  // let onLoader = () => {};
+  let cssBuildTask = async () => {
+    if (!isCssBundlingEnabled(remixConfig)) {
+      return;
+    }
+
+    try {
+      // The types aren't great when combining write: false and incremental: true
+      //  so we need to assert that it's an incremental build
+      cssCompiler = (await (!cssCompiler
+        ? esbuild.build({
+            ...createEsbuildConfig("css", remixConfig, options /* onLoader */),
+            metafile: true,
+            incremental: true,
+            write: false,
+          })
+        : cssCompiler.rebuild())) as esbuild.BuildIncremental;
+
+      invariant(
+        cssCompiler.metafile,
+        "Expected CSS compiler metafile to be defined. This is likely a bug in Remix. Please open an issue at https://github.com/remix-run/remix/issues/new"
+      );
+
+      let outputFiles = cssCompiler.outputFiles || [];
+
+      let isCssBundleFile = (
+        outputFile: esbuild.OutputFile,
+        extension: ".css" | ".css.map"
+      ): boolean => {
+        return (
+          path.dirname(outputFile.path) === remixConfig.assetsBuildDirectory &&
+          path.basename(outputFile.path).startsWith("css-bundle") &&
+          outputFile.path.endsWith(extension)
+        );
+      };
+
+      let cssBundleFile = outputFiles.find((outputFile) =>
+        isCssBundleFile(outputFile, ".css")
+      );
+
+      if (!cssBundleFile) {
+        writeCssBundleHref(undefined);
+        return;
+      }
+
+      let cssBundlePath = cssBundleFile.path;
+
+      let cssBundleHref =
+        remixConfig.publicPath +
+        path.relative(
+          remixConfig.assetsBuildDirectory,
+          path.resolve(cssBundlePath)
+        );
+
+      writeCssBundleHref(cssBundleHref);
+
+      let { css, map } = await postcss([
+        // We need to discard duplicate rules since "composes"
+        // in CSS Modules can result in duplicate styles
+        postcssDiscardDuplicates(),
+      ]).process(cssBundleFile.text, {
+        from: cssBundlePath,
+        to: cssBundlePath,
+        map: options.sourcemap && {
+          prev: outputFiles.find((outputFile) =>
+            isCssBundleFile(outputFile, ".css.map")
+          )?.text,
+          inline: false,
+          annotation: false,
+          sourcesContent: true,
+        },
+      });
+
+      await fse.ensureDir(path.dirname(cssBundlePath));
+
+      await Promise.all([
+        fse.writeFile(cssBundlePath, css),
+        options.mode !== "production" && map
+          ? fse.writeFile(`${cssBundlePath}.map`, map.toString()) // Write our updated source map rather than esbuild's
+          : null,
+        ...outputFiles
+          .filter((outputFile) => !/\.(css|js|map)$/.test(outputFile.path))
+          .map(async (asset) => {
+            await fse.ensureDir(path.dirname(asset.path));
+            await fse.writeFile(asset.path, asset.contents);
+          }),
+      ]);
+
+      return cssBundleHref;
+    } catch (error) {
+      writeCssBundleHref(undefined);
+      throw error;
+    }
+  };
+  return {
+    compile: cssBuildTask,
+    dispose: () => cssCompiler.rebuild.dispose(),
+  };
+};
diff --git a/packages/remix-dev/compiler/css/index.ts b/packages/remix-dev/compiler/css/index.ts
new file mode 100644
index 000000000..6b608d454
--- /dev/null
+++ b/packages/remix-dev/compiler/css/index.ts
@@ -0,0 +1,1 @@
+export * as compiler from "./compiler";
diff --git a/packages/remix-dev/compiler/plugins/cssBundleEntryModulePlugin.ts b/packages/remix-dev/compiler/css/plugins/bundleEntry.ts
similarity index 96%
rename from packages/remix-dev/compiler/plugins/cssBundleEntryModulePlugin.ts
rename to packages/remix-dev/compiler/css/plugins/bundleEntry.ts
index eaa62a021..93c8e3c0b 100644
--- a/packages/remix-dev/compiler/plugins/cssBundleEntryModulePlugin.ts
+++ b/packages/remix-dev/compiler/css/plugins/bundleEntry.ts
@@ -1,7 +1,7 @@
 import path from "node:path";
 import type { Plugin } from "esbuild";
 
-import type { RemixConfig } from "../../config";
+import type { RemixConfig } from "../../../config";
 
 export const cssBundleEntryModuleId = "__remix_cssBundleEntryModule__";
 const filter = new RegExp(`^${cssBundleEntryModuleId}$`);
diff --git a/packages/remix-dev/compiler/index.ts b/packages/remix-dev/compiler/index.ts
index 75da2f2c6..36a06a9e8 100644
--- a/packages/remix-dev/compiler/index.ts
+++ b/packages/remix-dev/compiler/index.ts
@@ -3,4 +3,4 @@ export { type WatchOptions, watch } from "./watch";
 
 export { type CompileOptions, parseMode } from "./options";
 export { logCompileFailure } from "./onCompileFailure";
-export { CompileResult } from "./remixCompiler";
+export { CompileResult } from "./compiler";
diff --git a/packages/remix-dev/compiler/assets.ts b/packages/remix-dev/compiler/manifest.ts
similarity index 92%
rename from packages/remix-dev/compiler/assets.ts
rename to packages/remix-dev/compiler/manifest.ts
index fad890502..67b87cbd4 100644
--- a/packages/remix-dev/compiler/assets.ts
+++ b/packages/remix-dev/compiler/manifest.ts
@@ -5,11 +5,10 @@ import type { RemixConfig } from "../config";
 import invariant from "../invariant";
 import { getRouteModuleExports } from "./routeExports";
 import { getHash } from "./utils/crypto";
-import { createUrl } from "./utils/url";
 
 type Route = RemixConfig["routes"][string];
 
-export interface AssetsManifest {
+type Manifest = {
   version: string;
   url?: string;
   entry: {
@@ -37,9 +36,10 @@ export interface AssetsManifest {
     runtime: string;
     routes: Record<string, { loaderHash: string }>;
   };
-}
+};
+export type Type = Manifest;
 
-export async function createAssetsManifest({
+export async function create({
   config,
   metafile,
   cssBundleHref,
@@ -48,8 +48,8 @@ export async function createAssetsManifest({
   config: RemixConfig;
   metafile: esbuild.Metafile;
   cssBundleHref?: string;
-  hmr?: AssetsManifest["hmr"];
-}): Promise<AssetsManifest> {
+  hmr?: Manifest["hmr"];
+}): Promise<Manifest> {
   function resolveUrl(outputPath: string): string {
     return createUrl(
       config.publicPath,
@@ -77,8 +77,8 @@ export async function createAssetsManifest({
     new Map()
   );
 
-  let entry: AssetsManifest["entry"] | undefined;
-  let routes: AssetsManifest["routes"] = {};
+  let entry: Manifest["entry"] | undefined;
+  let routes: Manifest["routes"] = {};
 
   for (let key of Object.keys(metafile.outputs).sort()) {
     let output = metafile.outputs[key];
@@ -129,10 +129,14 @@ export async function createAssetsManifest({
   return { version, entry, routes, cssBundleHref, hmr };
 }
 
+function createUrl(publicPath: string, file: string): string {
+  return publicPath + file.split(path.win32.sep).join("/");
+}
+
 type ImportsCache = { [routeId: string]: string[] };
 
 function optimizeRoutes(
-  routes: AssetsManifest["routes"],
+  routes: Manifest["routes"],
   entryImports: string[]
 ): void {
   // This cache is an optimization that allows us to avoid pruning the same
@@ -146,7 +150,7 @@ function optimizeRoutes(
 
 function optimizeRouteImports(
   routeId: string,
-  routes: AssetsManifest["routes"],
+  routes: Manifest["routes"],
   parentImports: string[],
   importsCache: ImportsCache
 ): string[] {
diff --git a/packages/remix-dev/compiler/plugins/cssFilePlugin.ts b/packages/remix-dev/compiler/plugins/cssImports.ts
similarity index 100%
rename from packages/remix-dev/compiler/plugins/cssFilePlugin.ts
rename to packages/remix-dev/compiler/plugins/cssImports.ts
diff --git a/packages/remix-dev/compiler/plugins/cssModulesPlugin.ts b/packages/remix-dev/compiler/plugins/cssModuleImports.ts
similarity index 100%
rename from packages/remix-dev/compiler/plugins/cssModulesPlugin.ts
rename to packages/remix-dev/compiler/plugins/cssModuleImports.ts
diff --git a/packages/remix-dev/compiler/plugins/cssSideEffectImportsPlugin.ts b/packages/remix-dev/compiler/plugins/cssSideEffectImports.ts
similarity index 100%
rename from packages/remix-dev/compiler/plugins/cssSideEffectImportsPlugin.ts
rename to packages/remix-dev/compiler/plugins/cssSideEffectImports.ts
diff --git a/packages/remix-dev/compiler/plugins/deprecatedRemixPackagePlugin.ts b/packages/remix-dev/compiler/plugins/deprecatedRemixPackage.ts
similarity index 100%
rename from packages/remix-dev/compiler/plugins/deprecatedRemixPackagePlugin.ts
rename to packages/remix-dev/compiler/plugins/deprecatedRemixPackage.ts
diff --git a/packages/remix-dev/compiler/plugins/emptyModulesPlugin.ts b/packages/remix-dev/compiler/plugins/emptyModules.ts
similarity index 100%
rename from packages/remix-dev/compiler/plugins/emptyModulesPlugin.ts
rename to packages/remix-dev/compiler/plugins/emptyModules.ts
diff --git a/packages/remix-dev/compiler/plugins/external.ts b/packages/remix-dev/compiler/plugins/external.ts
new file mode 100644
index 000000000..763ce2a3b
--- /dev/null
+++ b/packages/remix-dev/compiler/plugins/external.ts
@@ -0,0 +1,20 @@
+import type { Plugin } from "esbuild";
+
+/**
+ * Mark all URL imports as external so that each URL import is preserved in the build output.
+ */
+export const externalPlugin = (
+  filter: RegExp,
+  options: {
+    sideEffects?: boolean;
+  } = {}
+): Plugin => {
+  return {
+    name: "external",
+    setup(build) {
+      build.onResolve({ filter }, () => {
+        return { external: true, sideEffects: options.sideEffects };
+      });
+    },
+  };
+};
diff --git a/packages/remix-dev/compiler/plugins/nodeProtocolExternalPlugin.ts b/packages/remix-dev/compiler/plugins/nodeProtocolExternalPlugin.ts
deleted file mode 100644
index dab0168c8..000000000
--- a/packages/remix-dev/compiler/plugins/nodeProtocolExternalPlugin.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import type esbuild from "esbuild";
-
-/**
- * Node supports importing modules using the node: protocol. However, this causes resolution
- * to fail in the compiler. This plugin properly marks all `node:` imports as external.
- */
-export function NodeProtocolExternalPlugin(): esbuild.Plugin {
-  return {
-    name: "node-protocol-external",
-    setup(pluginBuild) {
-      pluginBuild.onResolve({ filter: /node:.*/ }, () => {
-        return { external: true, sideEffects: false };
-      });
-    },
-  };
-}
diff --git a/packages/remix-dev/compiler/plugins/urlImportsPlugin.ts b/packages/remix-dev/compiler/plugins/urlImportsPlugin.ts
deleted file mode 100644
index 6732f02a3..000000000
--- a/packages/remix-dev/compiler/plugins/urlImportsPlugin.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { Plugin } from "esbuild";
-
-/**
- * Mark all URL imports as external so that each URL import is preserved in the build output.
- */
-export const urlImportsPlugin = (): Plugin => {
-  return {
-    name: "url-imports",
-    setup(build) {
-      build.onResolve({ filter: /^https?:\/\// }, ({ path }) => {
-        /*
-        The vast majority of packages are side-effect free,
-        and URL imports don't have a mechanism for specifying that they are side-effect free.
-        
-        Mark all url imports as side-effect free so that they can be treeshaken by esbuild.
-        */
-        return { path, external: true, sideEffects: false };
-      });
-    },
-  };
-};
diff --git a/packages/remix-dev/compiler/plugins/vanillaExtractPluginCached.ts b/packages/remix-dev/compiler/plugins/vanillaExtract/cached.ts
similarity index 92%
rename from packages/remix-dev/compiler/plugins/vanillaExtractPluginCached.ts
rename to packages/remix-dev/compiler/plugins/vanillaExtract/cached.ts
index 068c44d86..b349b8c63 100644
--- a/packages/remix-dev/compiler/plugins/vanillaExtractPluginCached.ts
+++ b/packages/remix-dev/compiler/plugins/vanillaExtract/cached.ts
@@ -3,11 +3,11 @@ import type { Compiler } from "@vanilla-extract/integration";
 import { cssFileFilter, createCompiler } from "@vanilla-extract/integration";
 import type { Plugin } from "esbuild";
 
-import type { RemixConfig } from "../../config";
-import type { CompileOptions } from "../options";
-import { loaders } from "../loaders";
-import { getPostcssProcessor } from "../utils/postcss";
-import { vanillaExtractSideEffectsPlugin } from "./vanillaExtractSideEffectsPlugin";
+import type { RemixConfig } from "../../../config";
+import type { CompileOptions } from "../../options";
+import { loaders } from "../../loaders";
+import { getPostcssProcessor } from "../../utils/postcss";
+import { vanillaExtractSideEffectsPlugin } from "./sideEffects";
 
 const pluginName = "vanilla-extract-plugin-cached";
 const namespace = `${pluginName}-ns`;
diff --git a/packages/remix-dev/compiler/plugins/vanillaExtractPlugin.ts b/packages/remix-dev/compiler/plugins/vanillaExtract/index.ts
similarity index 69%
rename from packages/remix-dev/compiler/plugins/vanillaExtractPlugin.ts
rename to packages/remix-dev/compiler/plugins/vanillaExtract/index.ts
index b5a2479f0..9607fbf0b 100644
--- a/packages/remix-dev/compiler/plugins/vanillaExtractPlugin.ts
+++ b/packages/remix-dev/compiler/plugins/vanillaExtract/index.ts
@@ -1,9 +1,9 @@
 import type { Plugin } from "esbuild";
 
-import type { RemixConfig, VanillaExtractOptions } from "../../config";
-import type { CompileOptions } from "../options";
-import { vanillaExtractPluginCached } from "./vanillaExtractPluginCached";
-import { vanillaExtractPluginUncached } from "./vanillaExtractPluginUncached";
+import type { RemixConfig, VanillaExtractOptions } from "../../../config";
+import type { CompileOptions } from "../../options";
+import { vanillaExtractPluginCached } from "./cached";
+import { vanillaExtractPluginUncached } from "./uncached";
 
 export function vanillaExtractPlugin(options: {
   config: RemixConfig;
diff --git a/packages/remix-dev/compiler/plugins/vanillaExtractSideEffectsPlugin.ts b/packages/remix-dev/compiler/plugins/vanillaExtract/sideEffects.ts
similarity index 100%
rename from packages/remix-dev/compiler/plugins/vanillaExtractSideEffectsPlugin.ts
rename to packages/remix-dev/compiler/plugins/vanillaExtract/sideEffects.ts
diff --git a/packages/remix-dev/compiler/plugins/vanillaExtractPluginUncached.ts b/packages/remix-dev/compiler/plugins/vanillaExtract/uncached.ts
similarity index 94%
rename from packages/remix-dev/compiler/plugins/vanillaExtractPluginUncached.ts
rename to packages/remix-dev/compiler/plugins/vanillaExtract/uncached.ts
index f33fb12de..658a1dc90 100644
--- a/packages/remix-dev/compiler/plugins/vanillaExtractPluginUncached.ts
+++ b/packages/remix-dev/compiler/plugins/vanillaExtract/uncached.ts
@@ -10,11 +10,11 @@ import {
 import * as fse from "fs-extra";
 import * as esbuild from "esbuild";
 
-import type { RemixConfig } from "../../config";
-import type { CompileOptions } from "../options";
-import { loaders } from "../loaders";
-import { getPostcssProcessor } from "../utils/postcss";
-import { vanillaExtractSideEffectsPlugin } from "./vanillaExtractSideEffectsPlugin";
+import type { RemixConfig } from "../../../config";
+import type { CompileOptions } from "../../options";
+import { loaders } from "../../loaders";
+import { getPostcssProcessor } from "../../utils/postcss";
+import { vanillaExtractSideEffectsPlugin } from "./sideEffects";
 
 const pluginName = "vanilla-extract-plugin-uncached";
 const namespace = `${pluginName}-ns`;
diff --git a/packages/remix-dev/compiler/remixCompiler.ts b/packages/remix-dev/compiler/remixCompiler.ts
deleted file mode 100644
index 2e40d0891..000000000
--- a/packages/remix-dev/compiler/remixCompiler.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import type esbuild from "esbuild";
-
-import { createChannel } from "../channel";
-import type { RemixConfig } from "../config";
-import type { AssetsManifest } from "./assets";
-import type { BrowserCompiler } from "./compileBrowser";
-import { createBrowserCompiler } from "./compileBrowser";
-import type { ServerCompiler } from "./compilerServer";
-import { createServerCompiler } from "./compilerServer";
-import type { OnCompileFailure } from "./onCompileFailure";
-import type { CompileOptions } from "./options";
-
-type RemixCompiler = {
-  browser: BrowserCompiler;
-  server: ServerCompiler;
-};
-
-export const createRemixCompiler = (
-  remixConfig: RemixConfig,
-  options: CompileOptions
-): RemixCompiler => {
-  return {
-    browser: createBrowserCompiler(remixConfig, options),
-    server: createServerCompiler(remixConfig, options),
-  };
-};
-
-export type CompileResult = {
-  assetsManifest: AssetsManifest;
-  metafile: {
-    browser: esbuild.Metafile;
-    server: esbuild.Metafile;
-  };
-};
-
-export const compile = async (
-  compiler: RemixCompiler,
-  options: {
-    onCompileFailure?: OnCompileFailure;
-  } = {}
-): Promise<CompileResult | undefined> => {
-  try {
-    let assetsManifestChannel = createChannel<AssetsManifest>();
-    let browserPromise = compiler.browser.compile(assetsManifestChannel);
-    let serverPromise = compiler.server.compile(assetsManifestChannel);
-
-    // await browser/server _before_ assets manifest channel
-    // to fix https://github.com/remix-run/remix/issues/5631
-    // this is temporary and is actively being refactored
-    let browser = await browserPromise;
-    let server = await serverPromise;
-    return {
-      assetsManifest: await assetsManifestChannel.read(),
-      metafile: {
-        browser,
-        server,
-      },
-    };
-  } catch (error: unknown) {
-    options.onCompileFailure?.(error as Error);
-    return undefined;
-  }
-};
-
-export const dispose = (compiler: RemixCompiler): void => {
-  compiler.browser.dispose();
-  compiler.server.dispose();
-};
diff --git a/packages/remix-dev/compiler/compilerServer.ts b/packages/remix-dev/compiler/serverjs/compiler.ts
similarity index 76%
rename from packages/remix-dev/compiler/compilerServer.ts
rename to packages/remix-dev/compiler/serverjs/compiler.ts
index f087d23f4..8d5592789 100644
--- a/packages/remix-dev/compiler/compilerServer.ts
+++ b/packages/remix-dev/compiler/serverjs/compiler.ts
@@ -3,37 +3,33 @@ import * as esbuild from "esbuild";
 import * as fse from "fs-extra";
 import { NodeModulesPolyfillPlugin } from "@esbuild-plugins/node-modules-polyfill";
 
-import invariant from "../invariant";
-import type { ReadChannel } from "../channel";
-import type { RemixConfig } from "../config";
-import type { AssetsManifest } from "./assets";
-import { loaders } from "./loaders";
-import type { CompileOptions } from "./options";
-import { cssModulesPlugin } from "./plugins/cssModulesPlugin";
-import { cssSideEffectImportsPlugin } from "./plugins/cssSideEffectImportsPlugin";
-import { vanillaExtractPlugin } from "./plugins/vanillaExtractPlugin";
-import { cssFilePlugin } from "./plugins/cssFilePlugin";
-import { deprecatedRemixPackagePlugin } from "./plugins/deprecatedRemixPackagePlugin";
-import { emptyModulesPlugin } from "./plugins/emptyModulesPlugin";
-import { mdxPlugin } from "./plugins/mdx";
-import { serverAssetsManifestPlugin } from "./plugins/serverAssetsManifestPlugin";
-import { serverBareModulesPlugin } from "./plugins/serverBareModulesPlugin";
-import { serverEntryModulePlugin } from "./plugins/serverEntryModulePlugin";
-import { serverRouteModulesPlugin } from "./plugins/serverRouteModulesPlugin";
-import { urlImportsPlugin } from "./plugins/urlImportsPlugin";
-import { NodeProtocolExternalPlugin } from "./plugins/nodeProtocolExternalPlugin";
+import invariant from "../../invariant";
+import type { RemixConfig } from "../../config";
+import type * as Manifest from "../manifest";
+import { loaders } from "../loaders";
+import type { CompileOptions } from "../options";
+import { cssModulesPlugin } from "../plugins/cssModuleImports";
+import { cssSideEffectImportsPlugin } from "../plugins/cssSideEffectImports";
+import { vanillaExtractPlugin } from "../plugins/vanillaExtract";
+import { cssFilePlugin } from "../plugins/cssImports";
+import { deprecatedRemixPackagePlugin } from "../plugins/deprecatedRemixPackage";
+import { emptyModulesPlugin } from "../plugins/emptyModules";
+import { mdxPlugin } from "../plugins/mdx";
+import { serverAssetsManifestPlugin } from "./plugins/manifest";
+import { serverBareModulesPlugin } from "./plugins/bareImports";
+import { serverEntryModulePlugin } from "./plugins/entry";
+import { serverRouteModulesPlugin } from "./plugins/routes";
+import { externalPlugin } from "../plugins/external";
 
-export type ServerCompiler = {
+type Compiler = {
   // produce ./build/index.js
-  compile: (
-    manifestChannel: ReadChannel<AssetsManifest>
-  ) => Promise<esbuild.Metafile>;
+  compile: (manifest: Manifest.Type) => Promise<esbuild.Metafile>;
   dispose: () => void;
 };
 
 const createEsbuildConfig = (
   config: RemixConfig,
-  assetsManifestChannel: ReadChannel<AssetsManifest>,
+  manifest: Manifest.Type,
   options: CompileOptions
 ): esbuild.BuildOptions => {
   let stdin: esbuild.StdinOptions | undefined;
@@ -64,14 +60,14 @@ const createEsbuildConfig = (
       ? cssSideEffectImportsPlugin({ config, options })
       : null,
     cssFilePlugin({ config, options }),
-    urlImportsPlugin(),
+    externalPlugin(/^https?:\/\//, { sideEffects: false }),
     mdxPlugin(config),
     emptyModulesPlugin(config, /\.client(\.[jt]sx?)?$/),
     serverRouteModulesPlugin(config),
     serverEntryModulePlugin(config, { liveReloadPort: options.liveReloadPort }),
-    serverAssetsManifestPlugin(assetsManifestChannel.read()),
+    serverAssetsManifestPlugin(manifest),
     serverBareModulesPlugin(config, options.onWarning),
-    NodeProtocolExternalPlugin(),
+    externalPlugin(/^node:.*/, { sideEffects: false }),
   ].filter(isNotNull);
 
   if (config.serverPlatform !== "node") {
@@ -165,16 +161,12 @@ async function writeServerBuildResult(
   }
 }
 
-export const createServerCompiler = (
+export const create = (
   remixConfig: RemixConfig,
   options: CompileOptions
-): ServerCompiler => {
-  let compile = async (manifestChannel: ReadChannel<AssetsManifest>) => {
-    let esbuildConfig = createEsbuildConfig(
-      remixConfig,
-      manifestChannel,
-      options
-    );
+): Compiler => {
+  let compile = async (manifest: Manifest.Type) => {
+    let esbuildConfig = createEsbuildConfig(remixConfig, manifest, options);
     let { metafile, outputFiles } = await esbuild.build({
       ...esbuildConfig,
       write: false,
diff --git a/packages/remix-dev/compiler/serverjs/index.ts b/packages/remix-dev/compiler/serverjs/index.ts
new file mode 100644
index 000000000..6b608d454
--- /dev/null
+++ b/packages/remix-dev/compiler/serverjs/index.ts
@@ -0,0 +1,1 @@
+export * as compiler from "./compiler";
diff --git a/packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts b/packages/remix-dev/compiler/serverjs/plugins/bareImports.ts
similarity index 96%
rename from packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts
rename to packages/remix-dev/compiler/serverjs/plugins/bareImports.ts
index c6a673f9d..1be82b625 100644
--- a/packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts
+++ b/packages/remix-dev/compiler/serverjs/plugins/bareImports.ts
@@ -4,14 +4,14 @@ import { builtinModules } from "module";
 import { isAbsolute, relative } from "path";
 import type { Plugin } from "esbuild";
 
-import type { RemixConfig } from "../../config";
+import type { RemixConfig } from "../../../config";
 import {
   serverBuildVirtualModule,
   assetsManifestVirtualModule,
 } from "../virtualModules";
-import { isCssSideEffectImportPath } from "./cssSideEffectImportsPlugin";
-import { createMatchPath } from "../utils/tsconfig";
-import { getPreferredPackageManager } from "../../cli/getPreferredPackageManager";
+import { isCssSideEffectImportPath } from "../../plugins/cssSideEffectImports";
+import { createMatchPath } from "../../utils/tsconfig";
+import { getPreferredPackageManager } from "../../../cli/getPreferredPackageManager";
 
 /**
  * A plugin responsible for resolving bare module ids based on server target.
diff --git a/packages/remix-dev/compiler/plugins/serverEntryModulePlugin.ts b/packages/remix-dev/compiler/serverjs/plugins/entry.ts
similarity index 97%
rename from packages/remix-dev/compiler/plugins/serverEntryModulePlugin.ts
rename to packages/remix-dev/compiler/serverjs/plugins/entry.ts
index bb6b4137b..9ade9aa4b 100644
--- a/packages/remix-dev/compiler/plugins/serverEntryModulePlugin.ts
+++ b/packages/remix-dev/compiler/serverjs/plugins/entry.ts
@@ -1,6 +1,6 @@
 import type { Plugin } from "esbuild";
 
-import type { RemixConfig } from "../../config";
+import type { RemixConfig } from "../../../config";
 import {
   serverBuildVirtualModule,
   assetsManifestVirtualModule,
diff --git a/packages/remix-dev/compiler/plugins/serverAssetsManifestPlugin.ts b/packages/remix-dev/compiler/serverjs/plugins/manifest.ts
similarity index 63%
rename from packages/remix-dev/compiler/plugins/serverAssetsManifestPlugin.ts
rename to packages/remix-dev/compiler/serverjs/plugins/manifest.ts
index 06041f4f2..5600635cc 100644
--- a/packages/remix-dev/compiler/plugins/serverAssetsManifestPlugin.ts
+++ b/packages/remix-dev/compiler/serverjs/plugins/manifest.ts
@@ -1,17 +1,15 @@
 import type { Plugin } from "esbuild";
 import jsesc from "jsesc";
 
-import type { AssetsManifest } from "../../compiler/assets";
-import { assetsManifestVirtualModule } from "../../compiler/virtualModules";
+import type * as Manifest from "../../manifest";
+import { assetsManifestVirtualModule } from "../virtualModules";
 
 /**
  * Creates a virtual module called `@remix-run/dev/assets-manifest` that exports
  * the assets manifest. This is used in the server entry module to access the
  * assets manifest in the server build.
  */
-export function serverAssetsManifestPlugin(
-  assetsManifestPromise: Promise<AssetsManifest>
-): Plugin {
+export function serverAssetsManifestPlugin(manifest: Manifest.Type): Plugin {
   let filter = assetsManifestVirtualModule.filter;
 
   return {
@@ -25,9 +23,8 @@ export function serverAssetsManifestPlugin(
       });
 
       build.onLoad({ filter }, async () => {
-        let assetsManifest = await assetsManifestPromise;
         return {
-          contents: `export default ${jsesc(assetsManifest, { es6: true })};`,
+          contents: `export default ${jsesc(manifest, { es6: true })};`,
           loader: "js",
         };
       });
diff --git a/packages/remix-dev/compiler/plugins/serverRouteModulesPlugin.ts b/packages/remix-dev/compiler/serverjs/plugins/routes.ts
similarity index 93%
rename from packages/remix-dev/compiler/plugins/serverRouteModulesPlugin.ts
rename to packages/remix-dev/compiler/serverjs/plugins/routes.ts
index fd778131e..9c9f27857 100644
--- a/packages/remix-dev/compiler/plugins/serverRouteModulesPlugin.ts
+++ b/packages/remix-dev/compiler/serverjs/plugins/routes.ts
@@ -2,8 +2,8 @@ import * as path from "path";
 import * as fse from "fs-extra";
 import type esbuild from "esbuild";
 
-import type { RemixConfig } from "../../config";
-import { getLoaderForFile } from "../loaders";
+import type { RemixConfig } from "../../../config";
+import { getLoaderForFile } from "../../loaders";
 
 /**
  * This plugin loads route modules for the server build and prevents errors
diff --git a/packages/remix-dev/compiler/virtualModules.ts b/packages/remix-dev/compiler/serverjs/virtualModules.ts
similarity index 100%
rename from packages/remix-dev/compiler/virtualModules.ts
rename to packages/remix-dev/compiler/serverjs/virtualModules.ts
diff --git a/packages/remix-dev/compiler/utils/fs.ts b/packages/remix-dev/compiler/utils/fs.ts
deleted file mode 100644
index c64ac0622..000000000
--- a/packages/remix-dev/compiler/utils/fs.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { promises as fsp } from "fs";
-import * as path from "path";
-
-export async function writeFileSafe(
-  file: string,
-  contents: string
-): Promise<string> {
-  await fsp.mkdir(path.dirname(file), { recursive: true });
-  await fsp.writeFile(file, contents);
-  return file;
-}
diff --git a/packages/remix-dev/compiler/utils/url.ts b/packages/remix-dev/compiler/utils/url.ts
deleted file mode 100644
index 7c30e4d24..000000000
--- a/packages/remix-dev/compiler/utils/url.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import * as path from "path";
-
-export function createUrl(publicPath: string, file: string): string {
-  return publicPath + file.split(path.win32.sep).join("/");
-}
diff --git a/packages/remix-dev/compiler/watch.ts b/packages/remix-dev/compiler/watch.ts
index 7d6dfefef..bde96dc6d 100644
--- a/packages/remix-dev/compiler/watch.ts
+++ b/packages/remix-dev/compiler/watch.ts
@@ -4,11 +4,11 @@ import * as path from "path";
 
 import type { RemixConfig } from "../config";
 import { readConfig } from "../config";
+import { warnOnce } from "../warnOnce";
 import { logCompileFailure } from "./onCompileFailure";
 import type { CompileOptions } from "./options";
-import type { CompileResult } from "./remixCompiler";
-import { compile, createRemixCompiler, dispose } from "./remixCompiler";
-import { warnOnce } from "./warnings";
+import type { CompileResult } from "./compiler";
+import * as Compiler from "./compiler";
 
 function isEntryPoint(config: RemixConfig, file: string): boolean {
   let appFile = path.relative(config.appDirectory, file);
@@ -58,16 +58,16 @@ export async function watch(
   };
 
   let start = Date.now();
-  let compiler = createRemixCompiler(config, options);
+  let compiler = Compiler.create(config, options);
 
   // initial build
-  let result = await compile(compiler, { onCompileFailure });
+  let result = await compiler.compile();
   onInitialBuild?.(Date.now() - start, result);
 
   let restart = debounce(async () => {
     onRebuildStart?.();
     let start = Date.now();
-    dispose(compiler);
+    compiler.dispose();
 
     try {
       config = await reloadConfig(config.rootDirectory);
@@ -76,17 +76,15 @@ export async function watch(
       return;
     }
 
-    compiler = createRemixCompiler(config, options);
-    let result = await compile(compiler, { onCompileFailure });
+    compiler = Compiler.create(config, options);
+    let result = await compiler.compile();
     onRebuildFinish?.(Date.now() - start, result);
   }, 500);
 
   let rebuild = debounce(async () => {
     onRebuildStart?.();
     let start = Date.now();
-    let result = await compile(compiler, {
-      onCompileFailure,
-    });
+    let result = await compiler.compile();
     onRebuildFinish?.(Date.now() - start, result);
   }, 100);
 
@@ -132,6 +130,6 @@ export async function watch(
 
   return async () => {
     await watcher.close().catch(() => undefined);
-    dispose(compiler);
+    compiler.dispose();
   };
 }
diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts
index 3331b5c00..78a91825e 100644
--- a/packages/remix-dev/config.ts
+++ b/packages/remix-dev/config.ts
@@ -10,11 +10,11 @@ import type { RouteManifest, DefineRoutesFunction } from "./config/routes";
 import { defineRoutes } from "./config/routes";
 import { defineConventionalRoutes } from "./config/routesConvention";
 import { ServerMode, isValidServerMode } from "./config/serverModes";
-import { serverBuildVirtualModule } from "./compiler/virtualModules";
+import { serverBuildVirtualModule } from "./compiler/serverjs/virtualModules";
 import { writeConfigDefaults } from "./compiler/utils/tsconfig/write-config-defaults";
 import { flatRoutes } from "./config/flat-routes";
 import { getPreferredPackageManager } from "./cli/getPreferredPackageManager";
-import { warnOnce } from "./compiler/warnings";
+import { warnOnce } from "./warnOnce";
 
 export interface RemixMdxConfig {
   rehypePlugins?: any[];
diff --git a/packages/remix-dev/config/defaults/cloudflare/entry.server.react-stream.tsx b/packages/remix-dev/config/defaults/cloudflare/entry.server.react-stream.tsx
index b854691e5..3e08f46fe 100644
--- a/packages/remix-dev/config/defaults/cloudflare/entry.server.react-stream.tsx
+++ b/packages/remix-dev/config/defaults/cloudflare/entry.server.react-stream.tsx
@@ -13,7 +13,7 @@ export default async function handleRequest(
     <RemixServer context={remixContext} url={request.url} />,
     {
       signal: request.signal,
-      onError(error) {
+      onError(error: unknown) {
         console.error(error);
         responseStatusCode = 500;
       },
diff --git a/packages/remix-dev/config/defaults/deno/entry.server.react-stream.tsx b/packages/remix-dev/config/defaults/deno/entry.server.react-stream.tsx
index 96ff812aa..7be6c7480 100644
--- a/packages/remix-dev/config/defaults/deno/entry.server.react-stream.tsx
+++ b/packages/remix-dev/config/defaults/deno/entry.server.react-stream.tsx
@@ -13,7 +13,7 @@ export default async function handleRequest(
     <RemixServer context={remixContext} url={request.url} />,
     {
       signal: request.signal,
-      onError(error) {
+      onError(error: unknown) {
         console.error(error);
         responseStatusCode = 500;
       },
diff --git a/packages/remix-dev/config/defaults/node/entry.server.react-stream.tsx b/packages/remix-dev/config/defaults/node/entry.server.react-stream.tsx
index 98b32eb38..469de6d43 100644
--- a/packages/remix-dev/config/defaults/node/entry.server.react-stream.tsx
+++ b/packages/remix-dev/config/defaults/node/entry.server.react-stream.tsx
@@ -5,7 +5,7 @@ import { RemixServer } from "@remix-run/react";
 import isbot from "isbot";
 import { renderToPipeableStream } from "react-dom/server";
 
-const ABORT_DELAY = 5000;
+const ABORT_DELAY = 5_000;
 
 export default function handleRequest(
   request: Request,
diff --git a/packages/remix-dev/compiler/dependencies.ts b/packages/remix-dev/dependencies.ts
similarity index 91%
rename from packages/remix-dev/compiler/dependencies.ts
rename to packages/remix-dev/dependencies.ts
index 743fcef22..6e4fde792 100644
--- a/packages/remix-dev/compiler/dependencies.ts
+++ b/packages/remix-dev/dependencies.ts
@@ -1,13 +1,11 @@
 import * as fs from "fs";
 import * as path from "path";
 
-import type { RemixConfig } from "../config";
+import type { RemixConfig } from "./config";
 
 type PackageDependencies = { [packageName: string]: string };
 
-export function getPackageDependencies(
-  packageJsonFile: string
-): PackageDependencies {
+function getPackageDependencies(packageJsonFile: string): PackageDependencies {
   let pkg = JSON.parse(fs.readFileSync(packageJsonFile, "utf8"));
   return pkg?.dependencies || {};
 }
diff --git a/packages/remix-dev/index.ts b/packages/remix-dev/index.ts
index 206f30f19..bf2154fb2 100644
--- a/packages/remix-dev/index.ts
+++ b/packages/remix-dev/index.ts
@@ -6,5 +6,5 @@ export * as cli from "./cli/index";
 export { createApp } from "./cli/create";
 export { CliError } from "./cli/error";
 
-export type { AssetsManifest } from "./compiler/assets";
-export { getDependenciesToBundle } from "./compiler/dependencies";
+export type { Type as AssetsManifest } from "./compiler/manifest";
+export { getDependenciesToBundle } from "./dependencies";
diff --git a/packages/remix-dev/liveReload.ts b/packages/remix-dev/liveReload.ts
index 61f097f30..e000aec3c 100644
--- a/packages/remix-dev/liveReload.ts
+++ b/packages/remix-dev/liveReload.ts
@@ -1,6 +1,6 @@
 import WebSocket from "ws";
 
-import type { AssetsManifest } from "./compiler/assets";
+import type * as Manifest from "./compiler/manifest";
 import type * as HMR from "./hmr";
 
 type Message =
@@ -8,7 +8,7 @@ type Message =
   | { type: "LOG"; message: string }
   | {
       type: "HMR";
-      assetsManifest: AssetsManifest;
+      assetsManifest: Manifest.Type;
       updates: HMR.Update[];
     };
 
@@ -33,7 +33,7 @@ export let serve = (options: { port: number }) => {
     broadcast({ type: "LOG", message: _message });
   };
 
-  let hmr = (assetsManifest: AssetsManifest, updates: HMR.Update[]) => {
+  let hmr = (assetsManifest: Manifest.Type, updates: HMR.Update[]) => {
     broadcast({ type: "HMR", assetsManifest, updates });
   };
 
diff --git a/packages/remix-dev/compiler/warnings.ts b/packages/remix-dev/warnOnce.ts
similarity index 100%
rename from packages/remix-dev/compiler/warnings.ts
rename to packages/remix-dev/warnOnce.ts
diff --git a/templates/arc/app/entry.client.tsx b/templates/arc/app/entry.client.tsx
index 8338545d1..36f2e51a7 100644
--- a/templates/arc/app/entry.client.tsx
+++ b/templates/arc/app/entry.client.tsx
@@ -1,22 +1,18 @@
+/**
+ * By default, Remix will handle hydrating your app on the client for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.client
+ */
+
 import { RemixBrowser } from "@remix-run/react";
 import { startTransition, StrictMode } from "react";
 import { hydrateRoot } from "react-dom/client";
 
-function hydrate() {
-  startTransition(() => {
-    hydrateRoot(
-      document,
-      <StrictMode>
-        <RemixBrowser />
-      </StrictMode>
-    );
-  });
-}
-
-if (typeof requestIdleCallback === "function") {
-  requestIdleCallback(hydrate);
-} else {
-  // Safari doesn't support requestIdleCallback
-  // https://caniuse.com/requestidlecallback
-  setTimeout(hydrate, 1);
-}
+startTransition(() => {
+  hydrateRoot(
+    document,
+    <StrictMode>
+      <RemixBrowser />
+    </StrictMode>
+  );
+});
diff --git a/templates/arc/app/entry.server.tsx b/templates/arc/app/entry.server.tsx
index fb8ea8709..b38c99270 100644
--- a/templates/arc/app/entry.server.tsx
+++ b/templates/arc/app/entry.server.tsx
@@ -1,6 +1,17 @@
+/**
+ * By default, Remix will handle generating the HTTP Response for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.server
+ */
+
+import { PassThrough } from "node:stream";
 import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
-import { renderToString } from "react-dom/server";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5_000;
 
 export default function handleRequest(
   request: Request,
@@ -8,14 +19,101 @@ export default function handleRequest(
   responseHeaders: Headers,
   remixContext: EntryContext
 ) {
-  const markup = renderToString(
-    <RemixServer context={remixContext} url={request.url} />
-  );
+  return isbot(request.headers.get("user-agent"))
+    ? handleBotRequest(
+        request,
+        responseStatusCode,
+        responseHeaders,
+        remixContext
+      )
+    : handleBrowserRequest(
+        request,
+        responseStatusCode,
+        responseHeaders,
+        remixContext
+      );
+}
+
+function handleBotRequest(
+  request: Request,
+  responseStatusCode: number,
+  responseHeaders: Headers,
+  remixContext: EntryContext
+) {
+  return new Promise((resolve, reject) => {
+    const { pipe, abort } = renderToPipeableStream(
+      <RemixServer
+        context={remixContext}
+        url={request.url}
+        abortDelay={ABORT_DELAY}
+      />,
+      {
+        onAllReady() {
+          const body = new PassThrough();
+
+          responseHeaders.set("Content-Type", "text/html");
+
+          resolve(
+            new Response(body, {
+              headers: responseHeaders,
+              status: responseStatusCode,
+            })
+          );
+
+          pipe(body);
+        },
+        onShellError(error: unknown) {
+          reject(error);
+        },
+        onError(error: unknown) {
+          responseStatusCode = 500;
+          console.error(error);
+        },
+      }
+    );
+
+    setTimeout(abort, ABORT_DELAY);
+  });
+}
+
+function handleBrowserRequest(
+  request: Request,
+  responseStatusCode: number,
+  responseHeaders: Headers,
+  remixContext: EntryContext
+) {
+  return new Promise((resolve, reject) => {
+    const { pipe, abort } = renderToPipeableStream(
+      <RemixServer
+        context={remixContext}
+        url={request.url}
+        abortDelay={ABORT_DELAY}
+      />,
+      {
+        onShellReady() {
+          const body = new PassThrough();
+
+          responseHeaders.set("Content-Type", "text/html");
+
+          resolve(
+            new Response(body, {
+              headers: responseHeaders,
+              status: responseStatusCode,
+            })
+          );
 
-  responseHeaders.set("Content-Type", "text/html");
+          pipe(body);
+        },
+        onShellError(error: unknown) {
+          reject(error);
+        },
+        onError(error: unknown) {
+          console.error(error);
+          responseStatusCode = 500;
+        },
+      }
+    );
 
-  return new Response("<!DOCTYPE html>" + markup, {
-    headers: responseHeaders,
-    status: responseStatusCode,
+    setTimeout(abort, ABORT_DELAY);
   });
 }
diff --git a/templates/arc/package.json b/templates/arc/package.json
index cb0485a16..c06975401 100644
--- a/templates/arc/package.json
+++ b/templates/arc/package.json
@@ -14,6 +14,7 @@
     "@remix-run/node": "*",
     "@remix-run/react": "*",
     "cross-env": "^7.0.3",
+    "isbot": "^3.6.5",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   },
diff --git a/templates/cloudflare-pages/app/entry.client.tsx b/templates/cloudflare-pages/app/entry.client.tsx
index 8338545d1..36f2e51a7 100644
--- a/templates/cloudflare-pages/app/entry.client.tsx
+++ b/templates/cloudflare-pages/app/entry.client.tsx
@@ -1,22 +1,18 @@
+/**
+ * By default, Remix will handle hydrating your app on the client for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.client
+ */
+
 import { RemixBrowser } from "@remix-run/react";
 import { startTransition, StrictMode } from "react";
 import { hydrateRoot } from "react-dom/client";
 
-function hydrate() {
-  startTransition(() => {
-    hydrateRoot(
-      document,
-      <StrictMode>
-        <RemixBrowser />
-      </StrictMode>
-    );
-  });
-}
-
-if (typeof requestIdleCallback === "function") {
-  requestIdleCallback(hydrate);
-} else {
-  // Safari doesn't support requestIdleCallback
-  // https://caniuse.com/requestidlecallback
-  setTimeout(hydrate, 1);
-}
+startTransition(() => {
+  hydrateRoot(
+    document,
+    <StrictMode>
+      <RemixBrowser />
+    </StrictMode>
+  );
+});
diff --git a/templates/cloudflare-pages/app/entry.server.tsx b/templates/cloudflare-pages/app/entry.server.tsx
index ab0872209..361b45605 100644
--- a/templates/cloudflare-pages/app/entry.server.tsx
+++ b/templates/cloudflare-pages/app/entry.server.tsx
@@ -1,7 +1,13 @@
+/**
+ * By default, Remix will handle generating the HTTP Response for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.server
+ */
+
 import type { EntryContext } from "@remix-run/cloudflare";
 import { RemixServer } from "@remix-run/react";
-import { renderToReadableStream } from "react-dom/server";
 import isbot from "isbot";
+import { renderToReadableStream } from "react-dom/server";
 
 export default async function handleRequest(
   request: Request,
@@ -9,24 +15,23 @@ export default async function handleRequest(
   responseHeaders: Headers,
   remixContext: EntryContext
 ) {
-  const stream = await renderToReadableStream(
+  const body = await renderToReadableStream(
     <RemixServer context={remixContext} url={request.url} />,
     {
       signal: request.signal,
       onError(error: unknown) {
-        responseStatusCode = 500;
         console.error(error);
+        responseStatusCode = 500;
       },
     }
   );
 
   if (isbot(request.headers.get("user-agent"))) {
-    await stream.allReady;
+    await body.allReady;
   }
 
   responseHeaders.set("Content-Type", "text/html");
-
-  return new Response(stream, {
+  return new Response(body, {
     headers: responseHeaders,
     status: responseStatusCode,
   });
diff --git a/templates/cloudflare-workers/app/entry.client.tsx b/templates/cloudflare-workers/app/entry.client.tsx
index 8338545d1..36f2e51a7 100644
--- a/templates/cloudflare-workers/app/entry.client.tsx
+++ b/templates/cloudflare-workers/app/entry.client.tsx
@@ -1,22 +1,18 @@
+/**
+ * By default, Remix will handle hydrating your app on the client for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.client
+ */
+
 import { RemixBrowser } from "@remix-run/react";
 import { startTransition, StrictMode } from "react";
 import { hydrateRoot } from "react-dom/client";
 
-function hydrate() {
-  startTransition(() => {
-    hydrateRoot(
-      document,
-      <StrictMode>
-        <RemixBrowser />
-      </StrictMode>
-    );
-  });
-}
-
-if (typeof requestIdleCallback === "function") {
-  requestIdleCallback(hydrate);
-} else {
-  // Safari doesn't support requestIdleCallback
-  // https://caniuse.com/requestidlecallback
-  setTimeout(hydrate, 1);
-}
+startTransition(() => {
+  hydrateRoot(
+    document,
+    <StrictMode>
+      <RemixBrowser />
+    </StrictMode>
+  );
+});
diff --git a/templates/cloudflare-workers/app/entry.server.tsx b/templates/cloudflare-workers/app/entry.server.tsx
index 8ae6b6395..361b45605 100644
--- a/templates/cloudflare-workers/app/entry.server.tsx
+++ b/templates/cloudflare-workers/app/entry.server.tsx
@@ -1,7 +1,13 @@
+/**
+ * By default, Remix will handle generating the HTTP Response for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.server
+ */
+
 import type { EntryContext } from "@remix-run/cloudflare";
 import { RemixServer } from "@remix-run/react";
-import { renderToReadableStream } from "react-dom/server";
 import isbot from "isbot";
+import { renderToReadableStream } from "react-dom/server";
 
 export default async function handleRequest(
   request: Request,
@@ -9,7 +15,7 @@ export default async function handleRequest(
   responseHeaders: Headers,
   remixContext: EntryContext
 ) {
-  const stream = await renderToReadableStream(
+  const body = await renderToReadableStream(
     <RemixServer context={remixContext} url={request.url} />,
     {
       signal: request.signal,
@@ -21,12 +27,11 @@ export default async function handleRequest(
   );
 
   if (isbot(request.headers.get("user-agent"))) {
-    await stream.allReady;
+    await body.allReady;
   }
 
   responseHeaders.set("Content-Type", "text/html");
-
-  return new Response(stream, {
+  return new Response(body, {
     headers: responseHeaders,
     status: responseStatusCode,
   });
diff --git a/templates/deno/app/entry.client.tsx b/templates/deno/app/entry.client.tsx
index 3ffb6c2eb..186cd9344 100644
--- a/templates/deno/app/entry.client.tsx
+++ b/templates/deno/app/entry.client.tsx
@@ -1,22 +1,18 @@
+/**
+ * By default, Remix will handle hydrating your app on the client for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.client
+ */
+
 import { RemixBrowser } from "@remix-run/react";
 import { startTransition, StrictMode } from "react";
 import { hydrateRoot } from "react-dom/client";
 
-function hydrate() {
-  startTransition(() => {
-    hydrateRoot(
-      document,
-      <StrictMode>
-        <RemixBrowser />
-      </StrictMode>,
-    );
-  });
-}
-
-if (typeof requestIdleCallback === "function") {
-  requestIdleCallback(hydrate);
-} else {
-  // Safari doesn't support requestIdleCallback
-  // https://caniuse.com/requestidlecallback
-  setTimeout(hydrate, 1);
-}
+startTransition(() => {
+  hydrateRoot(
+    document,
+    <StrictMode>
+      <RemixBrowser />
+    </StrictMode>,
+  );
+});
diff --git a/templates/deno/app/entry.server.tsx b/templates/deno/app/entry.server.tsx
index 039c21c03..69103177f 100644
--- a/templates/deno/app/entry.server.tsx
+++ b/templates/deno/app/entry.server.tsx
@@ -1,7 +1,13 @@
+/**
+ * By default, Remix will handle generating the HTTP Response for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.server
+ */
+
 import type { EntryContext } from "@remix-run/deno";
 import { RemixServer } from "@remix-run/react";
-import { renderToReadableStream } from "react-dom/server";
 import isbot from "isbot";
+import { renderToReadableStream } from "react-dom/server";
 
 export default async function handleRequest(
   request: Request,
@@ -9,7 +15,7 @@ export default async function handleRequest(
   responseHeaders: Headers,
   remixContext: EntryContext,
 ) {
-  const stream = await renderToReadableStream(
+  const body = await renderToReadableStream(
     <RemixServer context={remixContext} url={request.url} />,
     {
       signal: request.signal,
@@ -21,12 +27,11 @@ export default async function handleRequest(
   );
 
   if (isbot(request.headers.get("user-agent"))) {
-    await stream.allReady;
+    await body.allReady;
   }
 
   responseHeaders.set("Content-Type", "text/html");
-
-  return new Response(stream, {
+  return new Response(body, {
     headers: responseHeaders,
     status: responseStatusCode,
   });
diff --git a/templates/express/app/entry.client.tsx b/templates/express/app/entry.client.tsx
index 8338545d1..36f2e51a7 100644
--- a/templates/express/app/entry.client.tsx
+++ b/templates/express/app/entry.client.tsx
@@ -1,22 +1,18 @@
+/**
+ * By default, Remix will handle hydrating your app on the client for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.client
+ */
+
 import { RemixBrowser } from "@remix-run/react";
 import { startTransition, StrictMode } from "react";
 import { hydrateRoot } from "react-dom/client";
 
-function hydrate() {
-  startTransition(() => {
-    hydrateRoot(
-      document,
-      <StrictMode>
-        <RemixBrowser />
-      </StrictMode>
-    );
-  });
-}
-
-if (typeof requestIdleCallback === "function") {
-  requestIdleCallback(hydrate);
-} else {
-  // Safari doesn't support requestIdleCallback
-  // https://caniuse.com/requestidlecallback
-  setTimeout(hydrate, 1);
-}
+startTransition(() => {
+  hydrateRoot(
+    document,
+    <StrictMode>
+      <RemixBrowser />
+    </StrictMode>
+  );
+});
diff --git a/templates/express/app/entry.server.tsx b/templates/express/app/entry.server.tsx
index c78d5047b..b38c99270 100644
--- a/templates/express/app/entry.server.tsx
+++ b/templates/express/app/entry.server.tsx
@@ -1,11 +1,17 @@
-import { PassThrough } from "stream";
+/**
+ * By default, Remix will handle generating the HTTP Response for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.server
+ */
+
+import { PassThrough } from "node:stream";
 import type { EntryContext } from "@remix-run/node";
 import { Response } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
 import isbot from "isbot";
 import { renderToPipeableStream } from "react-dom/server";
 
-const ABORT_DELAY = 5000;
+const ABORT_DELAY = 5_000;
 
 export default function handleRequest(
   request: Request,
@@ -60,8 +66,8 @@ function handleBotRequest(
           reject(error);
         },
         onError(error: unknown) {
-          console.error(error);
           responseStatusCode = 500;
+          console.error(error);
         },
       }
     );
diff --git a/templates/fly/app/entry.client.tsx b/templates/fly/app/entry.client.tsx
index 8338545d1..36f2e51a7 100644
--- a/templates/fly/app/entry.client.tsx
+++ b/templates/fly/app/entry.client.tsx
@@ -1,22 +1,18 @@
+/**
+ * By default, Remix will handle hydrating your app on the client for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.client
+ */
+
 import { RemixBrowser } from "@remix-run/react";
 import { startTransition, StrictMode } from "react";
 import { hydrateRoot } from "react-dom/client";
 
-function hydrate() {
-  startTransition(() => {
-    hydrateRoot(
-      document,
-      <StrictMode>
-        <RemixBrowser />
-      </StrictMode>
-    );
-  });
-}
-
-if (typeof requestIdleCallback === "function") {
-  requestIdleCallback(hydrate);
-} else {
-  // Safari doesn't support requestIdleCallback
-  // https://caniuse.com/requestidlecallback
-  setTimeout(hydrate, 1);
-}
+startTransition(() => {
+  hydrateRoot(
+    document,
+    <StrictMode>
+      <RemixBrowser />
+    </StrictMode>
+  );
+});
diff --git a/templates/fly/app/entry.server.tsx b/templates/fly/app/entry.server.tsx
index c78d5047b..b38c99270 100644
--- a/templates/fly/app/entry.server.tsx
+++ b/templates/fly/app/entry.server.tsx
@@ -1,11 +1,17 @@
-import { PassThrough } from "stream";
+/**
+ * By default, Remix will handle generating the HTTP Response for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.server
+ */
+
+import { PassThrough } from "node:stream";
 import type { EntryContext } from "@remix-run/node";
 import { Response } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
 import isbot from "isbot";
 import { renderToPipeableStream } from "react-dom/server";
 
-const ABORT_DELAY = 5000;
+const ABORT_DELAY = 5_000;
 
 export default function handleRequest(
   request: Request,
@@ -60,8 +66,8 @@ function handleBotRequest(
           reject(error);
         },
         onError(error: unknown) {
-          console.error(error);
           responseStatusCode = 500;
+          console.error(error);
         },
       }
     );
diff --git a/templates/netlify/app/entry.client.tsx b/templates/netlify/app/entry.client.tsx
index 8338545d1..36f2e51a7 100644
--- a/templates/netlify/app/entry.client.tsx
+++ b/templates/netlify/app/entry.client.tsx
@@ -1,22 +1,18 @@
+/**
+ * By default, Remix will handle hydrating your app on the client for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.client
+ */
+
 import { RemixBrowser } from "@remix-run/react";
 import { startTransition, StrictMode } from "react";
 import { hydrateRoot } from "react-dom/client";
 
-function hydrate() {
-  startTransition(() => {
-    hydrateRoot(
-      document,
-      <StrictMode>
-        <RemixBrowser />
-      </StrictMode>
-    );
-  });
-}
-
-if (typeof requestIdleCallback === "function") {
-  requestIdleCallback(hydrate);
-} else {
-  // Safari doesn't support requestIdleCallback
-  // https://caniuse.com/requestidlecallback
-  setTimeout(hydrate, 1);
-}
+startTransition(() => {
+  hydrateRoot(
+    document,
+    <StrictMode>
+      <RemixBrowser />
+    </StrictMode>
+  );
+});
diff --git a/templates/netlify/app/entry.server.tsx b/templates/netlify/app/entry.server.tsx
index fb8ea8709..b38c99270 100644
--- a/templates/netlify/app/entry.server.tsx
+++ b/templates/netlify/app/entry.server.tsx
@@ -1,6 +1,17 @@
+/**
+ * By default, Remix will handle generating the HTTP Response for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.server
+ */
+
+import { PassThrough } from "node:stream";
 import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
-import { renderToString } from "react-dom/server";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5_000;
 
 export default function handleRequest(
   request: Request,
@@ -8,14 +19,101 @@ export default function handleRequest(
   responseHeaders: Headers,
   remixContext: EntryContext
 ) {
-  const markup = renderToString(
-    <RemixServer context={remixContext} url={request.url} />
-  );
+  return isbot(request.headers.get("user-agent"))
+    ? handleBotRequest(
+        request,
+        responseStatusCode,
+        responseHeaders,
+        remixContext
+      )
+    : handleBrowserRequest(
+        request,
+        responseStatusCode,
+        responseHeaders,
+        remixContext
+      );
+}
+
+function handleBotRequest(
+  request: Request,
+  responseStatusCode: number,
+  responseHeaders: Headers,
+  remixContext: EntryContext
+) {
+  return new Promise((resolve, reject) => {
+    const { pipe, abort } = renderToPipeableStream(
+      <RemixServer
+        context={remixContext}
+        url={request.url}
+        abortDelay={ABORT_DELAY}
+      />,
+      {
+        onAllReady() {
+          const body = new PassThrough();
+
+          responseHeaders.set("Content-Type", "text/html");
+
+          resolve(
+            new Response(body, {
+              headers: responseHeaders,
+              status: responseStatusCode,
+            })
+          );
+
+          pipe(body);
+        },
+        onShellError(error: unknown) {
+          reject(error);
+        },
+        onError(error: unknown) {
+          responseStatusCode = 500;
+          console.error(error);
+        },
+      }
+    );
+
+    setTimeout(abort, ABORT_DELAY);
+  });
+}
+
+function handleBrowserRequest(
+  request: Request,
+  responseStatusCode: number,
+  responseHeaders: Headers,
+  remixContext: EntryContext
+) {
+  return new Promise((resolve, reject) => {
+    const { pipe, abort } = renderToPipeableStream(
+      <RemixServer
+        context={remixContext}
+        url={request.url}
+        abortDelay={ABORT_DELAY}
+      />,
+      {
+        onShellReady() {
+          const body = new PassThrough();
+
+          responseHeaders.set("Content-Type", "text/html");
+
+          resolve(
+            new Response(body, {
+              headers: responseHeaders,
+              status: responseStatusCode,
+            })
+          );
 
-  responseHeaders.set("Content-Type", "text/html");
+          pipe(body);
+        },
+        onShellError(error: unknown) {
+          reject(error);
+        },
+        onError(error: unknown) {
+          console.error(error);
+          responseStatusCode = 500;
+        },
+      }
+    );
 
-  return new Response("<!DOCTYPE html>" + markup, {
-    headers: responseHeaders,
-    status: responseStatusCode,
+    setTimeout(abort, ABORT_DELAY);
   });
 }
diff --git a/templates/netlify/package.json b/templates/netlify/package.json
index b8f1f93ee..72faa279b 100644
--- a/templates/netlify/package.json
+++ b/templates/netlify/package.json
@@ -13,6 +13,7 @@
     "@remix-run/node": "*",
     "@remix-run/react": "*",
     "cross-env": "^7.0.3",
+    "isbot": "^3.6.5",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   },
diff --git a/templates/remix/app/entry.client.tsx b/templates/remix/app/entry.client.tsx
index 8338545d1..36f2e51a7 100644
--- a/templates/remix/app/entry.client.tsx
+++ b/templates/remix/app/entry.client.tsx
@@ -1,22 +1,18 @@
+/**
+ * By default, Remix will handle hydrating your app on the client for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.client
+ */
+
 import { RemixBrowser } from "@remix-run/react";
 import { startTransition, StrictMode } from "react";
 import { hydrateRoot } from "react-dom/client";
 
-function hydrate() {
-  startTransition(() => {
-    hydrateRoot(
-      document,
-      <StrictMode>
-        <RemixBrowser />
-      </StrictMode>
-    );
-  });
-}
-
-if (typeof requestIdleCallback === "function") {
-  requestIdleCallback(hydrate);
-} else {
-  // Safari doesn't support requestIdleCallback
-  // https://caniuse.com/requestidlecallback
-  setTimeout(hydrate, 1);
-}
+startTransition(() => {
+  hydrateRoot(
+    document,
+    <StrictMode>
+      <RemixBrowser />
+    </StrictMode>
+  );
+});
diff --git a/templates/remix/app/entry.server.tsx b/templates/remix/app/entry.server.tsx
index c78d5047b..b38c99270 100644
--- a/templates/remix/app/entry.server.tsx
+++ b/templates/remix/app/entry.server.tsx
@@ -1,11 +1,17 @@
-import { PassThrough } from "stream";
+/**
+ * By default, Remix will handle generating the HTTP Response for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.server
+ */
+
+import { PassThrough } from "node:stream";
 import type { EntryContext } from "@remix-run/node";
 import { Response } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
 import isbot from "isbot";
 import { renderToPipeableStream } from "react-dom/server";
 
-const ABORT_DELAY = 5000;
+const ABORT_DELAY = 5_000;
 
 export default function handleRequest(
   request: Request,
@@ -60,8 +66,8 @@ function handleBotRequest(
           reject(error);
         },
         onError(error: unknown) {
-          console.error(error);
           responseStatusCode = 500;
+          console.error(error);
         },
       }
     );
diff --git a/templates/vercel/app/entry.client.tsx b/templates/vercel/app/entry.client.tsx
index 8338545d1..36f2e51a7 100644
--- a/templates/vercel/app/entry.client.tsx
+++ b/templates/vercel/app/entry.client.tsx
@@ -1,22 +1,18 @@
+/**
+ * By default, Remix will handle hydrating your app on the client for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.client
+ */
+
 import { RemixBrowser } from "@remix-run/react";
 import { startTransition, StrictMode } from "react";
 import { hydrateRoot } from "react-dom/client";
 
-function hydrate() {
-  startTransition(() => {
-    hydrateRoot(
-      document,
-      <StrictMode>
-        <RemixBrowser />
-      </StrictMode>
-    );
-  });
-}
-
-if (typeof requestIdleCallback === "function") {
-  requestIdleCallback(hydrate);
-} else {
-  // Safari doesn't support requestIdleCallback
-  // https://caniuse.com/requestidlecallback
-  setTimeout(hydrate, 1);
-}
+startTransition(() => {
+  hydrateRoot(
+    document,
+    <StrictMode>
+      <RemixBrowser />
+    </StrictMode>
+  );
+});
diff --git a/templates/vercel/app/entry.server.tsx b/templates/vercel/app/entry.server.tsx
index fb8ea8709..b38c99270 100644
--- a/templates/vercel/app/entry.server.tsx
+++ b/templates/vercel/app/entry.server.tsx
@@ -1,6 +1,17 @@
+/**
+ * By default, Remix will handle generating the HTTP Response for you.
+ * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
+ * For more information, see https://remix.run/docs/en/main/file-conventions/entry.server
+ */
+
+import { PassThrough } from "node:stream";
 import type { EntryContext } from "@remix-run/node";
+import { Response } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
-import { renderToString } from "react-dom/server";
+import isbot from "isbot";
+import { renderToPipeableStream } from "react-dom/server";
+
+const ABORT_DELAY = 5_000;
 
 export default function handleRequest(
   request: Request,
@@ -8,14 +19,101 @@ export default function handleRequest(
   responseHeaders: Headers,
   remixContext: EntryContext
 ) {
-  const markup = renderToString(
-    <RemixServer context={remixContext} url={request.url} />
-  );
+  return isbot(request.headers.get("user-agent"))
+    ? handleBotRequest(
+        request,
+        responseStatusCode,
+        responseHeaders,
+        remixContext
+      )
+    : handleBrowserRequest(
+        request,
+        responseStatusCode,
+        responseHeaders,
+        remixContext
+      );
+}
+
+function handleBotRequest(
+  request: Request,
+  responseStatusCode: number,
+  responseHeaders: Headers,
+  remixContext: EntryContext
+) {
+  return new Promise((resolve, reject) => {
+    const { pipe, abort } = renderToPipeableStream(
+      <RemixServer
+        context={remixContext}
+        url={request.url}
+        abortDelay={ABORT_DELAY}
+      />,
+      {
+        onAllReady() {
+          const body = new PassThrough();
+
+          responseHeaders.set("Content-Type", "text/html");
+
+          resolve(
+            new Response(body, {
+              headers: responseHeaders,
+              status: responseStatusCode,
+            })
+          );
+
+          pipe(body);
+        },
+        onShellError(error: unknown) {
+          reject(error);
+        },
+        onError(error: unknown) {
+          responseStatusCode = 500;
+          console.error(error);
+        },
+      }
+    );
+
+    setTimeout(abort, ABORT_DELAY);
+  });
+}
+
+function handleBrowserRequest(
+  request: Request,
+  responseStatusCode: number,
+  responseHeaders: Headers,
+  remixContext: EntryContext
+) {
+  return new Promise((resolve, reject) => {
+    const { pipe, abort } = renderToPipeableStream(
+      <RemixServer
+        context={remixContext}
+        url={request.url}
+        abortDelay={ABORT_DELAY}
+      />,
+      {
+        onShellReady() {
+          const body = new PassThrough();
+
+          responseHeaders.set("Content-Type", "text/html");
+
+          resolve(
+            new Response(body, {
+              headers: responseHeaders,
+              status: responseStatusCode,
+            })
+          );
 
-  responseHeaders.set("Content-Type", "text/html");
+          pipe(body);
+        },
+        onShellError(error: unknown) {
+          reject(error);
+        },
+        onError(error: unknown) {
+          console.error(error);
+          responseStatusCode = 500;
+        },
+      }
+    );
 
-  return new Response("<!DOCTYPE html>" + markup, {
-    headers: responseHeaders,
-    status: responseStatusCode,
+    setTimeout(abort, ABORT_DELAY);
   });
 }
diff --git a/templates/vercel/package.json b/templates/vercel/package.json
index 46ba5732c..5ae229ae2 100644
--- a/templates/vercel/package.json
+++ b/templates/vercel/package.json
@@ -11,6 +11,7 @@
     "@remix-run/react": "*",
     "@remix-run/vercel": "*",
     "@vercel/node": "^2.6.2",
+    "isbot": "^3.6.5",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   },


Test patch:
diff --git a/packages/remix-dev/__tests__/cssSideEffectsPlugin-test.ts b/packages/remix-dev/__tests__/cssSideEffectImports-test.ts
similarity index 99%
rename from packages/remix-dev/__tests__/cssSideEffectsPlugin-test.ts
rename to packages/remix-dev/__tests__/cssSideEffectImports-test.ts
index f9eac955a..55e3f7a24 100644
--- a/packages/remix-dev/__tests__/cssSideEffectsPlugin-test.ts
+++ b/packages/remix-dev/__tests__/cssSideEffectImports-test.ts
@@ -1,6 +1,6 @@
 import dedent from "dedent";
 
-import { addSuffixToCssSideEffectImports } from "../compiler/plugins/cssSideEffectImportsPlugin";
+import { addSuffixToCssSideEffectImports } from "../compiler/plugins/cssSideEffectImports";
 
 describe("addSuffixToCssSideEffectImports", () => {
   describe("adds suffix", () => {