From 7069d10a47dc0bf67cc46c67d236f3e7ba7040b4 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Sun, 22 Feb 2026 15:29:34 +0000
Subject: [PATCH 01/20] chore: disable trailing slash rule

---
 vercel.json | 1 -
 1 file changed, 1 deletion(-)

diff --git a/vercel.json b/vercel.json
index cb99d8f754..b4b2a9a978 100644
--- a/vercel.json
+++ b/vercel.json
@@ -1,6 +1,5 @@
 {
   "$schema": "https://openapi.vercel.sh/vercel.json",
-  "trailingSlash": false,
   "redirects": [
     {
       "source": "/(.*)",

From 0a484dd02ed2d656fd96fed5ee2f3dc0b075f154 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Sun, 22 Feb 2026 18:04:19 +0000
Subject: [PATCH 02/20] chore: enable trailing slash in nuxt config

---
 nuxt.config.ts | 1 +
 1 file changed, 1 insertion(+)

diff --git a/nuxt.config.ts b/nuxt.config.ts
index e64c2a9ad1..a23651cfb0 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -86,6 +86,7 @@ export default defineNuxtConfig({
     url: 'https://npmx.dev',
     name: 'npmx',
     description: 'A fast, modern browser for the npm registry',
+    trailingSlash: true,
   },
 
   router: {

From f471f07835ebf342a0dccc566b36e5ca9a8944cb Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Sun, 22 Feb 2026 18:27:47 +0000
Subject: [PATCH 03/20] chore: enable trailing slash via middleware

---
 app/middleware/trailing-slash.global.ts | 6 ++----
 nuxt.config.ts                          | 1 -
 2 files changed, 2 insertions(+), 5 deletions(-)

diff --git a/app/middleware/trailing-slash.global.ts b/app/middleware/trailing-slash.global.ts
index f0386e923b..dee8aa6a5e 100644
--- a/app/middleware/trailing-slash.global.ts
+++ b/app/middleware/trailing-slash.global.ts
@@ -8,12 +8,10 @@
  * - /docs/getting-started/?query=value → /docs/getting-started?query=value
  */
 export default defineNuxtRouteMiddleware(to => {
-  if (!import.meta.dev) return
-
-  if (to.path !== '/' && to.path.endsWith('/')) {
+  if (to.path !== '' && !to.path.endsWith('/')) {
     return navigateTo(
       {
-        path: to.path.slice(0, -1),
+        path: to.path + '/',
         query: to.query,
         hash: to.hash,
       },
diff --git a/nuxt.config.ts b/nuxt.config.ts
index a23651cfb0..e64c2a9ad1 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -86,7 +86,6 @@ export default defineNuxtConfig({
     url: 'https://npmx.dev',
     name: 'npmx',
     description: 'A fast, modern browser for the npm registry',
-    trailingSlash: true,
   },
 
   router: {

From 28c6ff0ef4d02c556e5a8705248ae5b7ea8cb495 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Sun, 22 Feb 2026 20:02:37 +0000
Subject: [PATCH 04/20] chore: improve trailing slash middleware

---
 app/middleware/trailing-slash.global.ts | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/app/middleware/trailing-slash.global.ts b/app/middleware/trailing-slash.global.ts
index dee8aa6a5e..c666ea0780 100644
--- a/app/middleware/trailing-slash.global.ts
+++ b/app/middleware/trailing-slash.global.ts
@@ -8,6 +8,13 @@
  * - /docs/getting-started/?query=value → /docs/getting-started?query=value
  */
 export default defineNuxtRouteMiddleware(to => {
+  if (import.meta.server) {
+    const event = useRequestEvent()
+    const url = event?.node.req.originalUrl || event?.node.req.url || ''
+
+    if (url.includes('_payload')) return
+  }
+
   if (to.path !== '' && !to.path.endsWith('/')) {
     return navigateTo(
       {

From 63f5f8244e9e84004d04e532df9c9b712fd184f0 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Sun, 22 Feb 2026 20:27:11 +0000
Subject: [PATCH 05/20] chore: improve version loading logic

---
 app/composables/npm/useResolvedVersion.ts | 24 +++++++++++------------
 app/pages/package/[[org]]/[name].vue      | 15 +++-----------
 2 files changed, 15 insertions(+), 24 deletions(-)

diff --git a/app/composables/npm/useResolvedVersion.ts b/app/composables/npm/useResolvedVersion.ts
index 992ce62b7e..98ee900c45 100644
--- a/app/composables/npm/useResolvedVersion.ts
+++ b/app/composables/npm/useResolvedVersion.ts
@@ -1,18 +1,18 @@
 import type { ResolvedPackageVersion } from 'fast-npm-meta'
 
-export function useResolvedVersion(
+export async function useResolvedVersion(
   packageName: MaybeRefOrGetter<string>,
   requestedVersion: MaybeRefOrGetter<string | null>,
 ) {
-  return useFetch(
-    () => {
-      const version = toValue(requestedVersion)
-      return version
-        ? `https://npm.antfu.dev/${toValue(packageName)}@${version}`
-        : `https://npm.antfu.dev/${toValue(packageName)}`
-    },
-    {
-      transform: (data: ResolvedPackageVersion) => data.version,
-    },
-  )
+  const versionData = useState<string | null>('versionData')
+
+  await callOnce(async () => {
+    const version = toValue(requestedVersion)
+    const name = toValue(packageName)
+    const url = version
+      ? `https://npm.antfu.dev/${name}@${version}`
+      : `https://npm.antfu.dev/${name}`
+    versionData.value = await $fetch<ResolvedPackageVersion>(url).then(data => data.version)
+  })
+  return versionData
 }
diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue
index 1ded55a34e..943aaa48f4 100644
--- a/app/pages/package/[[org]]/[name].vue
+++ b/app/pages/package/[[org]]/[name].vue
@@ -194,18 +194,9 @@ const { data: skillsData } = useLazyFetch<SkillsListResponse>(
 const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion)
 const { data: moduleReplacement } = useModuleReplacement(packageName)
 
-const {
-  data: resolvedVersion,
-  status: versionStatus,
-  error: versionError,
-} = await useResolvedVersion(packageName, requestedVersion)
-
-if (
-  versionStatus.value === 'error' &&
-  versionError.value?.statusCode &&
-  versionError.value.statusCode >= 400 &&
-  versionError.value.statusCode < 500
-) {
+const resolvedVersion = await useResolvedVersion(packageName, requestedVersion)
+
+if (resolvedVersion.value === null) {
   throw createError({
     statusCode: 404,
     statusMessage: $t('package.not_found'),

From 268874776b1bc0c1ea87f649882a7e9e0679cfcb Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Sun, 22 Feb 2026 21:00:14 +0000
Subject: [PATCH 06/20] chore: disable fallback for packages

---
 nuxt.config.ts | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/nuxt.config.ts b/nuxt.config.ts
index e64c2a9ad1..378fbcb910 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -133,14 +133,14 @@ export default defineNuxtConfig({
       },
     },
     // pages
-    '/package/:name': getISRConfig(60, { fallback: 'html' }),
-    '/package/:name/_payload.json': getISRConfig(60, { fallback: 'json' }),
-    '/package/:name/v/:version': getISRConfig(60, { fallback: 'html' }),
-    '/package/:name/v/:version/_payload.json': getISRConfig(60, { fallback: 'json' }),
-    '/package/:org/:name': getISRConfig(60, { fallback: 'html' }),
-    '/package/:org/:name/_payload.json': getISRConfig(60, { fallback: 'json' }),
-    '/package/:org/:name/v/:version': getISRConfig(60, { fallback: 'html' }),
-    '/package/:org/:name/v/:version/_payload.json': getISRConfig(60, { fallback: 'json' }),
+    '/package/:name': getISRConfig(60),
+    '/package/:name/_payload.json': getISRConfig(60),
+    '/package/:name/v/:version': getISRConfig(60),
+    '/package/:name/v/:version/_payload.json': getISRConfig(60),
+    '/package/:org/:name': getISRConfig(60),
+    '/package/:org/:name/_payload.json': getISRConfig(60),
+    '/package/:org/:name/v/:version': getISRConfig(60),
+    '/package/:org/:name/v/:version/_payload.json': getISRConfig(60),
     // infinite cache (versioned - doesn't change)
     '/package-code/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
     '/package-docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },

From b219252d1244eba7a8c65883111a714509520b17 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Sun, 22 Feb 2026 21:34:48 +0000
Subject: [PATCH 07/20] chore: rewrite version resolver

---
 app/composables/npm/useResolvedVersion.ts | 26 ++++++++++++-----------
 app/pages/package/[[org]]/[name].vue      |  2 +-
 2 files changed, 15 insertions(+), 13 deletions(-)

diff --git a/app/composables/npm/useResolvedVersion.ts b/app/composables/npm/useResolvedVersion.ts
index 98ee900c45..fda0185259 100644
--- a/app/composables/npm/useResolvedVersion.ts
+++ b/app/composables/npm/useResolvedVersion.ts
@@ -1,18 +1,20 @@
 import type { ResolvedPackageVersion } from 'fast-npm-meta'
 
-export async function useResolvedVersion(
+export function useResolvedVersion(
   packageName: MaybeRefOrGetter<string>,
   requestedVersion: MaybeRefOrGetter<string | null>,
 ) {
-  const versionData = useState<string | null>('versionData')
-
-  await callOnce(async () => {
-    const version = toValue(requestedVersion)
-    const name = toValue(packageName)
-    const url = version
-      ? `https://npm.antfu.dev/${name}@${version}`
-      : `https://npm.antfu.dev/${name}`
-    versionData.value = await $fetch<ResolvedPackageVersion>(url).then(data => data.version)
-  })
-  return versionData
+  return useAsyncData(
+    () => `resolved-version:${toValue(packageName)}:${toValue(requestedVersion) ?? 'latest'}`,
+    async () => {
+      const version = toValue(requestedVersion)
+      const name = toValue(packageName)
+      const url = version
+        ? `https://npm.antfu.dev/${name}@${version}`
+        : `https://npm.antfu.dev/${name}`
+      const data = await $fetch<ResolvedPackageVersion>(url)
+      return data.version
+    },
+    { default: () => null },
+  )
 }
diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue
index 943aaa48f4..9a253d08d7 100644
--- a/app/pages/package/[[org]]/[name].vue
+++ b/app/pages/package/[[org]]/[name].vue
@@ -194,7 +194,7 @@ const { data: skillsData } = useLazyFetch<SkillsListResponse>(
 const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion)
 const { data: moduleReplacement } = useModuleReplacement(packageName)
 
-const resolvedVersion = await useResolvedVersion(packageName, requestedVersion)
+const { data: resolvedVersion } = await useResolvedVersion(packageName, requestedVersion)
 
 if (resolvedVersion.value === null) {
   throw createError({

From 9adacac4c0df130d0fafe3e3da8ddccfee2eac13 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Sun, 22 Feb 2026 21:35:19 +0000
Subject: [PATCH 08/20] chore: disable extra caching for payload routes

---
 nuxt.config.ts | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/nuxt.config.ts b/nuxt.config.ts
index 378fbcb910..a381f5b27e 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -134,13 +134,9 @@ export default defineNuxtConfig({
     },
     // pages
     '/package/:name': getISRConfig(60),
-    '/package/:name/_payload.json': getISRConfig(60),
     '/package/:name/v/:version': getISRConfig(60),
-    '/package/:name/v/:version/_payload.json': getISRConfig(60),
     '/package/:org/:name': getISRConfig(60),
-    '/package/:org/:name/_payload.json': getISRConfig(60),
     '/package/:org/:name/v/:version': getISRConfig(60),
-    '/package/:org/:name/v/:version/_payload.json': getISRConfig(60),
     // infinite cache (versioned - doesn't change)
     '/package-code/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
     '/package-docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },

From 71fe94da0bb9e38065653455cb68360518cbd1b0 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Sun, 22 Feb 2026 23:12:13 +0000
Subject: [PATCH 09/20] fix: configure trailing slash for all links

---
 app/components/Code/DirectoryListing.vue                     | 1 +
 app/components/Code/FileTree.vue                             | 1 +
 app/components/DependencyPathPopup.vue                       | 1 +
 app/components/Header/MobileMenu.client.vue                  | 1 +
 app/components/Header/OrgsDropdown.vue                       | 3 +++
 app/components/Header/PackagesDropdown.vue                   | 3 +++
 app/components/Link/Base.vue                                 | 5 ++++-
 app/components/Org/MembersPanel.vue                          | 1 +
 app/components/Org/TeamsPanel.vue                            | 1 +
 app/components/Package/Card.vue                              | 1 +
 app/components/Package/ClaimPackageModal.vue                 | 2 ++
 app/components/Package/DeprecatedTree.vue                    | 1 +
 app/components/Package/TableRow.vue                          | 2 ++
 app/components/Package/VulnerabilityTree.vue                 | 2 ++
 app/components/SearchSuggestionCard.vue                      | 1 +
 app/components/Terminal/Install.vue                          | 2 ++
 app/components/VersionSelector.vue                           | 3 +++
 app/middleware/trailing-slash.global.ts                      | 2 ++
 app/pages/accessibility.vue                                  | 1 +
 .../[[org]]/[packageName]/v/[version]/[...filePath].vue      | 3 +++
 app/pages/package-docs/[...path].vue                         | 2 ++
 app/pages/privacy.vue                                        | 2 ++
 app/pages/~[username]/orgs.vue                               | 2 ++
 nuxt.config.ts                                               | 1 +
 24 files changed, 43 insertions(+), 1 deletion(-)

diff --git a/app/components/Code/DirectoryListing.vue b/app/components/Code/DirectoryListing.vue
index 31a6d1fe24..21ee584382 100644
--- a/app/components/Code/DirectoryListing.vue
+++ b/app/components/Code/DirectoryListing.vue
@@ -103,6 +103,7 @@ const bytesFormatter = useBytesFormatter()
         >
           <td colspan="2">
             <LinkBase
+              :trailing-slash="node.type === 'directory' ? 'append' : 'remove'"
               :to="getCodeRoute(node.path)"
               class="py-2 px-4 font-mono text-sm w-full"
               no-underline
diff --git a/app/components/Code/FileTree.vue b/app/components/Code/FileTree.vue
index 2e2d2352dd..a02efb36d7 100644
--- a/app/components/Code/FileTree.vue
+++ b/app/components/Code/FileTree.vue
@@ -87,6 +87,7 @@ watch(
       <!-- File -->
       <template v-else>
         <LinkBase
+          trailing-slash="remove"
           variant="button-secondary"
           :to="getFileRoute(node.path)"
           :aria-current="currentPath === node.path"
diff --git a/app/components/DependencyPathPopup.vue b/app/components/DependencyPathPopup.vue
index 9d0f40ba4d..6b5c2a028b 100644
--- a/app/components/DependencyPathPopup.vue
+++ b/app/components/DependencyPathPopup.vue
@@ -93,6 +93,7 @@ function parsePackageString(pkg: string): { name: string; version: string } {
           >
             <span v-if="idx > 0" class="text-fg-subtle me-1">└─</span>
             <NuxtLink
+              trailing-slash="append"
               :to="
                 packageRoute(
                   parsePackageString(pathItem).name,
diff --git a/app/components/Header/MobileMenu.client.vue b/app/components/Header/MobileMenu.client.vue
index 49c51497f5..4d53c01d5e 100644
--- a/app/components/Header/MobileMenu.client.vue
+++ b/app/components/Header/MobileMenu.client.vue
@@ -195,6 +195,7 @@ onUnmounted(deactivate)
                   </span>
                   <div>
                     <NuxtLink
+                      trailing-slash="append"
                       v-for="link in group.items"
                       :key="link.name"
                       :to="link.to"
diff --git a/app/components/Header/OrgsDropdown.vue b/app/components/Header/OrgsDropdown.vue
index b07b6352a7..3bff8196be 100644
--- a/app/components/Header/OrgsDropdown.vue
+++ b/app/components/Header/OrgsDropdown.vue
@@ -58,6 +58,7 @@ function handleKeydown(event: KeyboardEvent) {
     @keydown="handleKeydown"
   >
     <NuxtLink
+      trailing-slash="append"
       :to="{ name: '~username-orgs', params: { username } }"
       class="link-subtle font-mono text-sm inline-flex items-center gap-1"
     >
@@ -94,6 +95,7 @@ function handleKeydown(event: KeyboardEvent) {
           <ul v-else-if="orgs.length > 0" class="py-1 max-h-80 overflow-y-auto">
             <li v-for="org in orgs" :key="org">
               <NuxtLink
+                trailing-slash="append"
                 :to="{ name: 'org', params: { org } }"
                 class="block px-3 py-2 font-mono text-sm text-fg hover:bg-bg-subtle transition-colors"
               >
@@ -108,6 +110,7 @@ function handleKeydown(event: KeyboardEvent) {
 
           <div class="px-3 py-2 border-t border-border">
             <NuxtLink
+              trailing-slash="append"
               :to="{ name: '~username-orgs', params: { username } }"
               class="link-subtle font-mono text-xs inline-flex items-center gap-1"
             >
diff --git a/app/components/Header/PackagesDropdown.vue b/app/components/Header/PackagesDropdown.vue
index 3f70c648f2..364fa41ae9 100644
--- a/app/components/Header/PackagesDropdown.vue
+++ b/app/components/Header/PackagesDropdown.vue
@@ -58,6 +58,7 @@ function handleKeydown(event: KeyboardEvent) {
     @keydown="handleKeydown"
   >
     <NuxtLink
+      trailing-slash="append"
       :to="{ name: '~username', params: { username } }"
       class="link-subtle font-mono text-sm inline-flex items-center gap-1"
     >
@@ -94,6 +95,7 @@ function handleKeydown(event: KeyboardEvent) {
           <ul v-else-if="packages.length > 0" class="py-1 max-h-80 overflow-y-auto">
             <li v-for="pkg in packages" :key="pkg">
               <NuxtLink
+                trailing-slash="append"
                 :to="packageRoute(pkg)"
                 class="block px-3 py-2 font-mono text-sm text-fg hover:bg-bg-subtle transition-colors truncate"
               >
@@ -108,6 +110,7 @@ function handleKeydown(event: KeyboardEvent) {
 
           <div class="px-3 py-2 border-t border-border">
             <NuxtLink
+              trailing-slash="append"
               :to="{ name: '~username', params: { username } }"
               class="link-subtle font-mono text-xs inline-flex items-center gap-1"
             >
diff --git a/app/components/Link/Base.vue b/app/components/Link/Base.vue
index 4eeecda2bf..e6bfbabca7 100644
--- a/app/components/Link/Base.vue
+++ b/app/components/Link/Base.vue
@@ -35,8 +35,10 @@ const props = withDefaults(
 
     /** should only be used for links where the context makes it very clear they are clickable. Don't just use this, because you don't like underlines. */
     noUnderline?: boolean
+
+    trailingSlash?: 'append' | 'remove' | undefined
   }>(),
-  { variant: 'link', size: 'medium' },
+  { variant: 'link', size: 'medium', trailingSlash: 'append' },
 )
 
 const isLinkExternal = computed(
@@ -74,6 +76,7 @@ const isButtonMedium = computed(() => props.size === 'medium' && !isLink.value)
   <NuxtLink
     v-bind="props"
     v-else
+    :trailing-slash="trailingSlash"
     class="group/link gap-x-1 items-center"
     :class="{
       'flex': block,
diff --git a/app/components/Org/MembersPanel.vue b/app/components/Org/MembersPanel.vue
index 580f35ec28..145bba5711 100644
--- a/app/components/Org/MembersPanel.vue
+++ b/app/components/Org/MembersPanel.vue
@@ -446,6 +446,7 @@ watch(lastExecutionTime, () => {
         <div class="flex items-center justify-between">
           <div class="flex items-center gap-3">
             <NuxtLink
+              trailing-slash="append"
               :to="{ name: '~username', params: { username: member.name } }"
               class="font-mono text-sm text-fg hover:text-fg transition-colors duration-200"
             >
diff --git a/app/components/Org/TeamsPanel.vue b/app/components/Org/TeamsPanel.vue
index b0ddf52464..18fdab85b0 100644
--- a/app/components/Org/TeamsPanel.vue
+++ b/app/components/Org/TeamsPanel.vue
@@ -418,6 +418,7 @@ watch(lastExecutionTime, () => {
               class="flex items-center justify-start py-1 ps-2 pe-1 rounded hover:bg-bg-subtle transition-colors duration-200"
             >
               <NuxtLink
+                trailing-slash="append"
                 :to="{ name: '~username', params: { username: user } }"
                 class="font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200"
               >
diff --git a/app/components/Package/Card.vue b/app/components/Package/Card.vue
index 12d25268b7..da527cc48b 100644
--- a/app/components/Package/Card.vue
+++ b/app/components/Package/Card.vue
@@ -46,6 +46,7 @@ const numberFormatter = useNumberFormatter()
         class="font-mono text-sm sm:text-base font-medium text-fg group-hover:text-fg transition-colors duration-200 min-w-0 break-all"
       >
         <NuxtLink
+          trailing-slash="append"
           :to="packageRoute(result.package.name)"
           :prefetch-on="prefetch ? 'visibility' : 'interaction'"
           class="decoration-none scroll-mt-48 scroll-mb-6 after:content-[''] after:absolute after:inset-0"
diff --git a/app/components/Package/ClaimPackageModal.vue b/app/components/Package/ClaimPackageModal.vue
index 7d061331ba..4d3c1f843a 100644
--- a/app/components/Package/ClaimPackageModal.vue
+++ b/app/components/Package/ClaimPackageModal.vue
@@ -175,6 +175,7 @@ const previewPackageJson = computed(() => {
 
       <div class="flex gap-3">
         <NuxtLink
+          trailing-slash="append"
           :to="packageRoute(packageName)"
           class="flex-1 px-4 py-2 font-mono text-sm text-center text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-accent/70"
           @click="close"
@@ -289,6 +290,7 @@ const previewPackageJson = computed(() => {
               <span v-else class="w-4 h-4 shrink-0" />
               <div class="min-w-0">
                 <NuxtLink
+                  trailing-slash="append"
                   :to="packageRoute(pkg.name)"
                   class="font-mono text-sm text-fg hover:underline focus-visible:outline-accent/70 rounded"
                   target="_blank"
diff --git a/app/components/Package/DeprecatedTree.vue b/app/components/Package/DeprecatedTree.vue
index 079427c51b..2dbb22b774 100644
--- a/app/components/Package/DeprecatedTree.vue
+++ b/app/components/Package/DeprecatedTree.vue
@@ -84,6 +84,7 @@ function getDepthStyle(depth: DependencyDepth) {
               <DependencyPathPopup v-if="pkg.path && pkg.path.length > 1" :path="pkg.path" />
 
               <NuxtLink
+                trailing-slash="append"
                 :to="packageRoute(pkg.name, pkg.version)"
                 class="font-mono text-sm font-medium hover:underline truncate py-4"
                 :class="getDepthStyle(pkg.depth).text"
diff --git a/app/components/Package/TableRow.vue b/app/components/Package/TableRow.vue
index ce7929293f..430cd3833f 100644
--- a/app/components/Package/TableRow.vue
+++ b/app/components/Package/TableRow.vue
@@ -51,6 +51,7 @@ const allMaintainersText = computed(() => {
     <!-- Name (always visible) -->
     <td class="py-2 px-3">
       <NuxtLink
+        trailing-slash="append"
         :to="packageUrl"
         class="row-link font-mono text-sm text-fg hover:text-accent-fallback transition-colors duration-200"
         dir="ltr"
@@ -107,6 +108,7 @@ const allMaintainersText = computed(() => {
           :key="maintainer.username || maintainer.email"
         >
           <NuxtLink
+            trailing-slash="append"
             :to="{
               name: '~username',
               params: { username: maintainer.username || maintainer.name || '' },
diff --git a/app/components/Package/VulnerabilityTree.vue b/app/components/Package/VulnerabilityTree.vue
index 0eb15eba42..70d2e29daa 100644
--- a/app/components/Package/VulnerabilityTree.vue
+++ b/app/components/Package/VulnerabilityTree.vue
@@ -124,6 +124,7 @@ function getDepthStyle(depth: string | undefined) {
                 <DependencyPathPopup v-if="pkg.path && pkg.path.length > 1" :path="pkg.path" />
 
                 <NuxtLink
+                  trailing-slash="append"
                   :to="packageRoute(pkg.name, pkg.version)"
                   class="font-mono text-sm font-medium hover:underline truncate shrink min-w-0"
                   :class="getDepthStyle(pkg.depth).text"
@@ -160,6 +161,7 @@ function getDepthStyle(depth: string | undefined) {
                 <span class="truncate w-0 flex-1">{{ vuln.summary }}</span>
                 <NuxtLink
                   v-if="vuln.fixedIn"
+                  trailing-slash="append"
                   :to="packageRoute(pkg.name, vuln.fixedIn)"
                   class="shrink-0 font-mono text-emerald-600 dark:text-emerald-400 hover:underline"
                   :title="$t('package.vulnerabilities.fixed_in_title', { version: vuln.fixedIn })"
diff --git a/app/components/SearchSuggestionCard.vue b/app/components/SearchSuggestionCard.vue
index 26e29f7450..d2c63305c6 100644
--- a/app/components/SearchSuggestionCard.vue
+++ b/app/components/SearchSuggestionCard.vue
@@ -14,6 +14,7 @@ defineProps<{
 <template>
   <BaseCard :isExactMatch="isExactMatch">
     <NuxtLink
+      trailing-slash="append"
       :to="
         type === 'user'
           ? { name: '~username', params: { username: name } }
diff --git a/app/components/Terminal/Install.vue b/app/components/Terminal/Install.vue
index 190c5dbb32..9688992ce3 100644
--- a/app/components/Terminal/Install.vue
+++ b/app/components/Terminal/Install.vue
@@ -215,6 +215,7 @@ const copyDevInstallCommand = () =>
               ></code
             >
             <NuxtLink
+              trailing-slash="append"
               :to="packageRoute(typesPackageName!)"
               class="text-fg-subtle hover:text-fg-muted text-xs transition-colors focus-visible:outline-accent/70 rounded select-none"
               :title="$t('package.get_started.view_types', { package: typesPackageName })"
@@ -268,6 +269,7 @@ const copyDevInstallCommand = () =>
               :text="$t('package.create.view', { packageName: createPackageInfo.packageName })"
             >
               <NuxtLink
+                trailing-slash="append"
                 :to="packageRoute(createPackageInfo.packageName)"
                 class="inline-flex items-center justify-center min-w-6 min-h-6 -m-1 p-1 text-fg-muted hover:text-fg text-xs transition-colors focus-visible:outline-2 focus-visible:outline-accent/70 rounded"
               >
diff --git a/app/components/VersionSelector.vue b/app/components/VersionSelector.vue
index 5a0867ee8a..81509a8fef 100644
--- a/app/components/VersionSelector.vue
+++ b/app/components/VersionSelector.vue
@@ -554,6 +554,7 @@ watch(
 
             <!-- Version link -->
             <NuxtLink
+              trailing-slash="append"
               :to="getVersionUrl(group.primaryVersion.version)"
               class="flex-1 truncate hover:text-fg transition-colors"
               @click="isOpen = false"
@@ -583,6 +584,7 @@ watch(
           >
             <template v-for="v in group.versions.slice(1)" :key="v.version">
               <NuxtLink
+                trailing-slash="append"
                 :id="`version-${v.version}`"
                 :to="getVersionUrl(v.version)"
                 role="option"
@@ -622,6 +624,7 @@ watch(
         <!-- Link to package page for full version list -->
         <div class="border-t border-border mt-1 pt-1 px-3 py-2">
           <NuxtLink
+            trailing-slash="append"
             :to="packageRoute(packageName)"
             class="text-xs text-fg-subtle hover:text-fg transition-[color] focus-visible:outline-none focus-visible:text-fg"
             @click="isOpen = false"
diff --git a/app/middleware/trailing-slash.global.ts b/app/middleware/trailing-slash.global.ts
index c666ea0780..23607b240b 100644
--- a/app/middleware/trailing-slash.global.ts
+++ b/app/middleware/trailing-slash.global.ts
@@ -15,6 +15,8 @@ export default defineNuxtRouteMiddleware(to => {
     if (url.includes('_payload')) return
   }
 
+  if (to.path.startsWith('/package-code/')) return
+
   if (to.path !== '' && !to.path.endsWith('/')) {
     return navigateTo(
       {
diff --git a/app/pages/accessibility.vue b/app/pages/accessibility.vue
index 8f3750fb27..18f8089a04 100644
--- a/app/pages/accessibility.vue
+++ b/app/pages/accessibility.vue
@@ -58,6 +58,7 @@ const canGoBack = useCanGoBack()
             <i18n-t keypath="a11y.approach.p2" tag="span" scope="global">
               <template #about>
                 <NuxtLink
+                  trailing-slash="append"
                   :to="{ name: 'about' }"
                   class="text-fg-muted hover:text-fg underline decoration-fg-subtle/50 hover:decoration-fg"
                 >
diff --git a/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue b/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue
index 2202494d7c..8b3d787abe 100644
--- a/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue
+++ b/app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue
@@ -313,6 +313,7 @@ defineOgImageComponent('Default', {
         <!-- Package info and navigation -->
         <div class="flex items-center gap-2 mb-3 flex-wrap min-w-0">
           <NuxtLink
+            trailing-slash="append"
             :to="packageRoute(packageName, version)"
             class="font-mono text-lg font-medium hover:text-fg transition-colors min-w-0 truncate max-w-[60vw] sm:max-w-none"
             :title="packageName"
@@ -350,6 +351,7 @@ defineOgImageComponent('Default', {
         >
           <NuxtLink
             v-if="filePath"
+            trailing-slash="append"
             :to="getCurrentCodeUrlWithPath()"
             class="text-fg-muted hover:text-fg transition-colors shrink-0"
           >
@@ -360,6 +362,7 @@ defineOgImageComponent('Default', {
             <span class="text-fg-subtle">/</span>
             <NuxtLink
               v-if="i < breadcrumbs.length - 1"
+              trailing-slash="append"
               :to="getCurrentCodeUrlWithPath(crumb.path)"
               class="text-fg-muted hover:text-fg transition-colors"
             >
diff --git a/app/pages/package-docs/[...path].vue b/app/pages/package-docs/[...path].vue
index 07e6dbe922..1331f5c615 100644
--- a/app/pages/package-docs/[...path].vue
+++ b/app/pages/package-docs/[...path].vue
@@ -137,6 +137,7 @@ const showEmptyState = computed(() => docsData.value?.status !== 'ok')
           <div class="flex items-center gap-3 min-w-0">
             <NuxtLink
               v-if="packageName"
+              trailing-slash="append"
               :to="packageRoute(packageName)"
               class="font-mono text-lg sm:text-xl font-semibold text-fg hover:text-fg-muted transition-colors truncate"
             >
@@ -196,6 +197,7 @@ const showEmptyState = computed(() => docsData.value?.status !== 'ok')
             <div class="flex gap-4 mt-4">
               <NuxtLink
                 v-if="packageName"
+                trailing-slash="append"
                 :to="packageRoute(packageName)"
                 class="link-subtle font-mono text-sm"
               >
diff --git a/app/pages/privacy.vue b/app/pages/privacy.vue
index c0e93030a3..742d72e471 100644
--- a/app/pages/privacy.vue
+++ b/app/pages/privacy.vue
@@ -139,6 +139,7 @@ const { locale } = useI18n()
               </template>
               <template #settings>
                 <NuxtLink
+                  trailing-slash="append"
                   :to="{ name: 'settings' }"
                   class="text-fg-muted hover:text-fg underline decoration-fg-subtle/50 hover:decoration-fg"
                 >
@@ -267,6 +268,7 @@ const { locale } = useI18n()
             <i18n-t keypath="privacy_policy.authenticated.p2" tag="span" scope="global">
               <template #settings>
                 <NuxtLink
+                  trailing-slash="append"
                   :to="{ name: 'settings' }"
                   class="text-fg-muted hover:text-fg underline decoration-fg-subtle/50 hover:decoration-fg"
                 >
diff --git a/app/pages/~[username]/orgs.vue b/app/pages/~[username]/orgs.vue
index 138bd69139..236030cab3 100644
--- a/app/pages/~[username]/orgs.vue
+++ b/app/pages/~[username]/orgs.vue
@@ -136,6 +136,7 @@ defineOgImageComponent('Default', {
       <!-- Back link -->
       <nav aria-labelledby="back-to-profile">
         <NuxtLink
+          trailing-slash="append"
           :to="{ name: '~username', params: { username } }"
           id="back-to-profile"
           class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
@@ -194,6 +195,7 @@ defineOgImageComponent('Default', {
         <ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
           <li v-for="org in orgs" :key="org.name">
             <NuxtLink
+              trailing-slash="append"
               :to="{ name: 'org', params: { org: org.name } }"
               class="block p-5 bg-bg-subtle border border-border rounded-lg hover:border-fg-subtle transition-colors h-full"
             >
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 6af4cf4c88..33d9fc2a04 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -86,6 +86,7 @@ export default defineNuxtConfig({
     url: 'https://npmx.dev',
     name: 'npmx',
     description: 'A fast, modern browser for the npm registry',
+    trailingSlash: true,
   },
 
   router: {

From 12100537373404674e773ba37c59a66e730f2394 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Sun, 22 Feb 2026 23:20:39 +0000
Subject: [PATCH 10/20] test: add trailing slash in tests

---
 test/nuxt/components/VersionSelector.spec.ts         | 2 +-
 test/nuxt/components/compare/PackageSelector.spec.ts | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/nuxt/components/VersionSelector.spec.ts b/test/nuxt/components/VersionSelector.spec.ts
index dc3e2b59b4..90efa13e21 100644
--- a/test/nuxt/components/VersionSelector.spec.ts
+++ b/test/nuxt/components/VersionSelector.spec.ts
@@ -322,7 +322,7 @@ describe('VersionSelector', () => {
 
       const versionLink = component.findAll('a').find(a => a.text().includes('1.0.0'))
       expect(versionLink?.attributes('href')).toBe(
-        '/package-code/test-package/v/1.0.0/src/index.ts',
+        '/package-code/test-package/v/1.0.0/src/index.ts/',
       )
     })
   })
diff --git a/test/nuxt/components/compare/PackageSelector.spec.ts b/test/nuxt/components/compare/PackageSelector.spec.ts
index 54eece3168..01d3b8e9f7 100644
--- a/test/nuxt/components/compare/PackageSelector.spec.ts
+++ b/test/nuxt/components/compare/PackageSelector.spec.ts
@@ -40,7 +40,7 @@ describe('PackageSelector', () => {
         },
       })
 
-      const link = component.find('a[href="/package/lodash"]')
+      const link = component.find('a[href="/package/lodash/"]')
       expect(link.exists()).toBe(true)
     })
 

From 07af3d2b9c0902b28e67baddc4b3350a37130b1d Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Mon, 23 Feb 2026 08:46:23 +0000
Subject: [PATCH 11/20] test: update tests and rules

---
 app/middleware/trailing-slash.global.ts |  2 +-
 test/e2e/connector.spec.ts              | 18 ++++++-------
 test/e2e/create-command.spec.ts         | 16 ++++++------
 test/e2e/docs.spec.ts                   | 20 +++++++--------
 test/e2e/hydration.spec.ts              | 14 +++++-----
 test/e2e/interactions.spec.ts           | 34 ++++++++++++-------------
 test/e2e/package-manager-select.spec.ts |  2 +-
 test/e2e/url-compatibility.spec.ts      | 30 +++++++++++-----------
 8 files changed, 68 insertions(+), 68 deletions(-)

diff --git a/app/middleware/trailing-slash.global.ts b/app/middleware/trailing-slash.global.ts
index 23607b240b..113c2875ae 100644
--- a/app/middleware/trailing-slash.global.ts
+++ b/app/middleware/trailing-slash.global.ts
@@ -15,7 +15,7 @@ export default defineNuxtRouteMiddleware(to => {
     if (url.includes('_payload')) return
   }
 
-  if (to.path.startsWith('/package-code/')) return
+  if (to.path.startsWith('/package-code/') || to.path.startsWith('/api/')) return
 
   if (to.path !== '' && !to.path.endsWith('/')) {
     return navigateTo(
diff --git a/test/e2e/connector.spec.ts b/test/e2e/connector.spec.ts
index 84cd6035ef..bfac882712 100644
--- a/test/e2e/connector.spec.ts
+++ b/test/e2e/connector.spec.ts
@@ -18,7 +18,7 @@ test.describe.configure({ mode: 'serial' })
  * This helper waits for the packages link to appear as proof of successful connection.
  */
 async function expectConnected(page: Page, username = 'testuser') {
-  await expect(page.locator(`a[href="/~${username}"]`, { hasText: 'packages' })).toBeVisible({
+  await expect(page.locator(`a[href="/~${username}/"]`, { hasText: 'packages' })).toBeVisible({
     timeout: 10_000,
   })
 }
@@ -79,7 +79,7 @@ test.describe('Connector Connection', () => {
     await modal.getByRole('button', { name: /close/i }).click()
 
     // The "packages" link should disappear since we're disconnected
-    await expect(page.locator('a[href="/~testuser"]', { hasText: 'packages' })).not.toBeVisible({
+    await expect(page.locator('a[href="/~testuser/"]', { hasText: 'packages' })).not.toBeVisible({
       timeout: 5000,
     })
 
@@ -106,7 +106,7 @@ test.describe('Organization Management', () => {
   })
 
   test('shows org members when connected', async ({ page, gotoConnected }) => {
-    await gotoConnected('/@testorg')
+    await gotoConnected('/@testorg/')
 
     // The org management region contains the members panel
     const orgManagement = page.getByRole('region', { name: /organization management/i })
@@ -123,7 +123,7 @@ test.describe('Organization Management', () => {
   })
 
   test('can filter members by role', async ({ page, gotoConnected }) => {
-    await gotoConnected('/@testorg')
+    await gotoConnected('/@testorg/')
 
     const orgManagement = page.getByRole('region', { name: /organization management/i })
     await expect(orgManagement).toBeVisible({ timeout: 10_000 })
@@ -144,7 +144,7 @@ test.describe('Organization Management', () => {
   })
 
   test('can search members by name', async ({ page, gotoConnected }) => {
-    await gotoConnected('/@testorg')
+    await gotoConnected('/@testorg/')
 
     const orgManagement = page.getByRole('region', { name: /organization management/i })
     await expect(orgManagement).toBeVisible({ timeout: 10_000 })
@@ -162,7 +162,7 @@ test.describe('Organization Management', () => {
   })
 
   test('can add a new member operation', async ({ page, gotoConnected, mockConnector }) => {
-    await gotoConnected('/@testorg')
+    await gotoConnected('/@testorg/')
 
     const orgManagement = page.getByRole('region', { name: /organization management/i })
     await expect(orgManagement).toBeVisible({ timeout: 10_000 })
@@ -194,7 +194,7 @@ test.describe('Organization Management', () => {
   })
 
   test('can remove a member (adds operation)', async ({ page, gotoConnected, mockConnector }) => {
-    await gotoConnected('/@testorg')
+    await gotoConnected('/@testorg/')
 
     const orgManagement = page.getByRole('region', { name: /organization management/i })
     await expect(orgManagement).toBeVisible({ timeout: 10_000 })
@@ -215,7 +215,7 @@ test.describe('Organization Management', () => {
     gotoConnected,
     mockConnector,
   }) => {
-    await gotoConnected('/@testorg')
+    await gotoConnected('/@testorg/')
 
     const orgManagement = page.getByRole('region', { name: /organization management/i })
     await expect(orgManagement).toBeVisible({ timeout: 10_000 })
@@ -257,7 +257,7 @@ test.describe('Package Access Controls', () => {
   async function goToPackageConnected(page: Page, gotoConnected: (path: string) => Promise<void>) {
     await gotoConnected('/')
     await expectConnected(page)
-    await page.goto('/package/@nuxt/kit')
+    await page.goto('/package/@nuxt/kit/')
     await expect(page.locator('h1')).toContainText('kit', { timeout: 30_000 })
   }
 
diff --git a/test/e2e/create-command.spec.ts b/test/e2e/create-command.spec.ts
index 15f00ee4ce..a947685ec4 100644
--- a/test/e2e/create-command.spec.ts
+++ b/test/e2e/create-command.spec.ts
@@ -3,7 +3,7 @@ import { expect, test } from './test-utils'
 test.describe('Create Command', () => {
   test.describe('Visibility', () => {
     test('/vite - should show create command (same maintainers)', async ({ page, goto }) => {
-      await goto('/package/vite', { waitUntil: 'domcontentloaded' })
+      await goto('/package/vite/', { waitUntil: 'domcontentloaded' })
 
       // Create command section should be visible (SSR)
       // Use specific container to avoid matching README code blocks
@@ -19,7 +19,7 @@ test.describe('Create Command', () => {
       page,
       goto,
     }) => {
-      await goto('/package/next', { waitUntil: 'domcontentloaded' })
+      await goto('/package/next/', { waitUntil: 'domcontentloaded' })
 
       // Create command section should be visible (SSR)
       // Use specific container to avoid matching README code blocks
@@ -35,7 +35,7 @@ test.describe('Create Command', () => {
       page,
       goto,
     }) => {
-      await goto('/package/nuxt', { waitUntil: 'domcontentloaded' })
+      await goto('/package/nuxt/', { waitUntil: 'domcontentloaded' })
 
       // Create command section should be visible (SSR)
       // nuxt has create-nuxt package, so command is "npm create nuxt"
@@ -49,7 +49,7 @@ test.describe('Create Command', () => {
       page,
       goto,
     }) => {
-      await goto('/package/is-odd', { waitUntil: 'domcontentloaded' })
+      await goto('/package/is-odd/', { waitUntil: 'domcontentloaded' })
 
       // Wait for package to load
       await expect(page.locator('h1').filter({ hasText: 'is-odd' })).toBeVisible()
@@ -63,7 +63,7 @@ test.describe('Create Command', () => {
 
   test.describe('Copy Functionality', () => {
     test('hovering create command shows copy button', async ({ page, goto }) => {
-      await goto('/package/vite', { waitUntil: 'hydration' })
+      await goto('/package/vite/', { waitUntil: 'hydration' })
 
       await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 })
 
@@ -94,7 +94,7 @@ test.describe('Create Command', () => {
       // Grant clipboard permissions
       await context.grantPermissions(['clipboard-read', 'clipboard-write'])
 
-      await goto('/package/vite', { waitUntil: 'hydration' })
+      await goto('/package/vite/', { waitUntil: 'hydration' })
       await expect(page.locator('h1')).toContainText('vite', { timeout: 15000 })
 
       await expect(page.locator('main header').locator('text=/v\\d+\\.\\d+/')).toBeVisible({
@@ -124,7 +124,7 @@ test.describe('Create Command', () => {
 
   test.describe('Install Command Copy', () => {
     test('hovering install command shows copy button', async ({ page, goto }) => {
-      await goto('/package/is-odd', { waitUntil: 'hydration' })
+      await goto('/package/is-odd/', { waitUntil: 'hydration' })
 
       // Find the install command container
       const installCommandContainer = page.locator('.group\\/installcmd').first()
@@ -149,7 +149,7 @@ test.describe('Create Command', () => {
       // Grant clipboard permissions
       await context.grantPermissions(['clipboard-read', 'clipboard-write'])
 
-      await goto('/package/is-odd', { waitUntil: 'hydration' })
+      await goto('/package/is-odd/', { waitUntil: 'hydration' })
 
       // Find and hover over the install command container
       const installCommandContainer = page.locator('.group\\/installcmd').first()
diff --git a/test/e2e/docs.spec.ts b/test/e2e/docs.spec.ts
index f95a233515..d23e0f808b 100644
--- a/test/e2e/docs.spec.ts
+++ b/test/e2e/docs.spec.ts
@@ -3,7 +3,7 @@ import { expect, test } from './test-utils'
 test.describe('API Documentation Pages', () => {
   test('docs page loads and shows content for a package', async ({ page, goto }) => {
     // Use a small, stable package with TypeScript types
-    await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'networkidle' })
+    await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'networkidle' })
 
     // Page title should include package name
     await expect(page).toHaveTitle(/ufo.*docs/i)
@@ -24,7 +24,7 @@ test.describe('API Documentation Pages', () => {
   })
 
   test('docs page shows TOC sidebar on desktop', async ({ page, goto }) => {
-    await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'networkidle' })
+    await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'networkidle' })
 
     // TOC sidebar should be visible (on desktop viewport)
     const tocSidebar = page.locator('aside')
@@ -38,7 +38,7 @@ test.describe('API Documentation Pages', () => {
   })
 
   test('TOC links navigate to sections', async ({ page, goto }) => {
-    await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'networkidle' })
+    await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'networkidle' })
 
     // Click on Functions in TOC
     const functionsLink = page.locator('aside a[href="#section-function"]')
@@ -53,7 +53,7 @@ test.describe('API Documentation Pages', () => {
   })
 
   test('clicking symbol name scrolls to symbol', async ({ page, goto }) => {
-    await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'networkidle' })
+    await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'networkidle' })
 
     // Find a symbol link in the TOC
     const symbolLink = page.locator('aside a[href^="#function-"]').first()
@@ -67,14 +67,14 @@ test.describe('API Documentation Pages', () => {
   })
 
   test('docs page without version redirects to latest', async ({ page, goto }) => {
-    await goto('/package-docs/ufo', { waitUntil: 'networkidle' })
+    await goto('/package-docs/ufo/', { waitUntil: 'networkidle' })
 
     // Should redirect to include version
     await expect(page).toHaveURL(/\/package-docs\/ufo\/v\//)
   })
 
   test('package link in header navigates to package page', async ({ page, goto }) => {
-    await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'networkidle' })
+    await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'networkidle' })
 
     // Click on package name in header
     const packageLink = page.locator('header a').filter({ hasText: 'ufo' })
@@ -87,7 +87,7 @@ test.describe('API Documentation Pages', () => {
   test('docs page handles package gracefully when types unavailable', async ({ page, goto }) => {
     // Use a simple JS package - the page should load without crashing
     // regardless of whether it has types or shows an error state
-    await goto('/package-docs/is-odd/v/3.0.1', { waitUntil: 'networkidle' })
+    await goto('/package-docs/is-odd/v/3.0.1/', { waitUntil: 'networkidle' })
 
     // Header should always show the package name
     await expect(page.locator('header').getByText('is-odd')).toBeVisible()
@@ -105,7 +105,7 @@ test.describe('API Documentation Pages', () => {
 
 test.describe('Version Selector', () => {
   test('version selector dropdown shows versions', async ({ page, goto }) => {
-    await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'hydration' })
+    await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'hydration' })
 
     // Find and click the version selector button (wait for it to be visible)
     const versionButton = page.locator('header button').filter({ hasText: '1.6.3' })
@@ -123,7 +123,7 @@ test.describe('Version Selector', () => {
   })
 
   test('selecting a version navigates to that version', async ({ page, goto }) => {
-    await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'hydration' })
+    await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'hydration' })
 
     // Find and click the version selector button (wait for it to be visible)
     const versionButton = page.locator('header button').filter({ hasText: '1.6.3' })
@@ -157,7 +157,7 @@ test.describe('Version Selector', () => {
   })
 
   test('escape key closes version dropdown', async ({ page, goto }) => {
-    await goto('/package-docs/ufo/v/1.6.3', { waitUntil: 'hydration' })
+    await goto('/package-docs/ufo/v/1.6.3/', { waitUntil: 'hydration' })
 
     // Wait for version button to be visible
     const versionButton = page.locator('header button').filter({ hasText: '1.6.3' })
diff --git a/test/e2e/hydration.spec.ts b/test/e2e/hydration.spec.ts
index 0f1c6ae9e4..a79fd8674e 100644
--- a/test/e2e/hydration.spec.ts
+++ b/test/e2e/hydration.spec.ts
@@ -3,13 +3,13 @@ import { expect, test } from './test-utils'
 
 const PAGES = [
   '/',
-  '/about',
-  '/settings',
-  '/privacy',
-  '/compare',
-  '/search',
-  '/package/nuxt',
-  '/search?q=vue',
+  '/about/',
+  '/settings/',
+  '/privacy/',
+  '/compare/',
+  '/search/',
+  '/package/nuxt/',
+  '/search/?q=vue',
 ] as const
 
 // ---------------------------------------------------------------------------
diff --git a/test/e2e/interactions.spec.ts b/test/e2e/interactions.spec.ts
index b900bbafa7..2b752d4dc3 100644
--- a/test/e2e/interactions.spec.ts
+++ b/test/e2e/interactions.spec.ts
@@ -2,7 +2,7 @@ import { expect, test } from './test-utils'
 
 test.describe('Compare Page', () => {
   test('no-dep column renders separately from package columns', async ({ page, goto }) => {
-    await goto('/compare?packages=vue,__no_dependency__', { waitUntil: 'hydration' })
+    await goto('/compare/?packages=vue,__no_dependency__', { waitUntil: 'hydration' })
 
     const grid = page.locator('.comparison-grid')
     await expect(grid).toBeVisible({ timeout: 15000 })
@@ -20,7 +20,7 @@ test.describe('Compare Page', () => {
     goto,
   }) => {
     // Start with vue and no-dep
-    await goto('/compare?packages=vue,__no_dependency__', { waitUntil: 'hydration' })
+    await goto('/compare/?packages=vue,__no_dependency__', { waitUntil: 'hydration' })
 
     const grid = page.locator('.comparison-grid')
     await expect(grid).toBeVisible({ timeout: 15000 })
@@ -50,8 +50,8 @@ test.describe('Compare Page', () => {
 })
 
 test.describe('Search Pages', () => {
-  test('/search?q=vue → keyboard navigation (arrow keys + enter)', async ({ page, goto }) => {
-    await goto('/search?q=vue', { waitUntil: 'hydration' })
+  test('/search/?q=vue → keyboard navigation (arrow keys + enter)', async ({ page, goto }) => {
+    await goto('/search/?q=vue', { waitUntil: 'hydration' })
 
     await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({
       timeout: 15000,
@@ -73,7 +73,7 @@ test.describe('Search Pages', () => {
     await expect(page).toHaveURL(/\/(package|org|user)\/vue/)
   })
 
-  test('/search?q=vue → "/" focuses the search input from results', async ({ page, goto }) => {
+  test('/search/?q=vue → "/" focuses the search input from results', async ({ page, goto }) => {
     await goto('/search?q=vue', { waitUntil: 'hydration' })
 
     await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({
@@ -108,8 +108,8 @@ test.describe('Search Pages', () => {
     await expect(headerSearchInput).toBeFocused()
   })
 
-  test('/settings → search, keeps focus on search input', async ({ page, goto }) => {
-    await goto('/settings', { waitUntil: 'hydration' })
+  test('/settings/ → search, keeps focus on search input', async ({ page, goto }) => {
+    await goto('/settings/', { waitUntil: 'hydration' })
 
     const searchInput = page.locator('input[type="search"]')
     await expect(searchInput).toBeVisible()
@@ -130,7 +130,7 @@ test.describe('Search Pages', () => {
 
 test.describe('Keyboard Shortcuts', () => {
   test('"c" navigates to /compare', async ({ page, goto }) => {
-    await goto('/settings', { waitUntil: 'hydration' })
+    await goto('/settings/', { waitUntil: 'hydration' })
 
     await page.keyboard.press('c')
 
@@ -138,7 +138,7 @@ test.describe('Keyboard Shortcuts', () => {
   })
 
   test('"c" does not navigate when any modifier key is pressed', async ({ page, goto }) => {
-    await goto('/settings', { waitUntil: 'hydration' })
+    await goto('/settings/', { waitUntil: 'hydration' })
 
     await page.keyboard.press('Shift+c')
     await expect(page).toHaveURL(/\/settings/)
@@ -156,16 +156,16 @@ test.describe('Keyboard Shortcuts', () => {
     page,
     goto,
   }) => {
-    await goto('/package/vue', { waitUntil: 'hydration' })
+    await goto('/package/vue/', { waitUntil: 'hydration' })
 
     await page.keyboard.press('c')
 
     // Should navigate to /compare with the package in the query
-    await expect(page).toHaveURL(/\/compare\?packages=vue/)
+    await expect(page).toHaveURL(/\/compare\/\?packages=vue/)
   })
 
   test('"c" does not navigate when search input is focused', async ({ page, goto }) => {
-    await goto('/settings', { waitUntil: 'hydration' })
+    await goto('/settings/', { waitUntil: 'hydration' })
 
     const searchInput = page.locator('#header-search')
     await searchInput.focus()
@@ -174,7 +174,7 @@ test.describe('Keyboard Shortcuts', () => {
     await page.keyboard.press('c')
 
     // Should still be on settings, not navigated to compare
-    await expect(page).toHaveURL(/\/settings/)
+    await expect(page).toHaveURL(/\/settings\//)
     // The 'c' should have been typed into the input
     await expect(searchInput).toHaveValue('c')
   })
@@ -183,7 +183,7 @@ test.describe('Keyboard Shortcuts', () => {
     page,
     goto,
   }) => {
-    await goto('/package/vue', { waitUntil: 'hydration' })
+    await goto('/package/vue/', { waitUntil: 'hydration' })
 
     await page.keyboard.press('Shift+c')
     await expect(page).toHaveURL(/\/vue/)
@@ -198,7 +198,7 @@ test.describe('Keyboard Shortcuts', () => {
   })
 
   test('"," navigates to /settings', async ({ page, goto }) => {
-    await goto('/compare', { waitUntil: 'hydration' })
+    await goto('/compare/', { waitUntil: 'hydration' })
 
     await page.keyboard.press(',')
 
@@ -206,7 +206,7 @@ test.describe('Keyboard Shortcuts', () => {
   })
 
   test('"," does not navigate when any modifier key is pressed', async ({ page, goto }) => {
-    await goto('/settings', { waitUntil: 'hydration' })
+    await goto('/settings/', { waitUntil: 'hydration' })
 
     const searchInput = page.locator('#header-search')
     await searchInput.focus()
@@ -225,7 +225,7 @@ test.describe('Keyboard Shortcuts', () => {
   })
 
   test('"," does not navigate when search input is focused', async ({ page, goto }) => {
-    await goto('/compare', { waitUntil: 'hydration' })
+    await goto('/compare/', { waitUntil: 'hydration' })
 
     const searchInput = page.locator('#header-search')
     await searchInput.focus()
diff --git a/test/e2e/package-manager-select.spec.ts b/test/e2e/package-manager-select.spec.ts
index e5a185f741..8c5111c5cb 100644
--- a/test/e2e/package-manager-select.spec.ts
+++ b/test/e2e/package-manager-select.spec.ts
@@ -2,7 +2,7 @@ import { expect, test } from './test-utils'
 
 test.describe('Package Page', () => {
   test('/vue → package manager select dropdown works', async ({ page, goto }) => {
-    await goto('/package/vue', { waitUntil: 'hydration' })
+    await goto('/package/vue/', { waitUntil: 'hydration' })
 
     await expect(page.locator('h1')).toContainText('vue', { timeout: 15000 })
 
diff --git a/test/e2e/url-compatibility.spec.ts b/test/e2e/url-compatibility.spec.ts
index b555d7605e..993d0e2cdc 100644
--- a/test/e2e/url-compatibility.spec.ts
+++ b/test/e2e/url-compatibility.spec.ts
@@ -3,7 +3,7 @@ import { expect, test } from './test-utils'
 test.describe('npmjs.com URL Compatibility', () => {
   test.describe('Package Pages', () => {
     test('/package/vue → package page', async ({ page, goto }) => {
-      await goto('/package/vue', { waitUntil: 'domcontentloaded' })
+      await goto('/package/vue/', { waitUntil: 'domcontentloaded' })
 
       // Should show package name
       await expect(page.locator('h1')).toContainText('vue')
@@ -12,14 +12,14 @@ test.describe('npmjs.com URL Compatibility', () => {
     })
 
     test('/package/@nuxt/kit → scoped package page', async ({ page, goto }) => {
-      await goto('/package/@nuxt/kit', { waitUntil: 'domcontentloaded' })
+      await goto('/package/@nuxt/kit/', { waitUntil: 'domcontentloaded' })
 
       // Should show scoped package name
       await expect(page.locator('h1')).toContainText('@nuxt/kit')
     })
 
     test('/package/vue/v/3.5.27 → specific version', async ({ page, goto }) => {
-      await goto('/package/vue/v/3.5.27', { waitUntil: 'domcontentloaded' })
+      await goto('/package/vue/v/3.5.27/', { waitUntil: 'domcontentloaded' })
 
       // Should show package name
       await expect(page.locator('h1')).toContainText('vue')
@@ -31,7 +31,7 @@ test.describe('npmjs.com URL Compatibility', () => {
       page,
       goto,
     }) => {
-      await goto('/package/@nuxt/kit/v/3.20.0', { waitUntil: 'domcontentloaded' })
+      await goto('/package/@nuxt/kit/v/3.20.0/', { waitUntil: 'domcontentloaded' })
 
       // Should show scoped package name
       await expect(page.locator('h1')).toContainText('@nuxt/kit')
@@ -40,7 +40,7 @@ test.describe('npmjs.com URL Compatibility', () => {
     })
 
     test('/package/nonexistent-pkg-12345 → 404 handling', async ({ page, goto }) => {
-      await goto('/package/nonexistent-pkg-12345', { waitUntil: 'domcontentloaded' })
+      await goto('/package/nonexistent-pkg-12345/', { waitUntil: 'domcontentloaded' })
 
       // Should show error state - look for the heading specifically
       await expect(page.getByRole('heading', { name: /not found/i })).toBeVisible()
@@ -49,7 +49,7 @@ test.describe('npmjs.com URL Compatibility', () => {
 
   test.describe('Search Pages', () => {
     test('/search?q=vue → search results', async ({ page, goto }) => {
-      await goto('/search?q=vue', { waitUntil: 'domcontentloaded' })
+      await goto('/search/?q=vue', { waitUntil: 'domcontentloaded' })
 
       // Should show search input with query
       await expect(page.locator('input[type="search"]')).toHaveValue('vue')
@@ -58,7 +58,7 @@ test.describe('npmjs.com URL Compatibility', () => {
     })
 
     test('/search?q=keywords:framework → keyword search', async ({ page, goto }) => {
-      await goto('/search?q=keywords:framework', { waitUntil: 'domcontentloaded' })
+      await goto('/search/?q=keywords:framework', { waitUntil: 'domcontentloaded' })
 
       // Should show search input with query
       await expect(page.locator('input[type="search"]')).toHaveValue('keywords:framework')
@@ -67,7 +67,7 @@ test.describe('npmjs.com URL Compatibility', () => {
     })
 
     test('/search → empty search page', async ({ page, goto }) => {
-      await goto('/search', { waitUntil: 'domcontentloaded' })
+      await goto('/search/', { waitUntil: 'domcontentloaded' })
 
       // Should show empty state prompt
       await expect(page.locator('text=/start typing/i')).toBeVisible()
@@ -76,7 +76,7 @@ test.describe('npmjs.com URL Compatibility', () => {
 
   test.describe('User Profile Pages', () => {
     test('/~qwerzl → user profile', async ({ page, goto }) => {
-      await goto('/~qwerzl', { waitUntil: 'hydration' })
+      await goto('/~qwerzl/', { waitUntil: 'hydration' })
 
       // Should show username
       await expect(page.locator('h1')).toContainText('~qwerzl')
@@ -87,7 +87,7 @@ test.describe('npmjs.com URL Compatibility', () => {
     })
 
     test('/~nonexistent-user-12345 → empty user handling', async ({ page, goto }) => {
-      await goto('/~nonexistent-user-12345', { waitUntil: 'domcontentloaded' })
+      await goto('/~nonexistent-user-12345/', { waitUntil: 'domcontentloaded' })
 
       // Should show username in header
       await expect(page.locator('h1')).toContainText('~nonexistent-user-12345')
@@ -98,7 +98,7 @@ test.describe('npmjs.com URL Compatibility', () => {
 
   test.describe('Organization Pages', () => {
     test('/org/nuxt → organization page', async ({ page, goto }) => {
-      await goto('/org/nuxt', { waitUntil: 'domcontentloaded' })
+      await goto('/org/nuxt/', { waitUntil: 'domcontentloaded' })
 
       // Should show org name
       await expect(page.locator('h1')).toContainText('@nuxt')
@@ -107,7 +107,7 @@ test.describe('npmjs.com URL Compatibility', () => {
     })
 
     test('/org/nonexistent-org-12345 → 404 handling', async ({ page, goto }) => {
-      await goto('/org/nonexistent-org-12345', { waitUntil: 'domcontentloaded' })
+      await goto('/org/nonexistent-org-12345/', { waitUntil: 'domcontentloaded' })
 
       // Should show 404 error page
       await expect(page.locator('h1')).toContainText('Organization not found')
@@ -116,19 +116,19 @@ test.describe('npmjs.com URL Compatibility', () => {
 
   test.describe('Edge Cases', () => {
     test('package name with dots: /package/lodash.merge', async ({ page, goto }) => {
-      await goto('/package/lodash.merge', { waitUntil: 'domcontentloaded' })
+      await goto('/package/lodash.merge/', { waitUntil: 'domcontentloaded' })
 
       await expect(page.locator('h1')).toContainText('lodash.merge')
     })
 
     test('package name with hyphens: /package/is-odd', async ({ page, goto }) => {
-      await goto('/package/is-odd', { waitUntil: 'domcontentloaded' })
+      await goto('/package/is-odd/', { waitUntil: 'domcontentloaded' })
 
       await expect(page.locator('h1')).toContainText('is-odd')
     })
 
     test('scoped package with hyphens: /package/@types/node', async ({ page, goto }) => {
-      await goto('/package/@types/node', { waitUntil: 'domcontentloaded' })
+      await goto('/package/@types/node/', { waitUntil: 'domcontentloaded' })
 
       await expect(page.locator('h1')).toContainText('@types/node')
     })

From 1c2f2414e90dfbaaa054c3b7bb1a5a00996ec717 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Mon, 23 Feb 2026 09:09:51 +0000
Subject: [PATCH 12/20] test: disable default trailing slash for external links

---
 app/components/Link/Base.vue | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/app/components/Link/Base.vue b/app/components/Link/Base.vue
index e6bfbabca7..bcb5b025bb 100644
--- a/app/components/Link/Base.vue
+++ b/app/components/Link/Base.vue
@@ -36,9 +36,9 @@ const props = withDefaults(
     /** should only be used for links where the context makes it very clear they are clickable. Don't just use this, because you don't like underlines. */
     noUnderline?: boolean
 
-    trailingSlash?: 'append' | 'remove' | undefined
+    trailingSlash?: 'append' | 'remove' | undefined | null
   }>(),
-  { variant: 'link', size: 'medium', trailingSlash: 'append' },
+  { variant: 'link', size: 'medium' },
 )
 
 const isLinkExternal = computed(
@@ -76,7 +76,7 @@ const isButtonMedium = computed(() => props.size === 'medium' && !isLink.value)
   <NuxtLink
     v-bind="props"
     v-else
-    :trailing-slash="trailingSlash"
+    :trailing-slash="isLinkExternal ? undefined : (trailingSlash ?? 'append')"
     class="group/link gap-x-1 items-center"
     :class="{
       'flex': block,

From 8862b9a00b80e7118b263a81d03ddb4b368721c8 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Mon, 23 Feb 2026 09:17:06 +0000
Subject: [PATCH 13/20] test: fix trailing slash in tests

---
 test/e2e/create-command.spec.ts | 4 ++--
 test/e2e/docs.spec.ts           | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/test/e2e/create-command.spec.ts b/test/e2e/create-command.spec.ts
index a947685ec4..9bb2cb1f71 100644
--- a/test/e2e/create-command.spec.ts
+++ b/test/e2e/create-command.spec.ts
@@ -12,7 +12,7 @@ test.describe('Create Command', () => {
       await expect(createCommandSection.locator('code')).toContainText(/create vite/i)
 
       // Link to create-vite should be present (uses sr-only text, so check attachment not visibility)
-      await expect(page.locator('a[href="/package/create-vite"]').first()).toBeAttached()
+      await expect(page.locator('a[href="/package/create-vite/"]').first()).toBeAttached()
     })
 
     test('/next - should show create command (shared maintainer, same repo)', async ({
@@ -28,7 +28,7 @@ test.describe('Create Command', () => {
       await expect(createCommandSection.locator('code')).toContainText(/create next-app/i)
 
       // Link to create-next-app should be present (uses sr-only text, so check attachment not visibility)
-      await expect(page.locator('a[href="/package/create-next-app"]').first()).toBeAttached()
+      await expect(page.locator('a[href="/package/create-next-app/"]').first()).toBeAttached()
     })
 
     test('/nuxt - should show create command (same maintainer, same org)', async ({
diff --git a/test/e2e/docs.spec.ts b/test/e2e/docs.spec.ts
index d23e0f808b..aa456799d9 100644
--- a/test/e2e/docs.spec.ts
+++ b/test/e2e/docs.spec.ts
@@ -81,7 +81,7 @@ test.describe('API Documentation Pages', () => {
     await packageLink.click()
 
     // Should navigate to package page (URL ends with /ufo)
-    await expect(page).toHaveURL(/\/package\/ufo$/)
+    await expect(page).toHaveURL(/\/package\/ufo\/$/)
   })
 
   test('docs page handles package gracefully when types unavailable', async ({ page, goto }) => {

From 2588604cc150736b6df5dd74be4b58da3e783158 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Mon, 23 Feb 2026 11:18:00 +0000
Subject: [PATCH 14/20] test: fix trailing slash in tests

---
 test/e2e/interactions.spec.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/e2e/interactions.spec.ts b/test/e2e/interactions.spec.ts
index 2b752d4dc3..16a93afc3e 100644
--- a/test/e2e/interactions.spec.ts
+++ b/test/e2e/interactions.spec.ts
@@ -74,7 +74,7 @@ test.describe('Search Pages', () => {
   })
 
   test('/search/?q=vue → "/" focuses the search input from results', async ({ page, goto }) => {
-    await goto('/search?q=vue', { waitUntil: 'hydration' })
+    await goto('/search/?q=vue', { waitUntil: 'hydration' })
 
     await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({
       timeout: 15000,

From 9d32dbbf9ca8487c620930238988e8a9de19f487 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Mon, 23 Feb 2026 12:32:22 +0000
Subject: [PATCH 15/20] test: update trailing slash middleware

---
 app/middleware/trailing-slash.global.ts |  4 +++-
 test/e2e/connector.spec.ts              | 12 ++++++------
 2 files changed, 9 insertions(+), 7 deletions(-)

diff --git a/app/middleware/trailing-slash.global.ts b/app/middleware/trailing-slash.global.ts
index 113c2875ae..fcfff4bff6 100644
--- a/app/middleware/trailing-slash.global.ts
+++ b/app/middleware/trailing-slash.global.ts
@@ -8,11 +8,13 @@
  * - /docs/getting-started/?query=value → /docs/getting-started?query=value
  */
 export default defineNuxtRouteMiddleware(to => {
+  if (import.meta.prerender) return
+
   if (import.meta.server) {
     const event = useRequestEvent()
     const url = event?.node.req.originalUrl || event?.node.req.url || ''
 
-    if (url.includes('_payload')) return
+    if (url.includes('/_payload.json')) return
   }
 
   if (to.path.startsWith('/package-code/') || to.path.startsWith('/api/')) return
diff --git a/test/e2e/connector.spec.ts b/test/e2e/connector.spec.ts
index bfac882712..8eefa1466a 100644
--- a/test/e2e/connector.spec.ts
+++ b/test/e2e/connector.spec.ts
@@ -106,7 +106,7 @@ test.describe('Organization Management', () => {
   })
 
   test('shows org members when connected', async ({ page, gotoConnected }) => {
-    await gotoConnected('/@testorg/')
+    await gotoConnected('/@testorg')
 
     // The org management region contains the members panel
     const orgManagement = page.getByRole('region', { name: /organization management/i })
@@ -123,7 +123,7 @@ test.describe('Organization Management', () => {
   })
 
   test('can filter members by role', async ({ page, gotoConnected }) => {
-    await gotoConnected('/@testorg/')
+    await gotoConnected('/@testorg')
 
     const orgManagement = page.getByRole('region', { name: /organization management/i })
     await expect(orgManagement).toBeVisible({ timeout: 10_000 })
@@ -144,7 +144,7 @@ test.describe('Organization Management', () => {
   })
 
   test('can search members by name', async ({ page, gotoConnected }) => {
-    await gotoConnected('/@testorg/')
+    await gotoConnected('/@testorg')
 
     const orgManagement = page.getByRole('region', { name: /organization management/i })
     await expect(orgManagement).toBeVisible({ timeout: 10_000 })
@@ -162,7 +162,7 @@ test.describe('Organization Management', () => {
   })
 
   test('can add a new member operation', async ({ page, gotoConnected, mockConnector }) => {
-    await gotoConnected('/@testorg/')
+    await gotoConnected('/@testorg')
 
     const orgManagement = page.getByRole('region', { name: /organization management/i })
     await expect(orgManagement).toBeVisible({ timeout: 10_000 })
@@ -194,7 +194,7 @@ test.describe('Organization Management', () => {
   })
 
   test('can remove a member (adds operation)', async ({ page, gotoConnected, mockConnector }) => {
-    await gotoConnected('/@testorg/')
+    await gotoConnected('/@testorg')
 
     const orgManagement = page.getByRole('region', { name: /organization management/i })
     await expect(orgManagement).toBeVisible({ timeout: 10_000 })
@@ -215,7 +215,7 @@ test.describe('Organization Management', () => {
     gotoConnected,
     mockConnector,
   }) => {
-    await gotoConnected('/@testorg/')
+    await gotoConnected('/@testorg')
 
     const orgManagement = page.getByRole('region', { name: /organization management/i })
     await expect(orgManagement).toBeVisible({ timeout: 10_000 })

From dbcffe161669bfc8abc0bd7c1d71ac3082613750 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Mon, 23 Feb 2026 13:38:09 +0000
Subject: [PATCH 16/20] fix: improve trailing slash detection logic

---
 app/middleware/trailing-slash.global.ts | 31 +++++++++++++++++--------
 package.json                            |  4 ++--
 2 files changed, 23 insertions(+), 12 deletions(-)

diff --git a/app/middleware/trailing-slash.global.ts b/app/middleware/trailing-slash.global.ts
index fcfff4bff6..ce2e389cf7 100644
--- a/app/middleware/trailing-slash.global.ts
+++ b/app/middleware/trailing-slash.global.ts
@@ -1,23 +1,34 @@
 /**
- * Removes trailing slashes from URLs.
+ * Adds trailing slashes to URLs.
  *
- * This middleware only runs in development to maintain consistent behavior.
- * In production, Vercel handles this redirect via vercel.json.
+ * We use middleware to correctly handle all the cases, since Vercel doesn't handle it properly for nuxt app.
  *
- * - /package/vue/ → /package/vue
- * - /docs/getting-started/?query=value → /docs/getting-started?query=value
+ * - /package/vue → /package/vue/
+ * - /docs/getting-started?query=value → /docs/getting-started/?query=value
  */
 export default defineNuxtRouteMiddleware(to => {
-  if (import.meta.prerender) return
+  // request is marked as prerender for build and server ISR
+  // ignore rewrite for build prerender only
+  if (import.meta.prerender && process.env.NUXT_STAGE === 'build') return
+
+  // ignore for package-code (file viewer shouldn't relate on trailing slash) and api routes
+  if (
+    to.path.startsWith('/package-code/') ||
+    to.path.startsWith('/api/') ||
+    to.path.endsWith('/_payload.json')
+  )
+    return
 
   if (import.meta.server) {
     const event = useRequestEvent()
-    const url = event?.node.req.originalUrl || event?.node.req.url || ''
 
-    if (url.includes('/_payload.json')) return
-  }
+    // requests to /page-path/_payload.json are handled with to.path="/page-path", so we use the original url to check properly
+    const urlRaw = event?.node.req.originalUrl || event?.node.req.url || ''
+    const originalUrl = new URL(urlRaw, 'http://npmx.dev')
 
-  if (to.path.startsWith('/package-code/') || to.path.startsWith('/api/')) return
+    if (originalUrl.pathname.includes('/_payload.json') || originalUrl.pathname.endsWith('/'))
+      return
+  }
 
   if (to.path !== '' && !to.path.endsWith('/')) {
     return navigateTo(
diff --git a/package.json b/package.json
index 8454afa114..59eb46183c 100644
--- a/package.json
+++ b/package.json
@@ -10,9 +10,9 @@
     "url": "https://roe.dev"
   },
   "scripts": {
-    "build": "nuxt build",
+    "build": "NUXT_STAGE=build nuxt build",
     "build:lunaria": "node ./lunaria/lunaria.ts",
-    "build:test": "NODE_ENV=test pnpm build",
+    "build:test": "NUXT_STAGE=build NODE_ENV=test pnpm build",
     "dev": "nuxt dev",
     "dev:docs": "pnpm run --filter npmx-docs dev --port=3001",
     "i18n:check": "node scripts/compare-translations.ts",

From 575fa898f6b588b20cea028f6a6deac2ebd947eb Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Mon, 23 Feb 2026 14:00:18 +0000
Subject: [PATCH 17/20] fix: restore isr configs for dynamic pages

---
 nuxt.config.ts | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/nuxt.config.ts b/nuxt.config.ts
index 33d9fc2a04..d7c395b493 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -134,10 +134,10 @@ export default defineNuxtConfig({
       },
     },
     // pages
-    '/package/:name': getISRConfig(60),
-    '/package/:name/v/:version': getISRConfig(60),
-    '/package/:org/:name': getISRConfig(60),
-    '/package/:org/:name/v/:version': getISRConfig(60),
+    '/package/:name': getISRConfig(60, { fallback: 'html' }),
+    '/package/:name/v/:version': getISRConfig(60, { fallback: 'html' }),
+    '/package/:org/:name': getISRConfig(60, { fallback: 'html' }),
+    '/package/:org/:name/v/:version': getISRConfig(60, { fallback: 'html' }),
     // infinite cache (versioned - doesn't change)
     '/package-code/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
     '/package-docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },

From e33959f0d1a43a22a87a46364447867b3cf0e216 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Mon, 23 Feb 2026 14:11:47 +0000
Subject: [PATCH 18/20] fix: revert isr configs for dynamic pages

---
 nuxt.config.ts | 12 +++++-------
 1 file changed, 5 insertions(+), 7 deletions(-)

diff --git a/nuxt.config.ts b/nuxt.config.ts
index c46c219202..a2aaa277ec 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -134,10 +134,10 @@ export default defineNuxtConfig({
       },
     },
     // pages
-    '/package/:name': getISRConfig(60, { fallback: 'html' }),
-    '/package/:name/v/:version': getISRConfig(60, { fallback: 'html' }),
-    '/package/:org/:name': getISRConfig(60, { fallback: 'html' }),
-    '/package/:org/:name/v/:version': getISRConfig(60, { fallback: 'html' }),
+    '/package/:name': getISRConfig(60),
+    '/package/:name/v/:version': getISRConfig(3600),
+    '/package/:org/:name': getISRConfig(60),
+    '/package/:org/:name/v/:version': getISRConfig(3600),
     // infinite cache (versioned - doesn't change)
     '/package-code/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
     '/package-docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
@@ -371,8 +371,6 @@ function getISRConfig(expirationSeconds: number, options: ISRConfigOptions = {})
     }
   }
   return {
-    isr: {
-      expiration: expirationSeconds,
-    },
+    isr: expirationSeconds,
   }
 }

From 570dc335bee98157c3416598ad1e5370f85e1cd2 Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Mon, 23 Feb 2026 14:19:56 +0000
Subject: [PATCH 19/20] fix: add trailing slash to dynamic route rules

---
 nuxt.config.ts | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/nuxt.config.ts b/nuxt.config.ts
index a2aaa277ec..da522148ea 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -92,6 +92,7 @@ export default defineNuxtConfig({
   router: {
     options: {
       scrollBehaviorType: 'smooth',
+      strict: true,
     },
   },
 
@@ -138,6 +139,10 @@ export default defineNuxtConfig({
     '/package/:name/v/:version': getISRConfig(3600),
     '/package/:org/:name': getISRConfig(60),
     '/package/:org/:name/v/:version': getISRConfig(3600),
+    '/package/:name/': getISRConfig(60),
+    '/package/:name/v/:version/': getISRConfig(3600),
+    '/package/:org/:name/': getISRConfig(60),
+    '/package/:org/:name/v/:version/': getISRConfig(3600),
     // infinite cache (versioned - doesn't change)
     '/package-code/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },
     '/package-docs/**': { isr: true, cache: { maxAge: 365 * 24 * 60 * 60 } },

From 6b6e4101548ec55c0d0695a39d921cc5567a051d Mon Sep 17 00:00:00 2001
From: Vordgi <sasha2822222@gmail.com>
Date: Mon, 23 Feb 2026 14:29:53 +0000
Subject: [PATCH 20/20] fix: keep paths only with trailing slash

---
 nuxt.config.ts | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/nuxt.config.ts b/nuxt.config.ts
index da522148ea..76e01eef67 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -135,10 +135,6 @@ export default defineNuxtConfig({
       },
     },
     // pages
-    '/package/:name': getISRConfig(60),
-    '/package/:name/v/:version': getISRConfig(3600),
-    '/package/:org/:name': getISRConfig(60),
-    '/package/:org/:name/v/:version': getISRConfig(3600),
     '/package/:name/': getISRConfig(60),
     '/package/:name/v/:version/': getISRConfig(3600),
     '/package/:org/:name/': getISRConfig(60),
