I have been rebuilding charter sites for the last few seasons and the pattern that finally works in 2026 is Next.js with the App Router, server components, and a thin client layer for the booking flow. Here is the abridged build.
Site map
app/
layout.tsx
page.tsx // home
fleet/
page.tsx // list
[slug]/page.tsx // detail (server)
itineraries/
[slug]/page.tsx
api/
availability/route.ts
book/route.ts
Fleet detail is a server component, calendar is client. That single split solves 80% of the perf headache.
Server-rendered fleet detail
// app/fleet/[slug]/page.tsx
import { notFound } from 'next/navigation';
import BookingCalendar from '@/components/BookingCalendar';
export const revalidate = 86400; // 1 day
export default async function YachtPage({ params }: { params: { slug: string } }) {
const yacht = await getYacht(params.slug);
if (!yacht) notFound();
return (
<article>
<YachtHero yacht={yacht} />
<YachtSpecs yacht={yacht} />
<BookingCalendar yachtId={yacht.id} />
</article>
);
}
BookingCalendar is the only 'use client' component on the page.
Availability route
// app/api/availability/route.ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(req: Request) {
const url = new URL(req.url);
const yachtId = url.searchParams.get('yachtId');
const month = url.searchParams.get('month'); // YYYY-MM
const blackouts = await db.booking.findMany({
where: { yachtId, status: 'confirmed', monthKey: month! },
select: { startDate: true, endDate: true },
});
return NextResponse.json({ blackouts });
}
Keep the response narrow. The calendar only needs blackout ranges.
Server action for booking submission
'use server';
import { db } from '@/lib/db';
import { z } from 'zod';
const BookingSchema = z.object({
yachtId: z.string(),
start: z.string().date(),
end: z.string().date(),
guests: z.number().min(1).max(20),
email: z.string().email(),
});
export async function createBooking(input: unknown) {
const data = BookingSchema.parse(input);
// Check availability inside a transaction
const booking = await db.$transaction(async (tx) => {
const conflict = await tx.booking.findFirst({
where: {
yachtId: data.yachtId,
status: 'confirmed',
OR: [
{ startDate: { lte: data.end }, endDate: { gte: data.start } },
],
},
});
if (conflict) throw new Error('Dates unavailable');
return tx.booking.create({ data: { ...data, status: 'pending_payment' } });
});
return booking;
}
Validate, transact, return. Do not trust the calendar.
Performance notes
- Use
next/imageon every gallery image; setsizeshonestly. - Do not
prioritymore than the LCP image. - AVIF + WebP on by default in 2026 — keep it on.
- Cache fleet pages with
revalidate. A day is fine for a yacht site. - Move map and gallery interactivity to
next/dynamicwithssr: falseonly where you actually need it.
SEO route handlers worth adding
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllYachts, getAllItineraries } from '@/lib/cms';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const yachts = await getAllYachts();
const itineraries = await getAllItineraries();
const base = 'https://example.com';
return [
{ url: `${base}/`, lastModified: new Date() },
{ url: `${base}/fleet`, lastModified: new Date() },
...yachts.map((y) => ({ url: `${base}/fleet/${y.slug}`, lastModified: y.updatedAt })),
...itineraries.map((i) => ({ url: `${base}/itineraries/${i.slug}`, lastModified: i.updatedAt })),
];
}
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/admin'] },
sitemap: 'https://example.com/sitemap.xml',
};
}
Both file-based, both type-safe, no plugin to forget.
Hosting
Vercel for almost everyone. Self-host on a VPS only if you have got real DevOps and high traffic.
Want a head start
Our Sailvu Next.js template ships this exact layout — fleet, detail, calendar, booking, App Router. Saves you maybe two weeks of CSS and component plumbing.
The full guide with destination/itinerary pages and SEO schema is on the D2C blog if you want the long version.
Top comments (0)