> ## Documentation Index
> Fetch the complete documentation index at: https://mintlify.com/modrinth/code/llms.txt
> Use this file to discover all available pages before exploring further.

# Frontend (Web)

> Modrinth's Nuxt 3 web application

## 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

```typescript theme={null}
// 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

```vue pages/[type]/[id].vue theme={null}
<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

<Note>
  Rule of thumb: If it doesn't depend on Nuxt-specific APIs or website-only features, it belongs in `packages/ui`.
</Note>

### Component Example

```vue src/components/project/ProjectGallery.vue theme={null}
<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:

```typescript theme={null}
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`:

```typescript src/app.vue theme={null}
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:

```typescript theme={null}
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

<Warning>
  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
</Warning>

## State Management

### Pinia Stores

Client-side state is managed with Pinia:

```typescript store/auth.ts theme={null}
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:

```vue theme={null}
<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-1`   | Deepest background layer       |
| `bg-surface-1.5` | Odd row background (tables)    |
| `bg-surface-2`   | Even row, secondary panels     |
| `bg-surface-3`   | Headers, floating bars, inputs |
| `bg-surface-4`   | Cards, elevated surfaces       |
| `bg-surface-5`   | Borders, dividers              |

#### Text Colors

| Class            | Usage                            |
| ---------------- | -------------------------------- |
| `text-contrast`  | Primary headings                 |
| `text-primary`   | Default body text                |
| `text-secondary` | Reduced emphasis, secondary info |

#### Brand Colors

```vue theme={null}
<!-- 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

```vue theme={null}
<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>
```

<Warning>
  **Never use direct color values** like `bg-gray-800` or `text-white`. Always use semantic variables for theme compatibility.
</Warning>

### Scoped Styles

Use scoped styles for component-specific CSS:

```vue theme={null}
<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:

```vue layouts/default.vue theme={null}
<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:

```vue pages/index.vue theme={null}
<script setup>
definePageMeta({
	layout: 'default',
})
</script>
```

## Middleware

Route guards run before navigation:

```typescript middleware/auth.ts theme={null}
export default defineNuxtRouteMiddleware((to, from) => {
	const auth = useAuth()
	
	if (!auth.isAuthenticated) {
		return navigateTo('/login')
	}
})
```

Use in pages:

```vue pages/dashboard/index.vue theme={null}
<script setup>
definePageMeta({
	middleware: 'auth',
})
</script>
```

## i18n (Internationalization)

The frontend supports 34 languages using FormatJS.

**Translations**: `packages/ui/src/locales/`

```vue theme={null}
<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:

```typescript theme={null}
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

```bash theme={null}
# 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

```bash .env.local theme={null}
# 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

```bash theme={null}
pnpm web:build
```

### Production Build (Cloudflare Pages)

```bash theme={null}
pnpm pages:build
```

This uses the Cloudflare Pages Nitro preset.

## Pre-PR Checks

Before opening a PR:

```bash theme={null}
# 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

```vue theme={null}
<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

```vue theme={null}
<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

```typescript theme={null}
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:

```typescript src/components/ProjectCard.test.ts theme={null}
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

```bash theme={null}
pnpm --filter @modrinth/frontend run test
```

## Next Steps

<CardGroup cols={2}>
  <Card title="Desktop App" icon="desktop" href="/development/desktop-app">
    Learn about the Tauri desktop application
  </Card>

  <Card title="Packages" icon="box" href="/development/packages">
    Explore shared packages and libraries
  </Card>

  <Card title="Local Setup" icon="server" href="/development/local-setup">
    Set up the complete development environment
  </Card>

  <Card title="Testing" icon="flask" href="/development/testing">
    Testing strategies and best practices
  </Card>
</CardGroup>
