Skip to main content

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

pages/[type]/[id].vue
<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:
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:
store/auth.ts
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:
ClassUsage
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

ClassUsage
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:
layouts/default.vue
<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:
pages/index.vue
<script setup>
definePageMeta({
	layout: 'default',
})
</script>

Middleware

Route guards run before navigation:
middleware/auth.ts
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

.env.local
# 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

pnpm web:build

Production Build (Cloudflare Pages)

pnpm pages:build
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>

Infinite Scrolling

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