Repository: remix-run/remix
Pull request #6615 (2023-06-15 18:22:50)

feat(dev): stabilize v2 dev server
See new docs

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

Latest commit: 05103fc81641812bd842a6d036f3c78251bb39da

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/main/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/v2_dev?filename=.changeset/happy-cougars-develop.md&value=---%0A%0A---%0A%0Adocs(v2_dev)%3A%20instructions%20for%20using%20and%20migrating%20to%20%60v2_dev%60%0A)



Patch:
diff --git a/.changeset/clever-ways-love.md b/.changeset/clever-ways-love.md
new file mode 100644
index 000000000..b7ffe0a67
--- /dev/null
+++ b/.changeset/clever-ways-love.md
@@ -0,0 +1,9 @@
+---
+"@remix-run/dev": minor
+"@remix-run/react": minor
+"@remix-run/serve": minor
+"@remix-run/server-runtime": minor
+"@remix-run/testing": minor
+---
+
+stabilize v2 dev server
diff --git a/docs/other-api/adapter.md b/docs/other-api/adapter.md
index c3f90aea7..c710170fd 100644
--- a/docs/other-api/adapter.md
+++ b/docs/other-api/adapter.md
@@ -1,6 +1,6 @@
 ---
 title: "@remix-run/{adapter}"
-order: 2
+order: 3
 ---
 
 # Server Adapters
diff --git a/docs/other-api/dev-v2.md b/docs/other-api/dev-v2.md
new file mode 100644
index 000000000..d913f277f
--- /dev/null
+++ b/docs/other-api/dev-v2.md
@@ -0,0 +1,350 @@
+---
+title: "@remix-run/dev CLI (v2)"
+order: 2
+new: true
+---
+
+The Remix CLI comes from the `@remix-run/dev` package. It also includes the compiler. Make sure it is in your `package.json` `devDependencies` so it doesn't get deployed to your server.
+
+To get a full list of available commands and flags, run:
+
+```sh
+npx @remix-run/dev -h
+```
+
+## `remix build`
+
+Builds your app for production. This command will set `process.env.NODE_ENV` to `production` and minify the output for deployment.
+
+```sh
+remix build
+```
+
+### Options
+
+| Option                                   | flag          | config | default |
+| ---------------------------------------- | ------------- | ------ | ------- |
+| Generate sourcemaps for production build | `--sourcemap` | N/A    | `false` |
+
+## `remix dev`
+
+Builds your app and spins up the Remix dev server alongside your app server.
+
+The dev server will:
+
+1. Set `NODE_ENV` to `development`
+2. Watch your app code for changes and trigger rebuilds
+3. Restart your app server whenever rebuilds succeed
+4. Send code updates to the browser via Live Reload and HMR + Hot Data Revalidation
+
+### With `remix-serve`
+
+Enable the v2 dev server:
+
+```js filename=remix.config.js
+module.exports = {
+  future: {
+    v2_dev: true,
+  },
+};
+```
+
+That's it!
+
+### With custom app server
+
+If you used a template to get started, hopefully it has integration with the v2 dev server out-of-the-box.
+If not, you can follow these steps to integrate your project with `v2_dev`:
+
+1. Enable the v2 dev server:
+
+```js filename=remix.config.js
+module.exports = {
+  future: {
+    v2_dev: true,
+  },
+};
+```
+
+2. Replace your dev scripts in `package.json` and use `-c` to specify your app server command:
+
+```json
+{
+  "dev": "remix dev -c 'node ./server.js'"
+}
+```
+
+3. Ensure `broadcastDevReady` is called when your app server is up and running:
+
+```js filename=server.js lines=[12,25-27]
+import path from "node:path";
+
+import { broadcastDevReady } from "@remix-run/node";
+import express from "express";
+
+const BUILD_DIR = path.resolve(__dirname, "build");
+const build = require(BUILD_DIR);
+
+const app = express();
+
+// ... code for setting up your express app goes here ...
+
+app.all(
+  "*",
+  createRequestHandler({
+    build,
+    mode: process.env.NODE_ENV,
+  })
+);
+
+const port = 3000;
+app.listen(port, () => {
+  console.log(`👉 http://localhost:${port}`);
+
+  if (process.env.NODE_ENV === "development") {
+    broadcastDevReady(build);
+  }
+});
+```
+
+<docs-info>
+
+For CloudFlare, use `logDevReady` instead of `broadcastDevReady`.
+
+Why? `broadcastDevReady` uses `fetch` to send a ready message to the dev server,
+but CloudFlare does not support async I/O like `fetch` outside of request handling.
+
+</docs-info>
+
+### Options
+
+Options priority order is: 1. flags, 2. config, 3. defaults.
+
+| Option          | flag               | config           | default                                           |
+| --------------- | ------------------ | ---------------- | ------------------------------------------------- |
+| Command         | `-c` / `--command` | `command`        | `remix-serve <server build path>`                 |
+| No restart      | `--no-restart`     | `restart: false` | `restart: true`                                   |
+| Scheme          | `--scheme`         | `scheme`         | `https` if TLS key/cert are set, otherwise `http` |
+| Host            | `--host`           | `host`           | `localhost`                                       |
+| Port            | `--port`           | `port`           | Dynamically chosen open port                      |
+| TLS key         | `--tls-key`        | `tlsKey`         | N/A                                               |
+| TLS certificate | `--tls-cert`       | `tlsCert`        | N/A                                               |
+
+<docs-info>
+
+The scheme/host/port options only affect the Remix dev server, and **do not affect your app server**.
+Your app will run on your app server's normal URL.
+
+You most likely won't want to configure the scheme/host/port for the dev server,
+as those are implementation details used internally for hot updates.
+They exist in case you need fine-grain control, for example Docker networking or using specific open ports.
+
+</docs-info>
+
+For example, to override the port used by the dev server via config:
+
+```js filename=remix.config.js
+module.exports = {
+  future: {
+    v2_dev: {
+      port: 8001,
+    },
+  },
+};
+```
+
+### Keep app server running across rebuilds
+
+By default, the Remix dev server restarts your app server when rebuilds occur.
+This is a simple way to ensure that your app server is up-to-date with the latest code changes.
+
+If you'd like to opt-out of this behavior use the `--no-restart` flag:
+
+```sh
+remix dev --no-restart -c 'node ./server.js'
+```
+
+🚨 BUT that means you are now on the hook for applying changes to your running app server _and_ telling the dev server when those changes have been applied.
+
+> With great power comes great responsibility.
+
+Check out our [templates][templates] for examples on how to use `import` cache busting to apply code changes to your app server while it keeps running.
+
+If you're using CJS but looking at an ESM template, you'll need to swap out `import` cache busting with `require` cache busting:
+
+```diff
+- const stat = fs.statSync(BUILD_DIR);
+- build = import(BUILD_DIR + "?t=" + stat.mtimeMs);
++ for (const key in require.cache) {
++   if (key.startsWith(BUILD_DIR)) {
++     delete require.cache[key];
++   }
++ }
++ build = require(BUILD_DIR)
+```
+
+#### Pick up changes from other packages
+
+If you are using a monorepo, you might want Remix to perform hot updates not only when your app code changes, but whenever you change code in any of your apps dependencies.
+
+For example, you could have a UI library package (`packages/ui`) that is used within your Remix app (`packages/app`).
+To pick up changes in `packages/ui`, you can configure [watchPaths][watch-paths] to include your packages.
+
+#### Keep in-memory data and connections across rebuilds
+
+Every time you re-import code to apply changes to your app server, that code will be run.
+Rerunning each changed module works great in most cases, but sometimes you want to want to keep stuff around.
+
+For example, it'd be nice if your app only connected to your database once and kept that connection around across rebuilds.
+But since the connection is held in-memory, re-imports will wipe those out and cause your app to reconnect.
+
+Luckily, there's a trick to get around this: use `global` as a cache for keeping things in-memory across rebuilds!
+Here's a nifty utility adapted from [Jon Jensen's code][jenseng-code] for [his Remix Conf 2023 talk][jenseng-talk]:
+
+```ts filename=app/utils/remember.ts
+export function remember<T>(key: string, value: T) {
+  const g = global as any;
+  g.__singletons ??= {};
+  g.__singletons[key] ??= value;
+  return g.__singletons[key];
+}
+```
+
+And here's how to use it to keep stuff around across rebuilds:
+
+```ts filename=app/utils/db.server.ts
+import { PrismaClient } from "@prisma/client";
+
+import { remember } from "~/utils/remember";
+
+// hard-code a unique key so we can look up the client when this module gets re-imported
+export const db = remember("db", new PrismaClient());
+```
+
+### How to set up local HTTPS
+
+For this example, let's use [mkcert][mkcert].
+After you have it installed, make sure to:
+
+- Create a local Certificate Authority if you haven't already done so
+- Use `NODE_EXTRA_CA_CERTS` for Node compatibility
+
+```sh
+mkcert -install # create a local CA
+export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" # tell Node to use our local CA
+```
+
+Now, create the TLS key and certificate:
+
+```sh
+mkcert -key-file key.pem -cert-file cert.pem localhost
+```
+
+👆 You can change `localhost` to something else if you are using custom hostnames.
+
+Next, use the `key.pem` and `cert.pem` to get HTTPS working locally with your app server.
+This depends on what you are using for your app server.
+For example, here's how you could use HTTPS with an Express server:
+
+```ts filename=server.js
+import fs from "node:fs";
+import https from "node:https";
+import path from "node:path";
+
+import express from "express";
+
+const BUILD_DIR = path.resolve(__dirname, "build");
+const build = require(BUILD_DIR);
+
+const app = express();
+
+// ... code setting up your express app goes here ...
+
+let server = https.createServer(
+  {
+    key: fs.readFileSync("path/to/key.pem"),
+    cert: fs.readFileSync("path/to/cert.pem"),
+  },
+  app
+);
+
+let port = 3000;
+server.listen(port, () => {
+  console.log(`👉 https://localhost:${port}`);
+
+  if (process.env.NODE_ENV === "development") {
+    broadcastDevReady(build);
+  }
+});
+```
+
+### Troubleshooting
+
+#### Using MSW with `v2_dev`
+
+The dev server uses the `REMIX_DEV_HTTP_ORIGIN` environment variable to communicate its origin to the app server.
+You can use that to mock out the `/ping` endpoint used for hot update coordination:
+
+```ts
+import { rest } from "msw";
+
+export const server = setupServer(
+  rest.post(
+    `${process.env.REMIX_DEV_HTTP_ORIGIN}/ping`,
+    (req) => {
+      return req.passthrough();
+    }
+  )
+  // ... other request handlers go here ...
+);
+```
+
+#### HMR: hot updates losing app state
+
+Hot Module Replacement is supposed to keep your app's state around between hot updates.
+But in some cases React cannot distinguish between existing components being changed and new components being added.
+[React needs `key`s][react-keys] to disambiguate these cases and track changes when sibling elements are modified.
+
+Additionally, when adding or removing hooks, React Refresh treats that as a brand new component.
+So if you add `useLoaderData` to your component, you may lose state local to that component.
+
+These are limitations of React and [React Refresh][react-refresh], not Remix.
+
+#### HDR: every code change triggers HDR
+
+Hot Data Revalidation detects loader changes by trying to bundle each loader and then fingerprinting the content for each.
+It relies on treeshaking to determine whether your changes affect each loader or not.
+
+To ensure that treeshaking can reliably detect changes to loaders, make sure you declare that your app's package is side-effect free:
+
+```json filename=package.json
+{
+  "sideEffects": false
+}
+```
+
+#### HDR: harmless console errors when loader data is removed
+
+When you delete a loader or remove some of the data being returned by that loader, your app should be hot updated correctly.
+But you may notice console errors logged in your browser.
+
+React strict-mode and React Suspense can cause multiple renders when hot updates are applied.
+Most of these render correctly, including the final render that is visible to you.
+But intermediate renders can sometimes use new loader data with old React components, which is where those errors come from.
+
+We are continuing to investigate the underlying race condition to see if we can smooth that over.
+In the meantime, if those console errors bother you, you can refresh the page whenever they occur.
+
+#### HDR: performance
+
+When the v2 dev server builds (and rebuilds) your app, you may notice a slight slowdown as the dev server needs to crawl the dependencies for each loader.
+That way the dev server can detect loader changes on rebuilds.
+
+While the initial build slowdown is inherently a cost for HDR, we plan to optimize rebuilds so that there is no perceivable slowdown for HDR rebuilds.
+
+[templates]: https://github.com/remix-run/remix/tree/main/templates
+[watch-paths]: https://remix.run/docs/en/1.17.1/file-conventions/remix-config#watchpaths
+[jenseng-code]: https://github.com/jenseng/abuse-the-platform/blob/main/app/utils/singleton.ts
+[jenseng-talk]: https://www.youtube.com/watch?v=lbzNnN0F67Y
+[react-keys]: https://react.dev/learn/rendering-lists#why-does-react-need-keys
+[react-refresh]: https://github.com/facebook/react/tree/main/packages/react-refresh
diff --git a/docs/other-api/dev.md b/docs/other-api/dev.md
index 887fe5a37..14f9edb54 100644
--- a/docs/other-api/dev.md
+++ b/docs/other-api/dev.md
@@ -3,6 +3,14 @@ title: "@remix-run/dev (CLI)"
 order: 1
 ---
 
+<docs-warning>
+
+The Remix CLI is changing in v2.
+You can prepare for this change at your convenience with the `v2_dev` future flag.
+For instructions on making this change see the [v2 guide][v2-guide].
+
+</docs-warning>
+
 # Remix CLI
 
 The Remix CLI comes from the `@remix-run/dev` package. It also includes the compiler. Make sure it is in your `package.json` `devDependencies` so it doesn't get deployed to your server.
@@ -113,3 +121,4 @@ Skip deleting the `remix.init` folder after initialization has been run. Useful
 [remix-app-server]: ./serve
 [node-inspector]: https://nodejs.org/en/docs/guides/debugging-getting-started
 [templates-folder-of-the-remix-repository]: https://github.com/remix-run/remix/tree/main/templates
+[v2-guide]: ../pages/v2
diff --git a/docs/pages/api-development-strategy.md b/docs/pages/api-development-strategy.md
index d7830029e..49820c1d2 100644
--- a/docs/pages/api-development-strategy.md
+++ b/docs/pages/api-development-strategy.md
@@ -52,7 +52,7 @@ The lifecycle is thus either:
 
 | Flag                     | Description                                                           |
 | ------------------------ | --------------------------------------------------------------------- |
-| `unstable_dev`           | Enable the new development server (including HMR/HDR support)         |
+| `v2_dev`                 | Enable the new development server (including HMR/HDR support)         |
 | `v2_errorBoundary`       | Combine `ErrorBoundary`/`CatchBoundary` into a single `ErrorBoundary` |
 | `v2_headers`             | Leverage ancestor `headers` if children do not export `headers`       |
 | `v2_meta`                | Enable the new API for your `meta` functions                          |
diff --git a/docs/pages/v2.md b/docs/pages/v2.md
index 4435df722..9595d60a2 100644
--- a/docs/pages/v2.md
+++ b/docs/pages/v2.md
@@ -705,9 +705,88 @@ In your `remix.config.js`, you should specify either `serverModuleFormat: "cjs"`
 
 ## Dev Server
 
-We are still stabilizing the new dev server that enables HMR and simplifies integration with various servers. You can try it today with `unstable_dev` and referring to the [v1.16 Release Notes][v1-16-release-notes] to set it up.
+For configuration options, see the [`remix dev` docs][v2-dev-config].
 
-We expect to stabilize in Remix `v1.17.0`. Once that ships, we'll have more instructions here to prepare your app for v2.
+### `remix-serve`
+
+Enable the v2 dev server:
+
+```js filename=remix.config.js
+module.exports = {
+  future: {
+    v2_dev: true,
+  },
+};
+```
+
+That's it!
+
+### custom app server
+
+Check out our [templates][templates] for examples of how to integrate with `v2_dev`
+or follow these steps:
+
+1. Enable the v2 dev server:
+
+```js filename=remix.config.js
+module.exports = {
+  future: {
+    v2_dev: true,
+  },
+};
+```
+
+2. Update `scripts` in `package.json`:
+
+- Replace any `remix watch` with `remix dev`
+- Remove redundant `NODE_ENV=development`
+- Use `-c` / `--command` to run your app server
+
+For example:
+
+```diff filename=package.json
+{
+  "scripts": {
+-   "dev:remix": "cross-env NODE_ENV=development remix watch",
+-   "dev:server": "cross-env NODE_ENV=development node ./server.js"
++   "dev": "remix dev -c 'node ./server.js'",
+  }
+}
+```
+
+3. Send a "ready" message to the dev server once your app is running
+
+```ts filename=server.js lines=[1-2,11]
+import { broadcastDevReady } from "@remix-run/node";
+// import { logDevReady } from "@remix-run/cloudflare" // use `logDevReady` if using CloudFlare
+
+const BUILD_DIR = path.join(process.cwd(), "build");
+
+// ... code setting up your server goes here ...
+
+const port = 3000;
+app.listen(port, async () => {
+  console.log(`👉 http://localhost:${port}`);
+  broadcastDevReady(await import(BUILD_DIR));
+});
+```
+
+4. (Optional) `--no-restart`
+
+If you were relying on `require` cache purging, you can keep doing so by using the `--no-restart` flag:
+
+```sh
+remix dev --no-restart -c 'node ./server.js'
+```
+
+<docs-info>
+
+If you are purging your `require` cache for every request, you can instead switch to only busting the cache when your app server rebuilds.
+To do so, use a file watcher like `chokidar`.
+
+</docs-info>
+
+See our [templates][templates] and the [`remix dev` docs][v2-dev-docs] for examples on how to do this.
 
 [future-flags]: ./api-development-strategy
 [remix-config]: ../file-conventions/remix-config
@@ -716,3 +795,6 @@ We expect to stabilize in Remix `v1.17.0`. Once that ships, we'll have more inst
 [meta-v2-rfc]: https://github.com/remix-run/remix/discussions/4462
 [meta-v2-matches]: #the-matches-argument
 [v1-16-release-notes]: https://github.com/remix-run/remix/releases/tag/remix%401.16.0
+[v2-dev-config]: ../other-api/dev-v2
+[templates]: https://github.com/remix-run/remix/tree/main/templates
+[v2-dev-docs]: ../other-api/dev-v2.md
diff --git a/packages/remix-dev/cli/commands.ts b/packages/remix-dev/cli/commands.ts
index 98c47e5e8..bdfc8d275 100644
--- a/packages/remix-dev/cli/commands.ts
+++ b/packages/remix-dev/cli/commands.ts
@@ -173,7 +173,7 @@ export async function build(
     mode,
     sourcemap,
   };
-  if (mode === "development" && config.future.unstable_dev) {
+  if (mode === "development" && config.future.v2_dev) {
     let origin = await resolveDevOrigin(config);
     options.devOrigin = origin;
   }
@@ -213,7 +213,7 @@ export async function dev(
   flags: {
     debug?: boolean;
 
-    // unstable_dev
+    // v2_dev
     command?: string;
     scheme?: string;
     host?: string;
@@ -235,7 +235,7 @@ export async function dev(
 
   let config = await readConfig(remixRoot);
 
-  if (config.future.unstable_dev === false) {
+  if (config.future.v2_dev === false) {
     await devServer.serve(config, flags.port);
     return await new Promise(() => {});
   }
@@ -481,7 +481,7 @@ let resolveDevOrigin = async (
     tlsCert?: string;
   } = {}
 ): Promise<DevOrigin> => {
-  let dev = config.future.unstable_dev;
+  let dev = config.future.v2_dev;
   if (dev === false) throw Error("This should never happen");
 
   // prettier-ignore
@@ -517,7 +517,7 @@ let resolveDevServe = async (
   config: RemixConfig,
   flags: Partial<DevServeFlags> = {}
 ): Promise<DevServeFlags> => {
-  let dev = config.future.unstable_dev;
+  let dev = config.future.v2_dev;
   if (dev === false) throw Error("Cannot resolve dev options");
 
   let origin = await resolveDevOrigin(config, flags);
diff --git a/packages/remix-dev/cli/run.ts b/packages/remix-dev/cli/run.ts
index f218783ed..df1cdcaa4 100644
--- a/packages/remix-dev/cli/run.ts
+++ b/packages/remix-dev/cli/run.ts
@@ -42,7 +42,7 @@ ${colors.logoBlue("R")} ${colors.logoGreen("E")} ${colors.logoYellow(
     --debug             Attach Node.js inspector
     --port, -p          Choose the port from which to run your app
 
-    [unstable_dev]
+    [v2_dev]
     --command, -c       Command used to run your app server
     --scheme            Scheme for the dev server. Default: http
     --host              Host for the dev server. Default: localhost
diff --git a/packages/remix-dev/compiler/js/compiler.ts b/packages/remix-dev/compiler/js/compiler.ts
index f79bc2daa..69edb21f9 100644
--- a/packages/remix-dev/compiler/js/compiler.ts
+++ b/packages/remix-dev/compiler/js/compiler.ts
@@ -70,7 +70,7 @@ const createEsbuildConfig = (
 
   if (
     ctx.options.mode === "development" &&
-    ctx.config.future.unstable_dev !== false
+    ctx.config.future.v2_dev !== false
   ) {
     let defaultsDirectory = path.resolve(
       __dirname,
@@ -94,7 +94,7 @@ const createEsbuildConfig = (
     cssSideEffectImportsPlugin(ctx, {
       hmr:
         ctx.options.mode === "development" &&
-        ctx.config.future.unstable_dev !== false,
+        ctx.config.future.v2_dev !== false,
     }),
     cssFilePlugin(ctx),
     absoluteCssUrlsPlugin(),
@@ -105,7 +105,7 @@ const createEsbuildConfig = (
     externalPlugin(/^node:.*/, { sideEffects: false }),
   ];
 
-  if (ctx.options.mode === "development" && ctx.config.future.unstable_dev) {
+  if (ctx.options.mode === "development" && ctx.config.future.v2_dev) {
     plugins.push(hmrPlugin(ctx));
   }
 
@@ -159,7 +159,7 @@ export const create = async (
     let { metafile } = await compiler.rebuild();
 
     let hmr: Manifest["hmr"] | undefined = undefined;
-    if (ctx.options.mode === "development" && ctx.config.future.unstable_dev) {
+    if (ctx.options.mode === "development" && ctx.config.future.v2_dev) {
       let hmrRuntimeOutput = Object.entries(metafile.outputs).find(
         ([_, output]) => output.inputs["hmr-runtime:remix:hmr"]
       )?.[0];
diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts
index a814a2a23..73abaaa16 100644
--- a/packages/remix-dev/config.ts
+++ b/packages/remix-dev/config.ts
@@ -47,7 +47,7 @@ type Dev = {
 };
 
 interface FutureConfig {
-  unstable_dev: boolean | Dev;
+  v2_dev: boolean | Dev;
   /** @deprecated Use the `postcss` config option instead */
   unstable_postcss: boolean;
   /** @deprecated Use the `tailwind` config option instead */
@@ -489,7 +489,7 @@ export async function readConfig(
   if (appConfig.future) {
     if ("unstable_cssModules" in appConfig.future) {
       logger.warn(
-        'The "future.unstable_cssModules" config option has been removed',
+        "The `future.unstable_cssModules` config option has been removed",
         {
           details: [
             "CSS Modules are now enabled automatically.",
@@ -502,7 +502,7 @@ export async function readConfig(
 
     if ("unstable_cssSideEffectImports" in appConfig.future) {
       logger.warn(
-        'The "future.unstable_cssSideEffectImports" config option has been removed',
+        "The `future.unstable_cssSideEffectImports` config option has been removed",
         {
           details: [
             "CSS side-effect imports are now enabled automatically.",
@@ -515,7 +515,7 @@ export async function readConfig(
 
     if ("unstable_vanillaExtract" in appConfig.future) {
       logger.warn(
-        'The "future.unstable_vanillaExtract" config option has been removed.',
+        "The `future.unstable_vanillaExtract` config option has been removed.",
         {
           details: [
             "Vanilla Extract is now enabled automatically.",
@@ -528,7 +528,7 @@ export async function readConfig(
 
     if (appConfig.future.unstable_postcss !== undefined) {
       logger.warn(
-        'The "future.unstable_postcss" config option has been deprecated.',
+        "The `future.unstable_postcss` config option has been deprecated.",
         {
           details: [
             "PostCSS support is now stable.",
@@ -541,7 +541,7 @@ export async function readConfig(
 
     if (appConfig.future.unstable_tailwind !== undefined) {
       logger.warn(
-        'The "future.unstable_tailwind" config option has been deprecated.',
+        "The `future.unstable_tailwind` config option has been deprecated.",
         {
           details: [
             "Tailwind support is now stable.",
@@ -551,6 +551,17 @@ export async function readConfig(
         }
       );
     }
+
+    if ("unstable_dev" in appConfig.future) {
+      logger.warn("The `future.unstable_dev` config option has been removed", {
+        details: [
+          "The v2 dev server is now stable.",
+          "Use the `future.v2_dev` config option instead.",
+          "-> https://remix.run/docs/en/main/pages/v2#dev-server",
+        ],
+        key: "unstable_dev",
+      });
+    }
   }
 
   let mdx = appConfig.mdx;
@@ -772,7 +783,7 @@ export async function readConfig(
   }
 
   let future: FutureConfig = {
-    unstable_dev: appConfig.future?.unstable_dev ?? false,
+    v2_dev: appConfig.future?.v2_dev ?? false,
     unstable_postcss: appConfig.future?.unstable_postcss === true,
     unstable_tailwind: appConfig.future?.unstable_tailwind === true,
     v2_errorBoundary: appConfig.future?.v2_errorBoundary === true,
diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts
index cfef8123a..c948858b8 100644
--- a/packages/remix-react/entry.ts
+++ b/packages/remix-react/entry.ts
@@ -27,7 +27,7 @@ type Dev = {
 };
 
 export interface FutureConfig {
-  unstable_dev: boolean | Dev;
+  v2_dev: boolean | Dev;
   /** @deprecated Use the `postcss` config option instead */
   unstable_postcss: boolean;
   /** @deprecated Use the `tailwind` config option instead */
diff --git a/packages/remix-serve/cli.ts b/packages/remix-serve/cli.ts
index 06246b420..3f41af01b 100644
--- a/packages/remix-serve/cli.ts
+++ b/packages/remix-serve/cli.ts
@@ -34,7 +34,7 @@ let onListen = () => {
     );
   }
   if (
-    build.future?.unstable_dev !== false &&
+    build.future?.v2_dev !== false &&
     process.env.NODE_ENV === "development"
   ) {
     broadcastDevReady(build);
diff --git a/packages/remix-server-runtime/entry.ts b/packages/remix-server-runtime/entry.ts
index ea09f729b..03774f9ad 100644
--- a/packages/remix-server-runtime/entry.ts
+++ b/packages/remix-server-runtime/entry.ts
@@ -19,7 +19,7 @@ type Dev = {
 };
 
 export interface FutureConfig {
-  unstable_dev: boolean | Dev;
+  v2_dev: boolean | Dev;
   /** @deprecated Use the `postcss` config option instead */
   unstable_postcss: boolean;
   /** @deprecated Use the `tailwind` config option instead */


Test patch:
diff --git a/integration/hmr-log-test.ts b/integration/hmr-log-test.ts
index bfac33b09..6e3d2ffa4 100644
--- a/integration/hmr-log-test.ts
+++ b/integration/hmr-log-test.ts
@@ -15,7 +15,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({
     serverModuleFormat: "cjs",
     tailwind: true,
     future: {
-      unstable_dev: {
+      v2_dev: {
         port: options.devPort,
       },
       v2_routeConvention: true,
diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts
index 030aa6efb..f217123e3 100644
--- a/integration/hmr-test.ts
+++ b/integration/hmr-test.ts
@@ -15,7 +15,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({
     serverModuleFormat: "cjs",
     postcss: true,
     future: {
-      unstable_dev: {
+      v2_dev: {
         port: options.devPort,
       },
       v2_routeConvention: true,
diff --git a/integration/shared-route-imports-test.ts b/integration/shared-route-imports-test.ts
index cbedef0fa..8cd54fdc5 100644
--- a/integration/shared-route-imports-test.ts
+++ b/integration/shared-route-imports-test.ts
@@ -101,7 +101,7 @@ test.describe("v2 compiler", () => {
   test.beforeAll(async () => {
     fixture = await createFixture({
       config: {
-        future: { v2_routeConvention: true, unstable_dev: true },
+        future: { v2_routeConvention: true, v2_dev: true },
       },
       files: {
         "app/routes/parent.jsx": js`
diff --git a/packages/remix-dev/__tests__/cli-test.ts b/packages/remix-dev/__tests__/cli-test.ts
index 458cf17ec..0c1fe283f 100644
--- a/packages/remix-dev/__tests__/cli-test.ts
+++ b/packages/remix-dev/__tests__/cli-test.ts
@@ -116,7 +116,7 @@ describe("remix CLI", () => {
             --debug             Attach Node.js inspector
             --port, -p          Choose the port from which to run your app
 
-            [unstable_dev]
+            [v2_dev]
             --command, -c       Command used to run your app server
             --scheme            Scheme for the dev server. Default: http
             --host              Host for the dev server. Default: localhost
diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts
index 18d08dbf4..06bfcb816 100644
--- a/packages/remix-dev/__tests__/readConfig-test.ts
+++ b/packages/remix-dev/__tests__/readConfig-test.ts
@@ -46,9 +46,9 @@ describe("readConfig", () => {
         "entryServerFile": "entry.server.tsx",
         "entryServerFilePath": Any<String>,
         "future": Object {
-          "unstable_dev": false,
           "unstable_postcss": Any<Boolean>,
           "unstable_tailwind": Any<Boolean>,
+          "v2_dev": false,
           "v2_errorBoundary": Any<Boolean>,
           "v2_headers": Any<Boolean>,
           "v2_meta": Any<Boolean>,
diff --git a/packages/remix-server-runtime/__tests__/utils.ts b/packages/remix-server-runtime/__tests__/utils.ts
index b8168627b..18a2a532a 100644
--- a/packages/remix-server-runtime/__tests__/utils.ts
+++ b/packages/remix-server-runtime/__tests__/utils.ts
@@ -87,7 +87,7 @@ export function mockServerBuild(
       {}
     ),
     future: {
-      unstable_dev: {},
+      v2_dev: {},
     },
   };
 }
diff --git a/packages/remix-testing/create-remix-stub.tsx b/packages/remix-testing/create-remix-stub.tsx
index b7f0f4647..6d4fae6bb 100644
--- a/packages/remix-testing/create-remix-stub.tsx
+++ b/packages/remix-testing/create-remix-stub.tsx
@@ -124,7 +124,7 @@ export function createRemixStub(
     if (remixContextRef.current == null) {
       remixContextRef.current = {
         future: {
-          unstable_dev: false,
+          v2_dev: false,
           unstable_postcss: false,
           unstable_tailwind: false,
           v2_errorBoundary: false,