Repository: remix-run/remix
Pull request #5960 (2023-03-31 05:12:24)

feat(dev): stabilize PostCSS and Tailwind options
In order to reduce the number of unstable flags before the v2 release, this adds new top-level `postcss` and `tailwind` config options, the same as those introduced in the v2 branch. The difference is that in v2 these are enabled by default to provide an opt-out, but this PR adds them to v1 disabled by default to provide an opt-in.

To reduce unnecessary API churn before the v2 release, the existing `unstable_postcss` and `unstable_tailwind` future flags are still supported but marked as deprecated via JSDoc comments. Deprecation warnings are also logged to the terminal if either of them are used.

changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 73868cf4afbe7a9c2af85a4309b1e209c4d56c2a

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 18 packages</summary>

  | Name                          | Type  |
| ----------------------------- | ----- |
| @remix-run/dev                | Minor |
| @remix-run/react              | Minor |
| @remix-run/server-runtime     | Minor |
| create-remix                  | Minor |
| @remix-run/css-bundle         | Minor |
| @remix-run/testing            | Minor |
| @remix-run/cloudflare         | Minor |
| @remix-run/deno               | Minor |
| @remix-run/node               | Minor |
| @remix-run/cloudflare-pages   | Minor |
| @remix-run/cloudflare-workers | Minor |
| @remix-run/architect          | Minor |
| @remix-run/express            | Minor |
| @remix-run/netlify            | Minor |
| @remix-run/vercel             | Minor |
| @remix-run/serve              | Minor |
| remix                         | Minor |
| @remix-run/eslint-config      | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/remix-run/remix/new/markdalgleish/stable-postcss-tailwind?filename=.changeset/silly-chicken-impress.md&value=---%0A%22remix%22%3A%20patch%0A%22%40remix-run%2Fdev%22%3A%20patch%0A%22%40remix-run%2Freact%22%3A%20patch%0A%22%40remix-run%2Fserve%22%3A%20patch%0A%22%40remix-run%2Fserver-runtime%22%3A%20patch%0A---%0A%0Afeat(dev)%3A%20stabilize%20PostCSS%20and%20Tailwind%20options%0A)



Patch:
diff --git a/.changeset/postcss-option.md b/.changeset/postcss-option.md
new file mode 100644
index 000000000..dfa40b60f
--- /dev/null
+++ b/.changeset/postcss-option.md
@@ -0,0 +1,50 @@
+---
+"@remix-run/dev": minor
+"@remix-run/react": minor
+"@remix-run/server-runtime": minor
+---
+
+Stabilize built-in PostCSS support via the new `postcss` option in `remix.config.js`. As a result, the `future.unstable_postcss` option has also been deprecated.
+
+The `postcss` option is `false` by default, but when set to `true` will enable processing of all CSS files using PostCSS if `postcss.config.js` is present.
+
+If you followed the original PostCSS setup guide for Remix, you may have a folder structure that looks like this, separating your source files from its processed output:
+
+```
+.
+├── app
+│   └── styles (processed files)
+│       ├── app.css
+│       └── routes
+│           └── index.css
+└── styles (source files)
+    ├── app.css
+    └── routes
+        └── index.css
+```
+
+After you've enabled the new `postcss` option, you can delete the processed files from `app/styles` folder and move your source files from `styles` to `app/styles`:
+
+```
+.
+├── app
+│   └── styles (source files)
+│       ├── app.css
+│       └── routes
+│           └── index.css
+```
+
+You should then remove `app/styles` from your `.gitignore` file since it now contains source files rather than processed output.
+
+You can then update your `package.json` scripts to remove any usage of `postcss` since Remix handles this automatically. For example, if you had followed the original setup guide:
+
+```diff
+{
+  "scripts": {
+-    "dev:css": "postcss styles --base styles --dir app/styles -w",
+-    "build:css": "postcss styles --base styles --dir app/styles --env production",
+-    "dev": "concurrently \"npm run dev:css\" \"remix dev\""
++    "dev": "remix dev"
+  }
+}
+```
diff --git a/.changeset/tailwind-option.md b/.changeset/tailwind-option.md
new file mode 100644
index 000000000..b00300267
--- /dev/null
+++ b/.changeset/tailwind-option.md
@@ -0,0 +1,49 @@
+---
+"@remix-run/dev": minor
+"@remix-run/react": minor
+"@remix-run/server-runtime": minor
+---
+
+Stabilize built-in Tailwind support via the new `tailwind` option in `remix.config.js`. As a result, the `future.unstable_tailwind` option has also been deprecated.
+
+The `tailwind` option is `false` by default, but when set to `true` will enable built-in support for Tailwind functions and directives in your CSS files if `tailwindcss` is installed.
+
+If you followed the original Tailwind setup guide for Remix and want to make use of this feature, you should first delete the generated `app/tailwind.css`.
+
+Then, if you have a `styles/tailwind.css` file, you should move it to `app/tailwind.css`.
+
+```sh
+rm app/tailwind.css
+mv styles/tailwind.css app/tailwind.css
+```
+
+Otherwise, if you don't already have an `app/tailwind.css` file, you should create one with the following contents:
+
+```css
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+```
+
+You should then remove `/app/tailwind.css` from your `.gitignore` file since it now contains source code rather than processed output.
+
+You can then update your `package.json` scripts to remove any usage of `tailwindcss` since Remix handles this automatically. For example, if you had followed the original setup guide:
+
+```diff
+{
+  // ...
+  "scripts": {
+-    "build": "run-s \"build:*\"",
++    "build": "remix build",
+-    "build:css": "npm run generate:css -- --minify",
+-    "build:remix": "remix build",
+-    "dev": "run-p \"dev:*\"",
++    "dev": "remix dev",
+-    "dev:css": "npm run generate:css -- --watch",
+-    "dev:remix": "remix dev",
+-    "generate:css": "npx tailwindcss -o ./app/tailwind.css",
+    "start": "remix-serve build"
+  }
+  // ...
+}
+```
diff --git a/docs/file-conventions/remix-config.md b/docs/file-conventions/remix-config.md
index a558daa6d..9df558b19 100644
--- a/docs/file-conventions/remix-config.md
+++ b/docs/file-conventions/remix-config.md
@@ -66,6 +66,10 @@ dotfiles (like `.DS_Store` files) or CSS/test files you wish to colocate.
 The URL prefix of the browser build with a trailing slash. Defaults to
 `"/build/"`. This is the path the browser will use to find assets.
 
+## postcss
+
+Whether to process CSS using [PostCSS][postcss] if `postcss.config.js` is present. Defaults to `false`.
+
 ## routes
 
 A function for defining custom routes, in addition to those already defined
@@ -192,6 +196,10 @@ Defaults to `"cjs"`.
 The platform the server build is targeting, which can either be `"neutral"` or
 `"node"`. Defaults to `"node"`.
 
+## tailwind
+
+Whether to support [Tailwind functions and directives][tailwind-functions-and-directives] in CSS files if `tailwindcss` is installed. Defaults to `false`.
+
 ## watchPaths
 
 An array, string, or async function that defines custom directories, relative to the project root, to watch while running [remix dev][remix-dev]. These directories are in addition to [`appDirectory`][app-directory].
@@ -232,3 +240,5 @@ There are a few conventions that Remix uses you should be aware of.
 [remix-dev]: ../other-api/dev#remix-dev
 [app-directory]: #appDirectory
 [css-side-effect-imports]: ../guides/styling#css-side-effect-imports
+[postcss]: https://postcss.org
+[tailwind-functions-and-directives]: https://tailwindcss.com/docs/functions-and-directives
diff --git a/docs/guides/styling.md b/docs/guides/styling.md
index 2dca1d025..dce8ffc4a 100644
--- a/docs/guides/styling.md
+++ b/docs/guides/styling.md
@@ -403,31 +403,23 @@ export function links() {
 
 Perhaps the most popular way to style a Remix application in the community is to use [Tailwind CSS][tailwind]. It has the benefits of inline-style collocation for developer ergonomics and is able to generate a CSS file for Remix to import. The generated CSS file generally caps out around 8-10kb, even for large applications. Load that file into the `root.tsx` links and be done with it. If you don't have any CSS opinions, this is a great approach.
 
-There are a couple of options for integrating Tailwind into your Remix application. You can use Remix's built-in support, or integrate Tailwind manually using their CLI.
-
-### Built-in Tailwind Support
-
-<docs-warning>This feature is unstable and currently only available behind a feature flag. We're confident in the use cases it solves but the API and implementation may change in the future.</docs-warning>
-
-First, to enable built-in Tailwind support, set the `future.unstable_tailwind` feature flag in `remix.config.js`.
+To use the built-in Tailwind support, first enable the `tailwind` option in `remix.config.js`:
 
 ```js filename=remix.config.js
 /** @type {import('@remix-run/dev').AppConfig} */
 module.exports = {
-  future: {
-    unstable_tailwind: true,
-  },
+  tailwind: true,
   // ...
 };
 ```
 
-Then install Tailwind:
+Install Tailwind as a dev dependency:
 
 ```sh
 npm install -D tailwindcss
 ```
 
-Initialize a config file:
+Then initialize a config file:
 
 ```sh
 npx tailwindcss init
@@ -472,114 +464,6 @@ With this setup in place, you can also use [Tailwind's functions and directives]
 
 Note that if you're also using Remix's [built-in PostCSS support][built-in-post-css-support], the Tailwind PostCSS plugin will be automatically included if it's missing, but you can also choose to manually include the Tailwind plugin in your PostCSS config instead if you'd prefer.
 
-### Manual Tailwind Integration
-
-It's also possible to use Tailwind without leveraging the built-in support by using the `tailwindcss` CLI directly.
-
-First install a couple of dev dependencies:
-
-```sh
-npm install -D npm-run-all tailwindcss
-```
-
-Secondly, initialize a Tailwind config file:
-
-```sh
-npx tailwindcss init
-```
-
-Now we can tell it which files to generate classes from:
-
-```js filename=tailwind.config.js lines=[3]
-/** @type {import('tailwindcss').Config} */
-module.exports = {
-  content: ["./app/**/*.{ts,tsx,jsx,js}"],
-  theme: {
-    extend: {},
-  },
-  plugins: [],
-};
-```
-
-Update the package scripts to generate the Tailwind file during dev and for the production build
-
-```json filename=package.json lines=[4-10]
-{
-  // ...
-  "scripts": {
-    "build": "run-s \"build:*\"",
-    "build:css": "npm run generate:css -- --minify",
-    "build:remix": "remix build",
-    "dev": "run-p \"dev:*\"",
-    "dev:css": "npm run generate:css -- --watch",
-    "dev:remix": "remix dev",
-    "generate:css": "npx tailwindcss -o ./app/tailwind.css",
-    "start": "remix-serve build"
-  }
-  // ...
-}
-```
-
-Finally, import the generated CSS file into your app:
-
-```tsx filename=app/root.tsx
-import type { LinksFunction } from "@remix-run/node"; // or cloudflare/deno
-
-// ...
-
-import styles from "./tailwind.css";
-
-export const links: LinksFunction = () => [
-  { rel: "stylesheet", href: styles },
-];
-```
-
-If you want to use Tailwind's `@apply` method to extract custom classes, create a css file in the root directory, eg `./styles/tailwind.css`:
-
-```css filename=styles/tailwind.css
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-@layer components {
-  .custom-class {
-    @apply ...;
-  }
-}
-```
-
-Then alter how Tailwind is generating your css:
-
-```json filename=package.json lines=[10]
-{
-  // ...
-  "scripts": {
-    "build": "run-s \"build:*\"",
-    "build:css": "npm run generate:css -- --minify",
-    "build:remix": "remix build",
-    "dev": "run-p \"dev:*\"",
-    "dev:css": "npm run generate:css -- --watch",
-    "dev:remix": "remix dev",
-    "generate:css": "npx tailwindcss -i ./styles/tailwind.css -o ./app/tailwind.css",
-    "start": "remix-serve build"
-  }
-  // ...
-}
-```
-
-It isn't required, but it's recommended to add the generated file to your `.gitignore` list:
-
-```sh filename=.gitignore lines=[8]
-node_modules
-
-/.cache
-/build
-/public/build
-.env
-
-/app/tailwind.css
-```
-
 If you're using VS Code, it's recommended you install the [Tailwind IntelliSense extension][tailwind-intelli-sense-extension] for the best developer experience.
 
 ## Remote Stylesheets
@@ -601,43 +485,33 @@ export const links: LinksFunction = () => {
 
 ## PostCSS
 
-[PostCSS][postcss] is a popular tool with a rich plugin ecosystem, commonly used to prefix CSS for older browsers, transpile future CSS syntax, inline images, lint your styles and more.
-
-There are a couple of options for integrating PostCSS into your Remix application. You can use Remix's built-in support, or integrate PostCSS manually using their CLI.
-
-### Built-in PostCSS Support
-
-<docs-warning>This feature is unstable and currently only available behind a feature flag. We're confident in the use cases it solves but the API and implementation may change in the future.</docs-warning>
-
-When a PostCSS config is detected, Remix will automatically run PostCSS across all CSS in your project. For example, to use [Autoprefixer][autoprefixer]:
+[PostCSS][postcss] is a popular tool with a rich plugin ecosystem, commonly used to prefix CSS for older browsers, transpile future CSS syntax, inline images, lint your styles and more. When a PostCSS config is detected, Remix will automatically run PostCSS across all CSS in your project.
 
-1. Enable built-in PostCSS support by setting the the `future.unstable_postcss` feature flag in `remix.config.js`.
+For example, to use [Autoprefixer][autoprefixer], first enable the `postcss` option in `remix.config.js`:
 
-   ```js filename=remix.config.js
-   /** @type {import('@remix-run/dev').AppConfig} */
-   module.exports = {
-     future: {
-       unstable_postcss: true,
-     },
-     // ...
-   };
-   ```
+```js filename=remix.config.js
+/** @type {import('@remix-run/dev').AppConfig} */
+module.exports = {
+  postcss: true,
+  // ...
+};
+```
 
-2. Install any desired PostCSS plugins.
+Install the PostCSS plugin.
 
-   ```sh
-   npm install -D autoprefixer
-   ```
+```sh
+npm install -D autoprefixer
+```
 
-3. Add `postcss.config.js` in the Remix root with configuration for your plugins.
+Then add `postcss.config.js` in the Remix root referencing the plugin.
 
-   ```js filename=postcss.config.js
-   module.exports = {
-     plugins: {
-       autoprefixer: {},
-     },
-   };
-   ```
+```js filename=postcss.config.js
+module.exports = {
+  plugins: {
+    autoprefixer: {},
+  },
+};
+```
 
 If you're using [Vanilla Extract][vanilla-extract-2], since it's already playing the role of CSS preprocessor, you may want to apply a different set of PostCSS plugins relative to other styles. To support this, you can export a function from `postcss.config.js` which is given a context object that lets you know when Remix is processing a Vanilla Extract file.
 
@@ -653,105 +527,6 @@ module.exports = (ctx) => {
 };
 ```
 
-### Manual PostCSS Integration
-
-It's also possible to use PostCSS without leveraging the built-in support. Here's the gist of it:
-
-1. Use the `postcss` CLI directly alongside Remix
-2. Build CSS into the Remix app directory from a styles source directory
-3. Import your stylesheet to your modules like any other stylesheet
-
-Here's how to set it up:
-
-1. Install PostCSS along with its CLI and any desired plugins in your app.
-
-   ```sh
-   npm install -D postcss-cli postcss autoprefixer
-   ```
-
-2. Add `postcss.config.js` in the Remix root with configuration for your plugins.
-
-   ```js filename=postcss.config.js
-   module.exports = {
-     plugins: {
-       autoprefixer: {},
-     },
-   };
-   ```
-
-3. Add stylesheets to a `styles/` folder _next to `app/`_, we'll point postcss at this folder to build _into_ the `app/styles` folder next.
-
-   ```sh
-   mkdir styles
-   touch styles/app.css
-   ```
-
-4. Add some scripts to your `package.json`
-
-   ```json
-   {
-     "scripts": {
-       "dev:css": "postcss styles --base styles --dir app/styles -w",
-       "build:css": "postcss styles --base styles --dir app/styles --env production"
-     }
-   }
-   ```
-
-   These commands will process files from `./styles` into `./app/styles` where your Remix modules can import them.
-
-   ```
-   .
-   ├── app
-   │   └── styles (processed files)
-   │       ├── app.css
-   │       └── routes
-   │           └── index.css
-   └── styles (source files)
-       ├── app.css
-       └── routes
-           └── index.css
-   ```
-
-   We recommend adding `app/styles` to your `.gitignore`.
-
-5. Use it! When you're developing styles, open a terminal tab and run your new watch script:
-
-   ```sh
-   npm run dev:css
-   ```
-
-   When you're building for production, run
-
-   ```sh
-   npm run build:css
-   ```
-
-   Then import like any other css file:
-
-   ```tsx filename=root.tsx
-   import type { LinksFunction } from "@remix-run/node"; // or cloudflare/deno
-
-   import styles from "./styles/app.css";
-
-   export const links: LinksFunction = () => {
-     return [{ rel: "stylesheet", href: styles }];
-   };
-   ```
-
-You might want to use something like `concurrently` to avoid needing two terminal tabs to watch your CSS and run `remix dev`.
-
-```sh
-npm add -D concurrently
-```
-
-```json filename=package.json
-{
-  "scripts": {
-    "dev": "concurrently \"npm run dev:css\" \"remix dev\""
-  }
-}
-```
-
 ## CSS Preprocessors
 
 You can use CSS preprocessors like LESS and SASS. Doing so requires running an additional build process to convert these files to CSS files. This can be done via the command line tools provided by the preprocessor or any equivalent tool.
@@ -1084,5 +859,5 @@ module.exports = {
 [css-bundling]: #css-bundling
 [vanilla-extract]: https://vanilla-extract.style
 [sprinkles]: https://vanilla-extract.style/documentation/packages/sprinkles
-[built-in-post-css-support]: #built-in-postcss-support
+[built-in-post-css-support]: #postcss
 [vanilla-extract-2]: #vanilla-extract
diff --git a/docs/pages/api-development-strategy.md b/docs/pages/api-development-strategy.md
index c292a697a..deb1e7cb6 100644
--- a/docs/pages/api-development-strategy.md
+++ b/docs/pages/api-development-strategy.md
@@ -57,8 +57,6 @@ Here's the current future flags in Remix v1 today:
 | `unstable_cssModules`           | Enable CSS Modules Support                                            |
 | `unstable_cssSideEffectImports` | Enable CSS Side Effect imports                                        |
 | `unstable_dev`                  | Enable the new development server (including HMR/HDR support)         |
-| `unstable_postcss`              | Enable PostCSS Support                                                |
-| `unstable_tailwind`             | Enable TailwindCSS support                                            |
 | `unstable_vanillaExtract`       | Enable Vanilla Extract Support                                        |
 | `v2_errorBoundary`              | Combine `ErrorBoundary`/`CatchBoundary` into a single `ErrorBoundary` |
 | `v2_meta`                       | Enable the new API for your `meta` functions                          |
diff --git a/packages/remix-dev/compiler/utils/postcss.ts b/packages/remix-dev/compiler/utils/postcss.ts
index 6ffd09cb0..bf27ae437 100644
--- a/packages/remix-dev/compiler/utils/postcss.ts
+++ b/packages/remix-dev/compiler/utils/postcss.ts
@@ -20,7 +20,7 @@ const defaultContext: RemixPostcssContext = {
 };
 
 function isPostcssEnabled(config: RemixConfig) {
-  return config.future.unstable_postcss || config.future.unstable_tailwind;
+  return config.postcss || config.tailwind;
 }
 
 function getCacheKey({ config, context }: Required<Options>) {
@@ -45,7 +45,7 @@ export async function loadPostcssPlugins({
 
   let plugins: Array<AcceptedPlugin> = [];
 
-  if (config.future.unstable_postcss) {
+  if (config.postcss) {
     try {
       let postcssConfig = await loadConfig(
         // We're nesting our custom context values in a "remix"
@@ -61,7 +61,7 @@ export async function loadPostcssPlugins({
     }
   }
 
-  if (config.future.unstable_tailwind) {
+  if (config.tailwind) {
     let tailwindPlugin = await loadTailwindPlugin(config);
     if (tailwindPlugin && !hasTailwindPlugin(plugins)) {
       plugins.push(tailwindPlugin);
@@ -105,7 +105,7 @@ let tailwindPluginCache = new Map<string, AcceptedPlugin | null>();
 async function loadTailwindPlugin(
   config: RemixConfig
 ): Promise<AcceptedPlugin | null> {
-  if (!config.future.unstable_tailwind) {
+  if (!config.tailwind) {
     return null;
   }
 
diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts
index 6edd1fdb6..a6b8e3404 100644
--- a/packages/remix-dev/config.ts
+++ b/packages/remix-dev/config.ts
@@ -52,7 +52,9 @@ interface FutureConfig {
   unstable_cssModules: boolean;
   unstable_cssSideEffectImports: boolean;
   unstable_dev: boolean | Dev;
+  /** @deprecated Use the `postcss` config option instead */
   unstable_postcss: boolean;
+  /** @deprecated Use the `tailwind` config option instead */
   unstable_tailwind: boolean;
   unstable_vanillaExtract: boolean | VanillaExtractOptions;
   v2_errorBoundary: boolean;
@@ -122,6 +124,12 @@ export interface AppConfig {
    */
   mdx?: RemixMdxConfig | RemixMdxConfigFunction;
 
+  /**
+   * Whether to process CSS using PostCSS if `postcss.config.js` is present.
+   * Defaults to `false`.
+   */
+  postcss?: boolean;
+
   /**
    * A server entrypoint, relative to the root directory that becomes your
    * server's main module. If specified, Remix will compile this file along with
@@ -190,6 +198,12 @@ export interface AppConfig {
    */
   serverPlatform?: ServerPlatform;
 
+  /**
+   * Whether to support Tailwind functions and directives in CSS files if `tailwindcss` is installed.
+   * Defaults to `false`.
+   */
+  tailwind?: boolean;
+
   /**
    * A list of filenames or a glob patterns to match files in the `app/routes`
    * directory that Remix will ignore. Matching files will not be recognized as
@@ -282,6 +296,12 @@ export interface RemixConfig {
    */
   mdx?: RemixMdxConfig | RemixMdxConfigFunction;
 
+  /**
+   * Whether to process CSS using PostCSS if `postcss.config.js` is present.
+   * Defaults to `false`.
+   */
+  postcss: boolean;
+
   /**
    * The path to the server build file. This file should end in a `.js`.
    */
@@ -349,6 +369,12 @@ export interface RemixConfig {
    */
   serverPlatform: ServerPlatform;
 
+  /**
+   * Whether to support Tailwind functions and directives in CSS files if `tailwindcss` is installed.
+   * Defaults to `false`.
+   */
+  tailwind: boolean;
+
   /**
    * A list of directories to watch.
    */
@@ -455,7 +481,23 @@ export async function readConfig(
     serverModuleFormat === "esm" ? ["module", "main"] : ["main", "module"];
   serverMinify ??= false;
 
+  if (appConfig.future?.unstable_postcss !== undefined) {
+    warnOnce(
+      'The "future.unstable_postcss" config option is deprecated as this feature is now considered stable. Use the "postcss" config option instead.'
+    );
+  }
+
+  if (appConfig.future?.unstable_tailwind !== undefined) {
+    warnOnce(
+      'The "future.unstable_tailwind" config option is deprecated as this feature is now considered stable. Use the "tailwind" config option instead.'
+    );
+  }
+
   let mdx = appConfig.mdx;
+  let postcss =
+    appConfig.postcss ?? appConfig.future?.unstable_postcss === true;
+  let tailwind =
+    appConfig.tailwind ?? appConfig.future?.unstable_tailwind === true;
 
   let appDirectory = path.resolve(
     rootDirectory,
@@ -713,6 +755,8 @@ export async function readConfig(
     serverModuleFormat,
     serverPlatform,
     mdx,
+    postcss,
+    tailwind,
     watchPaths,
     tsconfigPath,
     future,
diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts
index 7d1832a97..62dbf4225 100644
--- a/packages/remix-react/entry.ts
+++ b/packages/remix-react/entry.ts
@@ -34,7 +34,9 @@ export interface FutureConfig {
   unstable_cssModules: boolean;
   unstable_cssSideEffectImports: boolean;
   unstable_dev: boolean | Dev;
+  /** @deprecated Use the `postcss` config option instead */
   unstable_postcss: boolean;
+  /** @deprecated Use the `tailwind` config option instead */
   unstable_tailwind: boolean;
   unstable_vanillaExtract: boolean | VanillaExtractOptions;
   v2_errorBoundary: boolean;
diff --git a/packages/remix-server-runtime/entry.ts b/packages/remix-server-runtime/entry.ts
index 16c6670db..d97218425 100644
--- a/packages/remix-server-runtime/entry.ts
+++ b/packages/remix-server-runtime/entry.ts
@@ -26,7 +26,9 @@ export interface FutureConfig {
   unstable_cssModules: boolean;
   unstable_cssSideEffectImports: boolean;
   unstable_dev: boolean | Dev;
+  /** @deprecated Use the `postcss` config option instead */
   unstable_postcss: boolean;
+  /** @deprecated Use the `tailwind` config option instead */
   unstable_tailwind: boolean;
   unstable_vanillaExtract: boolean | VanillaExtractOptions;
   v2_errorBoundary: boolean;


Test patch:
diff --git a/integration/css-modules-test.ts b/integration/css-modules-test.ts
index 3ccfcfe4b..bb8b664b6 100644
--- a/integration/css-modules-test.ts
+++ b/integration/css-modules-test.ts
@@ -25,8 +25,6 @@ test.describe("CSS Modules", () => {
         // ensure features don't clash
         unstable_cssModules: true,
         unstable_cssSideEffectImports: true,
-        unstable_postcss: true,
-        unstable_tailwind: true,
         unstable_vanillaExtract: true,
       },
       files: {
diff --git a/integration/css-side-effect-imports-test.ts b/integration/css-side-effect-imports-test.ts
index 8928686f1..33b97ba28 100644
--- a/integration/css-side-effect-imports-test.ts
+++ b/integration/css-side-effect-imports-test.ts
@@ -27,8 +27,6 @@ test.describe("CSS side-effect imports", () => {
               // ensure features don't clash
               unstable_cssModules: true,
               unstable_cssSideEffectImports: true,
-              unstable_postcss: true,
-              unstable_tailwind: true,
               unstable_vanillaExtract: true,
               v2_routeConvention: true,
             },
diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts
index 702db57ce..bd2626969 100644
--- a/integration/hmr-test.ts
+++ b/integration/hmr-test.ts
@@ -21,6 +21,22 @@ let fixture = (options: { port: number; appServerPort: number }) => ({
     v2_meta: true,
   },
   files: {
+    "remix.config.js": js`
+      module.exports = {
+        tailwind: true,
+        future: {
+          unstable_dev: {
+            port: ${options.port},
+            appServerPort: ${options.appServerPort},
+          },
+          unstable_cssModules: true,
+          v2_routeConvention: true,
+          v2_errorBoundary: true,
+          v2_normalizeFormMethod: true,
+          v2_meta: true,
+        },
+      };
+    `,
     "package.json": json({
       private: true,
       sideEffects: false,
diff --git a/integration/postcss-test.ts b/integration/postcss-test.ts
index 14a1e5d99..217bb83ae 100644
--- a/integration/postcss-test.ts
+++ b/integration/postcss-test.ts
@@ -27,21 +27,25 @@ async function jsonFromBase64CssContent({
   return JSON.parse(json);
 }
 
-test.describe("PostCSS", () => {
+test.describe("PostCSS enabled", () => {
   let fixture: Fixture;
   let appFixture: AppFixture;
 
   test.beforeAll(async () => {
     fixture = await createFixture({
-      future: {
-        v2_routeConvention: true,
-        unstable_cssModules: true,
-        unstable_cssSideEffectImports: true,
-        unstable_postcss: true,
-        unstable_tailwind: true,
-        unstable_vanillaExtract: true,
-      },
       files: {
+        "remix.config.js": js`
+          module.exports = {
+            postcss: true,
+            tailwind: true,
+            future: {
+              v2_routeConvention: true,
+              unstable_cssModules: true,
+              unstable_cssSideEffectImports: true,
+              unstable_vanillaExtract: true,
+            },
+          };
+        `,
         // We provide a test plugin that replaces the strings
         // "TEST_PADDING_VALUE" and "TEST_POSTCSS_CONTEXT".
         // This lets us assert that the plugin is being run
@@ -350,3 +354,173 @@ test.describe("PostCSS", () => {
     expect(padding).toBe(TEST_PADDING_VALUE);
   });
 });
+
+test.describe("PostCSS enabled via unstable future flag", () => {
+  let fixture: Fixture;
+  let appFixture: AppFixture;
+
+  test.beforeAll(async () => {
+    fixture = await createFixture({
+      future: {
+        unstable_postcss: true,
+      },
+      files: {
+        "postcss.config.js": js`
+          module.exports = (ctx) => ({
+            plugins: [
+              {
+                postcssPlugin: 'replace',
+                Declaration (decl) {
+                  decl.value = decl.value
+                    .replaceAll(
+                      "TEST_PADDING_VALUE",
+                      ${JSON.stringify(TEST_PADDING_VALUE)},
+                    );
+                },
+              },
+            ],
+          });
+        `,
+        "app/root.jsx": js`
+          import { Links, Outlet } from "@remix-run/react";
+          export default function Root() {
+            return (
+              <html>
+                <head>
+                  <Links />
+                </head>
+                <body>
+                  <Outlet />
+                </body>
+              </html>
+            )
+          }
+        `,
+        "app/routes/postcss-unstable-future-flag-test.jsx": js`
+          import { Test, links as testLinks } from "~/test-components/postcss-unstable-future-flag";
+          export function links() {
+            return [...testLinks()];
+          }
+          export default function() {
+            return <Test />;
+          }
+        `,
+        "app/test-components/postcss-unstable-future-flag/index.jsx": js`
+          import stylesHref from "./styles.css";
+          export function links() {
+            return [{ rel: 'stylesheet', href: stylesHref }];
+          }
+          export function Test() {
+            return (
+              <div data-testid="postcss-unstable-future-flag" className="postcss-unstable-future-flag-test">
+                <p>PostCSS unstable future flag test.</p>
+              </div>
+            );
+          }
+        `,
+        "app/test-components/postcss-unstable-future-flag/styles.css": css`
+          .postcss-unstable-future-flag-test {
+            padding: TEST_PADDING_VALUE;
+          }
+        `,
+      },
+    });
+    appFixture = await createAppFixture(fixture);
+  });
+
+  test.afterAll(() => appFixture.close());
+
+  test("uses PostCSS config", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/postcss-unstable-future-flag-test");
+    let locator = await page.getByTestId("postcss-unstable-future-flag");
+    let padding = await locator.evaluate((el) => getComputedStyle(el).padding);
+    expect(padding).toBe(TEST_PADDING_VALUE);
+  });
+});
+
+test.describe("PostCSS disabled", () => {
+  let fixture: Fixture;
+  let appFixture: AppFixture;
+
+  test.beforeAll(async () => {
+    fixture = await createFixture({
+      files: {
+        "remix.config.js": js`
+          module.exports = {
+            postcss: false,
+          };
+        `,
+        "postcss.config.js": js`
+          module.exports = (ctx) => ({
+            plugins: [
+              {
+                postcssPlugin: 'replace',
+                Declaration (decl) {
+                  decl.value = decl.value
+                    .replaceAll(
+                      "TEST_PADDING_VALUE",
+                      ${JSON.stringify(TEST_PADDING_VALUE)},
+                    );
+                },
+              },
+            ],
+          });
+        `,
+        "app/root.jsx": js`
+          import { Links, Outlet } from "@remix-run/react";
+          export default function Root() {
+            return (
+              <html>
+                <head>
+                  <Links />
+                </head>
+                <body>
+                  <Outlet />
+                </body>
+              </html>
+            )
+          }
+        `,
+        "app/routes/postcss-disabled-test.jsx": js`
+          import { Test, links as testLinks } from "~/test-components/postcss-disabled";
+          export function links() {
+            return [...testLinks()];
+          }
+          export default function() {
+            return <Test />;
+          }
+        `,
+        "app/test-components/postcss-disabled/index.jsx": js`
+          import stylesHref from "./styles.css";
+          export function links() {
+            return [{ rel: 'stylesheet', href: stylesHref }];
+          }
+          export function Test() {
+            return (
+              <div data-testid="postcss-disabled" className="postcss-disabled-test">
+                <p>PostCSS disabled test.</p>
+              </div>
+            );
+          }
+        `,
+        "app/test-components/postcss-disabled/styles.css": css`
+          .postcss-disabled-test {
+            padding: TEST_PADDING_VALUE;
+          }
+        `,
+      },
+    });
+    appFixture = await createAppFixture(fixture);
+  });
+
+  test.afterAll(() => appFixture.close());
+
+  test("ignores PostCSS config", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/postcss-disabled-test");
+    let locator = await page.locator("[data-testid='postcss-disabled']");
+    let padding = await locator.evaluate((el) => getComputedStyle(el).padding);
+    expect(padding).not.toBe(TEST_PADDING_VALUE);
+  });
+});
diff --git a/integration/tailwind-test.ts b/integration/tailwind-test.ts
index 2dee719e2..9daa6a0ba 100644
--- a/integration/tailwind-test.ts
+++ b/integration/tailwind-test.ts
@@ -43,18 +43,21 @@ function runTests(ext: typeof extensions[number]) {
 
   test.beforeAll(async () => {
     fixture = await createFixture({
-      future: {
-        // Enable all CSS future flags to
-        // ensure features don't clash
-        unstable_cssModules: true,
-        unstable_cssSideEffectImports: true,
-        unstable_postcss: true,
-        unstable_tailwind: true,
-        unstable_vanillaExtract: true,
-        v2_routeConvention: true,
-      },
-
       files: {
+        "remix.config.js": js`
+          module.exports = {
+            tailwind: true,
+            future: {
+              // Enable all CSS future flags to
+              // ensure features don't clash
+              unstable_cssModules: true,
+              unstable_cssSideEffectImports: true,
+              unstable_vanillaExtract: true,
+              v2_routeConvention: true,
+            },
+          };
+        `,
+
         [tailwindConfigName]: tailwindConfig,
 
         "app/tailwind.css": css`
@@ -326,10 +329,163 @@ function runTests(ext: typeof extensions[number]) {
   });
 }
 
-test.describe("Tailwind", () => {
+test.describe("Tailwind enabled", () => {
   for (let ext of extensions) {
     test.describe(`tailwind.config.${ext}`, () => {
       runTests(ext);
     });
   }
 });
+
+test.describe("Tailwind enabled via unstable future flag", () => {
+  let fixture: Fixture;
+  let appFixture: AppFixture;
+
+  test.beforeAll(async () => {
+    fixture = await createFixture({
+      future: {
+        unstable_tailwind: true,
+      },
+      files: {
+        "tailwind.config.js": js`
+          module.exports = {
+            content: ["./app/**/*.{ts,tsx,jsx,js}"],
+            theme: {
+              spacing: {
+                'test': ${JSON.stringify(TEST_PADDING_VALUE)}
+              },
+            },
+          }
+        `,
+        "app/tailwind.css": css`
+          @tailwind base;
+          @tailwind components;
+          @tailwind utilities;
+        `,
+        "app/root.jsx": js`
+          import { Links, Outlet } from "@remix-run/react";
+          import { cssBundleHref } from "@remix-run/css-bundle";
+          import tailwindHref from "./tailwind.css"
+          export function links() {
+            return [
+              { rel: "stylesheet", href: tailwindHref },
+              { rel: "stylesheet", href: cssBundleHref }
+            ];
+          }
+          export default function Root() {
+            return (
+              <html>
+                <head>
+                  <Links />
+                </head>
+                <body>
+                  <Outlet />
+                </body>
+              </html>
+            )
+          }
+        `,
+        "app/routes/unstable-future-flag-test.jsx": js`
+          export default function() {
+            return (
+              <div data-testid="unstable-future-flag" className="p-test">
+                Unstable future flag test
+              </div>
+            );
+          }
+        `,
+      },
+    });
+    appFixture = await createAppFixture(fixture);
+  });
+
+  test.afterAll(() => appFixture.close());
+
+  test("uses Tailwind config", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/unstable-future-flag-test");
+    let locator = page.getByTestId("unstable-future-flag");
+    let padding = await locator.evaluate(
+      (element) => window.getComputedStyle(element).padding
+    );
+    expect(padding).toBe(TEST_PADDING_VALUE);
+  });
+});
+
+test.describe("Tailwind disabled", () => {
+  let fixture: Fixture;
+  let appFixture: AppFixture;
+
+  test.beforeAll(async () => {
+    fixture = await createFixture({
+      files: {
+        "remix.config.js": js`
+          module.exports = {
+            tailwind: false,
+          };
+        `,
+
+        "tailwind.config.js": js`
+          module.exports = {
+            content: ["./app/**/*.{ts,tsx,jsx,js}"],
+            theme: {
+              spacing: {
+                'test': ${JSON.stringify(TEST_PADDING_VALUE)}
+              },
+            },
+          };
+        `,
+
+        "app/tailwind.css": css`
+          @tailwind base;
+          @tailwind components;
+          @tailwind utilities;
+        `,
+
+        "app/root.jsx": js`
+          import { Links, Outlet } from "@remix-run/react";
+          import tailwindHref from "./tailwind.css"
+          export function links() {
+            return [
+              { rel: "stylesheet", href: tailwindHref },
+            ];
+          }
+          export default function Root() {
+            return (
+              <html>
+                <head>
+                  <Links />
+                </head>
+                <body>
+                  <Outlet />
+                </body>
+              </html>
+            )
+          }
+        `,
+        "app/routes/tailwind-disabled-test.jsx": js`
+          export default function() {
+            return (
+              <div data-testid="tailwind-disabled" className="p-test">
+                Tailwind disabled test
+              </div>
+            );
+          }
+        `,
+      },
+    });
+    appFixture = await createAppFixture(fixture);
+  });
+
+  test.afterAll(() => appFixture.close());
+
+  test("ignores Tailwind config", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/tailwind-disabled-test");
+    let locator = page.getByTestId("tailwind-disabled");
+    let padding = await locator.evaluate(
+      (element) => window.getComputedStyle(element).padding
+    );
+    expect(padding).not.toBe(TEST_PADDING_VALUE);
+  });
+});
diff --git a/integration/vanilla-extract-test.ts b/integration/vanilla-extract-test.ts
index f91bb8615..400adef10 100644
--- a/integration/vanilla-extract-test.ts
+++ b/integration/vanilla-extract-test.ts
@@ -25,8 +25,6 @@ test.describe("Vanilla Extract", () => {
             // ensure features don't clash
             unstable_cssModules: true,
             unstable_cssSideEffectImports: true,
-            unstable_postcss: true,
-            unstable_tailwind: true,
             unstable_vanillaExtract: configuration.config,
           },
           files: {
diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts
index 657439b5f..7a504fdb2 100644
--- a/packages/remix-dev/__tests__/readConfig-test.ts
+++ b/packages/remix-dev/__tests__/readConfig-test.ts
@@ -66,6 +66,7 @@ describe("readConfig", () => {
           "v2_routeConvention": Any<Boolean>,
         },
         "mdx": undefined,
+        "postcss": false,
         "publicPath": "/build/",
         "relativeAssetsBuildDirectory": Any<String>,
         "rootDirectory": Any<String>,
@@ -90,6 +91,7 @@ describe("readConfig", () => {
         "serverMode": "production",
         "serverModuleFormat": "cjs",
         "serverPlatform": "node",
+        "tailwind": false,
         "tsconfigPath": Any<String>,
         "watchPaths": Array [],
       }