Skip to main content

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 (wraps openapi-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
info

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

NameHexUsage
Blue#2C5F8APrimary brand
Gold#8B6914Accent
Green#3D6B4FSuccess / BoM

Testament Colors

TestamentColorUsage
Old TestamentGoldOT-specific UI elements
New TestamentBlueNT-specific UI elements
Book of MormonGreenBoM-specific UI elements
D&CPurpleD&C-specific UI elements
Pearl of Great PriceBrownPGP-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
│ └── ...
warning

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

  1. Create the route file in the appropriate group:

    app/(app)/your-feature/page.tsx
  2. Use Server Components for data fetching

  3. Add 'use client' only for interactive sub-components

  4. Use @gospelib/sdk for API calls

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