Repository: remix-run/remix
Pull request #1830 (2022-02-07 12:07:59)

feat(remix-server-runtime): allow generic types for `SessionData`
Currently, all session-related functions use the default `SessionData` interface that is defined as `{ [key: string]: any }`.
This works fine, but doesn't provide any type information for TypeScript when using e.g. `session.get(...)`.

This PR adds generic types to
- the `Session` interface
- the `createSession(...)` function
- all built-in session stores

You can now create a session storage like this:
```ts
// app/sessions.ts
interface CustomSessionData {
  userId: number;
  error: string;
}

export const {
  getSession,
  commitSession,
  destroySession
} = createCookieSessionStorage<CustomSessionData>();
```
and use it like usual, but you will now get type information and auto-completion when using any of the `session.(has|get|set|flash|unset)` functions.

Using the generic session data type is optional and it defaults to the build-in `SessionData` interface, making this change fully backwards-compatible.


ryanflorence: Ooh, I like this, one problem though, the return of `session.get` needs to always be `<type> | undefined`, you never know if the session *actually* has that data in it since it's stored in a browser cookie, a database, etc. That storage is invisible to the types system.

But the type system *can* force you to put the right kind of value in for `set`, and then tell you the type if it's actually defined after a `get`.

Make that change and we can merge.
cmd-johnson: Hmm, i don't see it.
The return type of `Session<SD>.get(...)` already is `SD[K] | undefined`: https://github.com/remix-run/remix/blob/98d6ad7831622866e0ba937960fca3925dc4a942/packages/remix-server-runtime/sessions.ts#L43

and when using it, everything looks correct on my side:
```ts
const { getSession } = createCookieSessionStorage<{ test: string }>()
getSession().then(s => {
  const x = s.get('test') // x: string | undefined
  const y = x.toUpperCase()
  // error: ^ Object is possibly 'undefined'
})
```

Or do you mean this in the way that while the type system can tell me that I get a `string | undefined` all it wants, the fact remains that the session storage could actually return a `number`, at which point all bets are off?
In that case the return type of `get()` would have to be either `any` or (better) `unknown`, forcing you to verify that you actually got a `string` and not something entirely different.
This sounds a bit impractical to me, though.

---

On a different note:

While implementing a login via OAuth2 in my Remix app today, I came up with one more possible addition:
I noticed that everything I've stored in the session so far was _either_ persistent (`session.set(...)`) _or_ transient (`session.flash(...)`), never both.
It could make sense to extend the interface further and introduce a second type parameter that is solely used for values that can be `flash()`ed.
The second type parameter could default to the first (producing the same functionality as now), but you'd have the option to clarify which values you intend to be `set()` or `flash()`ed.
`get()` would have to accept keys of either type parameter.

e.g.
```ts
export interface Session<SD = SessionData, FD = SD> {
  readonly id: string;
  readonly data: FlashSessionData<SD, FD>;
  has(name: (keyof SD | keyof FD) & string): boolean;
  get<K extends (keyof SD | keyof FD) & string>(name: K): (
    | (K extends keyof SD ? SD[K] : undefined)
    | (K extends keyof FD ? FD[K] : undefined)
    | undefined
  )
  set<K extends keyof SD & string>(name: K, value: SD[K]): void;
  flash<K extends keyof FD & string>(name: K, value: FD[K]): void;
  unset(name: (keyof SD & keyof FD) & string): void;
}
```

You could then do things like
```ts
interface SessionData { both: string, onlySet: 'set' }
interface FlashSessionData { both: number, onlyFlash: 'flash' }

const { getSession } = createCookieSessionStorage<SessionData, FlashSessionData>()
getSession().then(s => {
  s.get('both') // string | number | undefined
  s.get('onlySet') // 'set' | undefined
  s.get('onlyFlash') // 'flash' | undefined
  s.set('both', 'some string') // ok
  s.set('both', 10) // error: number is not assignable to string
  s.flash('both', 10) // ok
  s.flash('both', 'some string') // error: string is not assignable to number
  s.set('onlySet', 'set') // ok
  s.set('onlyFlash', 'flash') // error: 'onlyFlash' is not assignable to 'both' | 'onlySet'
  s.flash('onlyFlash', 'flash') // ok
  s.flash('onlySet', 'set') // error: 'onlySet' is not assignable to 'both' | 'onlyFlash'
})
```

What do you think?
Should I add this to this PR as well?
remix-cla-bot[bot]: Thank you for signing the Contributor License Agreement. Let's get this merged! 🥳
cmd-johnson: I noticed I missed adding the type parameters to the `arcTableSessionStorage` and `fileStorage` functions and amended those changes.
cmd-johnson: I went ahead and split up the types for `get()` and `flash()` data as I outlined above. Happy to revert the change if you think that goes a bit too far.
ryanflorence: > Hmm, i don't see it.

Weird, I pulled down your code and was getting that it was just string all the time. I'll try again tomorrow and try to review the rest of your messages and code :)
cmd-johnson: @ryanflorence any chance of getting this merged?
I'll gladly rebase these changes onto the current dev branch and resolve the conflicts then.
MichaelDeBoey: @cmd-johnson Could you please rebase your branch onto latest `dev` & resolve conflicts?
cmd-johnson: @MichaelDeBoey Done!
JeffBeltran: i would love to see this merged in, is there anything i could do to help with that?
joseph-lozano: > the return of `session.get` needs to be `<type> | undefined`

Hi @ryanflorence 

Is there a reason to not just return null if the key doesn't exist in the session?

I was actually bit pretty hard by this because Prisma treats `undefined` as "do nothing", while `null` is actually a value.


kiliman: I agree. I think `Session.get()` should return `null` if key not present. This will match `FormData.get()`. It helps when similar APIs work the same way. 

https://developer.mozilla.org/en-US/docs/Web/API/FormData/get
cmd-johnson: Returning `undefined` is in line with how JavaScript's `Map` type works, though.

I think it also makes sense from a type perspective, where `null` means "I know that key/variable, but its value is empty" and `undefined` means "I don't know that key/variable". Always returning `null` would be throwing away type information that could come in handy.

There's use-cases for `undefined` (e.g. falling back to default values for parameters), that `null` wouldn't work with, and while you could work around that (`fn(session.has("key") ? session.get("key") : undefined)`), it's a lot more verbose.
Returning `undefined` also lets you check if a key is present and getting the actual value with a single call.

If you really want to get `null` instead of `undefined` you could use `session.get("key") ?? null` or write a tiny wrapper around `createSessionStorage(…)` and make `getSession(…)` return sessions with a modified `get` function that does it for you.
joseph-lozano: @cmd-johnson, yeah it is in line with the `Map` type. changing it to null would be break backwards compatibility (at run time!), and is probably a no-go.

If I am not mistaken, the default type for `SessionData` is still `any`. Do you think it would be possible to switch that to `unknown`?
cmd-johnson: I'm usually all for using `unknown`  over `any`, but this again would be a breaking change for anyone using the current session implementation without passing a custom `SessionData` type.

If you're concerned enough about types that you would like it to return `unknown`, you might as well pass a `SessionData` type and not deal with casting from `unknown` at all :wink: 
chaance: Hi @cmd-johnson -- I'm looking to *finally* get this bad boy finalized and merged. Just a few tiny conflicts as a result of docs being moved around and updated names. Mind giving this one more rebase so I can knock it out this week?
cmd-johnson: I'll try and get this done this evening :+1: 
changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: c77329e5e42f95af5f7325e94e7cbc011e437c85

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/cmd-johnson/remix/new/feature/generic-session-data?filename=.changeset/silver-moose-sneeze.md&value=---%0A%22remix%22%3A%20patch%0A%22%40remix-run%2Farchitect%22%3A%20patch%0A%22%40remix-run%2Fcloudflare%22%3A%20patch%0A%22%40remix-run%2Fdeno%22%3A%20patch%0A%22%40remix-run%2Fnode%22%3A%20patch%0A%22%40remix-run%2Fserve%22%3A%20patch%0A%22%40remix-run%2Fserver-runtime%22%3A%20patch%0A---%0A%0Afeat(remix-server-runtime)%3A%20allow%20generic%20types%20for%20%60SessionData%60%0A)



Patch:
diff --git a/.changeset/eleven-plums-film.md b/.changeset/eleven-plums-film.md
new file mode 100644
index 000000000..faa5b6c98
--- /dev/null
+++ b/.changeset/eleven-plums-film.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/dev": minor
+---
+
+Deprecate remix config `serverBuildTarget` in favor of a more flexible combination of the existing [publicPath](https://remix.run/file-conventions/remix-config#publicpath), [serverBuildPath](https://remix.run/file-conventions/remix-config#serverbuildpath), [serverDependenciesToBundle](https://remix.run/file-conventions/remix-config#serverdependenciestobundle) along with net new [serverMainFields](https://remix.run/file-conventions/remix-config#servermainfields), [serverConditions](https://remix.run/file-conventions/remix-config#serverconditions), [serverMinify](https://remix.run/file-conventions/remix-config#serverminify), and the un-deprecated [serverModuleFormat](https://remix.run/file-conventions/remix-config#servermoduleformat) and [serverPlatform](https://remix.run/file-conventions/remix-config#serverplatform) options.
diff --git a/.changeset/fresh-toes-shout.md b/.changeset/fresh-toes-shout.md
new file mode 100644
index 000000000..c72d0bd7e
--- /dev/null
+++ b/.changeset/fresh-toes-shout.md
@@ -0,0 +1,6 @@
+---
+"remix": patch
+"@remix-run/dev": patch
+---
+
+allow tailwind.config.cjs when using `future.unstable_tailwind`
diff --git a/.changeset/many-pans-attack.md b/.changeset/many-pans-attack.md
new file mode 100644
index 000000000..e343ff10f
--- /dev/null
+++ b/.changeset/many-pans-attack.md
@@ -0,0 +1,6 @@
+---
+"@remix-run/react": patch
+"@remix-run/server-runtime": patch
+---
+
+Improve efficiency of route manifest->tree transformation
diff --git a/.changeset/nine-apricots-breathe.md b/.changeset/nine-apricots-breathe.md
new file mode 100644
index 000000000..f25cd7ab6
--- /dev/null
+++ b/.changeset/nine-apricots-breathe.md
@@ -0,0 +1,8 @@
+---
+"@remix-run/architect": patch
+"@remix-run/express": patch
+"@remix-run/netlify": patch
+"@remix-run/vercel": patch
+---
+
+Fix Fetch Request creation for incoming URLs with double slashes
diff --git a/.changeset/olive-jokes-invent.md b/.changeset/olive-jokes-invent.md
new file mode 100644
index 000000000..016fa16b8
--- /dev/null
+++ b/.changeset/olive-jokes-invent.md
@@ -0,0 +1,6 @@
+---
+"remix": patch
+"@remix-run/react": patch
+---
+
+fix(react): live reload works without `<Scripts />`
diff --git a/.changeset/tame-swans-burn.md b/.changeset/tame-swans-burn.md
new file mode 100644
index 000000000..2fc25344f
--- /dev/null
+++ b/.changeset/tame-swans-burn.md
@@ -0,0 +1,23 @@
+---
+"remix": patch
+"@remix-run/dev": patch
+---
+
+flat route fixes and enhancements
+
+`app._index.tsx` and `app/index.tsx` are different routes. The first is an index route for the second, and will be rendered into the parent outlet. The second is the parent route itself.
+
+`index.tsx` no longer has any semantic meaning for "index routes", but rather the node module resolution convention of "index modules".
+
+`routes/app.tsx` and `routes/app/index.tsx` _are the same route_. You just moved it to a folder and made an `index.tsx` because that's how node module resolution looks for the module at `routes/app`.
+
+If you want an index route, you use `_index`
+
+| file name                     | route path          | layout                                     |
+| ----------------------------- | ------------------- | ------------------------------------------ |
+| `routes/index.tsx`            | `/index`            | root                                       |
+| `routes/_index.tsx`           | `/`                 | root                                       |
+| `routes/app.tsx`              | `/app`              | root                                       |
+| `routes/app/index.tsx`        | same route as above | root                                       |
+| `routes/app._index.tsx`       | `/app`              | `routes/app.tsx` or `routes/app/index.tsx` |
+| `routes/app._index/index.tsx` | same route as above | `routes/app.tsx` or `routes/app/index.tsx` |
diff --git a/.changeset/wise-masks-sniff.md b/.changeset/wise-masks-sniff.md
new file mode 100644
index 000000000..a75068fe7
--- /dev/null
+++ b/.changeset/wise-masks-sniff.md
@@ -0,0 +1,6 @@
+---
+"remix": patch
+"@remix-run/dev": patch
+---
+
+Add file loader for importing `.csv` files
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index a184d84c9..4b77873d0 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -72,7 +72,7 @@ jobs:
         run: |
           git config --local user.email "hello@remix.run"
           git config --local user.name "Remix Run Bot"
-          git checkout -b nightly/${{steps.version.outputs.NEXT_VERSION}}
+          git checkout -b nightly/${{ steps.version.outputs.NEXT_VERSION }}
           yarn run version ${{steps.version.outputs.NEXT_VERSION}} --skip-prompt
           git push origin --tags
 
@@ -94,10 +94,11 @@ jobs:
     needs: [nightly]
     name: 📝 Comment on related issues and pull requests
     if: github.repository == 'remix-run/remix' && needs.nightly.outputs.NEXT_VERSION
-    uses: remix-run/remix/.github/workflows/release-comments.yml@main
+    uses: ./.github/workflows/release-comments.yml
     with:
       ref: "refs/tags/v${{ needs.nightly.outputs.NEXT_VERSION }}"
-      packageVersionToFollow: "remix"
+      package_version_to_follow: "remix"
+      release_branch: "dev"
 
   deployments:
     needs: [nightly]
diff --git a/.github/workflows/release-comments.yml b/.github/workflows/release-comments.yml
index 33906867e..39cf05874 100644
--- a/.github/workflows/release-comments.yml
+++ b/.github/workflows/release-comments.yml
@@ -6,7 +6,10 @@ on:
       ref:
         required: true
         type: string
-      packageVersionToFollow:
+      package_version_to_follow:
+        required: true
+        type: string
+      release_branch:
         required: true
         type: string
 
@@ -38,5 +41,5 @@ jobs:
           GITHUB_TOKEN: ${{ github.token }}
           VERSION: ${{ inputs.ref }}
           DEFAULT_BRANCH: "main"
-          NIGHTLY_BRANCH: "dev"
-          PACKAGE_VERSION_TO_FOLLOW: ${{ inputs.packageVersionToFollow }}
+          RELEASE_BRANCH: ${{ inputs.release_branch }}
+          PACKAGE_VERSION_TO_FOLLOW: ${{ inputs.package_version_to_follow }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 117867f2a..8be64fe08 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -21,7 +21,7 @@ jobs:
       !contains(github.ref, 'nightly')
     runs-on: ubuntu-latest
     outputs:
-      publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}
+      published_packages: ${{ steps.changesets.outputs.publishedPackages }}
       published: ${{ steps.changesets.outputs.published }}
     steps:
       - name: 🛑 Cancel Previous Runs
@@ -65,13 +65,13 @@ jobs:
           GITHUB_TOKEN: ${{ secrets.NIGHTLY_PAT }}
           NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
 
-  findPackageVersion:
+  find_package_version:
     name: 🦋 Find Package
     needs: [release]
     runs-on: ubuntu-latest
     if: github.repository == 'remix-run/remix' && needs.release.outputs.published == 'true'
     outputs:
-      packageVersion: ${{ steps.findPackageVersion.outputs.packageVersion }}
+      package_version: ${{ steps.find_package_version.outputs.package_version }}
     steps:
       - name: 🛑 Cancel Previous Runs
         uses: styfle/cancel-workflow-action@0.11.0
@@ -85,27 +85,28 @@ jobs:
           node-version: 16
           cache: "npm"
 
-      - id: findPackageVersion
+      - id: find_package_version
         run: |
-          packageVersion=$(node ./scripts/release/find-release-from-changeset.js)
-          echo "packageVersion=${packageVersion}" >> $GITHUB_OUTPUT
+          package_version=$(node ./scripts/release/find-release-from-changeset.js)
+          echo "package_version=${package_version}" >> $GITHUB_OUTPUT
         env:
-          packageVersionToFollow: "remix"
-          publishedPackages: ${{ needs.release.outputs.publishedPackages }}
+          PACKAGE_VERSION_TO_FOLLOW: "remix"
+          PUBLISHED_PACKAGES: ${{ needs.release.outputs.published_packages }}
 
   comment:
     name: 📝 Comment on related issues and pull requests
-    if: github.repository == 'remix-run/remix' && needs.findPackageVersion.outputs.packageVersion != ''
-    needs: [release, findPackageVersion]
+    if: github.repository == 'remix-run/remix' && needs.find_package_version.outputs.package_version != ''
+    needs: [release, find_package_version]
     uses: ./.github/workflows/release-comments.yml
     with:
-      ref: refs/tags/remix@${{ needs.findPackageVersion.outputs.packageVersion }}
-      packageVersionToFollow: "remix"
+      ref: refs/tags/remix@${{ needs.find_package_version.outputs.package_version }}
+      package_version_to_follow: "remix"
+      release_branch: ${{ github.ref_name }}
 
   deployments:
     name: 🚀 Deployment Tests
     if: github.repository == 'remix-run/remix'
-    needs: [release, findPackageVersion]
+    needs: [release, find_package_version]
     uses: ./.github/workflows/deployments.yml
     secrets:
       TEST_AWS_ACCESS_KEY_ID: ${{ secrets.TEST_AWS_ACCESS_KEY_ID }}
@@ -124,7 +125,7 @@ jobs:
   stacks:
     name: 🥞 Remix Stacks Test
     if: github.repository == 'remix-run/remix'
-    needs: [release, findPackageVersion]
+    needs: [release, find_package_version]
     uses: ./.github/workflows/stacks.yml
     with:
-      version: ${{ needs.findPackageVersion.outputs.packageVersion }}
+      version: ${{ needs.find_package_version.outputs.package_version }}
diff --git a/contributors.yml b/contributors.yml
index 903d4b02a..03a66ab99 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -1,10 +1,10 @@
 - aaronpowell96
 - aaronshaf
 - abereghici
-- adamwathan
 - abotsi
 - accidentaldeveloper
 - achinchen
+- adamwathan
 - adicuco
 - ahabhgk
 - ahbruns
@@ -179,6 +179,7 @@
 - hzhu
 - IAmLuisJ
 - ianduvall
+- ikarus-akhil
 - illright
 - imzshh
 - ionut-botizan
@@ -260,6 +261,7 @@
 - klauspaiva
 - knowler
 - konradkalemba
+- krolebord
 - kubaprzetakiewicz
 - kuldar
 - kumard3
@@ -331,6 +333,7 @@
 - michaseel
 - mikeybinnswebdesign
 - mirzafaizan
+- mitchelldirt
 - mjackson
 - mjangir
 - mkrtchian
@@ -352,6 +355,7 @@
 - nickytonline
 - niconiahi
 - nielsdb97
+- NikkiDelRosso
 - ninjaPixel
 - niwsa
 - nobeeakon
@@ -365,6 +369,7 @@
 - oott123
 - orballo
 - pacexy
+- panteliselef
 - pcattori
 - penspinner
 - penx
@@ -486,6 +491,4 @@
 - zainfathoni
 - zayenz
 - zhe
-- mitchelldirt
-- krolebord
-- panteliselef
+- kayac-chang
diff --git a/docs/file-conventions/remix-config.md b/docs/file-conventions/remix-config.md
index a37bc5b1b..3a975ed16 100644
--- a/docs/file-conventions/remix-config.md
+++ b/docs/file-conventions/remix-config.md
@@ -19,7 +19,6 @@ module.exports = {
     });
   },
   serverBuildPath: "build/index.js",
-  serverBuildTarget: "node-cjs",
 };
 ```
 
@@ -103,7 +102,8 @@ either a `.js` or `.ts` file extension.
 ## serverBuildDirectory
 
 <docs-warning>This option is deprecated and will likely be removed in a future
-stable release. Use [`serverBuildPath`][server-build-path] instead.</docs-warning>
+stable release. Use [`serverBuildPath`][server-build-path]
+instead.</docs-warning>
 
 The path to the server build, relative to `remix.config.js`. Defaults to
 "build". This needs to be deployed to your server.
@@ -111,13 +111,19 @@ The path to the server build, relative to `remix.config.js`. Defaults to
 ## serverBuildPath
 
 The path to the server build file, relative to `remix.config.js`. This file
-should end in a `.js` extension and should be deployed to your server.
-
-If omitted, the default build path will be based on your
-[`serverBuildTarget`][server-build-target].
+should end in a `.js` extension and should be deployed to your server. Defaults
+to `"build/index.js"`.
 
 ## serverBuildTarget
 
+<docs-warning>This option is deprecated and will likely be removed in a future
+stable release. Use a combination of [`publicPath`][public-path],
+[`serverBuildPath`][server-build-path], [`serverConditions`][server-conditions],
+[`serverDependenciesToBundle`][server-dependencies-to-bundle]
+[`serverMainFields`][server-main-fields], [`serverMinify`][server-minify],
+[`serverModuleFormat`][server-module-format] and/or
+[`serverPlatform`][server-platform] instead.</docs-warning>
+
 The target of the server build. Defaults to `"node-cjs"`.
 
 The `serverBuildTarget` can be one of the following:
@@ -130,11 +136,22 @@ The `serverBuildTarget` can be one of the following:
 - [`"node-cjs"`][node-cjs]
 - [`"vercel"`][vercel]
 
+## serverConditions
+
+The order of conditions to use when resolving server dependencies' `exports`
+field in `package.json`.
+
 ## serverDependenciesToBundle
 
-A list of regex patterns that determines if a module is transpiled and included in the server bundle. This can be useful when consuming ESM only packages in a CJS build, or when consuming packages with [CSS side-effect imports][css-side-effect-imports].
+A list of regex patterns that determines if a module is transpiled and included
+in the server bundle. This can be useful when consuming ESM only packages in a
+CJS build, or when consuming packages with [CSS side effect
+imports][css-side-effect-imports].
 
-For example, the `unified` ecosystem is all ESM-only. Let's also say we're using a `@sindresorhus/slugify` which is ESM-only as well. Here's how you would be able to consume those packages in a CJS app without having to use dynamic imports:
+For example, the `unified` ecosystem is all ESM-only. Let's also say we're using
+a `@sindresorhus/slugify` which is ESM-only as well. Here's how you would be
+able to consume those packages in a CJS app without having to use dynamic
+imports:
 
 ```ts filename=remix.config.js lines=[8-13]
 /** @type {import('@remix-run/dev').AppConfig} */
@@ -153,6 +170,29 @@ module.exports = {
 };
 ```
 
+If you want to bundle all server dependencies, you can set
+`serverDependenciesToBundle` to `"all"`.
+
+## serverMainFields
+
+The order of main fields to use when resolving server dependencies. Defaults to
+`["main", "module"]` when `serverModuleFormat` is set to `"cjs"`. Defaults to
+`["module", "main"]` when `serverModuleFormat` is set to `"esm"`.
+
+## serverMinify
+
+Whether to minify the server build in production or not. Defaults to `false`.
+
+## serverModuleFormat
+
+The output format of the server build, which can either be `"cjs"` or `"esm"`.
+Defaults to `"cjs"`.
+
+## serverPlatform
+
+The platform the server build is targeting, which can either be `"neutral"` or
+`"node"`. Defaults to `"node"`.
+
 ## 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].
@@ -173,8 +213,14 @@ There are a few conventions that Remix uses you should be aware of.
 <docs-info>[Dilum Sanjaya][dilum-sanjaya] made [an awesome visualization][an-awesome-visualization] of how routes in the file system map to the URL in your app that might help you understand these conventions.</docs-info>
 
 [minimatch]: https://www.npmjs.com/package/minimatch
+[public-path]: #publicpath
 [server-build-path]: #serverbuildpath
-[server-build-target]: #serverbuildtarget
+[server-conditions]: #serverconditions
+[server-dependencies-to-bundle]: #serverdependenciestobundle
+[server-main-fields]: #servermainfields
+[server-minify]: #serverminify
+[server-module-format]: #servermoduleformat
+[server-platform]: #serverplatform
 [arc]: https://arc.codes
 [cloudflare-pages]: https://pages.cloudflare.com
 [cloudflare-workers]: https://workers.cloudflare.com
diff --git a/docs/file-conventions/route-files-v2.md b/docs/file-conventions/route-files-v2.md
index 641c8ed96..01ad1e804 100644
--- a/docs/file-conventions/route-files-v2.md
+++ b/docs/file-conventions/route-files-v2.md
@@ -315,8 +315,8 @@ Consider these routes:
 
 ```
 routes/
-  _landing.about.tsx
   _landing._index.tsx
+  _landing.about.tsx
   _landing.tsx
   app._index.tsx
   app.projects.tsx
@@ -324,55 +324,53 @@ routes/
   app_.projects.$id.roadmap.tsx
 ```
 
-Some, or all of them can be folders holding their own modules inside:
+Some, or all of them can be folders holding their own modules inside.
 
 ```
 routes/
+  _landing._index/
+    route.tsx
+    scroll-experience.tsx
   _landing.about/
-    index.tsx
     employee-profile-card.tsx
     get-employee-data.server.tsx
+    route.tsx
     team-photo.jpg
-  _landing._index/
-    index.tsx
-    scroll-experience.tsx
   _landing/
-    index.tsx
     header.tsx
     footer.tsx
+    route.tsx
   app._index/
-    index.tsx
+    route.tsx
     stats.tsx
   app.projects/
-    index.tsx
-    project-card.tsx
     get-projects.server.tsx
+    project-card.tsx
     project-buttons.tsx
+    route.tsx
   app/
-    index.tsx
     primary-nav.tsx
+    route.tsx
     footer.tsx
   app_.projects.$id.roadmap/
-    index.tsx
+    route.tsx
     chart.tsx
     update-timeline.server.tsx
   contact-us.tsx
 ```
 
-Note that `app/index.tsx` is _not_ the the "index route" for `app/`. It is the node module resolution "index module" for the folder `routes/app/`. The index route for `app/` is `app._index/index.tsx`. The only thing that contributes to the route path is the folder name.
+Note that the `route.tsx` file is the actual route module, the rest are ignored by the convention.
 
 ```
 # these are the same route:
 routes/app.tsx
-routes/app/index.tsx
+routes/app/route.tsx
 
 # as are these
 routes/app._index.tsx
-route/app._index/index.tsx
+route/app._index/route.tsx
 ```
 
-In other words `_index` has meaning for Remix index routes, `index` has node module resolution meaning and creates index modules.
-
 ## Scaling
 
 Our general recommendation for scale is to make every route a folder and put the modules used exclusively by that route in the folder, then put the shared modules outside of routes folder elsewhere. This has a couple benefits:
diff --git a/docs/utils/sessions.md b/docs/utils/sessions.md
index af56f4f9e..2626e6521 100644
--- a/docs/utils/sessions.md
+++ b/docs/utils/sessions.md
@@ -21,12 +21,20 @@ Remix comes with several pre-built session storage options for common scenarios,
 
 This is an example of a cookie session storage:
 
-```js filename=app/sessions.js
-// app/sessions.js
+```ts filename=app/sessions.ts
+// app/sessions.ts
 import { createCookieSessionStorage } from "@remix-run/node"; // or cloudflare/deno
 
+type SessionData = {
+  userId: string;
+};
+
+type SessionFlashData = {
+  error: string;
+};
+
 const { getSession, commitSession, destroySession } =
-  createCookieSessionStorage({
+  createCookieSessionStorage<SessionData, SessionFlashData>({
     // a Cookie from `createCookie` or the CookieOptions to create one
     cookie: {
       name: "__session",
@@ -49,7 +57,7 @@ const { getSession, commitSession, destroySession } =
 export { getSession, commitSession, destroySession };
 ```
 
-We recommend setting up your session storage object in `app/sessions.js` so all routes that need to access session data can import from the same spot (also, see our [Route Module Constraints][constraints]).
+We recommend setting up your session storage object in `app/sessions.ts` so all routes that need to access session data can import from the same spot (also, see our [Route Module Constraints][constraints]).
 
 The input/output to a session storage object are HTTP cookies. `getSession()` retrieves the current session from the incoming request's `Cookie` header, and `commitSession()`/`destroySession()` provide the `Set-Cookie` header for the outgoing response.
 
diff --git a/integration/helpers/cf-template/remix.config.js b/integration/helpers/cf-template/remix.config.js
index a4fac8985..ab7a9e7da 100644
--- a/integration/helpers/cf-template/remix.config.js
+++ b/integration/helpers/cf-template/remix.config.js
@@ -1,9 +1,14 @@
 /** @type {import('@remix-run/dev').AppConfig} */
 module.exports = {
-  serverBuildTarget: "cloudflare-workers",
-  server: "./server.js",
   devServerBroadcastDelay: 1000,
   ignoredRouteFiles: ["**/.*"],
+  server: "./server.js",
+  serverConditions: ["worker"],
+  serverDependenciesToBundle: "all",
+  serverMainFields: ["browser", "module", "main"],
+  serverMinify: true,
+  serverModuleFormat: "esm",
+  serverPlatform: "neutral",
   // appDirectory: "app",
   // assetsBuildDirectory: "public/build",
   // serverBuildPath: "build/index.js",
diff --git a/integration/helpers/deno-template/remix.config.js b/integration/helpers/deno-template/remix.config.js
index d621422e5..4ad663f5b 100644
--- a/integration/helpers/deno-template/remix.config.js
+++ b/integration/helpers/deno-template/remix.config.js
@@ -1,7 +1,5 @@
 /** @type {import('@remix-run/dev').AppConfig} */
 module.exports = {
-  serverBuildTarget: "deno",
-  server: "./server.ts",
   /*
   If live reload causes page to re-render without changes (live reload is too fast),
   increase the dev server broadcast delay.
@@ -10,6 +8,12 @@ module.exports = {
   */
   devServerBroadcastDelay: 300,
   ignoredRouteFiles: ["**/.*"],
+  server: "./server.ts",
+  serverConditions: ["deno", "worker"],
+  serverDependenciesToBundle: "all",
+  serverMainFields: ["module", "main"],
+  serverModuleFormat: "esm",
+  serverPlatform: "neutral",
   // appDirectory: "app",
   // assetsBuildDirectory: "public/build",
   // serverBuildPath: "build/index.js",
diff --git a/packages/remix-architect/server.ts b/packages/remix-architect/server.ts
index 45e971869..cbbfa9f1b 100644
--- a/packages/remix-architect/server.ts
+++ b/packages/remix-architect/server.ts
@@ -62,7 +62,7 @@ export function createRemixRequest(event: APIGatewayProxyEventV2): NodeRequest {
   let host = event.headers["x-forwarded-host"] || event.headers.host;
   let search = event.rawQueryString.length ? `?${event.rawQueryString}` : "";
   let scheme = process.env.ARC_SANDBOX ? "http" : "https";
-  let url = new URL(event.rawPath + search, `${scheme}://${host}`);
+  let url = new URL(`${scheme}://${host}${event.rawPath}${search}`);
   let isFormData = event.headers["content-type"]?.includes(
     "multipart/form-data"
   );
diff --git a/packages/remix-architect/sessions/arcTableSessionStorage.ts b/packages/remix-architect/sessions/arcTableSessionStorage.ts
index 9c2909886..3c362d3e0 100644
--- a/packages/remix-architect/sessions/arcTableSessionStorage.ts
+++ b/packages/remix-architect/sessions/arcTableSessionStorage.ts
@@ -44,10 +44,13 @@ interface ArcTableSessionStorageOptions {
  *     _idx *String
  *     _ttl TTL
  */
-export function createArcTableSessionStorage({
+export function createArcTableSessionStorage<
+  Data = SessionData,
+  FlashData = Data
+>({
   cookie,
   ...props
-}: ArcTableSessionStorageOptions): SessionStorage {
+}: ArcTableSessionStorageOptions): SessionStorage<Data, FlashData> {
   async function getTable() {
     if (typeof props.table === "string") {
       let tables = await arc.tables();
@@ -74,7 +77,7 @@ export function createArcTableSessionStorage({
           continue;
         }
 
-        let params = {
+        let params: Record<string, unknown> = {
           [props.idx]: id,
           ...data,
         };
@@ -99,7 +102,7 @@ export function createArcTableSessionStorage({
     },
     async updateData(id, data, expires) {
       let table = await getTable();
-      let params = {
+      let params: Record<string, unknown> = {
         [props.idx]: id,
         ...data,
       };
diff --git a/packages/remix-cloudflare/sessions/workersKVStorage.ts b/packages/remix-cloudflare/sessions/workersKVStorage.ts
index 45a4573b4..094cc76a1 100644
--- a/packages/remix-cloudflare/sessions/workersKVStorage.ts
+++ b/packages/remix-cloudflare/sessions/workersKVStorage.ts
@@ -1,6 +1,7 @@
 import type {
   SessionStorage,
   SessionIdStorageStrategy,
+  SessionData,
 } from "@remix-run/server-runtime";
 
 import { createSessionStorage } from "../implementations";
@@ -24,10 +25,13 @@ interface WorkersKVSessionStorageOptions {
  * The advantage of using this instead of cookie session storage is that
  * KV Store may contain much more data than cookies.
  */
-export function createWorkersKVSessionStorage({
+export function createWorkersKVSessionStorage<
+  Data = SessionData,
+  FlashData = Data
+>({
   cookie,
   kv,
-}: WorkersKVSessionStorageOptions): SessionStorage {
+}: WorkersKVSessionStorageOptions): SessionStorage<Data, FlashData> {
   return createSessionStorage({
     cookie,
     async createData(data, expires) {
diff --git a/packages/remix-deno/sessions/fileStorage.ts b/packages/remix-deno/sessions/fileStorage.ts
index dd91c3fdc..5b7627122 100644
--- a/packages/remix-deno/sessions/fileStorage.ts
+++ b/packages/remix-deno/sessions/fileStorage.ts
@@ -1,6 +1,7 @@
 import * as path from "https://deno.land/std@0.128.0/path/mod.ts";
 
 import type {
+  SessionData,
   SessionIdStorageStrategy,
   SessionStorage,
 } from "@remix-run/server-runtime";
@@ -25,10 +26,10 @@ interface FileSessionStorageOptions {
  * The advantage of using this instead of cookie session storage is that
  * files may contain much more data than cookies.
  */
-export function createFileSessionStorage({
+export function createFileSessionStorage<Data = SessionData, FlashData = Data>({
   cookie,
   dir,
-}: FileSessionStorageOptions): SessionStorage {
+}: FileSessionStorageOptions): SessionStorage<Data, FlashData> {
   return createSessionStorage({
     cookie,
     async createData(data, expires) {
diff --git a/packages/remix-dev/compiler/compilerServer.ts b/packages/remix-dev/compiler/compilerServer.ts
index d0b995b2b..01bb2f1e2 100644
--- a/packages/remix-dev/compiler/compilerServer.ts
+++ b/packages/remix-dev/compiler/compilerServer.ts
@@ -45,11 +45,6 @@ const createEsbuildConfig = (
     };
   }
 
-  let isCloudflareRuntime = ["cloudflare-pages", "cloudflare-workers"].includes(
-    config.serverBuildTarget ?? ""
-  );
-  let isDenoRuntime = config.serverBuildTarget === "deno";
-
   let { mode } = options;
   let outputCss = false;
 
@@ -83,11 +78,7 @@ const createEsbuildConfig = (
     stdin,
     entryPoints,
     outfile: config.serverBuildPath,
-    conditions: isCloudflareRuntime
-      ? ["worker"]
-      : isDenoRuntime
-      ? ["deno", "worker"]
-      : undefined,
+    conditions: config.serverConditions,
     platform: config.serverPlatform,
     format: config.serverModuleFormat,
     treeShaking: true,
@@ -99,12 +90,8 @@ const createEsbuildConfig = (
     // PR makes dev mode behave closer to production in terms of dead
     // code elimination / tree shaking is concerned.
     minifySyntax: true,
-    minify: options.mode === "production" && isCloudflareRuntime,
-    mainFields: isCloudflareRuntime
-      ? ["browser", "module", "main"]
-      : config.serverModuleFormat === "esm"
-      ? ["module", "main"]
-      : ["main", "module"],
+    minify: options.mode === "production" && config.serverMinify,
+    mainFields: config.serverMainFields,
     target: options.target,
     loader: loaders,
     bundle: true,
diff --git a/packages/remix-dev/compiler/loaders.ts b/packages/remix-dev/compiler/loaders.ts
index 9f64e9815..bd8d331e3 100644
--- a/packages/remix-dev/compiler/loaders.ts
+++ b/packages/remix-dev/compiler/loaders.ts
@@ -5,6 +5,7 @@ export const loaders: { [ext: string]: esbuild.Loader } = {
   ".aac": "file",
   ".avif": "file",
   ".css": "file",
+  ".csv": "file",
   ".eot": "file",
   ".fbx": "file",
   ".flac": "file",
@@ -30,6 +31,7 @@ export const loaders: { [ext: string]: esbuild.Loader } = {
   ".ogg": "file",
   ".otf": "file",
   ".png": "file",
+  ".psd": "file",
   ".sql": "text",
   ".svg": "file",
   ".ts": "ts",
diff --git a/packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts b/packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts
index e2c840cd8..8aaaa390d 100644
--- a/packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts
+++ b/packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts
@@ -22,12 +22,10 @@ export function serverBareModulesPlugin(
   remixConfig: RemixConfig,
   onWarning?: (warning: string, key: string) => void
 ): Plugin {
-  let isDenoRuntime = remixConfig.serverBuildTarget === "deno";
-
   // Resolve paths according to tsconfig paths property
-  let matchPath = isDenoRuntime
-    ? undefined
-    : createMatchPath(remixConfig.tsconfigPath);
+  let matchPath = remixConfig.tsconfigPath
+    ? createMatchPath(remixConfig.tsconfigPath)
+    : undefined;
   function resolvePath(id: string) {
     if (!matchPath) {
       return id;
@@ -103,12 +101,8 @@ export function serverBareModulesPlugin(
           }
         }
 
-        switch (remixConfig.serverBuildTarget) {
-          // Always bundle everything for cloudflare.
-          case "cloudflare-pages":
-          case "cloudflare-workers":
-          case "deno":
-            return undefined;
+        if (remixConfig.serverDependenciesToBundle === "all") {
+          return undefined;
         }
 
         for (let pattern of remixConfig.serverDependenciesToBundle) {
@@ -124,8 +118,7 @@ export function serverBareModulesPlugin(
           onWarning &&
           !isNodeBuiltIn(packageName) &&
           kind !== "dynamic-import" &&
-          (!remixConfig.serverBuildTarget ||
-            remixConfig.serverBuildTarget === "node-cjs")
+          remixConfig.serverPlatform === "node"
         ) {
           warnOnceIfEsmOnlyPackage(packageName, path, onWarning);
         }
diff --git a/packages/remix-dev/compiler/utils/postcss.ts b/packages/remix-dev/compiler/utils/postcss.ts
index cbc3ae448..1a6ae4023 100644
--- a/packages/remix-dev/compiler/utils/postcss.ts
+++ b/packages/remix-dev/compiler/utils/postcss.ts
@@ -4,6 +4,7 @@ import type { AcceptedPlugin, Processor } from "postcss";
 import postcss from "postcss";
 
 import type { RemixConfig } from "../../config";
+import { findConfig } from "../../config";
 
 interface Options {
   config: RemixConfig;
@@ -119,13 +120,18 @@ async function loadTailwindPlugin(
 
   try {
     // First ensure they have a Tailwind config
-    require.resolve("./tailwind.config", { paths: [rootDirectory] });
+    // tailwind doesn't support esm config files yet
+    let tailwindConfigExtensions = [".js", ".cjs"];
+    let tailwindConfig = findConfig(
+      rootDirectory,
+      "tailwind.config",
+      tailwindConfigExtensions
+    );
+    if (!tailwindConfig) throw new Error("No Tailwind config found");
 
     // Load Tailwind from the project directory
-    tailwindPath = require.resolve("tailwindcss", {
-      paths: [rootDirectory],
-    });
-  } catch (err) {
+    tailwindPath = require.resolve("tailwindcss", { paths: [rootDirectory] });
+  } catch {
     // If they don't have a Tailwind config or Tailwind installed, just ignore it.
     return null;
   }
diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts
index 701e7a114..89b8fe377 100644
--- a/packages/remix-dev/config.ts
+++ b/packages/remix-dev/config.ts
@@ -76,23 +76,6 @@ export interface AppConfig {
     defineRoutes: DefineRoutesFunction
   ) => Promise<ReturnType<DefineRoutesFunction>>;
 
-  /**
-   * The path to the server build, relative to `remix.config.js`. Defaults to
-   * "build".
-   *
-   * @deprecated Use {@link ServerConfig.serverBuildPath} instead.
-   */
-  serverBuildDirectory?: string;
-
-  /**
-   * The path to the server build file, relative to `remix.config.js`. This file
-   * should end in a `.js` extension and should be deployed to your server.
-   *
-   * If omitted, the default build path will be based on your
-   * {@link ServerConfig.serverBuildTarget}.
-   */
-  serverBuildPath?: string;
-
   /**
    * The path to the browser build, relative to `remix.config.js`. Defaults to
    * "public/build".
@@ -103,7 +86,7 @@ export interface AppConfig {
    * The path to the browser build, relative to remix.config.js. Defaults to
    * "public/build".
    *
-   * @deprecated Use `{@link ServerConfig.assetsBuildDirectory}` instead
+   * @deprecated Use `{@link AppConfig.assetsBuildDirectory}` instead
    */
   browserBuildDirectory?: string;
 
@@ -130,45 +113,79 @@ export interface AppConfig {
   mdx?: RemixMdxConfig | RemixMdxConfigFunction;
 
   /**
-   * The output format of the server build. Defaults to "cjs".
-   *
-   * @deprecated Use {@link ServerConfig.serverBuildTarget} instead.
+   * A server entrypoint, relative to the root directory that becomes your
+   * server's main module. If specified, Remix will compile this file along with
+   * your application into a single file to be deployed to your server. This
+   * file can use either a `.js` or `.ts` file extension.
    */
-  serverModuleFormat?: ServerModuleFormat;
+  server?: string;
 
   /**
-   * The platform the server build is targeting. Defaults to "node".
+   * The path to the server build, relative to `remix.config.js`. Defaults to
+   * "build".
    *
-   * @deprecated Use {@link ServerConfig.serverBuildTarget} instead.
+   * @deprecated Use {@link AppConfig.serverBuildPath} instead.
    */
-  serverPlatform?: ServerPlatform;
+  serverBuildDirectory?: string;
 
   /**
-   * The target of the server build. Defaults to "node-cjs".
+   * The path to the server build file, relative to `remix.config.js`. This file
+   * should end in a `.js` extension and should be deployed to your server.
    */
-  serverBuildTarget?: ServerBuildTarget;
+  serverBuildPath?: string;
 
   /**
-   * A server entrypoint, relative to the root directory that becomes your
-   * server's main module. If specified, Remix will compile this file along with
-   * your application into a single file to be deployed to your server. This
-   * file can use either a `.js` or `.ts` file extension.
+   * The target of the server build. Defaults to "node-cjs".
+   *
+   * @deprecated Use a combination of `{@link AppConfig.publicPath}`, `{@link AppConfig.serverBuildPath}`, `{@link AppConfig.serverConditions}`, `{@link AppConfig.serverDependenciesToBundle}`, `{@link AppConfig.serverMainFields}`, `{@link AppConfig.serverMinify}`, `{@link AppConfig.serverModuleFormat}` and/or `{@link AppConfig.serverPlatform}` instead.
    */
-  server?: string;
+  serverBuildTarget?: ServerBuildTarget;
 
   /**
-   * 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
-   * routes.
+   * The order of conditions to use when resolving server dependencies'
+   * `exports` field in `package.json`.
+   *
+   * For more information, see: https://esbuild.github.io/api/#conditions
    */
-  ignoredRouteFiles?: string[];
+  serverConditions?: string[];
 
   /**
    * A list of patterns that determined if a module is transpiled and included
    * in the server bundle. This can be useful when consuming ESM only packages
    * in a CJS build.
    */
-  serverDependenciesToBundle?: Array<string | RegExp>;
+  serverDependenciesToBundle?: "all" | Array<string | RegExp>;
+
+  /**
+   * The order of main fields to use when resolving server dependencies.
+   * Defaults to `["main", "module"]`.
+   *
+   * For more information, see: https://esbuild.github.io/api/#main-fields
+   */
+  serverMainFields?: string[];
+
+  /**
+   * Whether to minify the server build in production or not.
+   * Defaults to `false`.
+   */
+  serverMinify?: boolean;
+
+  /**
+   * The output format of the server build. Defaults to "cjs".
+   */
+  serverModuleFormat?: ServerModuleFormat;
+
+  /**
+   * The platform the server build is targeting. Defaults to "node".
+   */
+  serverPlatform?: ServerPlatform;
+
+  /**
+   * 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
+   * routes.
+   */
+  ignoredRouteFiles?: string[];
 
   /**
    * A function for defining custom directories to watch while running `remix dev`, in addition to `appDirectory`.
@@ -215,12 +232,6 @@ export interface RemixConfig {
    */
   routes: RouteManifest;
 
-  /**
-   * The path to the server build file. This file should end in a `.js`. Defaults
-   * are based on {@link ServerConfig.serverBuildTarget}.
-   */
-  serverBuildPath: string;
-
   /**
    * The absolute path to the assets build directory.
    */
@@ -236,11 +247,6 @@ export interface RemixConfig {
    */
   publicPath: string;
 
-  /**
-   * The mode to use to run the server.
-   */
-  serverMode: ServerMode;
-
   /**
    * The port number to use for the dev (asset) server.
    */
@@ -257,36 +263,71 @@ export interface RemixConfig {
   mdx?: RemixMdxConfig | RemixMdxConfigFunction;
 
   /**
-   * The output format of the server build. Defaults to "cjs".
-   */
-  serverModuleFormat: ServerModuleFormat;
-
-  /**
-   * The platform the server build is targeting. Defaults to "node".
+   * The path to the server build file. This file should end in a `.js`.
    */
-  serverPlatform: ServerPlatform;
+  serverBuildPath: string;
 
   /**
-   * The target of the server build.
-   */
+   * The target of the server build. Defaults to "node-cjs".
+   *
+   * @deprecated Use a combination of `{@link AppConfig.publicPath}`, `{@link AppConfig.serverBuildPath}`, `{@link AppConfig.serverConditions}`, `{@link AppConfig.serverDependenciesToBundle}`, `{@link AppConfig.serverMainFields}`, `{@link AppConfig.serverMinify}`, `{@link AppConfig.serverModuleFormat}` and/or `{@link AppConfig.serverPlatform}` instead.   */
   serverBuildTarget?: ServerBuildTarget;
 
   /**
-   * The default entry module for the server build if a {@see RemixConfig.customServer} is not provided.
+   * The default entry module for the server build if a {@see AppConfig.server}
+   * is not provided.
    */
   serverBuildTargetEntryModule: string;
 
   /**
-   * A server entrypoint relative to the root directory that becomes your server's main module.
+   * The order of conditions to use when resolving server dependencies'
+   * `exports` field in `package.json`.
+   *
+   * For more information, see: https://esbuild.github.io/api/#conditions
    */
-  serverEntryPoint?: string;
+  serverConditions?: string[];
 
   /**
    * A list of patterns that determined if a module is transpiled and included
    * in the server bundle. This can be useful when consuming ESM only packages
    * in a CJS build.
    */
-  serverDependenciesToBundle: Array<string | RegExp>;
+  serverDependenciesToBundle: "all" | Array<string | RegExp>;
+
+  /**
+   * A server entrypoint relative to the root directory that becomes your
+   * server's main module.
+   */
+  serverEntryPoint?: string;
+
+  /**
+   * The order of main fields to use when resolving server dependencies.
+   * Defaults to `["main", "module"]`.
+   *
+   * For more information, see: https://esbuild.github.io/api/#main-fields
+   */
+  serverMainFields: string[];
+
+  /**
+   * Whether to minify the server build in production or not.
+   * Defaults to `false`.
+   */
+  serverMinify: boolean;
+
+  /**
+   * The mode to use to run the server.
+   */
+  serverMode: ServerMode;
+
+  /**
+   * The output format of the server build. Defaults to "cjs".
+   */
+  serverModuleFormat: ServerModuleFormat;
+
+  /**
+   * The platform the server build is targeting. Defaults to "node".
+   */
+  serverPlatform: ServerPlatform;
 
   /**
    * A list of directories to watch.
@@ -318,7 +359,7 @@ export async function readConfig(
   }
 
   let rootDirectory = path.resolve(remixRoot);
-  let configFile = findConfig(rootDirectory, "remix.config");
+  let configFile = findConfig(rootDirectory, "remix.config", configExts);
 
   let appConfig: AppConfig = {};
   if (configFile) {
@@ -342,20 +383,41 @@ export async function readConfig(
     }
   }
 
-  let customServerEntryPoint = appConfig.server;
-  let serverBuildTarget: ServerBuildTarget | undefined =
-    appConfig.serverBuildTarget;
-  let serverModuleFormat: ServerModuleFormat =
-    appConfig.serverModuleFormat || "cjs";
-  let serverPlatform: ServerPlatform = appConfig.serverPlatform || "node";
-  switch (appConfig.serverBuildTarget) {
-    case "cloudflare-pages":
-    case "cloudflare-workers":
-    case "deno":
-      serverModuleFormat = "esm";
-      serverPlatform = "neutral";
-      break;
+  let isCloudflareRuntime = ["cloudflare-pages", "cloudflare-workers"].includes(
+    appConfig.serverBuildTarget ?? ""
+  );
+  let isDenoRuntime = appConfig.serverBuildTarget === "deno";
+
+  let serverBuildPath = resolveServerBuildPath(rootDirectory, appConfig);
+  let serverBuildTarget = appConfig.serverBuildTarget;
+  let serverBuildTargetEntryModule = `export * from ${JSON.stringify(
+    serverBuildVirtualModule.id
+  )};`;
+  let serverConditions = appConfig.serverConditions;
+  let serverDependenciesToBundle = appConfig.serverDependenciesToBundle || [];
+  let serverEntryPoint = appConfig.server;
+  let serverMainFields = appConfig.serverMainFields;
+  let serverMinify = appConfig.serverMinify;
+  let serverModuleFormat = appConfig.serverModuleFormat || "cjs";
+  let serverPlatform = appConfig.serverPlatform || "node";
+  if (isCloudflareRuntime) {
+    serverConditions ??= ["worker"];
+    serverDependenciesToBundle = "all";
+    serverMainFields ??= ["browser", "module", "main"];
+    serverMinify ??= true;
+    serverModuleFormat = "esm";
+    serverPlatform = "neutral";
+  }
+  if (isDenoRuntime) {
+    serverConditions ??= ["deno", "worker"];
+    serverDependenciesToBundle = "all";
+    serverMainFields ??= ["module", "main"];
+    serverModuleFormat = "esm";
+    serverPlatform = "neutral";
   }
+  serverMainFields ??=
+    serverModuleFormat === "esm" ? ["module", "main"] : ["main", "module"];
+  serverMinify ??= false;
 
   let mdx = appConfig.mdx;
 
@@ -379,35 +441,6 @@ export async function readConfig(
     throw new Error(`Missing "entry.server" file in ${appDirectory}`);
   }
 
-  let serverBuildPath = "build/index.js";
-  switch (serverBuildTarget) {
-    case "arc":
-      serverBuildPath = "server/index.js";
-      break;
-    case "cloudflare-pages":
-      serverBuildPath = "functions/[[path]].js";
-      break;
-    case "netlify":
-      serverBuildPath = ".netlify/functions-internal/server.js";
-      break;
-    case "vercel":
-      serverBuildPath = "api/index.js";
-      break;
-  }
-  serverBuildPath = path.resolve(rootDirectory, serverBuildPath);
-
-  // retain deprecated behavior for now
-  if (appConfig.serverBuildDirectory) {
-    serverBuildPath = path.resolve(
-      rootDirectory,
-      path.join(appConfig.serverBuildDirectory, "index.js")
-    );
-  }
-
-  if (appConfig.serverBuildPath) {
-    serverBuildPath = path.resolve(rootDirectory, appConfig.serverBuildPath);
-  }
-
   let assetsBuildDirectory =
     appConfig.assetsBuildDirectory ||
     appConfig.browserBuildDirectory ||
@@ -425,13 +458,8 @@ export async function readConfig(
   process.env.REMIX_DEV_SERVER_WS_PORT = `${devServerPort}`;
   let devServerBroadcastDelay = appConfig.devServerBroadcastDelay || 0;
 
-  let defaultPublicPath = "/build/";
-  switch (serverBuildTarget) {
-    case "arc":
-      defaultPublicPath = "/_static/build/";
-      break;
-  }
-
+  let defaultPublicPath =
+    appConfig.serverBuildTarget === "arc" ? "/_static/build/" : "/build/";
   let publicPath = addTrailingSlash(appConfig.publicPath || defaultPublicPath);
 
   let rootRouteFile = findEntry(appDirectory, "root");
@@ -477,12 +505,6 @@ export async function readConfig(
     );
   }
 
-  let serverBuildTargetEntryModule = `export * from ${JSON.stringify(
-    serverBuildVirtualModule.id
-  )};`;
-
-  let serverDependenciesToBundle = appConfig.serverDependenciesToBundle || [];
-
   // When tsconfigPath is undefined, the default "tsconfig.json" is not
   // found in the root directory.
   let tsconfigPath: string | undefined;
@@ -525,13 +547,16 @@ export async function readConfig(
     rootDirectory,
     routes,
     serverBuildPath,
-    serverMode,
-    serverModuleFormat,
-    serverPlatform,
     serverBuildTarget,
     serverBuildTargetEntryModule,
-    serverEntryPoint: customServerEntryPoint,
+    serverConditions,
     serverDependenciesToBundle,
+    serverEntryPoint,
+    serverMainFields,
+    serverMinify,
+    serverMode,
+    serverModuleFormat,
+    serverPlatform,
     mdx,
     watchPaths,
     tsconfigPath,
@@ -556,11 +581,49 @@ function findEntry(dir: string, basename: string): string | undefined {
 
 const configExts = [".js", ".cjs", ".mjs"];
 
-function findConfig(dir: string, basename: string): string | undefined {
-  for (let ext of configExts) {
-    let file = path.resolve(dir, basename + ext);
+export function findConfig(
+  dir: string,
+  basename: string,
+  extensions: string[]
+): string | undefined {
+  for (let ext of extensions) {
+    let name = basename + ext;
+    let file = path.join(dir, name);
     if (fse.existsSync(file)) return file;
   }
 
   return undefined;
 }
+
+const resolveServerBuildPath = (
+  rootDirectory: string,
+  appConfig: AppConfig
+) => {
+  let serverBuildPath = "build/index.js";
+
+  switch (appConfig.serverBuildTarget) {
+    case "arc":
+      serverBuildPath = "server/index.js";
+      break;
+    case "cloudflare-pages":
+      serverBuildPath = "functions/[[path]].js";
+      break;
+    case "netlify":
+      serverBuildPath = ".netlify/functions-internal/server.js";
+      break;
+    case "vercel":
+      serverBuildPath = "api/index.js";
+      break;
+  }
+
+  // retain deprecated behavior for now
+  if (appConfig.serverBuildDirectory) {
+    serverBuildPath = path.join(appConfig.serverBuildDirectory, "index.js");
+  }
+
+  if (appConfig.serverBuildPath) {
+    serverBuildPath = appConfig.serverBuildPath;
+  }
+
+  return path.resolve(rootDirectory, serverBuildPath);
+};
diff --git a/packages/remix-dev/config/flat-routes.ts b/packages/remix-dev/config/flat-routes.ts
index a2e76dd73..dad8c43f7 100644
--- a/packages/remix-dev/config/flat-routes.ts
+++ b/packages/remix-dev/config/flat-routes.ts
@@ -1,7 +1,13 @@
 import path from "node:path";
 import fg from "fast-glob";
 
-import type { ConfigRoute, DefineRouteFunction, RouteManifest } from "./routes";
+import type {
+  ConfigRoute,
+  DefineRouteFunction,
+  DefineRouteOptions,
+  RouteManifest,
+} from "./routes";
+import { normalizeSlashes } from "./routes";
 import { createRouteId, defineRoutes } from "./routes";
 import {
   escapeEnd,
@@ -23,12 +29,13 @@ export function flatRoutes(
     absolute: true,
     cwd: path.join(appDirectory, "routes"),
     ignore: ignoredFilePatterns,
+    onlyFiles: true,
   });
 
   // fast-glob will return posix paths even on windows
   // convert posix to os specific paths
   let routePathsForOS = routePaths.map((routePath) => {
-    return path.join(...routePath.split(path.posix.sep));
+    return path.normalize(routePath);
   });
 
   return flatRoutesUniversal(appDirectory, routePathsForOS);
@@ -51,39 +58,47 @@ export function flatRoutesUniversal(
   prefix: string = "routes"
 ): RouteManifest {
   let routeMap = getRouteMap(appDirectory, routePaths, prefix);
-
   let uniqueRoutes = new Map<string, string>();
+  let routes = Array.from(routeMap.values());
 
   function defineNestedRoutes(
     defineRoute: DefineRouteFunction,
     parentId?: string
   ): void {
-    let childRoutes = Array.from(routeMap.values()).filter(
-      (routeInfo) => routeInfo.parentId === parentId
-    );
+    let childRoutes = routes.filter((routeInfo) => {
+      return routeInfo.parentId === parentId;
+    });
     let parentRoute = parentId ? routeMap.get(parentId) : undefined;
     let parentRoutePath = parentRoute?.path ?? "/";
     for (let childRoute of childRoutes) {
       let routePath = childRoute.path?.slice(parentRoutePath.length) ?? "";
       // remove leading slash
       routePath = routePath.replace(/^\//, "");
+
       let index = childRoute.index;
       let fullPath = childRoute.path;
 
       let uniqueRouteId = (fullPath || "") + (index ? "?index" : "");
+
       if (uniqueRouteId) {
         let conflict = uniqueRoutes.get(uniqueRouteId);
         if (conflict) {
           throw new Error(
-            `Path ${JSON.stringify(fullPath)} defined by route ${JSON.stringify(
-              childRoute.id
-            )} conflicts with route ${JSON.stringify(conflict)}`
+            `Path ${JSON.stringify(fullPath)} defined by route ` +
+              `${JSON.stringify(childRoute.id)} ` +
+              `conflicts with route ${JSON.stringify(conflict)}`
           );
         }
         uniqueRoutes.set(uniqueRouteId, childRoute.id);
       }
+
+      let childRouteOptions: DefineRouteOptions = {
+        id: path.posix.join(prefix, childRoute.id),
+        index: childRoute.index ? true : undefined,
+      };
+
       if (index) {
-        let invalidChildRoutes = Object.values(routeMap).filter(
+        let invalidChildRoutes = routes.filter(
           (routeInfo) => routeInfo.parentId === childRoute.id
         );
 
@@ -93,25 +108,20 @@ export function flatRoutesUniversal(
           );
         }
 
-        defineRoute(routePath, routeMap.get(childRoute.id!)!.file, {
-          index: true,
-        });
+        defineRoute(routePath, childRoute.file, childRouteOptions);
       } else {
-        defineRoute(routePath, routeMap.get(childRoute.id!)!.file, () => {
+        defineRoute(routePath, childRoute.file, childRouteOptions, () => {
           defineNestedRoutes(defineRoute, childRoute.id);
         });
       }
     }
   }
 
-  let routes = defineRoutes(defineNestedRoutes);
-
-  return routes;
+  return defineRoutes(defineNestedRoutes);
 }
 
 export function isIndexRoute(routeId: string) {
-  let isFlatFile = !routeId.includes(path.posix.sep);
-  return isFlatFile ? routeId.endsWith("_index") : /\/index$/.test(routeId);
+  return routeId.endsWith("_index");
 }
 
 type State =
@@ -126,12 +136,25 @@ type State =
 
 export function getRouteSegments(routeId: string) {
   let routeSegments: string[] = [];
+  let rawRouteSegments: string[] = [];
   let index = 0;
   let routeSegment = "";
   let rawRouteSegment = "";
   let state: State = "NORMAL";
-  let pushRouteSegment = (routeSegment: string) => {
-    if (!routeSegment) return;
+  let hasFolder = routeId.includes(path.posix.sep);
+
+  /**
+   * @see https://github.com/remix-run/remix/pull/5160#issuecomment-1402157424
+   */
+  if (hasFolder && (routeId.endsWith("/index") || routeId.endsWith("/route"))) {
+    let last = routeId.lastIndexOf(path.posix.sep);
+    if (last >= 0) {
+      routeId = routeId.substring(0, last);
+    }
+  }
+
+  let pushRouteSegment = (segment: string, rawSegment: string) => {
+    if (!segment) return;
 
     let notSupportedInRR = (segment: string, char: string) => {
       throw new Error(
@@ -140,18 +163,20 @@ export function getRouteSegments(routeId: string) {
       );
     };
 
-    if (rawRouteSegment.includes("*")) {
-      return notSupportedInRR(rawRouteSegment, "*");
+    if (rawSegment.includes("*")) {
+      return notSupportedInRR(rawSegment, "*");
     }
 
-    if (rawRouteSegment.includes(":")) {
-      return notSupportedInRR(rawRouteSegment, ":");
+    if (rawSegment.includes(":")) {
+      return notSupportedInRR(rawSegment, ":");
     }
 
-    if (rawRouteSegment.includes("/")) {
-      return notSupportedInRR(routeSegment, "/");
+    if (rawSegment.includes("/")) {
+      return notSupportedInRR(segment, "/");
     }
-    routeSegments.push(routeSegment);
+
+    routeSegments.push(segment);
+    rawRouteSegments.push(rawSegment);
   };
 
   while (index < routeId.length) {
@@ -161,7 +186,7 @@ export function getRouteSegments(routeId: string) {
     switch (state) {
       case "NORMAL": {
         if (isSegmentSeparator(char)) {
-          pushRouteSegment(routeSegment);
+          pushRouteSegment(routeSegment, rawRouteSegment);
           routeSegment = "";
           rawRouteSegment = "";
           state = "NORMAL";
@@ -169,10 +194,12 @@ export function getRouteSegments(routeId: string) {
         }
         if (char === escapeStart) {
           state = "ESCAPE";
+          rawRouteSegment += char;
           break;
         }
         if (char === optionalStart) {
           state = "OPTIONAL";
+          rawRouteSegment += char;
           break;
         }
         if (!routeSegment && char == paramPrefixChar) {
@@ -193,6 +220,7 @@ export function getRouteSegments(routeId: string) {
       case "ESCAPE": {
         if (char === escapeEnd) {
           state = "NORMAL";
+          rawRouteSegment += char;
           break;
         }
 
@@ -203,13 +231,14 @@ export function getRouteSegments(routeId: string) {
       case "OPTIONAL": {
         if (char === optionalEnd) {
           routeSegment += "?";
-          rawRouteSegment += "?";
+          rawRouteSegment += char;
           state = "NORMAL";
           break;
         }
 
         if (char === escapeStart) {
           state = "OPTIONAL_ESCAPE";
+          rawRouteSegment += char;
           break;
         }
 
@@ -231,6 +260,7 @@ export function getRouteSegments(routeId: string) {
       case "OPTIONAL_ESCAPE": {
         if (char === escapeEnd) {
           state = "OPTIONAL";
+          rawRouteSegment += char;
           break;
         }
 
@@ -242,8 +272,8 @@ export function getRouteSegments(routeId: string) {
   }
 
   // process remaining segment
-  pushRouteSegment(routeSegment);
-  return routeSegments;
+  pushRouteSegment(routeSegment, rawRouteSegment);
+  return [routeSegments, rawRouteSegments];
 }
 
 function findParentRouteId(
@@ -265,11 +295,13 @@ function getRouteInfo(
   filePath: string
 ): RouteInfo {
   let filePathWithoutApp = filePath.slice(appDirectory.length + 1);
-  let routeId = createRouteId(filePathWithoutApp);
+  let routeId = createFlatRouteId(filePathWithoutApp);
   let routeIdWithoutRoutes = routeId.slice(routeDirectory.length + 1);
   let index = isIndexRoute(routeIdWithoutRoutes);
-  let routeSegments = getRouteSegments(routeIdWithoutRoutes);
-  let routePath = createRoutePath(routeSegments, index);
+  let [routeSegments, rawRouteSegments] =
+    getRouteSegments(routeIdWithoutRoutes);
+
+  let routePath = createRoutePath(routeSegments, rawRouteSegments, index);
 
   return {
     id: routeIdWithoutRoutes,
@@ -281,21 +313,28 @@ function getRouteInfo(
   };
 }
 
-export function createRoutePath(routeSegments: string[], isIndex: boolean) {
+export function createRoutePath(
+  routeSegments: string[],
+  rawRouteSegments: string[],
+  isIndex: boolean
+) {
   let result = "";
 
   if (isIndex) {
     routeSegments = routeSegments.slice(0, -1);
   }
 
-  for (let segment of routeSegments) {
+  for (let index = 0; index < routeSegments.length; index++) {
+    let segment = routeSegments[index];
+    let rawSegment = rawRouteSegments[index];
+
     // skip pathless layout segments
-    if (segment.startsWith("_")) {
+    if (segment.startsWith("_") && rawSegment.startsWith("_")) {
       continue;
     }
 
     // remove trailing slash
-    if (segment.endsWith("_")) {
+    if (segment.endsWith("_") && rawSegment.endsWith("_")) {
       segment = segment.slice(0, -1);
     }
 
@@ -308,8 +347,8 @@ export function createRoutePath(routeSegments: string[], isIndex: boolean) {
 function getRouteMap(
   appDirectory: string,
   routePaths: string[],
-  prefix: string = "routes"
-) {
+  prefix: string
+): Readonly<Map<string, RouteInfo>> {
   let routeMap = new Map<string, RouteInfo>();
   let nameMap = new Map<string, RouteInfo>();
 
@@ -332,12 +371,20 @@ function getRouteMap(
   return routeMap;
 }
 
-function isRouteModuleFile(filepath: string) {
+function isRouteModuleFile(filePath: string) {
   // flat files only need correct extension
-  let isFlatFile = !filepath.includes(path.sep);
-  if (isFlatFile) {
-    return routeModuleExts.includes(path.extname(filepath));
-  }
+  let normalizedFilePath = normalizeSlashes(filePath);
+  let isFlatFile = !filePath.includes(path.posix.sep);
+  let hasExt = routeModuleExts.includes(path.extname(filePath));
+  if (isFlatFile) return hasExt;
+  let basename = normalizedFilePath.slice(0, -path.extname(filePath).length);
+  return basename.endsWith(`/route`) || basename.endsWith(`/index`);
+}
 
-  return isIndexRoute(createRouteId(filepath));
+function createFlatRouteId(filePath: string) {
+  let routeId = createRouteId(filePath);
+  if (routeId.includes(path.posix.sep) && routeId.endsWith("/index")) {
+    routeId = routeId.split(path.posix.sep).slice(0, -1).join(path.posix.sep);
+  }
+  return routeId;
 }
diff --git a/packages/remix-dev/modules.ts b/packages/remix-dev/modules.ts
index 574aec47a..3ce374f31 100644
--- a/packages/remix-dev/modules.ts
+++ b/packages/remix-dev/modules.ts
@@ -14,6 +14,10 @@ declare module "*.css" {
   let asset: string;
   export default asset;
 }
+declare module "*.csv" {
+  let asset: string;
+  export default asset;
+}
 declare module "*.eot" {
   let asset: string;
   export default asset;
@@ -112,6 +116,10 @@ declare module "*.png" {
   let asset: string;
   export default asset;
 }
+declare module "*.psd" {
+  let asset: string;
+  export default asset;
+}
 declare module "*.sql" {
   let asset: string;
   export default asset;
diff --git a/packages/remix-express/server.ts b/packages/remix-express/server.ts
index 4f6fe881f..009652fd3 100644
--- a/packages/remix-express/server.ts
+++ b/packages/remix-express/server.ts
@@ -93,8 +93,7 @@ export function createRemixRequest(
   req: express.Request,
   res: express.Response
 ): NodeRequest {
-  let origin = `${req.protocol}://${req.get("host")}`;
-  let url = new URL(req.url, origin);
+  let url = new URL(`${req.protocol}://${req.get("host")}${req.url}`);
 
   // Abort action/loaders once we can no longer write a response
   let controller = new NodeAbortController();
diff --git a/packages/remix-netlify/server.ts b/packages/remix-netlify/server.ts
index ccd601009..5932d3f04 100644
--- a/packages/remix-netlify/server.ts
+++ b/packages/remix-netlify/server.ts
@@ -63,7 +63,7 @@ export function createRemixRequest(event: HandlerEvent): NodeRequest {
   } else {
     let origin = event.headers.host;
     let rawPath = getRawPath(event);
-    url = new URL(rawPath, `http://${origin}`);
+    url = new URL(`http://${origin}${rawPath}`);
   }
 
   // Note: No current way to abort these for Netlify, but our router expects
diff --git a/packages/remix-node/sessions/fileStorage.ts b/packages/remix-node/sessions/fileStorage.ts
index bc03da253..9f6908010 100644
--- a/packages/remix-node/sessions/fileStorage.ts
+++ b/packages/remix-node/sessions/fileStorage.ts
@@ -4,6 +4,7 @@ import * as path from "path";
 import type {
   SessionStorage,
   SessionIdStorageStrategy,
+  SessionData,
 } from "@remix-run/server-runtime";
 
 import { createSessionStorage } from "../implementations";
@@ -29,10 +30,10 @@ interface FileSessionStorageOptions {
  *
  * @see https://remix.run/utils/sessions#createfilesessionstorage-node
  */
-export function createFileSessionStorage({
+export function createFileSessionStorage<Data = SessionData, FlashData = Data>({
   cookie,
   dir,
-}: FileSessionStorageOptions): SessionStorage {
+}: FileSessionStorageOptions): SessionStorage<Data, FlashData> {
   return createSessionStorage({
     cookie,
     async createData(data, expires) {
diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx
index 348891a42..2109abd41 100644
--- a/packages/remix-react/components.tsx
+++ b/packages/remix-react/components.tsx
@@ -1570,7 +1570,7 @@ export const LiveReload =
                 function remixLiveReloadConnect(config) {
                   let protocol = location.protocol === "https:" ? "wss:" : "ws:";
                   let host = location.hostname;
-                  let port = (window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || ${String(
+                  let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || ${String(
                     port
                   )};
                   let socketPath = protocol + "//" + host + ":" + port + "/socket";
diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx
index 78ee22b84..230c49a46 100644
--- a/packages/remix-react/routes.tsx
+++ b/packages/remix-react/routes.tsx
@@ -45,87 +45,109 @@ export interface EntryRoute extends Route {
   parentId?: string;
 }
 
+// Create a map of routes by parentId to use recursively instead of
+// repeatedly filtering the manifest.
+function groupRoutesByParentId(manifest: RouteManifest<EntryRoute>) {
+  let routes: Record<string, Omit<EntryRoute, "children">[]> = {};
+
+  Object.values(manifest).forEach((route) => {
+    let parentId = route.parentId || "";
+    if (!routes[parentId]) {
+      routes[parentId] = [];
+    }
+    routes[parentId].push(route);
+  });
+
+  return routes;
+}
+
 export function createServerRoutes(
   manifest: RouteManifest<EntryRoute>,
   routeModules: RouteModules,
   future: FutureConfig,
-  parentId?: string
+  parentId: string = "",
+  routesByParentId: Record<
+    string,
+    Omit<EntryRoute, "children">[]
+  > = groupRoutesByParentId(manifest)
 ): DataRouteObject[] {
-  return Object.values(manifest)
-    .filter((route) => route.parentId === parentId)
-    .map((route) => {
-      let hasErrorBoundary =
-        future.v2_errorBoundary === true
-          ? route.id === "root" || route.hasErrorBoundary
-          : route.id === "root" ||
-            route.hasCatchBoundary ||
-            route.hasErrorBoundary;
-      let dataRoute: DataRouteObject = {
-        caseSensitive: route.caseSensitive,
-        element: <RemixRoute id={route.id} />,
-        errorElement: hasErrorBoundary ? (
-          <RemixRouteError id={route.id} />
-        ) : undefined,
-        id: route.id,
-        index: route.index,
-        path: route.path,
-        handle: routeModules[route.id].handle,
-        // Note: we don't need loader/action/shouldRevalidate on these routes
-        // since they're for a static render
-      };
-
-      let children = createServerRoutes(
-        manifest,
-        routeModules,
-        future,
-        route.id
-      );
-      if (children.length > 0) dataRoute.children = children;
-      return dataRoute;
-    });
+  return (routesByParentId[parentId] || []).map((route) => {
+    let hasErrorBoundary =
+      future.v2_errorBoundary === true
+        ? route.id === "root" || route.hasErrorBoundary
+        : route.id === "root" ||
+          route.hasCatchBoundary ||
+          route.hasErrorBoundary;
+    let dataRoute: DataRouteObject = {
+      caseSensitive: route.caseSensitive,
+      element: <RemixRoute id={route.id} />,
+      errorElement: hasErrorBoundary ? (
+        <RemixRouteError id={route.id} />
+      ) : undefined,
+      id: route.id,
+      index: route.index,
+      path: route.path,
+      handle: routeModules[route.id].handle,
+      // Note: we don't need loader/action/shouldRevalidate on these routes
+      // since they're for a static render
+    };
+
+    let children = createServerRoutes(
+      manifest,
+      routeModules,
+      future,
+      route.id,
+      routesByParentId
+    );
+    if (children.length > 0) dataRoute.children = children;
+    return dataRoute;
+  });
 }
 
 export function createClientRoutes(
   manifest: RouteManifest<EntryRoute>,
   routeModulesCache: RouteModules,
   future: FutureConfig,
-  parentId?: string
+  parentId: string = "",
+  routesByParentId: Record<
+    string,
+    Omit<EntryRoute, "children">[]
+  > = groupRoutesByParentId(manifest)
 ): DataRouteObject[] {
-  return Object.values(manifest)
-    .filter((entryRoute) => entryRoute.parentId === parentId)
-    .map((route) => {
-      let hasErrorBoundary =
-        future.v2_errorBoundary === true
-          ? route.id === "root" || route.hasErrorBoundary
-          : route.id === "root" ||
-            route.hasCatchBoundary ||
-            route.hasErrorBoundary;
-
-      let dataRoute: DataRouteObject = {
-        caseSensitive: route.caseSensitive,
-        element: <RemixRoute id={route.id} />,
-        errorElement: hasErrorBoundary ? (
-          <RemixRouteError id={route.id} />
-        ) : undefined,
-        id: route.id,
-        index: route.index,
-        path: route.path,
-        // handle gets added in via useMatches since we aren't guaranteed to
-        // have the route module available here
-        handle: undefined,
-        loader: createDataFunction(route, routeModulesCache, false),
-        action: createDataFunction(route, routeModulesCache, true),
-        shouldRevalidate: createShouldRevalidate(route, routeModulesCache),
-      };
-      let children = createClientRoutes(
-        manifest,
-        routeModulesCache,
-        future,
-        route.id
-      );
-      if (children.length > 0) dataRoute.children = children;
-      return dataRoute;
-    });
+  return (routesByParentId[parentId] || []).map((route) => {
+    let hasErrorBoundary =
+      future.v2_errorBoundary === true
+        ? route.id === "root" || route.hasErrorBoundary
+        : route.id === "root" ||
+          route.hasCatchBoundary ||
+          route.hasErrorBoundary;
+
+    let dataRoute: DataRouteObject = {
+      caseSensitive: route.caseSensitive,
+      element: <RemixRoute id={route.id} />,
+      errorElement: hasErrorBoundary ? (
+        <RemixRouteError id={route.id} />
+      ) : undefined,
+      id: route.id,
+      index: route.index,
+      path: route.path,
+      // handle gets added in via useMatches since we aren't guaranteed to
+      // have the route module available here
+      handle: undefined,
+      loader: createDataFunction(route, routeModulesCache, false),
+      action: createDataFunction(route, routeModulesCache, true),
+      shouldRevalidate: createShouldRevalidate(route, routeModulesCache),
+    };
+    let children = createClientRoutes(
+      manifest,
+      routeModulesCache,
+      future,
+      route.id,
+      routesByParentId
+    );
+    if (children.length > 0) dataRoute.children = children;
+    return dataRoute;
+  });
 }
 
 function createShouldRevalidate(
diff --git a/packages/remix-server-runtime/index.ts b/packages/remix-server-runtime/index.ts
index 81bd17898..6e3d7cdb1 100644
--- a/packages/remix-server-runtime/index.ts
+++ b/packages/remix-server-runtime/index.ts
@@ -44,6 +44,7 @@ export type {
   DataFunctionArgs,
   EntryContext,
   ErrorBoundaryComponent,
+  FlashSessionData,
   HandleDataRequestFunction,
   HandleDocumentRequestFunction,
   HeadersFunction,
diff --git a/packages/remix-server-runtime/reexport.ts b/packages/remix-server-runtime/reexport.ts
index a8ac033a8..854514295 100644
--- a/packages/remix-server-runtime/reexport.ts
+++ b/packages/remix-server-runtime/reexport.ts
@@ -60,4 +60,5 @@ export type {
   SessionData,
   SessionIdStorageStrategy,
   SessionStorage,
+  FlashSessionData,
 } from "./sessions";
diff --git a/packages/remix-server-runtime/routes.ts b/packages/remix-server-runtime/routes.ts
index 6c63ae870..c90802e1c 100644
--- a/packages/remix-server-runtime/routes.ts
+++ b/packages/remix-server-runtime/routes.ts
@@ -4,6 +4,7 @@ import type {
   LoaderFunctionArgs,
 } from "@remix-run/router";
 
+import type { AppLoadContext } from "./data";
 import { callRouteActionRR, callRouteLoaderRR } from "./data";
 import type { FutureConfig } from "./entry";
 import type { ServerRouteModule } from "./routeModules";
@@ -39,16 +40,34 @@ export interface ServerRoute extends Route {
   module: ServerRouteModule;
 }
 
+function groupRoutesByParentId(manifest: ServerRouteManifest) {
+  let routes: Record<string, Omit<ServerRoute, "children">[]> = {};
+
+  Object.values(manifest).forEach((route) => {
+    let parentId = route.parentId || "";
+    if (!routes[parentId]) {
+      routes[parentId] = [];
+    }
+    routes[parentId].push(route);
+  });
+
+  return routes;
+}
+
+// Create a map of routes by parentId to use recursively instead of
+// repeatedly filtering the manifest.
 export function createRoutes(
   manifest: ServerRouteManifest,
-  parentId?: string
+  parentId: string = "",
+  routesByParentId: Record<
+    string,
+    Omit<ServerRoute, "children">[]
+  > = groupRoutesByParentId(manifest)
 ): ServerRoute[] {
-  return Object.entries(manifest)
-    .filter(([, route]) => route.parentId === parentId)
-    .map(([id, route]) => ({
-      ...route,
-      children: createRoutes(manifest, id),
-    }));
+  return (routesByParentId[parentId] || []).map((route) => ({
+    ...route,
+    children: createRoutes(manifest, route.id, routesByParentId),
+  }));
 }
 
 // Convert the Remix ServerManifest into DataRouteObject's for use with
@@ -56,54 +75,61 @@ export function createRoutes(
 export function createStaticHandlerDataRoutes(
   manifest: ServerRouteManifest,
   future: FutureConfig,
-  parentId?: string
+  parentId: string = "",
+  routesByParentId: Record<
+    string,
+    Omit<ServerRoute, "children">[]
+  > = groupRoutesByParentId(manifest)
 ): AgnosticDataRouteObject[] {
-  return Object.values(manifest)
-    .filter((route) => route.parentId === parentId)
-    .map((route) => {
-      let hasErrorBoundary =
-        future.v2_errorBoundary === true
-          ? route.id === "root" || route.module.ErrorBoundary != null
-          : route.id === "root" ||
-            route.module.CatchBoundary != null ||
-            route.module.ErrorBoundary != null;
-      let commonRoute = {
-        // Always include root due to default boundaries
-        hasErrorBoundary,
-        id: route.id,
-        path: route.path,
-        loader: route.module.loader
-          ? (args: LoaderFunctionArgs) =>
-              callRouteLoaderRR({
-                request: args.request,
-                params: args.params,
-                loadContext: args.context,
-                loader: route.module.loader!,
-                routeId: route.id,
-              })
-          : undefined,
-        action: route.module.action
-          ? (args: ActionFunctionArgs) =>
-              callRouteActionRR({
-                request: args.request,
-                params: args.params,
-                loadContext: args.context,
-                action: route.module.action!,
-                routeId: route.id,
-              })
-          : undefined,
-        handle: route.module.handle,
-      };
+  return (routesByParentId[parentId] || []).map((route) => {
+    let hasErrorBoundary =
+      future.v2_errorBoundary === true
+        ? route.id === "root" || route.module.ErrorBoundary != null
+        : route.id === "root" ||
+          route.module.CatchBoundary != null ||
+          route.module.ErrorBoundary != null;
+    let commonRoute = {
+      // Always include root due to default boundaries
+      hasErrorBoundary,
+      id: route.id,
+      path: route.path,
+      loader: route.module.loader
+        ? (args: LoaderFunctionArgs) =>
+            callRouteLoaderRR({
+              request: args.request,
+              params: args.params,
+              loadContext: args.context,
+              loader: route.module.loader!,
+              routeId: route.id,
+            })
+        : undefined,
+      action: route.module.action
+        ? (args: ActionFunctionArgs) =>
+            callRouteActionRR({
+              request: args.request,
+              params: args.params,
+              loadContext: args.context,
+              action: route.module.action!,
+              routeId: route.id,
+            })
+        : undefined,
+      handle: route.module.handle,
+    };
 
-      return route.index
-        ? {
-            index: true,
-            ...commonRoute,
-          }
-        : {
-            caseSensitive: route.caseSensitive,
-            children: createStaticHandlerDataRoutes(manifest, future, route.id),
-            ...commonRoute,
-          };
-    });
+    return route.index
+      ? {
+          index: true,
+          ...commonRoute,
+        }
+      : {
+          caseSensitive: route.caseSensitive,
+          children: createStaticHandlerDataRoutes(
+            manifest,
+            future,
+            route.id,
+            routesByParentId
+          ),
+          ...commonRoute,
+        };
+  });
 }
diff --git a/packages/remix-server-runtime/sessions.ts b/packages/remix-server-runtime/sessions.ts
index 6d3a05bec..341d3c379 100644
--- a/packages/remix-server-runtime/sessions.ts
+++ b/packages/remix-server-runtime/sessions.ts
@@ -16,7 +16,7 @@ export interface SessionData {
  *
  * @see https://remix.run/utils/sessions#session-api
  */
-export interface Session {
+export interface Session<Data = SessionData, FlashData = Data> {
   /**
    * A unique identifier for this session.
    *
@@ -31,44 +31,58 @@ export interface Session {
    * This is useful mostly for SessionStorage internally to access the raw
    * session data to persist.
    */
-  readonly data: SessionData;
+  readonly data: FlashSessionData<Data, FlashData>;
 
   /**
    * Returns `true` if the session has a value for the given `name`, `false`
    * otherwise.
    */
-  has(name: string): boolean;
+  has(name: (keyof Data | keyof FlashData) & string): boolean;
 
   /**
    * Returns the value for the given `name` in this session.
    */
-  get(name: string): any;
+  get<Key extends (keyof Data | keyof FlashData) & string>(
+    name: Key
+  ):
+    | (Key extends keyof Data ? Data[Key] : undefined)
+    | (Key extends keyof FlashData ? FlashData[Key] : undefined)
+    | undefined;
 
   /**
    * Sets a value in the session for the given `name`.
    */
-  set(name: string, value: any): void;
+  set<Key extends keyof Data & string>(name: Key, value: Data[Key]): void;
 
   /**
    * Sets a value in the session that is only valid until the next `get()`.
    * This can be useful for temporary values, like error messages.
    */
-  flash(name: string, value: any): void;
+  flash<Key extends keyof FlashData & string>(
+    name: Key,
+    value: FlashData[Key]
+  ): void;
 
   /**
    * Removes a value from the session.
    */
-  unset(name: string): void;
+  unset(name: keyof Data & string): void;
 }
 
-function flash(name: string): string {
+export type FlashSessionData<Data, FlashData> = Partial<
+  Data & {
+    [Key in keyof FlashData as FlashDataKey<Key & string>]: FlashData[Key];
+  }
+>;
+type FlashDataKey<Key extends string> = `__flash_${Key}__`;
+function flash<Key extends string>(name: Key): FlashDataKey<Key> {
   return `__flash_${name}__`;
 }
 
-export type CreateSessionFunction = (
-  initialData?: SessionData,
+export type CreateSessionFunction = <Data = SessionData, FlashData = Data>(
+  initialData?: Data,
   id?: string
-) => Session;
+) => Session<Data, FlashData>;
 
 /**
  * Creates a new Session object.
@@ -78,26 +92,35 @@ export type CreateSessionFunction = (
  *
  * @see https://remix.run/utils/sessions#createsession
  */
-export const createSession: CreateSessionFunction = (
-  initialData = {},
+export const createSession: CreateSessionFunction = <
+  Data = SessionData,
+  FlashData = Data
+>(
+  initialData: Partial<Data> = {},
   id = ""
-) => {
-  let map = new Map<string, any>(Object.entries(initialData));
+): Session<Data, FlashData> => {
+  let map = new Map(Object.entries(initialData)) as Map<
+    keyof Data | FlashDataKey<keyof FlashData & string>,
+    any
+  >;
 
   return {
     get id() {
       return id;
     },
     get data() {
-      return Object.fromEntries(map);
+      return Object.fromEntries(map) as FlashSessionData<Data, FlashData>;
     },
     has(name) {
-      return map.has(name) || map.has(flash(name));
+      return (
+        map.has(name as keyof Data) ||
+        map.has(flash(name as keyof FlashData & string))
+      );
     },
     get(name) {
-      if (map.has(name)) return map.get(name);
+      if (map.has(name as keyof Data)) return map.get(name as keyof Data);
 
-      let flashName = flash(name);
+      let flashName = flash(name as keyof FlashData & string);
       if (map.has(flashName)) {
         let value = map.get(flashName);
         map.delete(flashName);
@@ -145,7 +168,7 @@ export const isSession: IsSessionFunction = (object): object is Session => {
  * A SessionStorage creates Session objects using a `Cookie` header as input.
  * Then, later it generates the `Set-Cookie` header to be used in the response.
  */
-export interface SessionStorage {
+export interface SessionStorage<Data = SessionData, FlashData = Data> {
   /**
    * Parses a Cookie header from a HTTP request and returns the associated
    * Session. If there is no session associated with the cookie, this will
@@ -154,14 +177,14 @@ export interface SessionStorage {
   getSession(
     cookieHeader?: string | null,
     options?: CookieParseOptions
-  ): Promise<Session>;
+  ): Promise<Session<Data, FlashData>>;
 
   /**
    * Stores all data in the Session and returns the Set-Cookie header to be
    * used in the HTTP response.
    */
   commitSession(
-    session: Session,
+    session: Session<Data, FlashData>,
     options?: CookieSerializeOptions
   ): Promise<string>;
 
@@ -170,7 +193,7 @@ export interface SessionStorage {
    * header to be used in the HTTP response.
    */
   destroySession(
-    session: Session,
+    session: Session<Data, FlashData>,
     options?: CookieSerializeOptions
   ): Promise<string>;
 }
@@ -184,7 +207,10 @@ export interface SessionStorage {
  * database or on disk. A set of create, read, update, and delete operations
  * are provided for managing the session data.
  */
-export interface SessionIdStorageStrategy {
+export interface SessionIdStorageStrategy<
+  Data = SessionData,
+  FlashData = Data
+> {
   /**
    * The Cookie used to store the session id, or options used to automatically
    * create one.
@@ -194,17 +220,24 @@ export interface SessionIdStorageStrategy {
   /**
    * Creates a new record with the given data and returns the session id.
    */
-  createData: (data: SessionData, expires?: Date) => Promise<string>;
+  createData: (
+    data: FlashSessionData<Data, FlashData>,
+    expires?: Date
+  ) => Promise<string>;
 
   /**
    * Returns data for a given session id, or `null` if there isn't any.
    */
-  readData: (id: string) => Promise<SessionData | null>;
+  readData: (id: string) => Promise<FlashSessionData<Data, FlashData> | null>;
 
   /**
    * Updates data for the given session id.
    */
-  updateData: (id: string, data: SessionData, expires?: Date) => Promise<void>;
+  updateData: (
+    id: string,
+    data: FlashSessionData<Data, FlashData>,
+    expires?: Date
+  ) => Promise<void>;
 
   /**
    * Deletes data for a given session id from the data store.
@@ -212,9 +245,12 @@ export interface SessionIdStorageStrategy {
   deleteData: (id: string) => Promise<void>;
 }
 
-export type CreateSessionStorageFunction = (
-  strategy: SessionIdStorageStrategy
-) => SessionStorage;
+export type CreateSessionStorageFunction = <
+  Data = SessionData,
+  FlashData = Data
+>(
+  strategy: SessionIdStorageStrategy<Data, FlashData>
+) => SessionStorage<Data, FlashData>;
 
 /**
  * Creates a SessionStorage object using a SessionIdStorageStrategy.
diff --git a/packages/remix-server-runtime/sessions/cookieStorage.ts b/packages/remix-server-runtime/sessions/cookieStorage.ts
index 2c3223f82..9cf4766da 100644
--- a/packages/remix-server-runtime/sessions/cookieStorage.ts
+++ b/packages/remix-server-runtime/sessions/cookieStorage.ts
@@ -1,6 +1,10 @@
 import type { CreateCookieFunction } from "../cookies";
 import { isCookie } from "../cookies";
-import type { SessionStorage, SessionIdStorageStrategy } from "../sessions";
+import type {
+  SessionStorage,
+  SessionIdStorageStrategy,
+  SessionData,
+} from "../sessions";
 import { warnOnceAboutSigningSessionCookie, createSession } from "../sessions";
 
 interface CookieSessionStorageOptions {
@@ -11,9 +15,12 @@ interface CookieSessionStorageOptions {
   cookie?: SessionIdStorageStrategy["cookie"];
 }
 
-export type CreateCookieSessionStorageFunction = (
+export type CreateCookieSessionStorageFunction = <
+  Data = SessionData,
+  FlashData = Data
+>(
   options?: CookieSessionStorageOptions
-) => SessionStorage;
+) => SessionStorage<Data, FlashData>;
 
 /**
  * Creates and returns a SessionStorage object that stores all session data
diff --git a/packages/remix-server-runtime/sessions/memoryStorage.ts b/packages/remix-server-runtime/sessions/memoryStorage.ts
index f7ebcedf7..d8072528f 100644
--- a/packages/remix-server-runtime/sessions/memoryStorage.ts
+++ b/packages/remix-server-runtime/sessions/memoryStorage.ts
@@ -3,6 +3,7 @@ import type {
   SessionStorage,
   SessionIdStorageStrategy,
   CreateSessionStorageFunction,
+  FlashSessionData,
 } from "../sessions";
 
 interface MemorySessionStorageOptions {
@@ -13,9 +14,12 @@ interface MemorySessionStorageOptions {
   cookie?: SessionIdStorageStrategy["cookie"];
 }
 
-export type CreateMemorySessionStorageFunction = (
+export type CreateMemorySessionStorageFunction = <
+  Data = SessionData,
+  FlashData = Data
+>(
   options?: MemorySessionStorageOptions
-) => SessionStorage;
+) => SessionStorage<Data, FlashData>;
 
 /**
  * Creates and returns a simple in-memory SessionStorage object, mostly useful
@@ -30,9 +34,14 @@ export const createMemorySessionStorageFactory =
   (
     createSessionStorage: CreateSessionStorageFunction
   ): CreateMemorySessionStorageFunction =>
-  ({ cookie } = {}) => {
+  <Data = SessionData, FlashData = Data>({
+    cookie,
+  }: MemorySessionStorageOptions = {}): SessionStorage<Data, FlashData> => {
     let uniqueId = 0;
-    let map = new Map<string, { data: SessionData; expires?: Date }>();
+    let map = new Map<
+      string,
+      { data: FlashSessionData<Data, FlashData>; expires?: Date }
+    >();
 
     return createSessionStorage({
       cookie,
diff --git a/packages/remix-vercel/server.ts b/packages/remix-vercel/server.ts
index 2827b96f6..6707693cb 100644
--- a/packages/remix-vercel/server.ts
+++ b/packages/remix-vercel/server.ts
@@ -82,7 +82,7 @@ export function createRemixRequest(
   let host = req.headers["x-forwarded-host"] || req.headers["host"];
   // doesn't seem to be available on their req object!
   let protocol = req.headers["x-forwarded-proto"] || "https";
-  let url = new URL(req.url!, `${protocol}://${host}`);
+  let url = new URL(`${protocol}://${host}${req.url}`);
 
   // Abort action/loaders once we can no longer write a response
   let controller = new NodeAbortController();
diff --git a/scripts/release/comment.ts b/scripts/release/comment.ts
index 84aad5e78..5d6354486 100644
--- a/scripts/release/comment.ts
+++ b/scripts/release/comment.ts
@@ -5,6 +5,7 @@ import {
   PR_FILES_STARTS_WITH,
   IS_STABLE_RELEASE,
   AWAITING_RELEASE_LABEL,
+  DRY_RUN,
 } from "./constants";
 import {
   closeIssue,
@@ -41,19 +42,21 @@ async function commentOnIssuesAndPrsAboutRelease() {
   for (let pr of merged) {
     console.log(`commenting on pr ${getGitHubUrl("pull", pr.number)}`);
 
-    promises.push(
-      commentOnPullRequest({
-        owner: OWNER,
-        repo: REPO,
-        pr: pr.number,
-        version: VERSION,
-      })
-    );
+    if (!DRY_RUN) {
+      promises.push(
+        commentOnPullRequest({
+          owner: OWNER,
+          repo: REPO,
+          pr: pr.number,
+          version: VERSION,
+        })
+      );
+    }
 
     let prLabels = pr.labels.map((label) => label.name);
     let prIsAwaitingRelease = prLabels.includes(AWAITING_RELEASE_LABEL);
 
-    if (IS_STABLE_RELEASE && prIsAwaitingRelease) {
+    if (IS_STABLE_RELEASE && prIsAwaitingRelease && !DRY_RUN) {
       promises.push(
         removeLabel({ owner: OWNER, repo: REPO, issue: pr.number })
       );
@@ -75,20 +78,24 @@ async function commentOnIssuesAndPrsAboutRelease() {
       let issueUrl = getGitHubUrl("issue", issue.number);
       console.log(`commenting on issue ${issueUrl}`);
 
-      promises.push(
-        commentOnIssue({
-          owner: OWNER,
-          repo: REPO,
-          issue: issue.number,
-          version: VERSION,
-        })
-      );
+      if (!DRY_RUN) {
+        promises.push(
+          commentOnIssue({
+            owner: OWNER,
+            repo: REPO,
+            issue: issue.number,
+            version: VERSION,
+          })
+        );
+      }
 
       if (IS_STABLE_RELEASE) {
         console.log(`closing issue ${issueUrl}`);
-        promises.push(
-          closeIssue({ owner: OWNER, repo: REPO, issue: issue.number })
-        );
+        if (!DRY_RUN) {
+          promises.push(
+            closeIssue({ owner: OWNER, repo: REPO, issue: issue.number })
+          );
+        }
       }
     }
   }
diff --git a/scripts/release/constants.ts b/scripts/release/constants.ts
index 68a58fca1..8039c782e 100644
--- a/scripts/release/constants.ts
+++ b/scripts/release/constants.ts
@@ -3,8 +3,8 @@ import { cleanupRef, cleanupTagName, isNightly, isStable } from "./utils";
 if (!process.env.DEFAULT_BRANCH) {
   throw new Error("DEFAULT_BRANCH is required");
 }
-if (!process.env.NIGHTLY_BRANCH) {
-  throw new Error("NIGHTLY_BRANCH is required");
+if (!process.env.RELEASE_BRANCH) {
+  throw new Error("RELEASE_BRANCH is required");
 }
 if (!process.env.GITHUB_TOKEN) {
   throw new Error("GITHUB_TOKEN is required");
@@ -28,8 +28,9 @@ export const VERSION = cleanupTagName(cleanupRef(process.env.VERSION));
 export const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
 export const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY;
 export const DEFAULT_BRANCH = process.env.DEFAULT_BRANCH;
-export const NIGHTLY_BRANCH = process.env.NIGHTLY_BRANCH;
+export const RELEASE_BRANCH = process.env.RELEASE_BRANCH;
 export const PR_FILES_STARTS_WITH = ["packages/"];
 export const IS_NIGHTLY_RELEASE = isNightly(VERSION);
 export const AWAITING_RELEASE_LABEL = "awaiting release";
 export const IS_STABLE_RELEASE = isStable(VERSION);
+export const DRY_RUN = process.env.DRY_RUN === "true";
diff --git a/scripts/release/find-release-from-changeset.js b/scripts/release/find-release-from-changeset.js
index 3a4e417bf..a499ec59e 100644
--- a/scripts/release/find-release-from-changeset.js
+++ b/scripts/release/find-release-from-changeset.js
@@ -32,6 +32,6 @@ function findReleaseFromChangeset(publishedPackages, packageVersionToFollow) {
 }
 
 findReleaseFromChangeset(
-  process.env.publishedPackages,
-  process.env.packageVersionToFollow
+  process.env.PUBLISHED_PACKAGES,
+  process.env.PACKAGE_VERSION_TO_FOLLOW
 );
diff --git a/scripts/release/github.ts b/scripts/release/github.ts
index 8621975d9..c51ed223d 100644
--- a/scripts/release/github.ts
+++ b/scripts/release/github.ts
@@ -3,7 +3,7 @@ import * as semver from "semver";
 
 import {
   PR_FILES_STARTS_WITH,
-  NIGHTLY_BRANCH,
+  RELEASE_BRANCH,
   DEFAULT_BRANCH,
   PACKAGE_VERSION_TO_FOLLOW,
   AWAITING_RELEASE_LABEL,
@@ -54,15 +54,15 @@ export async function prsMergedSinceLastTag({
   let prs: Awaited<ReturnType<typeof getMergedPRsBetweenTags>> = [];
 
   // if both the current and previous tags are prereleases
-  // we can just get the PRs for the "dev" branch
-  // but if one of them is stable, we should wind up all of them from both the main and dev branches
+  // we can just get the PRs for the `release` branch
+  // but if one of them is stable, we should wind up all of them from both the main and `release` branches
   if (currentTag.isPrerelease && previousTag.isPrerelease) {
     prs = await getMergedPRsBetweenTags(
       owner,
       repo,
       previousTag,
       currentTag,
-      NIGHTLY_BRANCH
+      RELEASE_BRANCH
     );
   } else {
     let [nightly, stable] = await Promise.all([
@@ -71,7 +71,7 @@ export async function prsMergedSinceLastTag({
         repo,
         previousTag,
         currentTag,
-        NIGHTLY_BRANCH
+        RELEASE_BRANCH
       ),
       getMergedPRsBetweenTags(
         owner,
diff --git a/templates/arc/remix.config.js b/templates/arc/remix.config.js
index 48474bad3..a42ab1855 100644
--- a/templates/arc/remix.config.js
+++ b/templates/arc/remix.config.js
@@ -1,10 +1,9 @@
 /** @type {import('@remix-run/dev').AppConfig} */
 module.exports = {
-  serverBuildTarget: "arc",
-  server: "./server.js",
   ignoredRouteFiles: ["**/.*"],
+  publicPath: "/_static/build/",
+  server: "./server.js",
+  serverBuildPath: "server/index.js",
   // appDirectory: "app",
   // assetsBuildDirectory: "public/build",
-  // serverBuildPath: "server/index.js",
-  // publicPath: "/_static/build/",
 };
diff --git a/templates/cloudflare-pages/remix.config.js b/templates/cloudflare-pages/remix.config.js
index 7d8260a36..1a0ec28bf 100644
--- a/templates/cloudflare-pages/remix.config.js
+++ b/templates/cloudflare-pages/remix.config.js
@@ -1,11 +1,16 @@
 /** @type {import('@remix-run/dev').AppConfig} */
 module.exports = {
-  serverBuildTarget: "cloudflare-pages",
-  server: "./server.js",
   devServerBroadcastDelay: 1000,
   ignoredRouteFiles: ["**/.*"],
+  server: "./server.js",
+  serverBuildPath: "functions/[[path]].js",
+  serverConditions: ["worker"],
+  serverDependenciesToBundle: "all",
+  serverMainFields: ["browser", "module", "main"],
+  serverMinify: true,
+  serverModuleFormat: "esm",
+  serverPlatform: "neutral",
   // appDirectory: "app",
   // assetsBuildDirectory: "public/build",
-  // serverBuildPath: "functions/[[path]].js",
   // publicPath: "/build/",
 };
diff --git a/templates/cloudflare-workers/remix.config.js b/templates/cloudflare-workers/remix.config.js
index a4fac8985..ab7a9e7da 100644
--- a/templates/cloudflare-workers/remix.config.js
+++ b/templates/cloudflare-workers/remix.config.js
@@ -1,9 +1,14 @@
 /** @type {import('@remix-run/dev').AppConfig} */
 module.exports = {
-  serverBuildTarget: "cloudflare-workers",
-  server: "./server.js",
   devServerBroadcastDelay: 1000,
   ignoredRouteFiles: ["**/.*"],
+  server: "./server.js",
+  serverConditions: ["worker"],
+  serverDependenciesToBundle: "all",
+  serverMainFields: ["browser", "module", "main"],
+  serverMinify: true,
+  serverModuleFormat: "esm",
+  serverPlatform: "neutral",
   // appDirectory: "app",
   // assetsBuildDirectory: "public/build",
   // serverBuildPath: "build/index.js",
diff --git a/templates/deno/remix.config.js b/templates/deno/remix.config.js
index d621422e5..4ad663f5b 100644
--- a/templates/deno/remix.config.js
+++ b/templates/deno/remix.config.js
@@ -1,7 +1,5 @@
 /** @type {import('@remix-run/dev').AppConfig} */
 module.exports = {
-  serverBuildTarget: "deno",
-  server: "./server.ts",
   /*
   If live reload causes page to re-render without changes (live reload is too fast),
   increase the dev server broadcast delay.
@@ -10,6 +8,12 @@ module.exports = {
   */
   devServerBroadcastDelay: 300,
   ignoredRouteFiles: ["**/.*"],
+  server: "./server.ts",
+  serverConditions: ["deno", "worker"],
+  serverDependenciesToBundle: "all",
+  serverMainFields: ["module", "main"],
+  serverModuleFormat: "esm",
+  serverPlatform: "neutral",
   // appDirectory: "app",
   // assetsBuildDirectory: "public/build",
   // serverBuildPath: "build/index.js",
diff --git a/templates/netlify/remix.config.js b/templates/netlify/remix.config.js
index 55112f224..167e139f3 100644
--- a/templates/netlify/remix.config.js
+++ b/templates/netlify/remix.config.js
@@ -1,13 +1,12 @@
 /** @type {import('@remix-run/dev').AppConfig} */
 module.exports = {
-  serverBuildTarget: "netlify",
+  ignoredRouteFiles: ["**/.*"],
   server:
     process.env.NETLIFY || process.env.NETLIFY_LOCAL
       ? "./server.js"
       : undefined,
-  ignoredRouteFiles: ["**/.*"],
+  serverBuildPath: ".netlify/functions-internal/server.js",
   // appDirectory: "app",
   // assetsBuildDirectory: "public/build",
-  // serverBuildPath: ".netlify/functions-internal/server.js",
   // publicPath: "/build/",
 };
diff --git a/templates/vercel/remix.config.js b/templates/vercel/remix.config.js
index 3a84dd6cc..3add2266a 100644
--- a/templates/vercel/remix.config.js
+++ b/templates/vercel/remix.config.js
@@ -1,13 +1,12 @@
 /** @type {import('@remix-run/dev').AppConfig} */
 module.exports = {
-  serverBuildTarget: "vercel",
-  // When running locally in development mode, we use the built in remix
+  ignoredRouteFiles: ["**/.*"],
+  // When running locally in development mode, we use the built-in remix
   // server. This does not understand the vercel lambda module format,
   // so we default back to the standard build output.
   server: process.env.NODE_ENV === "development" ? undefined : "./server.js",
-  ignoredRouteFiles: ["**/.*"],
+  serverBuildPath: "api/index.js",
   // appDirectory: "app",
   // assetsBuildDirectory: "public/build",
-  // serverBuildPath: "api/index.js",
   // publicPath: "/build/",
 };


Test patch:
diff --git a/packages/remix-architect/__tests__/server-test.ts b/packages/remix-architect/__tests__/server-test.ts
index 969275754..2d02c5136 100644
--- a/packages/remix-architect/__tests__/server-test.ts
+++ b/packages/remix-architect/__tests__/server-test.ts
@@ -101,6 +101,38 @@ describe("architect createRequestHandler", () => {
         });
     });
 
+    it("handles root // requests", async () => {
+      mockedCreateRequestHandler.mockImplementation(() => async (req) => {
+        return new Response(`URL: ${new URL(req.url).pathname}`);
+      });
+
+      // We don't have a real app to test, but it doesn't matter. We won't ever
+      // call through to the real createRequestHandler
+      // @ts-expect-error
+      await lambdaTester(createRequestHandler({ build: undefined }))
+        .event(createMockEvent({ rawPath: "//" }))
+        .expectResolve((res) => {
+          expect(res.statusCode).toBe(200);
+          expect(res.body).toBe("URL: //");
+        });
+    });
+
+    it("handles nested // requests", async () => {
+      mockedCreateRequestHandler.mockImplementation(() => async (req) => {
+        return new Response(`URL: ${new URL(req.url).pathname}`);
+      });
+
+      // We don't have a real app to test, but it doesn't matter. We won't ever
+      // call through to the real createRequestHandler
+      // @ts-expect-error
+      await lambdaTester(createRequestHandler({ build: undefined }))
+        .event(createMockEvent({ rawPath: "//foo//bar" }))
+        .expectResolve((res) => {
+          expect(res.statusCode).toBe(200);
+          expect(res.body).toBe("URL: //foo//bar");
+        });
+    });
+
     it("handles null body", async () => {
       mockedCreateRequestHandler.mockImplementation(() => async () => {
         return new Response(null, { status: 200 });
diff --git a/packages/remix-dev/__tests__/flat-routes-test.ts b/packages/remix-dev/__tests__/flat-routes-test.ts
index 5446afec4..5c55ab790 100644
--- a/packages/remix-dev/__tests__/flat-routes-test.ts
+++ b/packages/remix-dev/__tests__/flat-routes-test.ts
@@ -88,9 +88,11 @@ describe("flatRoutes", () => {
 
     for (let [input, expected] of tests) {
       it(`"${input}" -> "${expected}"`, () => {
-        let routeSegments = getRouteSegments(input);
         let isIndex = isIndexRoute(input);
-        expect(createRoutePath(routeSegments, isIndex)).toBe(expected);
+        let [routeSegments, rawRouteSegments] = getRouteSegments(input);
+        expect(createRoutePath(routeSegments, rawRouteSegments, isIndex)).toBe(
+          expected
+        );
       });
     }
 
@@ -182,7 +184,7 @@ describe("flatRoutes", () => {
         },
       ],
       [
-        "routes/_landing.tsx",
+        "routes/_landing/index.tsx",
         {
           id: "routes/_landing",
           parentId: "root",
@@ -190,12 +192,12 @@ describe("flatRoutes", () => {
         },
       ],
       [
-        "routes/_landing._index.tsx",
+        "routes/_landing._index/index.tsx",
         {
           id: "routes/_landing._index",
-          index: true,
           parentId: "routes/_landing",
           path: undefined,
+          index: true,
         },
       ],
       [
@@ -207,43 +209,26 @@ describe("flatRoutes", () => {
         },
       ],
       [
-        "routes/about.tsx",
+        "routes/_about.tsx",
         {
-          id: "routes/about",
+          id: "routes/_about",
           parentId: "root",
-          path: "about",
-        },
-      ],
-      [
-        "routes/about._index.tsx",
-        {
-          id: "routes/about._index",
-          index: true,
-          parentId: "routes/about",
           path: undefined,
         },
       ],
       [
-        "routes/about.$.tsx",
+        "routes/_about.faq.tsx",
         {
-          id: "routes/about.$",
-          parentId: "routes/about",
-          path: "*",
-        },
-      ],
-      [
-        "routes/about.faq.tsx",
-        {
-          id: "routes/about.faq",
-          parentId: "routes/about",
+          id: "routes/_about.faq",
+          parentId: "routes/_about",
           path: "faq",
         },
       ],
       [
-        "routes/about.$splat.tsx",
+        "routes/_about.$splat.tsx",
         {
-          id: "routes/about.$splat",
-          parentId: "routes/about",
+          id: "routes/_about.$splat",
+          parentId: "routes/_about",
           path: ":splat",
         },
       ],
@@ -288,6 +273,22 @@ describe("flatRoutes", () => {
           path: ":id",
         },
       ],
+      [
+        "routes/folder/route.tsx",
+        {
+          id: "routes/folder/route",
+          parentId: "root",
+          path: "folder",
+        },
+      ],
+      [
+        "routes/[route].tsx",
+        {
+          id: "routes/[route]",
+          parentId: "root",
+          path: "route",
+        },
+      ],
 
       // Opt out of parent layout
       [
@@ -326,17 +327,9 @@ describe("flatRoutes", () => {
       ],
 
       [
-        "routes/app.skipall.tsx",
-        {
-          id: "routes/app.skipall",
-          parentId: "routes/app",
-          path: "skipall",
-        },
-      ],
-      [
-        "routes/app_.skipall_/index.tsx",
+        "routes/app_.skipall_._index.tsx",
         {
-          id: "routes/app_.skipall_/index",
+          id: "routes/app_.skipall_._index",
           index: true,
           parentId: "root",
           path: "app/skipall",
@@ -345,34 +338,34 @@ describe("flatRoutes", () => {
 
       // Escaping route segments
       [
-        "routes/about.[$splat].tsx",
+        "routes/_about.[$splat].tsx",
         {
-          id: "routes/about.[$splat]",
-          parentId: "routes/about",
+          id: "routes/_about.[$splat]",
+          parentId: "routes/_about",
           path: "$splat",
         },
       ],
       [
-        "routes/about.[[].tsx",
+        "routes/_about.[[].tsx",
         {
-          id: "routes/about.[[]",
-          parentId: "routes/about",
+          id: "routes/_about.[[]",
+          parentId: "routes/_about",
           path: "[",
         },
       ],
       [
-        "routes/about.[]].tsx",
+        "routes/_about.[]].tsx",
         {
-          id: "routes/about.[]]",
-          parentId: "routes/about",
+          id: "routes/_about.[]]",
+          parentId: "routes/_about",
           path: "]",
         },
       ],
       [
-        "routes/about.[.].tsx",
+        "routes/_about.[.].tsx",
         {
-          id: "routes/about.[.]",
-          parentId: "routes/about",
+          id: "routes/_about.[.]",
+          parentId: "routes/_about",
           path: ".",
         },
       ],
@@ -477,18 +470,18 @@ describe("flatRoutes", () => {
 
       // Optional + escaped route segments
       [
-        "routes/([index]).tsx",
+        "routes/([_index]).tsx",
         {
-          id: "routes/([index])",
+          id: "routes/([_index])",
           parentId: "root",
-          path: "index?",
+          path: "_index?",
         },
       ],
       [
-        "routes/([i]ndex).([[]).([[]]).tsx",
+        "routes/(_[i]ndex).([[]).([[]]).tsx",
         {
-          id: "routes/([i]ndex).([[]).([[]])",
-          parentId: "routes/([index])",
+          id: "routes/(_[i]ndex).([[]).([[]])",
+          parentId: "routes/([_index])",
           path: "[?/[]?",
         },
       ],
@@ -584,9 +577,16 @@ describe("flatRoutes", () => {
       [
         "routes/brand/index.tsx",
         {
-          id: "routes/brand/index",
+          id: "routes/brand",
           parentId: "root",
           path: "brand",
+        },
+      ],
+      [
+        "routes/brand._index.tsx",
+        {
+          id: "routes/brand._index",
+          parentId: "routes/brand",
           index: true,
         },
       ],
@@ -600,12 +600,10 @@ describe("flatRoutes", () => {
       ],
     ];
 
-    let files: [string, Omit<ConfigRoute, "file">][] = testFiles.map(
-      ([file, route]) => {
-        let filepath = file.split("/").join(path.sep);
-        return [filepath, { ...route, file: filepath }];
-      }
-    );
+    let files: [string, ConfigRoute][] = testFiles.map(([file, route]) => {
+      let filepath = file.split("/").join(path.sep);
+      return [filepath, { ...route, file: filepath }];
+    });
 
     let routeManifest = flatRoutesUniversal(
       APP_DIR,
diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts
index 4e48ced91..7ca6e2616 100644
--- a/packages/remix-dev/__tests__/readConfig-test.ts
+++ b/packages/remix-dev/__tests__/readConfig-test.ts
@@ -67,8 +67,14 @@ describe("readConfig", () => {
         "serverBuildPath": Any<String>,
         "serverBuildTarget": undefined,
         "serverBuildTargetEntryModule": "export * from \\"@remix-run/dev/server-build\\";",
+        "serverConditions": undefined,
         "serverDependenciesToBundle": Array [],
         "serverEntryPoint": undefined,
+        "serverMainFields": Array [
+          "main",
+          "module",
+        ],
+        "serverMinify": false,
         "serverMode": "production",
         "serverModuleFormat": "cjs",
         "serverPlatform": "node",
diff --git a/packages/remix-express/__tests__/server-test.ts b/packages/remix-express/__tests__/server-test.ts
index dbf5cf9d5..1e50480ce 100644
--- a/packages/remix-express/__tests__/server-test.ts
+++ b/packages/remix-express/__tests__/server-test.ts
@@ -64,6 +64,30 @@ describe("express createRequestHandler", () => {
       expect(res.headers["x-powered-by"]).toBe("Express");
     });
 
+    it("handles root // URLs", async () => {
+      mockedCreateRequestHandler.mockImplementation(() => async (req) => {
+        return new Response("URL: " + new URL(req.url).pathname);
+      });
+
+      let request = supertest(createApp());
+      let res = await request.get("//");
+
+      expect(res.status).toBe(200);
+      expect(res.text).toBe("URL: //");
+    });
+
+    it("handles nested // URLs", async () => {
+      mockedCreateRequestHandler.mockImplementation(() => async (req) => {
+        return new Response("URL: " + new URL(req.url).pathname);
+      });
+
+      let request = supertest(createApp());
+      let res = await request.get("//foo//bar");
+
+      expect(res.status).toBe(200);
+      expect(res.text).toBe("URL: //foo//bar");
+    });
+
     it("handles null body", async () => {
       mockedCreateRequestHandler.mockImplementation(() => async () => {
         return new Response(null, { status: 200 });
diff --git a/packages/remix-netlify/__tests__/server-test.ts b/packages/remix-netlify/__tests__/server-test.ts
index a5f0998d0..794c088a8 100644
--- a/packages/remix-netlify/__tests__/server-test.ts
+++ b/packages/remix-netlify/__tests__/server-test.ts
@@ -38,7 +38,9 @@ function createMockEvent(event: Partial<HandlerEvent> = {}): HandlerEvent {
     rawQuery: "",
     path: "/",
     httpMethod: "GET",
-    headers: {},
+    headers: {
+      host: "localhost:3000",
+    },
     multiValueHeaders: {},
     queryStringParameters: null,
     multiValueQueryStringParameters: null,
@@ -74,6 +76,80 @@ describe("netlify createRequestHandler", () => {
         });
     });
 
+    it("handles root // requests", async () => {
+      mockedCreateRequestHandler.mockImplementation(() => async (req) => {
+        return new Response(`URL: ${new URL(req.url).pathname}`);
+      });
+
+      // We don't have a real app to test, but it doesn't matter. We won't ever
+      // call through to the real createRequestHandler
+      // @ts-expect-error
+      await lambdaTester(createRequestHandler({ build: undefined }))
+        .event(createMockEvent({ rawUrl: "http://localhost:3000//" }))
+        .expectResolve((res) => {
+          expect(res.statusCode).toBe(200);
+          expect(res.body).toBe("URL: //");
+        });
+    });
+
+    it("handles nested // requests", async () => {
+      mockedCreateRequestHandler.mockImplementation(() => async (req) => {
+        return new Response(`URL: ${new URL(req.url).pathname}`);
+      });
+
+      // We don't have a real app to test, but it doesn't matter. We won't ever
+      // call through to the real createRequestHandler
+      // @ts-expect-error
+      await lambdaTester(createRequestHandler({ build: undefined }))
+        .event(createMockEvent({ rawUrl: "http://localhost:3000//foo//bar" }))
+        .expectResolve((res) => {
+          expect(res.statusCode).toBe(200);
+          expect(res.body).toBe("URL: //foo//bar");
+        });
+    });
+
+    it("handles root // requests (development)", async () => {
+      let oldEnv = process.env.NODE_ENV;
+      process.env.NODE_ENV = "development";
+
+      mockedCreateRequestHandler.mockImplementation(() => async (req) => {
+        return new Response(`URL: ${new URL(req.url).pathname}`);
+      });
+
+      // We don't have a real app to test, but it doesn't matter. We won't ever
+      // call through to the real createRequestHandler
+      // @ts-expect-error
+      await lambdaTester(createRequestHandler({ build: undefined }))
+        .event(createMockEvent({ path: "//" }))
+        .expectResolve((res) => {
+          expect(res.statusCode).toBe(200);
+          expect(res.body).toBe("URL: //");
+        });
+
+      process.env.NODE_ENV = oldEnv;
+    });
+
+    it("handles nested // requests (development)", async () => {
+      let oldEnv = process.env.NODE_ENV;
+      process.env.NODE_ENV = "development";
+
+      mockedCreateRequestHandler.mockImplementation(() => async (req) => {
+        return new Response(`URL: ${new URL(req.url).pathname}`);
+      });
+
+      // We don't have a real app to test, but it doesn't matter. We won't ever
+      // call through to the real createRequestHandler
+      // @ts-expect-error
+      await lambdaTester(createRequestHandler({ build: undefined }))
+        .event(createMockEvent({ path: "//foo//bar" }))
+        .expectResolve((res) => {
+          expect(res.statusCode).toBe(200);
+          expect(res.body).toBe("URL: //foo//bar");
+        });
+
+      process.env.NODE_ENV = oldEnv;
+    });
+
     it("handles null body", async () => {
       mockedCreateRequestHandler.mockImplementation(() => async () => {
         return new Response(null, { status: 200 });
diff --git a/packages/remix-react/__tests__/components-test.tsx b/packages/remix-react/__tests__/components-test.tsx
index f9dcb3309..d9ef31e43 100644
--- a/packages/remix-react/__tests__/components-test.tsx
+++ b/packages/remix-react/__tests__/components-test.tsx
@@ -47,14 +47,14 @@ describe("<LiveReload />", () => {
       LiveReload = require("../components").LiveReload;
       let { container } = render(<LiveReload />);
       expect(container.querySelector("script")).toHaveTextContent(
-        "let port = (window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 8002;"
+        "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 8002;"
       );
     });
 
     it("can set the port explicitly", () => {
       let { container } = render(<LiveReload port={4321} />);
       expect(container.querySelector("script")).toHaveTextContent(
-        "let port = (window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 4321;"
+        "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 4321;"
       );
     });
 
@@ -62,7 +62,7 @@ describe("<LiveReload />", () => {
       process.env.REMIX_DEV_SERVER_WS_PORT = "1234";
       let { container } = render(<LiveReload />);
       expect(container.querySelector("script")).toHaveTextContent(
-        "let port = (window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 1234;"
+        "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 1234;"
       );
     });
 
diff --git a/packages/remix-vercel/__tests__/server-test.ts b/packages/remix-vercel/__tests__/server-test.ts
index 33697171f..59f248e90 100644
--- a/packages/remix-vercel/__tests__/server-test.ts
+++ b/packages/remix-vercel/__tests__/server-test.ts
@@ -68,6 +68,34 @@ describe("vercel createRequestHandler", () => {
       expect(res.text).toBe("URL: /foo/bar");
     });
 
+    it("handles root // requests", async () => {
+      mockedCreateRequestHandler.mockImplementation(() => async (req) => {
+        return new Response(`URL: ${new URL(req.url).pathname}`);
+      });
+
+      let request = supertest(createApp());
+      // note: vercel's createServerWithHelpers requires a x-now-bridge-request-id
+      let res = await request.get("//").set({ "x-now-bridge-request-id": "2" });
+
+      expect(res.status).toBe(200);
+      expect(res.text).toBe("URL: //");
+    });
+
+    it("handles nested // requests", async () => {
+      mockedCreateRequestHandler.mockImplementation(() => async (req) => {
+        return new Response(`URL: ${new URL(req.url).pathname}`);
+      });
+
+      let request = supertest(createApp());
+      // note: vercel's createServerWithHelpers requires a x-now-bridge-request-id
+      let res = await request
+        .get("//foo//bar")
+        .set({ "x-now-bridge-request-id": "2" });
+
+      expect(res.status).toBe(200);
+      expect(res.text).toBe("URL: //foo//bar");
+    });
+
     it("handles null body", async () => {
       mockedCreateRequestHandler.mockImplementation(() => async () => {
         return new Response(null, { status: 200 });