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
│ ├── eprints/ # Eprint detail pages
│ ├── search/ # Search results page
│ ├── fields/ # Field taxonomy pages
│ └── governance/ # Governance and proposals
├── components/
│ ├── annotations/ # PDF text annotation and entity link 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
│ ├── eprints/ # Eprint 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 { useEprint, useEprints } from '@/lib/hooks';

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

// Fetch paginated list
const { data, isLoading } = useEprints({ 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 eprints
['eprints'][
// Eprint list with filters
('eprints', 'list', { limit: 10, field: 'cs.AI' })
][
// Single eprint detail
('eprints', 'detail', 'at://did:plc:example/pub.chive.eprint.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>;

Eprint components

Located in components/eprints/:

ComponentDescription
EprintCardSummary card with title, authors, abstract. Supports default, compact, and featured variants
EprintListPaginated list of eprint cards
EprintMetadataFull metadata display (DOI, dates, versions)
EprintMetricsView counts, downloads, engagement stats
EprintVersionsVersion history timeline
EprintSourceSource repository badge (arXiv, bioRxiv, etc.)
AuthorChipClickable author name with avatar
AuthorHeaderFull author profile header
AuthorEprintsPaginated eprints by author
AuthorStatsAuthor metrics (h-index, citations, eprints)
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
EprintAbstractExpandable rich text abstract display
StaticAbstractNon-expandable truncated abstract for lists

Example:

import { EprintCard, EprintCardSkeleton } from '@/components/eprints/eprint-card';
import { usePrefetchEprint } from '@/lib/hooks';

function EprintList({ eprints, isLoading }) {
const prefetch = usePrefetchEprint();

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

return eprints.map((eprint) => (
<EprintCard key={eprint.uri} eprint={eprint} 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 eprints..."
size="lg"
/>;

Knowledge graph components

Located in components/knowledge-graph/:

ComponentDescription
FieldCardField node display with stats
FieldHierarchyHierarchical field tree view
NodeExternalIdsLinks to Wikidata, LCSH, etc.
FieldEprintsEprints in a field
FieldRelationshipsBroader/narrower/related terms
NodeSearchSearch for knowledge graph nodes
KnowledgeGraphViewerInteractive graph visualization

The KnowledgeGraphViewer supports pagination for large graphs to prevent performance issues.

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
eprintUri={eprint.uri}
onEndorse={() => setShowEndorseDialog(true)}
currentUserDid={user?.did}
/>;

Review components

Located in components/reviews/. Reviews are document-level discussion (no text span targeting):

ComponentDescription
ReviewFormCreate/edit review with character count
InlineReplyFormCompact reply form
ReviewListPaginated reviews
ReviewThreadThreaded discussion display
ReviewCardSingle review with actions
AnnotationBodyRendererRenders rich text body content for both annotations and reviews
TargetSpanPreviewShows referenced text for document-level reviews
ParentReviewPreviewShows parent review when replying
DeleteReviewDialogConfirmation dialog for soft-deleting a review
DocumentLocationCardShows annotation location context in the PDF

Example:

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

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

Annotation components

Located in components/annotations/. These handle inline PDF annotations and entity linking:

ComponentDescription
AnnotationEditorRich text editor with @ and # triggers for knowledge graph references
AnnotationPreviewPreview of annotation body content
AnnotationSidebarSidebar listing annotations and entity links grouped by page
EntityLinkDialogDialog for linking a text span to a Wikidata or graph entity
NodeMentionAutocompleteAutocomplete dropdown for @ and # node mentions
WikidataSearchWikidata entity search for entity linking

Example:

import { AnnotationEditor, EntityLinkDialog } from '@/components/annotations';

<AnnotationEditor value={body} onChange={setBody} placeholder="Add your annotation..." />;

<EntityLinkDialog
open={isOpen}
onOpenChange={setIsOpen}
selectedText="neural networks"
onLink={handleLink}
/>;

Tag components

Located in components/tags/:

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

Form components

Located in components/forms/:

ComponentDescription
NodeAutocompleteFlexible autocomplete for knowledge graph nodes
FieldSearchSearch and select academic fields
CreditAutocompleteSelect contribution credit types
ConferenceAutocompleteSearch conferences
JournalAutocompleteSearch journals
FunderAutocompleteSearch funders/funding bodies
DOIAutocompleteSearch by DOI
LocationAutocompleteGeographic location search
EprintAuthorEditorEdit author metadata
ContributionTypeSelectorSelect contribution types for endorsements

The NodeAutocomplete component is the primary autocomplete for governance-controlled nodes. It supports filtering by kind and subkind:

import { NodeAutocomplete } from '@/components/forms/node-autocomplete';

// Select a license
<NodeAutocomplete
kind="object"
subkind="license"
label="License"
value={selectedLicenseUri}
onSelect={(node) => setValue('licenseUri', node.uri)}
/>

// Select an endorsement type
<NodeAutocomplete
kind="type"
subkind="endorsement-kind"
label="Contribution Type"
onSelect={(node) => setContributionType(node)}
/>

Editor components

Located in components/editor/:

ComponentDescription
RichTextRendererRenders rich text items with entity references
CrossReferenceExtensionTipTap extension for [[ autocomplete of entity references

See Frontend rich text for detailed documentation on the rich text system.

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%;
/* ... */
}

Mobile responsiveness

useIsMobile hook

The useIsMobile hook (lib/hooks/use-mobile.ts) detects viewport width using window.matchMedia. The breakpoint is 768px, matching Tailwind's md breakpoint.

import { useIsMobile } from '@/lib/hooks/use-mobile';

function MyComponent() {
const isMobile = useIsMobile();
return isMobile ? <MobileView /> : <DesktopView />;
}

SidebarLayout and Sheet drawers

SidebarLayout (components/layout/sidebar-layout.tsx) provides a responsive sidebar used by the dashboard, admin, and governance pages. On desktop, it renders a fixed-width sidebar alongside the main content. On mobile, it converts the sidebar into a Sheet drawer triggered by a menu button.

import { SidebarLayout } from '@/components/layout/sidebar-layout';

<SidebarLayout sidebar={<NavigationMenu />} sidebarWidth="md" sidebarTitle="Dashboard">
<DashboardContent />
</SidebarLayout>;

Props:

PropTypeDefaultDescription
sidebarReactNode(required)Sidebar content
sidebarWidth'sm' | 'md' | 'lg''md'Desktop sidebar width
sidebarPosition'left' | 'right''left'Which side the sidebar appears on
stickyNavigationbooleanfalseSticky positioning on desktop
sidebarTitlestring'Navigation'Label on the mobile Sheet trigger

The Sheet automatically closes on route changes.

MobileSearch

MobileSearch (components/navigation/mobile-search.tsx) provides a search trigger visible only below the sm breakpoint. It opens a top Sheet with a search input. The desktop SearchBar is hidden at the same breakpoint using sm:hidden.

Admin table overflow

Admin tables (user management, proposal review) use overflow-x-auto on the table container to enable horizontal scrolling on narrow viewports:

<div className="overflow-x-auto">
<Table>{/* columns */}</Table>
</div>

Responsive grid conventions

Use these Tailwind breakpoint conventions for grid layouts:

LayoutClassesDescription
Card gridsgrid grid-cols-1 sm:grid-cols-2 md:grid-cols-31 column on mobile, 2 on tablet, 3 on desktop
Two-columngrid grid-cols-1 md:grid-cols-2Stacks on mobile, side-by-side on desktop
Sidebar + mainUse SidebarLayout instead of manual gridHandles Sheet conversion automatically

Hooks reference

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

Eprint hooks

HookDescription
useEprint(uri)Fetch single eprint by AT-URI
useEprints(params)Paginated eprint list
useEprintsByAuthor(did)Eprints by author DID
usePrefetchEprint()Returns function to prefetch on hover
import { useEprint, eprintKeys } from '@/lib/hooks';

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

// Cache invalidation
queryClient.invalidateQueries({ queryKey: eprintKeys.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
useSimilarPapers(uri)Related papers by similarity (supports weights)
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 (including weights)
useUpdateDiscoverySettings()Mutation to update preferences
useMutedAuthors()Set of muted author DIDs
useMuteAuthor()Mutation to mute an author
useUnmuteAuthor()Mutation to unmute an author
usePersonalizedAuthors()Personalized author discovery for /authors page
import { useSimilarPapers, useRecordInteraction, useDiscoverySettings } from '@/lib/hooks';

const { data: settings } = useDiscoverySettings();
const { data, isLoading } = useSimilarPapers(eprintUri, {
weights: settings?.relatedPapersWeights,
});

const { mutate: recordInteraction } = useRecordInteraction();
recordInteraction({
eprintUri,
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 (via edges)
useFieldEprints(id)Eprints in field
usePrefetchField()Prefetch field on hover

Node hooks (unified graph model)

HookDescription
useNode(id)Single node by ID
useNodes(options)List nodes with kind/subkind filters
useNodeChildren(id)Child nodes (via broader edges)
useNodeParents(id)Parent nodes (via broader edges)
useSearchNodes(query)Search nodes by label

Edge hooks

HookDescription
useEdges(nodeId)Edges for a node
useEdgesByRelation()Edges filtered by relation slug

Review hooks

HookDescription
useReviews(eprintUri)Document-level reviews for an eprint
useReviewThread(reviewUri)Threaded replies to a review
useCreateReview()Create document-level review mutation
useDeleteReview()Delete review mutation
usePrefetchReviews()Prefetch reviews on hover
import { useReviews, useCreateReview } from '@/lib/hooks';

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

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

Annotation hooks

HookDescription
useAnnotations(eprintUri, params?)Annotations for an eprint (with optional motivation filter)
useAnnotationsForPage(eprintUri, pageNumber)Annotations targeting a specific PDF page
useAnnotationThread(annotationUri)Thread with parent and nested replies
useCreateAnnotation()Create inline annotation mutation
useCreateEntityLink()Create entity link mutation
useDeleteAnnotation()Delete annotation mutation
useDeleteEntityLink()Delete entity link mutation

Query key factory:

import { annotationKeys } from '@/lib/hooks/use-annotations';

// Invalidate all annotations for an eprint
queryClient.invalidateQueries({ queryKey: annotationKeys.forEprint(eprintUri) });

// Invalidate a specific thread
queryClient.invalidateQueries({ queryKey: annotationKeys.thread(annotationUri) });

Example:

import { useAnnotations, useCreateAnnotation } from '@/lib/hooks';

const { data, isLoading } = useAnnotations(eprintUri);
const createAnnotation = useCreateAnnotation();

await createAnnotation.mutateAsync({
eprintUri,
content: 'Interesting claim here.',
target: { source: eprintUri, selector: { type: 'TextQuoteSelector', exact: 'selected text' } },
motivation: 'commenting',
});

Endorsement hooks

HookDescription
useEndorsements(eprintUri)All endorsements for eprint
useEndorsementSummary(eprintUri)Counts by contribution type
useUserEndorsement(eprintUri, 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
useEprintTags(eprintUri)Tags on an eprint
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
useClaimableEprints(did)Eprints 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: eprintUri,
collection: COLLECTIONS.EPRINT,
});

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 eprints
useBacklinks(eprintUri)Bluesky posts referencing eprint
useBacklinkCounts(eprintUri)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

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, {
eprintUri: 'at://did:plc:abc/pub.chive.eprint.submission/123',
contributions: ['methodological', 'empirical'],
comment: 'Excellent methodology!',
});

Page routes

Public routes

RouteDescription
/Home page with trending and recent eprints
/aboutAbout Chive
/searchSearch results with faceted filtering
/browseBrowse eprints by category
/trendingTrending eprints and tags
/eprintsEprints listing
/eprints/[...uri]Eprint detail with reviews and endorsements
/authorsAuthor discovery and search
/authors/[did]Author profile with their eprints
/tagsTag cloud and popular tags
/tags/[tag]Eprints for a specific tag
/fieldsField taxonomy browser
/fields/[id]Field detail with eprints
/fields/exploreInteractive field explorer
/graphKnowledge graph visualization
/governanceGovernance overview
/governance/proposalsProposal listings
/governance/proposals/[id]Proposal detail with voting
/governance/proposals/newCreate new proposal
/governance/moderationProposal review dashboard
/governance/adminGovernance administration

Authentication routes

RouteDescription
/loginLogin page
/oauth/callbackOAuth callback handler
/oauth/paper-callbackPaper OAuth callback
/oauth/paper-popupPaper OAuth popup flow
/onboarding/link-accountsAccount linking onboarding

Dashboard routes (authenticated)

RouteDescription
/dashboardUser dashboard overview
/dashboard/eprintsUser's eprints
/dashboard/claimsAuthorship claims management
/dashboard/reviewsUser's reviews
/dashboard/endorsementsUser's endorsements
/dashboard/notificationsNotifications center
/dashboard/settingsUser settings and preferences

Submission routes (authenticated)

RouteDescription
/submitEprint submission wizard
/submit/claim/[source]/[externalId]Claim external paper (arXiv, etc.)

Alpha program routes

RouteDescription
/applyAlpha tester application
/pendingPending application status
/coming-soonComing soon placeholder

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.eprint.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

Next steps