Skip to main content

ADR-0016: Next.js 14 as Meta-Framework

Status

Accepted - 2025-01-26


Context

TVL Platform web application (built with React 18 - ADR-0015) needs a meta-framework for routing, server-side rendering, and optimization:

Business Requirements

  • Fast page loads (SEO, user experience)
  • Good SEO (search engines can index pages)
  • Easy deployment (Vercel integration)

Technical Requirements

  • Server-side rendering (SSR)
  • Static site generation (SSG) for marketing pages
  • API routes (backend endpoints)
  • File-based routing
  • Image optimization
  • TypeScript support

Constraints

  • React 18 (ADR-0015)
  • TypeScript 5.3+ (ADR-0005)
  • Tailwind CSS 3.x (ADR-0017)
  • Vercel deployment (ADR-0004)

Decision

Next.js 14 with App Router (React Server Components) as the meta-framework.

Rationale

  1. Built for React: Official React framework recommendation
  2. Server Components: React Server Components out-of-the-box
  3. Vercel Integration: Seamless deployment to Vercel
  4. SEO: Server-side rendering built-in
  5. Performance: Automatic code splitting, image optimization

Alternatives Considered

Alternative 1: Create React App (CRA)

Rejected

Pros:

  • Simple setup
  • No configuration needed
  • Official React starter

Cons:

  • Client-Side Only: No SSR (bad for SEO)
  • Deprecated: React team recommends frameworks instead
  • No Built-in Routing: Must add React Router
  • No API Routes: Cannot build backend endpoints
  • Slower Builds: No incremental builds

Decision: Next.js provides SSR and better DX.


Alternative 2: Remix

Rejected

Pros:

  • Excellent data loading (loaders and actions)
  • Great developer experience
  • Built-in forms and mutations
  • Nested routing

Cons:

  • Smaller Ecosystem: Fewer Next.js plugins
  • Smaller Hiring Pool: Harder to find Remix developers
  • Less Mature: Newer than Next.js (2021 vs 2016)
  • Deployment: Less deployment options than Next.js

Decision: Next.js more mature and larger ecosystem.


Alternative 3: Gatsby

Rejected

Pros:

  • Excellent static site generation
  • GraphQL data layer
  • Rich plugin ecosystem

Cons:

  • Slow Builds: 10+ minutes for large sites
  • Static-First: SSR added later (not primary focus)
  • Complex: GraphQL adds complexity
  • Declining Popularity: Losing to Next.js

Decision: Next.js faster builds and better SSR.


Alternative 4: Vite + React Router

Rejected

Pros:

  • Extremely fast dev server
  • Simple configuration
  • Lightweight

Cons:

  • No SSR Out-of-Box: Must configure manually
  • No API Routes: Separate backend needed
  • More Setup: Must add routing, data fetching, etc.
  • No Image Optimization: Manual implementation

Decision: Next.js provides SSR and features out-of-box.


Consequences

Positive

  1. Performance

    • Automatic code splitting (each page is separate bundle)
    • Image optimization (next/image)
    • Font optimization (next/font)
    • Streaming SSR (faster Time to First Byte)
  2. SEO

    • Server-side rendering (search engines index content)
    • Meta tags management (next/head)
    • Sitemap generation
    • Structured data (JSON-LD)
  3. Developer Experience

    • File-based routing (no React Router config)
    • Fast Refresh (instant hot reload)
    • TypeScript out-of-the-box
    • API routes (backend endpoints in same codebase)
  4. React Server Components

    • Fetch data on server (no client-side fetch)
    • Smaller client bundles (server components don't ship JS)
    • Automatic loading states
    • Streaming
  5. Deployment

    • Vercel one-click deployment
    • Automatic HTTPS
    • Global CDN
    • Preview deployments for PRs

Negative

  1. Vendor Lock-in (Mild)

    • Optimized for Vercel (but works elsewhere)
    • Mitigation: Can deploy to Railway, Fly.io, AWS
  2. Learning Curve

    • App Router different from Pages Router
    • Server Components new paradigm
    • Mitigation: Excellent documentation
  3. Build Time

    • Slower than Vite for development
    • Mitigation: Turbopack (Next.js 14 bundler) faster
  4. Complex Caching

    • Next.js caching can be confusing
    • Mitigation: Use revalidate and cache correctly

App Router Structure

apps/web/
├── app/ # App Router (Next.js 14)
│ ├── (auth)/ # Route group (layout without path)
│ │ ├── login/
│ │ │ └── page.tsx # /login
│ │ └── signup/
│ │ └── page.tsx # /signup
│ │
│ ├── (dashboard)/ # Route group (shared layout)
│ │ ├── layout.tsx # Dashboard layout
│ │ ├── bookings/
│ │ │ └── page.tsx # /bookings
│ │ ├── properties/
│ │ │ ├── page.tsx # /properties
│ │ │ └── [id]/
│ │ │ └── page.tsx # /properties/[id]
│ │ └── settings/
│ │ └── page.tsx # /settings
│ │
│ ├── api/ # API routes
│ │ └── bookings/
│ │ └── route.ts # /api/bookings
│ │
│ ├── layout.tsx # Root layout
│ └── page.tsx # Homepage (/)

├── components/ # React components
├── lib/ # Utilities
└── public/ # Static assets

Server Components (Default)

// app/bookings/page.tsx
// Server Component (default in App Router)
export default async function BookingsPage() {
// Fetch on server (no useEffect needed)
const bookings = await db.query.bookings.findMany();

return (
<div>
<h1>Bookings</h1>
{bookings.map(booking => (
<BookingCard key={booking.id} booking={booking} />
))}
</div>
);
}

Benefits:

  • No client-side fetch (faster)
  • No loading states (SSR)
  • Smaller JS bundle (server components don't ship to client)

Client Components (When Needed)

'use client'; // Mark as Client Component

import { useState } from 'react';

export function BookingForm() {
const [guestName, setGuestName] = useState('');

return (
<form>
<input
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
/>
</form>
);
}

When to use:

  • Need useState, useEffect, or other hooks
  • Need event listeners (onClick, onChange)
  • Need browser APIs (window, localStorage)

Data Fetching Patterns

async function BookingsPage() {
const bookings = await fetch('https://api.tvl.com/bookings', {
next: { revalidate: 60 } // Cache for 60 seconds
}).then(res => res.json());

return <BookingsList bookings={bookings} />;
}

2. Server Actions (Forms)

// app/bookings/actions.ts
'use server';

export async function createBooking(formData: FormData) {
const guestName = formData.get('guestName');

await db.insert(bookings).values({
guestName: guestName as string,
// ...
});

revalidatePath('/bookings'); // Refresh page data
}

// app/bookings/new/page.tsx
import { createBooking } from '../actions';

export default function NewBookingPage() {
return (
<form action={createBooking}>
<input name="guestName" required />
<button type="submit">Create</button>
</form>
);
}

3. API Routes

// app/api/bookings/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
const bookings = await db.query.bookings.findMany();
return NextResponse.json({ bookings });
}

export async function POST(request: NextRequest) {
const data = await request.json();
const booking = await db.insert(bookings).values(data);
return NextResponse.json({ booking }, { status: 201 });
}

Routing Patterns

1. Dynamic Routes

// app/properties/[id]/page.tsx
export default async function PropertyPage({
params
}: {
params: { id: string }
}) {
const property = await db.query.properties.findFirst({
where: eq(properties.id, params.id)
});

return <PropertyDetails property={property} />;
}

2. Route Groups (Layouts without Path)

// app/(dashboard)/layout.tsx
export default function DashboardLayout({ children }) {
return (
<div>
<Sidebar />
<main>{children}</main>
</div>
);
}

3. Parallel Routes

// app/@modal/(.)properties/[id]/page.tsx
export default function PropertyModal({ params }) {
return (
<Modal>
<PropertyDetails id={params.id} />
</Modal>
);
}

Image Optimization

import Image from 'next/image';

export function PropertyCard({ property }) {
return (
<div>
<Image
src={property.photoUrl}
alt={property.name}
width={800}
height={600}
priority // Load immediately (above fold)
/>
</div>
);
}

Benefits:

  • Automatic WebP conversion
  • Lazy loading
  • Responsive images
  • Blur placeholder

Metadata & SEO

// app/bookings/[id]/page.tsx
import type { Metadata } from 'next';

export async function generateMetadata({
params
}: {
params: { id: string }
}): Promise<Metadata> {
const booking = await db.query.bookings.findFirst({
where: eq(bookings.id, params.id)
});

return {
title: `Booking for ${booking.guestName}`,
description: `Booking from ${booking.checkIn} to ${booking.checkOut}`,
openGraph: {
title: `Booking for ${booking.guestName}`,
images: [booking.propertyImage]
}
};
}

Environment Variables

// .env.local
DATABASE_URL=postgresql://...
NEXT_PUBLIC_API_URL=https://api.tvl.com

// Usage
console.log(process.env.DATABASE_URL); // Server-side only
console.log(process.env.NEXT_PUBLIC_API_URL); // Client + Server

Rule: NEXT_PUBLIC_* variables exposed to browser.


Deployment

# Install Vercel CLI
npm install -g vercel

# Deploy
vercel

# Production deployment
vercel --prod

Docker (Alternative)

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static
CMD ["node", "server.js"]

Validation Checklist

  • Next.js 14 installed
  • App Router configured (not Pages Router)
  • TypeScript configured
  • Server Components used by default
  • Client Components marked with 'use client'
  • Image optimization configured
  • Metadata for SEO
  • API routes for backend
  • Environment variables configured
  • Deployment to Vercel configured

References