Skip to main content

Protected Dashboard

Build an authenticated dashboard with route UX and backend-enforced data access.

Result

Anonymous navigation redirects to sign-in. Authenticated users receive server-rendered dashboard data. Convex rejects callers without access even if they bypass the page.

Backend

convex/dashboard.ts
import { query } from './_generated/server'
import { ConvexError } from 'convex/values'

export const get = query({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new ConvexError({ code: 'UNAUTHENTICATED' })

    const projects = await ctx.db
      .query('projects')
      .withIndex('by_owner', (q) => q.eq('ownerId', identity.subject))
      .take(20)

    return {
      projectCount: projects.length,
      recentProjects: projects.slice(0, 5),
    }
  },
})

Page

app/pages/dashboard.vue
<script setup lang="ts">
import { api } from '#convex/api'

definePageMeta({ convexAuth: true })

const {
  data: dashboard,
  status,
  error,
} = await useConvexQuery(
  api.dashboard.get,
  {},
  {
    auth: 'required',
  },
)
</script>

<template>
  <main>
    <h1>Dashboard</h1>
    <DashboardSkeleton v-if="status === 'pending'" />
    <p v-else-if="error">Dashboard data is unavailable.</p>
    <section v-else-if="dashboard">
      <p>{{ dashboard.projectCount }} projects</p>
      <ProjectList :projects="dashboard.recentProjects" />
    </section>
  </main>
</template>

Security boundary

convexAuth controls navigation. auth: 'required' controls query execution. The identity and ownership check in dashboard.get controls data access.

Keep all three because they solve different problems.

Verify

  • Anonymous page navigation redirects.
  • Direct anonymous Convex query rejects.
  • User A cannot receive user B's projects.
  • Reloading as an authenticated user renders dashboard data without an anonymous flash.