diff --git a/packages/vite/src/node/build.ts b/packages/vite/src/node/build.ts
index 65563735927d0f..fe147c1cd1b48d 100644
--- a/packages/vite/src/node/build.ts
+++ b/packages/vite/src/node/build.ts
@@ -27,7 +27,13 @@ import { isDepsOptimizerEnabled, resolveConfig } from './config'
 import { buildReporterPlugin } from './plugins/reporter'
 import { buildEsbuildPlugin } from './plugins/esbuild'
 import { terserPlugin } from './plugins/terser'
-import { copyDir, emptyDir, lookupFile, normalizePath } from './utils'
+import {
+  copyDir,
+  emptyDir,
+  joinUrlSegments,
+  lookupFile,
+  normalizePath
+} from './utils'
 import { manifestPlugin } from './plugins/manifest'
 import type { Logger } from './logger'
 import { dataURIPlugin } from './plugins/dataUri'
@@ -1071,7 +1077,7 @@ export function toOutputFilePathInJS(
   if (relative && !config.build.ssr) {
     return toRelative(filename, hostId)
   }
-  return config.base + filename
+  return joinUrlSegments(config.base, filename)
 }
 
 export function createToImportMetaURLBasedRelativeRuntime(
diff --git a/packages/vite/src/node/plugins/asset.ts b/packages/vite/src/node/plugins/asset.ts
index a7a8f2554bf0a1..8a74736715bd9c 100644
--- a/packages/vite/src/node/plugins/asset.ts
+++ b/packages/vite/src/node/plugins/asset.ts
@@ -19,7 +19,7 @@ import {
 } from '../build'
 import type { Plugin } from '../plugin'
 import type { ResolvedConfig } from '../config'
-import { cleanUrl, getHash, normalizePath } from '../utils'
+import { cleanUrl, getHash, joinUrlSegments, normalizePath } from '../utils'
 import { FS_PREFIX } from '../constants'
 
 export const assetUrlRE = /__VITE_ASSET__([a-z\d]{8})__(?:\$_(.*?)__)?/g
@@ -249,9 +249,8 @@ function fileToDevUrl(id: string, config: ResolvedConfig) {
     // (this is special handled by the serve static middleware
     rtn = path.posix.join(FS_PREFIX + id)
   }
-  const origin = config.server?.origin ?? ''
-  const devBase = config.base
-  return origin + devBase + rtn.replace(/^\//, '')
+  const base = joinUrlSegments(config.server?.origin ?? '', config.base)
+  return joinUrlSegments(base, rtn.replace(/^\//, ''))
 }
 
 export function getAssetFilename(
@@ -396,7 +395,7 @@ export function publicFileToBuiltUrl(
 ): string {
   if (config.command !== 'build') {
     // We don't need relative base or renderBuiltUrl support during dev
-    return config.base + url.slice(1)
+    return joinUrlSegments(config.base, url)
   }
   const hash = getHash(url)
   let cache = publicAssetUrlCache.get(config)
diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts
index 09b70bbb0391aa..ad46e5ba738f37 100644
--- a/packages/vite/src/node/plugins/css.ts
+++ b/packages/vite/src/node/plugins/css.ts
@@ -39,6 +39,7 @@ import {
   isDataUrl,
   isExternalUrl,
   isObject,
+  joinUrlSegments,
   normalizePath,
   parseRequest,
   processSrcSet,
@@ -211,7 +212,7 @@ export function cssPlugin(config: ResolvedConfig): Plugin {
           if (encodePublicUrlsInCSS(config)) {
             return publicFileToBuiltUrl(url, config)
           } else {
-            return config.base + url.slice(1)
+            return joinUrlSegments(config.base, url)
           }
         }
         const resolved = await resolveUrl(url, importer)
@@ -249,7 +250,6 @@ export function cssPlugin(config: ResolvedConfig): Plugin {
         // server only logic for handling CSS @import dependency hmr
         const { moduleGraph } = server
         const thisModule = moduleGraph.getModuleById(id)
-        const devBase = config.base
         if (thisModule) {
           // CSS modules cannot self-accept since it exports values
           const isSelfAccepting =
@@ -258,6 +258,7 @@ export function cssPlugin(config: ResolvedConfig): Plugin {
             // record deps in the module graph so edits to @import css can trigger
             // main import to hot update
             const depModules = new Set<string | ModuleNode>()
+            const devBase = config.base
             for (const file of deps) {
               depModules.add(
                 isCSSRequest(file)
@@ -387,10 +388,9 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
         }
 
         const cssContent = await getContentWithSourcemap(css)
-        const devBase = config.base
         const code = [
           `import { updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle } from ${JSON.stringify(
-            path.posix.join(devBase, CLIENT_PUBLIC_PATH)
+            path.posix.join(config.base, CLIENT_PUBLIC_PATH)
           )}`,
           `const __vite__id = ${JSON.stringify(id)}`,
           `const __vite__css = ${JSON.stringify(cssContent)}`,
diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts
index 5e8296e5ad3aad..7ea1d846183bb5 100644
--- a/packages/vite/src/node/server/index.ts
+++ b/packages/vite/src/node/server/index.ts
@@ -547,8 +547,7 @@ export async function createServer(
   }
 
   // base
-  const devBase = config.base
-  if (devBase !== '/') {
+  if (config.base !== '/') {
     middlewares.use(baseMiddleware(server))
   }
 
@@ -652,7 +651,6 @@ async function startServer(
 
   const protocol = options.https ? 'https' : 'http'
   const info = server.config.logger.info
-  const devBase = server.config.base
 
   const serverPort = await httpServerStart(httpServer, {
     port,
@@ -681,7 +679,8 @@ async function startServer(
   }
 
   if (options.open && !isRestart) {
-    const path = typeof options.open === 'string' ? options.open : devBase
+    const path =
+      typeof options.open === 'string' ? options.open : server.config.base
     openBrowser(
       path.startsWith('http')
         ? path
diff --git a/packages/vite/src/node/server/middlewares/base.ts b/packages/vite/src/node/server/middlewares/base.ts
index 27960f900b44b7..93d7b4950323d9 100644
--- a/packages/vite/src/node/server/middlewares/base.ts
+++ b/packages/vite/src/node/server/middlewares/base.ts
@@ -1,12 +1,13 @@
 import type { Connect } from 'dep-types/connect'
 import type { ViteDevServer } from '..'
+import { joinUrlSegments } from '../../utils'
 
 // this middleware is only active when (config.base !== '/')
 
 export function baseMiddleware({
   config
 }: ViteDevServer): Connect.NextHandleFunction {
-  const devBase = config.base
+  const devBase = config.base.endsWith('/') ? config.base : config.base + '/'
 
   // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
   return function viteBaseMiddleware(req, res, next) {
@@ -29,18 +30,18 @@ export function baseMiddleware({
     if (path === '/' || path === '/index.html') {
       // redirect root visit to based url with search and hash
       res.writeHead(302, {
-        Location: devBase + (parsed.search || '') + (parsed.hash || '')
+        Location: config.base + (parsed.search || '') + (parsed.hash || '')
       })
       res.end()
       return
     } else if (req.headers.accept?.includes('text/html')) {
       // non-based page visit
-      const redirectPath = devBase + url.slice(1)
+      const redirectPath = joinUrlSegments(config.base, url)
       res.writeHead(404, {
         'Content-Type': 'text/html'
       })
       res.end(
-        `The server is configured with a public base URL of ${devBase} - ` +
+        `The server is configured with a public base URL of ${config.base} - ` +
           `did you mean to visit <a href="${redirectPath}">${redirectPath}</a> instead?`
       )
       return
diff --git a/packages/vite/src/node/server/middlewares/indexHtml.ts b/packages/vite/src/node/server/middlewares/indexHtml.ts
index 326281b853f079..3c57832db0fd20 100644
--- a/packages/vite/src/node/server/middlewares/indexHtml.ts
+++ b/packages/vite/src/node/server/middlewares/indexHtml.ts
@@ -26,6 +26,7 @@ import {
   ensureWatchedFile,
   fsPathFromId,
   injectQuery,
+  joinUrlSegments,
   normalizePath,
   processSrcSetSync,
   wrapId
@@ -93,7 +94,8 @@ const processNodeUrl = (
   const devBase = config.base
   if (startsWithSingleSlashRE.test(url)) {
     // prefix with base (dev only, base is never relative)
-    overwriteAttrValue(s, sourceCodeLocation, devBase + url.slice(1))
+    const fullUrl = joinUrlSegments(devBase, url)
+    overwriteAttrValue(s, sourceCodeLocation, fullUrl)
   } else if (
     url.startsWith('.') &&
     originalUrl &&
@@ -132,7 +134,7 @@ const devHtmlHook: IndexHtmlTransformHook = async (
   const trailingSlash = htmlPath.endsWith('/')
   if (!trailingSlash && fs.existsSync(filename)) {
     proxyModulePath = htmlPath
-    proxyModuleUrl = base + htmlPath.slice(1)
+    proxyModuleUrl = joinUrlSegments(base, htmlPath)
   } else {
     // There are users of vite.transformIndexHtml calling it with url '/'
     // for SSR integrations #7993, filename is root for this case
diff --git a/packages/vite/src/node/ssr/ssrManifestPlugin.ts b/packages/vite/src/node/ssr/ssrManifestPlugin.ts
index 1cc2d79d770fe8..9a68b5ea22afe5 100644
--- a/packages/vite/src/node/ssr/ssrManifestPlugin.ts
+++ b/packages/vite/src/node/ssr/ssrManifestPlugin.ts
@@ -5,7 +5,7 @@ import type { OutputChunk } from 'rollup'
 import type { ResolvedConfig } from '..'
 import type { Plugin } from '../plugin'
 import { preloadMethod } from '../plugins/importAnalysisBuild'
-import { normalizePath } from '../utils'
+import { joinUrlSegments, normalizePath } from '../utils'
 
 export function ssrManifestPlugin(config: ResolvedConfig): Plugin {
   // module id => preload assets mapping
@@ -23,15 +23,15 @@ export function ssrManifestPlugin(config: ResolvedConfig): Plugin {
             const mappedChunks =
               ssrManifest[normalizedId] ?? (ssrManifest[normalizedId] = [])
             if (!chunk.isEntry) {
-              mappedChunks.push(base + chunk.fileName)
+              mappedChunks.push(joinUrlSegments(base, chunk.fileName))
               // <link> tags for entry chunks are already generated in static HTML,
               // so we only need to record info for non-entry chunks.
               chunk.viteMetadata.importedCss.forEach((file) => {
-                mappedChunks.push(base + file)
+                mappedChunks.push(joinUrlSegments(base, file))
               })
             }
             chunk.viteMetadata.importedAssets.forEach((file) => {
-              mappedChunks.push(base + file)
+              mappedChunks.push(joinUrlSegments(base, file))
             })
           }
           if (chunk.code.includes(preloadMethod)) {
@@ -59,7 +59,7 @@ export function ssrManifestPlugin(config: ResolvedConfig): Plugin {
                   const chunk = bundle[filename] as OutputChunk | undefined
                   if (chunk) {
                     chunk.viteMetadata.importedCss.forEach((file) => {
-                      deps.push(join(base, file)) // TODO:base
+                      deps.push(joinUrlSegments(base, file)) // TODO:base
                     })
                     chunk.imports.forEach(addDeps)
                   }
diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts
index 5e7081813a8c06..bead9cee598504 100644
--- a/packages/vite/src/node/utils.ts
+++ b/packages/vite/src/node/utils.ts
@@ -1191,3 +1191,16 @@ export const isNonDriveRelativeAbsolutePath = (p: string): boolean => {
   if (!isWindows) return p.startsWith('/')
   return windowsDrivePathPrefixRE.test(p)
 }
+
+export function joinUrlSegments(a: string, b: string): string {
+  if (!a || !b) {
+    return a || b || ''
+  }
+  if (a.endsWith('/')) {
+    a = a.substring(0, a.length - 1)
+  }
+  if (!b.startsWith('/')) {
+    b = '/' + b
+  }
+  return a + b
+}