Overview
The Modrinth web interface is built with Nuxt 3, providing a server-side rendered (SSR) web application for browsing and managing Minecraft mods and modpacks.
Location : apps/frontend/
Framework : Nuxt 3
UI Library : Vue 3 + Tailwind CSS v3
Deployment : Cloudflare Pages
Architecture
Nuxt 3 with SSR
The frontend uses Nuxt 3’s hybrid rendering:
Server-Side Rendering (SSR) : Pages are rendered on the server for SEO and initial load performance
Client-Side Hydration : Vue takes over for interactivity after initial render
SPA Navigation : Subsequent navigation is client-side for speed
User Request → Nuxt Server → Render Vue → HTML → Browser → Hydrate → Interactive
Data Fetching Flow
// Server-side (initial page load)
const { data } = useQuery ({
queryKey: [ 'project' , projectId ],
queryFn : () => client . labrinth . projects_v3 . get ( projectId ),
})
// → Uses $fetch on server
// → Serialized to page HTML
// → Hydrated on client
// Client-side (subsequent navigation)
// → Uses ofetch from @modrinth/api-client
// → Cached by TanStack Query
Directory Structure
apps/frontend/
├── src/
│ ├── pages/ # File-based routing
│ │ ├── index.vue # Homepage (modrinth.com/)
│ │ ├── [type]/ # Project type pages
│ │ │ ├── [id]/ # Project detail page
│ │ │ │ └── version/
│ │ │ │ └── [version].vue
│ │ ├── dashboard/ # User dashboard
│ │ ├── settings/ # User settings
│ │ └── ...
│ ├── components/ # Website-specific components
│ │ ├── ui/ # UI components
│ │ ├── project/ # Project-related components
│ │ ├── moderation/ # Moderation tools
│ │ └── ...
│ ├── composables/ # Vue composables
│ │ ├── queries/ # TanStack Query options
│ │ ├── fetch.js # (deprecated)
│ │ └── ...
│ ├── providers/ # DI context providers
│ │ ├── version/ # Version modal provider
│ │ └── project/ # Project page provider
│ ├── plugins/ # Nuxt plugins
│ │ ├── 01.tanstack-query.ts
│ │ ├── 02.api-client.ts
│ │ └── ...
│ ├── middleware/ # Route guards
│ │ ├── auth.ts # Authentication check
│ │ └── ...
│ ├── layouts/ # Page layouts
│ │ ├── default.vue # Default layout
│ │ └── dashboard.vue # Dashboard layout
│ ├── server/ # Server-side code
│ │ ├── routes/ # Server API routes
│ │ └── middleware/ # Server middleware
│ ├── store/ # Pinia stores
│ │ ├── auth.ts # Auth state
│ │ ├── cosmetics.ts # User cosmetics
│ │ └── ...
│ ├── helpers/ # Utility functions
│ ├── locales/ # i18n translations
│ └── app.vue # Root component
├── public/ # Static assets
├── .env.local # Environment template
├── nuxt.config.ts # Nuxt configuration
└── package.json
File-Based Routing
Nuxt uses the pages/ directory for automatic routing:
pages/
├── index.vue → /
├── [type]/
│ ├── [id].vue → /mod/sodium, /modpack/fabulously-optimized
│ └── [id]/
│ └── version/
│ └── [version].vue → /mod/sodium/version/abc123
├── dashboard/
│ ├── index.vue → /dashboard
│ ├── projects.vue → /dashboard/projects
│ └── collections.vue → /dashboard/collections
└── settings/
├── index.vue → /settings
└── account.vue → /settings/account
Dynamic Routes
< script setup lang = "ts" >
const route = useRoute ()
const projectId = route . params . id as string
const projectType = route . params . type as string
const { data : project } = useQuery ({
queryKey: [ 'project' , projectId ],
queryFn : () => client . labrinth . projects_v3 . get ( projectId ),
})
</ script >
< template >
< div >
< h1 > {{ project ?. title }} </ h1 >
< p > {{ project ?. description }} </ p >
</ div >
</ template >
Components
Website-Specific vs Shared
Website-specific components (src/components/):
Admin panels
Moderation tools
Dashboard widgets
Brand-specific components
Anything that depends on Nuxt APIs
Shared components (packages/ui/src/components/):
Buttons, inputs, modals
Project cards
Version lists
Anything reusable across web and app
Rule of thumb: If it doesn’t depend on Nuxt-specific APIs or website-only features, it belongs in packages/ui.
Component Example
src/components/project/ProjectGallery.vue
< script setup lang = "ts" >
import type { Project } from '@modrinth/api-client'
interface Props {
project : Project
}
const props = defineProps < Props >()
const selectedImage = ref ( 0 )
</ script >
< template >
< div class = "gallery" >
< div class = "gallery-main" >
< img
: src = " project . gallery [ selectedImage ] "
: alt = " project . title "
class = "w-full rounded-lg"
/>
</ div >
< div class = "gallery-thumbnails" >
< img
v-for = " ( image , index ) in project . gallery "
: key = " index "
: src = " image "
@ click = " selectedImage = index "
class = "thumbnail"
: class = " { active: index === selectedImage } "
/>
</ div >
</ div >
</ template >
< style scoped >
.gallery-main {
@ apply bg-surface- 4 rounded-lg overflow-hidden ;
}
.thumbnail {
@ apply w- 20 h- 20 object-cover rounded cursor-pointer ;
@ apply border- 2 border-transparent ;
@ apply hover :border-brand transition-colors;
}
.thumbnail.active {
@ apply border-brand ;
}
</ style >
Data Fetching
API Client
Use @modrinth/api-client via injectModrinthClient() for all API calls:
import { injectModrinthClient } from '@modrinth/ui'
const { labrinth , archon } = injectModrinthClient ()
// Fetch project
const project = await labrinth . projects_v3 . get ( 'sodium' )
// Fetch user's servers
const servers = await archon . servers_v1 . list ()
The client is provided in src/app.vue:
import { NuxtModrinthClient , AuthFeature } from '@modrinth/api-client'
import { provideModrinthClient } from '@modrinth/ui'
const client = new NuxtModrinthClient ({
userAgent: 'modrinth/web' ,
features: [
new AuthFeature ({
token : async () => auth . value . token ,
}),
],
})
provideModrinthClient ( client )
TanStack Query
Use TanStack Query (@tanstack/vue-query) for server state management:
import { useQuery , useMutation } from '@tanstack/vue-query'
import { injectModrinthClient } from '@modrinth/ui'
const { labrinth } = injectModrinthClient ()
// Query
const { data , isLoading , error } = useQuery ({
queryKey: [ 'project' , projectId ],
queryFn : () => labrinth . projects_v3 . get ( projectId ),
staleTime: 5 * 60 * 1000 , // 5 minutes
})
// Mutation
const { mutateAsync : updateProject } = useMutation ({
mutationFn : ( data ) => labrinth . projects_v3 . update ( projectId , data ),
onSuccess : () => {
// Invalidate cache
queryClient . invalidateQueries ({ queryKey: [ 'project' , projectId ] })
},
})
See the tanstack-query skill (.claude/skills/tanstack-query/SKILL.md) for patterns and conventions.
Deprecated Composables
These composables are deprecated and should not be used in new code:
useAsyncData - Use TanStack Query instead
useBaseFetch - Use client.labrinth.* modules instead
useServersFetch - Use client.archon.* modules instead
State Management
Pinia Stores
Client-side state is managed with Pinia:
import { defineStore } from 'pinia'
import type { User } from '@modrinth/api-client'
export const useAuth = defineStore ( 'auth' , () => {
const user = ref < User | null >( null )
const token = ref < string | null >( null )
const isAuthenticated = computed (() => user . value !== null )
function setUser ( newUser : User , newToken : string ) {
user . value = newUser
token . value = newToken
localStorage . setItem ( 'auth_token' , newToken )
}
function logout () {
user . value = null
token . value = null
localStorage . removeItem ( 'auth_token' )
}
return { user , token , isAuthenticated , setUser , logout }
})
Usage:
< script setup >
import { useAuth } from '~/store/auth'
const auth = useAuth ()
</ script >
< template >
< div v-if = " auth . isAuthenticated " >
Welcome, {{ auth . user . username }}!
</ div >
</ template >
Server State (TanStack Query)
For data from the API, always use TanStack Query instead of Pinia.
Styling
Tailwind CSS
All styling uses Tailwind CSS with semantic color variables.
Surface Colors (Backgrounds)
Use surface-* variables for backgrounds:
Class Usage bg-surface-1Deepest background layer bg-surface-1.5Odd row background (tables) bg-surface-2Even row, secondary panels bg-surface-3Headers, floating bars, inputs bg-surface-4Cards, elevated surfaces bg-surface-5Borders, dividers
Text Colors
Class Usage text-contrastPrimary headings text-primaryDefault body text text-secondaryReduced emphasis, secondary info
Brand Colors
<!-- Brand color -->
< button class = "bg-brand text-white" > Primary Action </ button >
<!-- Semantic colors -->
< div class = "bg-red text-red" > Error message </ div >
< div class = "bg-green text-green" > Success message </ div >
< div class = "bg-orange text-orange" > Warning message </ div >
Example
< template >
< div class = "bg-surface-4 rounded-lg p-4" >
< h2 class = "text-contrast text-xl font-bold mb-2" > {{ title }} </ h2 >
< p class = "text-primary mb-4" > {{ description }} </ p >
< button class = "bg-brand text-white px-4 py-2 rounded" >
Learn More
</ button >
</ div >
</ template >
Never use direct color values like bg-gray-800 or text-white. Always use semantic variables for theme compatibility.
Scoped Styles
Use scoped styles for component-specific CSS:
< style scoped >
.custom-card {
@ apply bg-surface- 4 rounded-lg p- 6;
@ apply border border-surface- 5;
}
.custom-card:hover {
@ apply border-brand ;
}
</ style >
Layouts
Layouts wrap pages with common UI elements:
< template >
< div class = "min-h-screen bg-surface-1" >
< NavBar />
< main class = "container mx-auto px-4 py-8" >
< slot / >
</ main >
< Footer / >
</ div >
</ template >
Use in pages:
< script setup >
definePageMeta ({
layout: 'default' ,
})
</ script >
Middleware
Route guards run before navigation:
export default defineNuxtRouteMiddleware (( to , from ) => {
const auth = useAuth ()
if ( ! auth . isAuthenticated ) {
return navigateTo ( '/login' )
}
} )
Use in pages:
pages/dashboard/index.vue
< script setup >
definePageMeta ({
middleware: 'auth' ,
})
</ script >
i18n (Internationalization)
The frontend supports 34 languages using FormatJS.
Translations : packages/ui/src/locales/
< script setup >
import { useI18n } from 'vue-i18n'
const { t } = useI18n ()
</ script >
< template >
< h1 > {{ t ( 'common.welcome' ) }} </ h1 >
< p > {{ t ( 'project.downloads' , { count: downloadCount }) }} </ p >
</ template >
Dependency Injection
Services are provided via Vue’s provide/inject using the createContext pattern:
import { createContext } from '@modrinth/ui'
const [ provideMyService , injectMyService ] = createContext < MyService >( 'MyService' )
// Provide
const service = new MyService ()
provideMyService ( service )
// Inject
const service = injectMyService ()
See the dependency-injection skill (.claude/skills/dependency-injection/SKILL.md) for details.
Development
Running Locally
# Install dependencies (from root)
pnpm install
# Copy environment file
cd apps/frontend
cp .env.local .env
# Start dev server (from root)
pnpm web:dev
The website will be available at http://localhost:3000
Hot Module Replacement
Vite provides instant HMR for:
Vue components
CSS/Tailwind
TypeScript/JavaScript
Changes appear in the browser without full page reload.
Environment Variables
# API endpoints
NUXT_PUBLIC_LABRINTH_URL = https://api.modrinth.com
NUXT_PUBLIC_ARCHON_URL = https://archon.modrinth.com
# Auth (for local development, use test keys)
NUXT_PUBLIC_GITHUB_CLIENT_ID = ...
# Rate limiting (server-side only)
RATE_LIMIT_KEY = ...
Building
Development Build
Production Build (Cloudflare Pages)
This uses the Cloudflare Pages Nitro preset.
Pre-PR Checks
Before opening a PR:
# Run all checks (from root)
pnpm prepr:frontend:web
# Or run individually:
pnpm --filter @modrinth/frontend run lint # ESLint
pnpm --filter @modrinth/frontend run fix # Auto-fix
pnpm --filter @modrinth/frontend run type-check # TypeScript
Common Patterns
Loading States
< script setup >
const { data : project , isLoading } = useQuery ({
queryKey: [ 'project' , projectId ],
queryFn : () => labrinth . projects_v3 . get ( projectId ),
})
</ script >
< template >
< div v-if = " isLoading " >
< LoadingSpinner />
</ div >
< div v-else-if = " project " >
< ProjectDetail : project = " project " />
</ div >
</ template >
Error Handling
< script setup >
const { data , error , isError } = useQuery ({
queryKey: [ 'project' , projectId ],
queryFn : () => labrinth . projects_v3 . get ( projectId ),
})
</ script >
< template >
< div v-if = " isError " class = "bg-red-highlight text-red p-4 rounded" >
< p > {{ error . message }} </ p >
</ div >
</ template >
import { useInfiniteQuery } from '@tanstack/vue-query'
const { data , fetchNextPage , hasNextPage } = useInfiniteQuery ({
queryKey: [ 'projects' , filters ],
queryFn : ({ pageParam = 0 }) =>
labrinth . projects_v3 . search ({ offset: pageParam , limit: 20 }),
getNextPageParam : ( lastPage , pages ) =>
lastPage . length === 20 ? pages . length * 20 : undefined ,
})
// Use with @vueuse/core
import { useInfiniteScroll } from '@vueuse/core'
useInfiniteScroll (
containerRef ,
() => {
if ( hasNextPage . value ) fetchNextPage ()
},
{ distance: 100 }
)
Testing
Component Testing
Tests are located alongside components:
src/components/ProjectCard.test.ts
import { mount } from '@vue/test-utils'
import ProjectCard from './ProjectCard.vue'
describe ( 'ProjectCard' , () => {
it ( 'renders project title' , () => {
const wrapper = mount ( ProjectCard , {
props: {
project: { title: 'Sodium' , description: 'Performance mod' },
},
})
expect ( wrapper . text ()). toContain ( 'Sodium' )
})
})
Run Tests
pnpm --filter @modrinth/frontend run test
Next Steps
Desktop App Learn about the Tauri desktop application
Packages Explore shared packages and libraries
Local Setup Set up the complete development environment
Testing Testing strategies and best practices