diff --git a/AGENTS.md b/AGENTS.md
index d3a4d3eb..27b813ce 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,7 +8,7 @@ Background context on how the stack fits together.
### frontend/backend routing
-- The Go server reverse-proxies `/v2/*` to the Next.js app in `./frontend-v2` (see `proxyHandler` in `server/src/main.go`). Next.js itself is unaware of this prefix — its routes start at `/`, so internal links and route definitions should never include `/v2`. For example, link to `/activists/123`, not `/v2/activists/123`.
+- The Go server reverse-proxies `/v2/*` to the Next.js app in `./frontend-v2` (see `proxyHandler` in `server/src/main.go`). Next.js has `basePath: '/v2'` configured (`next.config.ts`), so it automatically prepends `/v2` to all routes. Next.js navigation (``, `router.push()`, `redirect()`, and route definitions) must therefore omit the `/v2` prefix — Next.js adds it. For example, ``, not ``.
- The Go API lives at the same origin. The `ApiClient` in `frontend-v2/src/lib/api.ts` calls paths like `api/activists`, `event/get`, etc. directly without a `/v2` prefix.
## frontend
diff --git a/frontend-v2/src/app/(authed)/activists/[id]/activist-detail.tsx b/frontend-v2/src/app/(authed)/activists/[id]/activist-detail.tsx
index e15bed9c..efa1df03 100644
--- a/frontend-v2/src/app/(authed)/activists/[id]/activist-detail.tsx
+++ b/frontend-v2/src/app/(authed)/activists/[id]/activist-detail.tsx
@@ -2,8 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
-import Link from 'next/link'
-import { ArrowLeft, EyeOff, GitMerge, Pencil } from 'lucide-react'
+import { EyeOff, GitMerge, Pencil } from 'lucide-react'
import {
API_PATH,
apiClient,
@@ -133,22 +132,6 @@ export function ActivistDetail({ activistId }: { activistId: number }) {
return (
<>
-
-
- {/**
- * Back button does not really go back - just goes to /activists.
- * Detecting if router.back() actually goes back to /activists is
- * complicated but may be implemented in the future to preserve the
- * page state / scroll position.
- */}
-
- View all Activists
-
-
-
+
+
+
+ View all Activists
+
+
diff --git a/frontend-v2/src/app/(authed)/activists/activist-sheet.tsx b/frontend-v2/src/app/(authed)/activists/activist-sheet.tsx
new file mode 100644
index 00000000..4b143f24
--- /dev/null
+++ b/frontend-v2/src/app/(authed)/activists/activist-sheet.tsx
@@ -0,0 +1,71 @@
+'use client'
+
+import * as DialogPrimitive from '@radix-ui/react-dialog'
+import Link from 'next/link'
+import { ExternalLink, X } from 'lucide-react'
+import { useState } from 'react'
+import { ActivistDetail } from './[id]/activist-detail'
+
+interface ActivistSheetProps {
+ activistId: number | null
+ onClose: () => void
+}
+
+export function ActivistSheet({ activistId, onClose }: ActivistSheetProps) {
+ // Holds the last non-null activistId. Never cleared back to null so content
+ // stays mounted and visible while Radix plays the exit animation.
+ const [displayActivistId, setDisplayActivistId] = useState(
+ null,
+ )
+ const [prevActivistId, setPrevActivistId] = useState(null)
+
+ // React derived-state pattern: update synchronously during render so content
+ // is present on the first open paint without waiting for an effect.
+ if (activistId !== null && activistId !== prevActivistId) {
+ setPrevActivistId(activistId)
+ setDisplayActivistId(activistId)
+ }
+
+ return (
+ {
+ if (!open) onClose()
+ }}
+ >
+
+
+
+ Activist Details
+
+
+ View and edit activist information
+
+
+
+ {displayActivistId !== null && (
+
+
+ Full page
+
+ )}
+
+
+ Close
+
+
+
+
+ {displayActivistId !== null && (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend-v2/src/app/(authed)/activists/activists-page.tsx b/frontend-v2/src/app/(authed)/activists/activists-page.tsx
index 0f80fde1..e33820ef 100644
--- a/frontend-v2/src/app/(authed)/activists/activists-page.tsx
+++ b/frontend-v2/src/app/(authed)/activists/activists-page.tsx
@@ -2,6 +2,7 @@
import { useMemo, useState } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
+import { useQueryState, parseAsInteger } from 'nuqs'
import {
apiClient,
API_PATH,
@@ -16,6 +17,7 @@ import { ActivistTable } from './activists-table'
import { ActivistFilters } from './filters/activist-filters'
import { ColumnSelector } from './column-selector'
import { SortSelector } from './sort-selector'
+import { ActivistSheet } from './activist-sheet'
import { buildQueryOptions } from './filter-api-query'
import type { ActivistsQueryState, SortColumn } from './query-state'
import { DEFAULT_SORT } from './query-state'
@@ -33,6 +35,11 @@ export default function ActivistsPage({
const { user } = useAuthedPageContext()
const isAdmin = user.Roles.includes('admin')
+ const [selectedActivistId, setSelectedActivistId] = useQueryState(
+ 'activist',
+ parseAsInteger.withOptions({ history: 'push', scroll: false }),
+ )
+
const {
filters,
selectedColumns,
@@ -157,92 +164,100 @@ export default function ActivistsPage({
const tableSort = isPlaceholderData ? settledTableState.sort : sort
return (
-