Next.js App Router
The GospeLib web app (apps/web) and admin dashboard (apps/admin) both use Next.js 15 with the App Router. This guide covers the conventions, patterns, and Tailwind v4 setup used across both apps.
Architecture
- Framework: Next.js 15 with App Router
- Rendering: Server Components by default;
'use client'only when state or effects are needed - Styling: Tailwind CSS v4 with
@tailwindcss/postcss - UI Components: shadcn/ui in
components/ui/— extend via wrappers, never modify in-place - State: Zustand for client-side state, TanStack Query for server state
- SDK:
@gospelib/sdk(wrapsopenapi-fetch) for API calls
Route Groups
Routes are organized into two groups:
app/
├── (marketing)/ # Public pages — no auth required
│ ├── page.tsx # Landing page
│ ├── pricing/
│ └── about/
├── (app)/ # Authenticated pages
│ ├── layout.tsx # Auth check wrapper
│ ├── reader/
│ ├── study/
│ └── settings/
├── layout.tsx # Root layout
└── globals.css
(marketing)/— Public routes rendered with ISR for fast initial load(app)/— Authenticated routes; the group layout checks for a valid session
Route groups (parenthesized directories) don't affect the URL. (app)/reader/page.tsx renders at /reader.
Server Components vs Client Components
Default to Server Components. Use 'use client' only when you need:
- React state (
useState,useReducer) - Effects (
useEffect) - Browser APIs (
window,localStorage) - Event handlers (
onClick,onChange) - Zustand stores or TanStack Query hooks
Data Fetching in Server Components
Fetch data directly in page.tsx — no need for useEffect or TanStack Query:
// app/(app)/reader/[passageId]/page.tsx
import { client } from '@gospelib/sdk';
export default async function PassagePage({ params }: { params: { passageId: string } }) {
const { data, error } = await client.GET('/api/v1/passages/{passage_id}', {
params: { path: { passage_id: params.passageId } },
});
if (error) return <PassageError code={error.code} />;
return <PassageContent passage={data} />;
}
Client Components for Interactivity
'use client';
import { useState } from 'react';
export function WordPopover({ word }: { word: WordAlignment }) {
const [isOpen, setIsOpen] = useState(false);
return (
<span onClick={() => setIsOpen(!isOpen)} className="cursor-pointer">
{word.token}
{isOpen && <LexiconPopover strongs={word.strongs} />}
</span>
);
}
Tailwind CSS v4
Both apps use Tailwind CSS v4 with the @tailwindcss/postcss plugin:
// postcss.config.mjs
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
Content Sources
// tailwind.config.ts
export default {
content: ['./app/**/*.{ts,tsx}', '../../packages/ui/src/**/*.{ts,tsx}'],
};
Brand Colors
| Name | Hex | Usage |
|---|---|---|
| Blue | #2C5F8A | Primary brand |
| Gold | #8B6914 | Accent |
| Green | #3D6B4F | Success / BoM |
Testament Colors
| Testament | Color | Usage |
|---|---|---|
| Old Testament | Gold | OT-specific UI elements |
| New Testament | Blue | NT-specific UI elements |
| Book of Mormon | Green | BoM-specific UI elements |
| D&C | Purple | D&C-specific UI elements |
| Pearl of Great Price | Brown | PGP-specific UI elements |
shadcn/ui
Components from shadcn/ui are installed in components/ui/. These are copy-pasted, not imported from a package:
app/
├── components/
│ └── ui/
│ ├── button.tsx
│ ├── dialog.tsx
│ └── ...
Never modify shadcn/ui components directly. Instead, create wrapper components that compose them.
// components/PassageDialog.tsx — wrapper around shadcn Dialog
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
export function PassageDialog({ passage, children }) {
return (
<Dialog>
{children}
<DialogContent>
<DialogTitle>{passage.reference}</DialogTitle>
<p>{passage.text}</p>
</DialogContent>
</Dialog>
);
}
SDK Usage
The @gospelib/sdk package wraps openapi-fetch for type-safe API calls:
import { client } from '@gospelib/sdk';
// Fully typed — paths, params, and response types from OpenAPI spec
const { data, error } = await client.GET('/api/v1/passages/{passage_id}', {
params: { path: { passage_id: 'gen.1.1' } },
});
Configuration
Next.js config in next.config.ts:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
transpilePackages: ['@gospelib/ui', '@gospelib/sdk'],
};
export default nextConfig;
Adding a New Page
-
Create the route file in the appropriate group:
app/(app)/your-feature/page.tsx -
Use Server Components for data fetching
-
Add
'use client'only for interactive sub-components -
Use
@gospelib/sdkfor API calls -
Use Tailwind classes and shadcn/ui components for styling
Verify It Works
# Start the dev server
cd apps/web && pnpm dev
# Web app runs at http://localhost:3002
# Admin runs at http://localhost:3001