DEV Community

Cover image for Building a yacht charter site with Next.js (App Router): the parts nobody tells you
DesignToCodes
DesignToCodes

Posted on • Originally published at designtocodes.com

Building a yacht charter site with Next.js (App Router): the parts nobody tells you

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Validate, transact, return. Do not trust the calendar.

Performance notes

  • Use next/image on every gallery image; set sizes honestly.
  • Do not priority more 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/dynamic with ssr: false only 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 })),
  ];
}
Enter fullscreen mode Exit fullscreen mode
// 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',
  };
}
Enter fullscreen mode Exit fullscreen mode

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)