Skip to main content

Frontend

This guide covers the Next.js 15 web application.

Overview

The frontend is a React 19 application using:

  • Next.js 15 with App Router
  • TanStack Query v5 for data fetching
  • Radix UI primitives via shadcn/ui
  • Tailwind CSS for styling
  • Geist font family
  • openapi-fetch for type-safe API calls

Project structure

web/
├── app/ # Next.js App Router pages
│ ├── layout.tsx # Root layout with providers
│ ├── page.tsx # Home page
│ ├── authors/ # Author profile pages
│ ├── preprints/ # Preprint detail pages
│ ├── search/ # Search results page
│ ├── fields/ # Field taxonomy pages
│ └── governance/ # Governance and proposals
├── components/
│ ├── annotations/ # PDF text annotation components
│ ├── backlinks/ # Bluesky backlink display
│ ├── endorsements/ # Endorsement panel and badges
│ ├── enrichment/ # Citation enrichment display
│ ├── knowledge-graph/ # Field cards and relationships
│ ├── navigation/ # Header, nav, theme toggle
│ ├── preprints/ # Preprint cards, lists, PDF viewer
│ ├── providers/ # React context providers
│ ├── reviews/ # Review forms and threads
│ ├── search/ # Search input, facets, results
│ ├── share/ # Bluesky share components
│ ├── skeletons/ # Loading placeholders
│ ├── tags/ # Tag chips, clouds, inputs
│ └── ui/ # shadcn/ui primitives
├── lib/
│ ├── api/ # API client and types
│ ├── atproto/ # ATProto record creation
│ ├── auth/ # OAuth and session management
│ ├── bluesky/ # Bluesky API integration
│ ├── hooks/ # TanStack Query hooks
│ └── utils/ # Utility functions
└── styles/
└── globals.css # CSS variables and base styles

Getting started

Install dependencies:

cd web
pnpm install

Start the development server:

pnpm dev

Open http://localhost:3000.

Data fetching

Use TanStack Query hooks for all API calls:

import { usePreprint, usePreprints } from '@/lib/hooks';

// Fetch a single preprint
const { data, isLoading, error } = usePreprint(uri);

// Fetch paginated list
const { data, isLoading } = usePreprints({ limit: 10 });

Query configuration

The query client uses these defaults:

  • staleTime: 30 seconds (data refetched after this period)
  • gcTime: 5 minutes (cache garbage collection)
  • retry: 1 attempt on failure
  • refetchOnWindowFocus: true

Query keys

Query keys follow a flat array pattern:

// All preprints
['preprints'][
// Preprint list with filters
('preprints', 'list', { limit: 10, field: 'cs.AI' })
][
// Single preprint detail
('preprints', 'detail', 'at://did:plc:example/pub.chive.preprint.submission/123')
][
// Search results
('search', { q: 'neural networks', limit: 20 })
];

Components

UI primitives

Located in components/ui/. These follow the shadcn/ui pattern built on Radix UI:

ComponentDescription
ButtonPrimary action with variant and size props
CardContent container with header, content, footer
DialogModal dialogs with accessible focus management
InputText input with consistent styling
TextareaMulti-line text input
SelectDropdown selection with Radix primitives
CheckboxBoolean input with indeterminate state
RadioGroupSingle selection from options
TabsTabbed content panels
TooltipHover information overlays
PopoverClick-triggered floating content
DropdownMenuContext menu with keyboard navigation
ScrollAreaStyled scrollable container
SkeletonLoading placeholder animations
BadgeSmall labels and status indicators
AvatarUser profile images with fallback
AlertInformational and error messages
SeparatorVisual divider
LabelForm field labels
SonnerToast notifications

Example:

import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardContent } from '@/components/ui/card';

<Card>
<CardHeader>
<h3>Title</h3>
</CardHeader>
<CardContent>
<Button variant="outline" size="sm">
Click me
</Button>
</CardContent>
</Card>;

Preprint components

Located in components/preprints/:

ComponentDescription
PreprintCardSummary card with title, authors, abstract. Supports default, compact, and featured variants
PreprintListPaginated list of preprint cards
PreprintMetadataFull metadata display (DOI, dates, versions)
PreprintMetricsView counts, downloads, engagement stats
PreprintVersionsVersion history timeline
PreprintSourceSource repository badge (arXiv, bioRxiv, etc.)
AuthorChipClickable author name with avatar
AuthorHeaderFull author profile header
AuthorPreprintsPaginated preprints by author
AuthorStatsAuthor metrics (h-index, citations, preprints)
FieldBadgeField taxonomy badge
OrcidBadgeORCID identifier with verification
PDFViewerEmbedded PDF display
PDFAnnotationOverlayText selection and annotation layer
PDFSelectionPopoverContext menu for PDF text selection
PDFTextSelectionHandlerCaptures text selections in PDF

Example:

import { PreprintCard, PreprintCardSkeleton } from '@/components/preprints/preprint-card';
import { usePrefetchPreprint } from '@/lib/hooks';

function PreprintList({ preprints, isLoading }) {
const prefetch = usePrefetchPreprint();

if (isLoading) {
return <PreprintCardSkeleton />;
}

return preprints.map(preprint => (
<PreprintCard
key={preprint.uri}
preprint={preprint}
onPrefetch={prefetch}
/>
));
}

Search components

Located in components/search/:

ComponentDescription
SearchInputSearch field with autocomplete support
SearchInputWithParamsSearch input synced with URL params
InlineSearchCompact search for headers
SearchAutocompleteDropdown suggestions
SearchHighlightHighlights matching terms in results
SearchEmptyEmpty state for no results
SearchPaginationPage navigation controls
FacetChipActive filter indicator

Example:

import { SearchInputWithParams } from '@/components/search/search-input';

<SearchInputWithParams
paramKey="q"
searchRoute="/search"
placeholder="Search preprints..."
size="lg"
/>;

Knowledge graph components

Located in components/knowledge-graph/:

ComponentDescription
FieldCardField node display with stats
FieldExternalIdsLinks to Wikidata, LCSH, etc.
FieldPreprintsPreprints in a field
FieldRelationshipsBroader/narrower/related terms

Endorsement components

Located in components/endorsements/:

ComponentDescription
EndorsementPanelFull endorsement display with filtering
EndorsementBadgeContribution type badge
EndorsementBadgeGroupGrouped badges by type
EndorsementSummaryBadgeTotal count badge
EndorsementListList of endorsements
EndorsementSummaryCompactCompact summary for cards
EndorsementIndicatorMinimal count indicator

Example:

import { EndorsementPanel } from '@/components/endorsements/endorsement-panel';

<EndorsementPanel
preprintUri={preprint.uri}
onEndorse={() => setShowEndorseDialog(true)}
currentUserDid={user?.did}
/>;

Review components

Located in components/reviews/:

ComponentDescription
ReviewFormCreate/edit review with character count
InlineReplyFormCompact reply form
ReviewListPaginated reviews
ReviewThreadThreaded discussion display
ReviewCardSingle review with actions
AnnotationBodyRendererRenders annotation content
TargetSpanPreviewShows selected text being annotated
ParentReviewPreviewShows parent review when replying

Example:

import { ReviewForm } from '@/components/reviews/review-form';

<ReviewForm
preprintUri={preprint.uri}
onSubmit={async (data) => {
await createReview.mutateAsync(data);
}}
onCancel={() => setShowForm(false)}
isLoading={createReview.isPending}
/>;

Tag components

Located in components/tags/:

ComponentDescription
TagChipClickable tag display
TagCloudTag visualization by frequency
TagInputAutocomplete tag entry
TagListHorizontal tag list

Adding new components

  1. Check if shadcn/ui has the component: https://ui.shadcn.com
  2. If yes, copy the component code to components/ui/
  3. If no, create a new component following the CVA pattern

CVA (class-variance-authority) example:

import { cva, type VariantProps } from 'class-variance-authority';

const buttonVariants = cva('base-classes', {
variants: {
variant: {
default: 'bg-primary text-white',
outline: 'border border-input',
},
size: {
default: 'h-9 px-4',
sm: 'h-8 px-3',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
});

Theming

The app uses next-themes for dark mode:

  • System preference detection
  • Manual toggle (light/dark/system)
  • No flash on page load

CSS variables in styles/globals.css define colors:

:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* ... */
}

.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... */
}

Hooks reference

All TanStack Query hooks are organized by domain and exported from lib/hooks/index.ts.

Preprint hooks

HookDescription
usePreprint(uri)Fetch single preprint by AT-URI
usePreprints(params)Paginated preprint list
usePreprintsByAuthor(did)Preprints by author DID
usePrefetchPreprint()Returns function to prefetch on hover
import { usePreprint, preprintKeys } from '@/lib/hooks';

const { data, isLoading, error } = usePreprint(
'at://did:plc:abc/pub.chive.preprint.submission/123'
);

// Cache invalidation
queryClient.invalidateQueries({ queryKey: preprintKeys.all });

Search hooks

HookDescription
useSearch(query)Full-text search with pagination
useInstantSearch(query)Debounced instant search
useFacetedSearch(query, facets)Search with PMEST facet filters
useFacetCounts(query)Facet value counts
useLiveFacetedSearch()Combined search state and facets

Facet utilities:

import {
addFacetValue,
removeFacetValue,
toggleFacetValue,
clearDimensionFilters,
countTotalFilters,
isFacetSelected,
} from '@/lib/hooks';

const newFacets = addFacetValue(currentFacets, 'fields', 'cs.AI');

Discovery hooks

HookDescription
useForYouFeed()Personalized recommendations (infinite query)
useSimilarPapers(uri)Related papers by similarity
useCitations(uri)Citation network (citing/cited-by)
useEnrichment(uri)External metadata (Semantic Scholar, OpenAlex)
useRecordInteraction()Mutation to log user interactions
usePrefetchSimilarPapers()Prefetch similar papers on hover
useDiscoverySettings()User discovery preferences
useUpdateDiscoverySettings()Mutation to update preferences
import { useForYouFeed, useRecordInteraction } from '@/lib/hooks';

const {
data,
isLoading,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useForYouFeed({ limit: 10 });

const allRecommendations = data?.pages.flatMap(p => p.recommendations) ?? [];

const { mutate: recordInteraction } = useRecordInteraction();
recordInteraction({
preprintUri,
type: 'dismiss',
recommendationId: 'rec-123',
});

Author hooks

HookDescription
useAuthor(did)Author profile by DID
useAuthorProfile(did)Extended profile with metrics
useAuthorMetrics(did)Author statistics
usePrefetchAuthor()Prefetch author on hover

Utilities:

import { hasOrcid, formatOrcidUrl } from '@/lib/hooks';

if (hasOrcid(author)) {
console.log(formatOrcidUrl(author.orcid)); // https://orcid.org/0000-0002-...
}

Field hooks

HookDescription
useField(id)Single field by ID
useFields()All fields (for taxonomy display)
useFieldChildren(id)Narrower terms
useFieldPreprints(id)Preprints in field
usePrefetchField()Prefetch field on hover

Review hooks

HookDescription
useReviews(preprintUri)Reviews for a preprint
useInlineReviews(preprintUri)Inline annotations only
useReviewThread(reviewUri)Threaded replies
useCreateReview()Create review mutation
useDeleteReview()Delete review mutation
usePrefetchReviews()Prefetch reviews on hover
import { useReviews, useCreateReview } from '@/lib/hooks';

const { data: reviews, isLoading } = useReviews(preprintUri);
const createReview = useCreateReview();

await createReview.mutateAsync({
content: 'Great methodology!',
preprintUri,
motivation: 'commenting',
});

Endorsement hooks

HookDescription
useEndorsements(preprintUri)All endorsements for preprint
useEndorsementSummary(preprintUri)Counts by contribution type
useUserEndorsement(preprintUri, did)Check if user has endorsed
useCreateEndorsement()Create endorsement mutation
useUpdateEndorsement()Update endorsement mutation
useDeleteEndorsement()Delete endorsement mutation
usePrefetchEndorsements()Prefetch endorsements on hover

Constants:

import {
CONTRIBUTION_TYPES,
CONTRIBUTION_TYPE_LABELS,
CONTRIBUTION_TYPE_DESCRIPTIONS,
CONTRIBUTION_TYPE_CATEGORIES,
} from '@/lib/hooks/use-endorsement';

// CONTRIBUTION_TYPES: ['methodological', 'analytical', 'theoretical', ...]
// CONTRIBUTION_TYPE_CATEGORIES: { 'Core Research': [...], 'Technical': [...] }

Tag hooks

HookDescription
usePreprintTags(preprintUri)Tags on a preprint
useTagSuggestions(query)Autocomplete suggestions
useTrendingTags()Popular tags
useTagSearch(query)Search all tags
useTagDetail(tagId)Single tag with stats
useCreateTag()Add tag mutation
useDeleteTag()Remove tag mutation
usePrefetchTags()Prefetch tags on hover

Claiming hooks

HookDescription
useUserClaims()Current user's claims
useClaim(claimId)Single claim details
useClaimablePreprints(did)Preprints available to claim
usePendingClaims()Claims awaiting approval
useStartClaim()Start claim mutation
useCollectEvidence()Gather verification evidence
useCompleteClaim()Submit claim for review
useApproveClaim()Approve claim (trusted editors)
useRejectClaim()Reject claim (trusted editors)
usePaperSuggestions(profileMetadata)Suggested papers to claim

Activity hooks

HookDescription
useActivityFeed(options)User's activity feed
useLogActivity()Log activity mutation
useMarkActivityFailed()Mark activity as failed
useActivityLogging()Combined activity logging utilities
import { useLogActivity, COLLECTIONS, generateRkey } from '@/lib/hooks';

const { mutate: logActivity } = useLogActivity();
logActivity({
category: 'read',
action: 'view',
targetUri: preprintUri,
collection: COLLECTIONS.PREPRINT,
});

Profile autocomplete hooks

HookDescription
useOrcidAutocomplete(query)ORCID ID suggestions
useAffiliationAutocomplete(query)Institution suggestions
useKeywordAutocomplete(query)Research keyword suggestions
useAuthorIdDiscovery(orcid)Find matching author IDs

Other hooks

HookDescription
useTrending()Trending preprints
useBacklinks(preprintUri)Bluesky posts referencing preprint
useBacklinkCounts(preprintUri)Backlink counts by source
useShareToBluesky()Share mutation for Bluesky
useMentionAutocomplete(query)@mention suggestions
useGovernance*Governance proposal hooks
useIntegrations()External service integrations

Authentication

The frontend uses AT Protocol OAuth for authentication.

OAuth flow

User clicks "Sign in"


Enter handle (e.g., user.bsky.social)


Redirect to PDS authorization endpoint


User approves access to Chive AppView


Redirect back with authorization code


Exchange code for access token


Store session in secure cookie

Auth utilities

Located in lib/auth/:

ModuleDescription
oauth-client.tsATProto OAuth client setup
session.tsSession management
middleware.tsRoute protection
import { getCurrentAgent, isAuthenticated } from '@/lib/auth/oauth-client';

const agent = getCurrentAgent();
if (agent) {
// User is authenticated, can write to their PDS
await agent.com.atproto.repo.createRecord({
repo: agent.session.did,
collection: 'pub.chive.review.comment',
record: { /* ... */ },
});
}

Writing to PDS

User content (reviews, endorsements, tags) is written directly to the user's PDS:

import { createEndorsementRecord } from '@/lib/atproto/record-creator';
import { getCurrentAgent } from '@/lib/auth/oauth-client';

const agent = getCurrentAgent();
if (!agent) throw new Error('Not authenticated');

await createEndorsementRecord(agent, {
preprintUri: 'at://did:plc:abc/pub.chive.preprint.submission/123',
contributions: ['methodological', 'empirical'],
comment: 'Excellent methodology!',
});

Page routes

RouteDescription
/Home page with trending and recent preprints
/searchSearch results with faceted filtering
/preprints/[uri]Preprint detail with reviews and endorsements
/authors/[did]Author profile with their preprints
/fieldsField taxonomy browser
/fields/[id]Field detail with preprints
/governanceGovernance proposals list
/governance/[proposalId]Proposal detail with voting
/claimsUser's authorship claims
/settingsUser settings and preferences

Testing

Unit tests

Run with Vitest:

pnpm test           # Run once
pnpm test:watch # Watch mode
pnpm test:coverage # With coverage report

Test files are co-located with components:

components/ui/button.tsx
components/ui/button.test.tsx

Component tests

Playwright Component Testing for visual/interaction tests:

pnpm test:ct

Test files use .spec.tsx extension in tests/component/.

Storybook

View and develop components in isolation:

pnpm storybook

Opens at http://localhost:6006.

Story files are co-located:

components/ui/button.tsx
components/ui/button.stories.tsx

API client

The API client uses openapi-fetch with generated types:

import { api } from '@/lib/api/client';

// Type-safe API call
const { data, error } = await api.GET('/xrpc/pub.chive.preprint.getSubmission', {
params: { query: { uri } },
});

Regenerating types

When the backend API changes:

pnpm openapi:generate

This fetches /openapi.json from the backend and generates lib/api/schema.d.ts.

Server vs client components

Server Components (default):

  • Data fetching at build/request time
  • No client-side JavaScript
  • Use for static content, layouts

Client Components ('use client'):

  • Interactive UI (buttons, forms, toggles)
  • Hooks (useState, useEffect)
  • Browser APIs

Example:

// Server Component (default)
export default async function Page() {
const data = await fetchData(); // Runs on server
return <div>{data.title}</div>;
}

// Client Component
('use client');

export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Scripts

ScriptDescription
pnpm devStart development server
pnpm buildProduction build
pnpm startStart production server
pnpm lintRun ESLint
pnpm testRun unit tests
pnpm test:ctRun component tests
pnpm storybookStart Storybook
pnpm openapi:generateRegenerate API types