Next.js 15 & TypeScript Guide

Complete guide to Next.js 15 App Router and TypeScript development patterns used in this project

Next.js 15 & TypeScript Guide

Complete guide to Next.js 15 App Router and TypeScript development patterns used in this project.

Next.js 15 App Router

App Router Structure

The project uses Next.js 15's App Router with the following structure:
src/app/
├── layout.tsx              # Root layout
├── page.tsx                # Home page
├── globals.css             # Global styles
├── api/                    # API routes
│   ├── debug/             # Debug endpoints
│   ├── emails/            # Email handling
│   └── google-calendar/   # Calendar integration
├── blog/                  # Blog pages
│   ├── layout.tsx         # Blog layout
│   ├── [[...slug]]/      # Dynamic blog post routes
│   ├── [category]/       # Category pages
│   └── tags/             # Tag pages
├── contact/               # Contact page
├── docs/                  # Documentation
│   ├── layout.tsx         # Docs layout
│   └── [[...slug]]/      # Dynamic doc routes
├── legal/                 # Legal pages
├── services/              # Service pages
└── sitemap.ts             # Sitemap generation

Layout System

Root Layout

// src/app/layout.tsx
import "@/app/globals.css";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
 
import { CookieConsent } from "@/components/cookie-consent";
import { SiteFooter } from "@/components/layout/partials/footer/footer";
import { SiteHeader } from "@/components/layout/partials/header/header";
import { PixelEvents } from "@/components/analytics/pixel-events";
import { META_THEME_COLORS, siteConfig } from "@/config/site";
import { ThemeProvider } from "@/providers/theme-provider";
 
import type { Metadata, Viewport } from "next";
 
const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});
 
const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});
 
export const metadata: Metadata = {
  title: {
    default: siteConfig.name,
    template: `%s - ${siteConfig.name}`,
  },
  metadataBase: new URL(siteConfig.url),
  description: siteConfig.description,
  // ... more metadata
};
 
export const viewport: Viewport = {
  themeColor: META_THEME_COLORS.light,
};
 
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
            try {
              if (localStorage.theme === 'dark' || ((!('theme' in localStorage) || localStorage.theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
                document.querySelector('meta[name="theme-color"]').setAttribute('content', '${META_THEME_COLORS.dark}')
              }
            } catch (_) {}
          `,
          }}
        />
      </head>
      <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          <div className="bg-background flex min-h-screen flex-col">
            <SiteHeader />
            <main className="flex-1">{children}</main>
            <CookieConsent />
            <PixelEvents />
            <SiteFooter />
            <Toaster position="top-right" />
          </div>
        </ThemeProvider>
      </body>
    </html>
  );
}

Nested Layouts

// src/app/docs/layout.tsx
import { DocsNav } from "@/components/layout/navs/docs-nav";
import { DocsSidebar } from "@/components/layout/sidebar/docs-sidebar";
 
export default function DocsLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex min-h-screen">
      <DocsSidebar />
      <main className="flex-1">
        <DocsNav />
        {children}
      </main>
    </div>
  );
}

Dynamic Routes

Catch-All Routes

// src/app/docs/[[...slug]]/page.tsx
import { notFound } from "next/navigation";
import { allDocs } from "contentlayer/generated";
 
interface PageProps {
  params: {
    slug?: string[];
  };
}
 
export default function DocPage({ params }: PageProps) {
  const slug = params.slug?.join("/") ?? "";
  const doc = allDocs.find((doc) => doc.slugAsParams === slug);
 
  if (!doc) {
    notFound();
  }
 
  return (
    <article>
      <h1>{doc.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: doc.body.html }} />
    </article>
  );
}
 
export async function generateStaticParams() {
  return allDocs.map((doc) => ({
    slug: doc.slugAsParams.split("/"),
  }));
}

Dynamic Segments

// src/app/blog/[category]/page.tsx
import { allPosts } from "contentlayer/generated";
 
interface PageProps {
  params: {
    category: string;
  };
}
 
export default function CategoryPage({ params }: PageProps) {
  const posts = allPosts.filter((post) => post.category === params.category);
 
  return (
    <div>
      <h1>Posts in {params.category}</h1>
      {posts.map((post) => (
        <article key={post.slug}>
          <h2>{post.title}</h2>
          <p>{post.summary}</p>
        </article>
      ))}
    </div>
  );
}
 
export async function generateStaticParams() {
  const categories = [...new Set(allPosts.map((post) => post.category))];
  return categories.map((category) => ({
    category,
  }));
}

API Routes

Basic API Route

// src/app/api/contact/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Resend } from "resend";
 
const resend = new Resend(process.env.RESEND_API_KEY);
 
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
 
    const { data, error } = await resend.emails.send({
      from: "contact@cascadiamarque.com",
      to: ["carl@cascadiamarque.com"],
      subject: "New Contact Form Submission",
      html: `
        <h2>New Contact Form Submission</h2>
        <p><strong>Name:</strong> ${body.name}</p>
        <p><strong>Email:</strong> ${body.email}</p>
        <p><strong>Message:</strong> ${body.message}</p>
      `,
    });
 
    if (error) {
      return NextResponse.json({ error: "Failed to send email" }, { status: 500 });
    }
 
    return NextResponse.json({ success: true, data });
  } catch (error) {
    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
  }
}

Google Calendar API Route

// src/app/api/google-calendar/events/route.ts
import { NextRequest, NextResponse } from "next/server";
import { google } from "googleapis";
 
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
 
    const auth = new google.auth.GoogleAuth({
      credentials: {
        client_email: process.env.GOOGLE_CLIENT_EMAIL,
        private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
      },
      scopes: ["https://www.googleapis.com/auth/calendar"],
    });
 
    const calendar = google.calendar({ version: "v3", auth });
 
    const event = {
      summary: body.title,
      description: body.description,
      start: {
        dateTime: body.startTime,
        timeZone: "America/Los_Angeles",
      },
      end: {
        dateTime: body.endTime,
        timeZone: "America/Los_Angeles",
      },
      attendees: [{ email: body.email }, { email: "carl@cascadiamarque.com" }],
      conferenceData: {
        createRequest: {
          requestId: `meeting-${Date.now()}`,
          conferenceSolutionKey: {
            type: "hangoutsMeet",
          },
        },
      },
    };
 
    const response = await calendar.events.insert({
      calendarId: process.env.GOOGLE_CALENDAR_ID,
      requestBody: event,
      conferenceDataVersion: 1,
    });
 
    return NextResponse.json({ success: true, event: response.data });
  } catch (error) {
    console.error("Calendar API error:", error);
    return NextResponse.json({ error: "Failed to create event" }, { status: 500 });
  }
}

TypeScript Patterns

Type Definitions

Content Types

// src/types/docs.d.ts
export interface Doc {
  _id: string;
  _raw: {
    sourceFilePath: string;
    sourceFileName: string;
    sourceFileDir: string;
    contentType: string;
    flattenedPath: string;
  };
  type: "Doc";
  title: string;
  description: string;
  published: boolean;
  slug: string;
  slugAsParams: string;
  toc?: boolean;
  updated?: string;
  author?: string;
  date?: string;
  image?: string;
  links?: {
    doc?: string;
    api?: string;
  };
  body: {
    raw: string;
    code: string;
  };
}
// src/types/nav.d.ts
export interface MainNavItem {
  title: string;
  href: string;
  disabled?: boolean;
  external?: boolean;
  icon?: React.ComponentType<{ className?: string }>;
  label?: string;
}
 
export interface SidebarNavItem {
  title: string;
  href: string;
  disabled?: boolean;
  external?: boolean;
  icon?: React.ComponentType<{ className?: string }>;
  label?: string;
  items?: SidebarNavItem[];
}

Component Props

// src/components/ui/button.tsx
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { forwardRef } from "react";
 
import { cn } from "@/lib/utils";
 
const buttonVariants = cva(
  "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
);
 
export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}
 
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button";
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  }
);
Button.displayName = "Button";
 
export { Button, buttonVariants };

Utility Types

Content Utilities

// src/lib/content/utils.ts
import { allDocs, allPosts, allServices } from "contentlayer/generated";
 
export function getDocBySlug(slug: string) {
  return allDocs.find((doc) => doc.slugAsParams === slug);
}
 
export function getPostsByCategory(category: string) {
  return allPosts.filter((post) => post.category === category);
}
 
export function getFeaturedPosts() {
  return allPosts.filter((post) => post.featured);
}
 
export function getServicesByCategory(category: string) {
  return allServices.filter((service) => service.category === category);
}

Type Guards

// src/lib/utils.ts
export function isDoc(content: any): content is Doc {
  return content && content.type === "Doc";
}
 
export function isPost(content: any): content is Post {
  return content && content.type === "Post";
}
 
export function isService(content: any): content is Service {
  return content && content.type === "Service";
}

Generic Components

Container Component

// src/components/container/container.tsx
import { cn } from "@/lib/utils";
import { forwardRef } from "react";
 
interface ContainerProps extends React.HTMLAttributes<HTMLDivElement> {
  size?: "sm" | "md" | "lg" | "xl" | "full";
}
 
const Container = forwardRef<HTMLDivElement, ContainerProps>(
  ({ className, size = "lg", ...props }, ref) => {
    return (
      <div
        ref={ref}
        className={cn(
          "mx-auto w-full px-4",
          {
            "max-w-2xl": size === "sm",
            "max-w-4xl": size === "md",
            "max-w-6xl": size === "lg",
            "max-w-7xl": size === "xl",
            "max-w-none": size === "full",
          },
          className
        )}
        {...props}
      />
    );
  }
);
 
Container.displayName = "Container";
 
export { Container };

Text Components

// src/components/text/heading.tsx
import { cn } from "@/lib/utils";
import { forwardRef } from "react";
 
interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {
  level: 1 | 2 | 3 | 4 | 5 | 6;
}
 
const Heading = forwardRef<HTMLHeadingElement, HeadingProps>(
  ({ className, level, children, ...props }, ref) => {
    const Component = `h${level}` as keyof JSX.IntrinsicElements;
 
    return (
      <Component
        ref={ref}
        className={cn(
          "font-bold tracking-tight",
          {
            "text-4xl md:text-5xl": level === 1,
            "text-3xl md:text-4xl": level === 2,
            "text-2xl md:text-3xl": level === 3,
            "text-xl md:text-2xl": level === 4,
            "text-lg md:text-xl": level === 5,
            "text-base md:text-lg": level === 6,
          },
          className
        )}
        {...props}
      >
        {children}
      </Component>
    );
  }
);
 
Heading.displayName = "Heading";
 
export { Heading };

Performance Optimization

Static Generation

// Generate static params for dynamic routes
export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((post) => ({
    slug: post.slug.split("/").slice(1), // Remove leading slash
  }));
}
 
// Generate metadata for SEO
export async function generateMetadata({ params }: { params: { slug: string[] } }) {
  const slug = params.slug.join("/");
  const post = getPostBySlug(slug);
 
  if (!post) {
    return {
      title: "Post Not Found",
    };
  }
 
  return {
    title: post.title,
    description: post.summary,
    openGraph: {
      title: post.title,
      description: post.summary,
      images: post.image ? [post.image] : [],
    },
  };
}

Dynamic Imports

// Lazy load heavy components
import dynamic from "next/dynamic";
 
const HeavyChart = dynamic(() => import("@/components/charts/heavy-chart"), {
  loading: () => <div>Loading chart...</div>,
  ssr: false, // Disable SSR for client-only components
});
 
// Conditional imports
const AdminPanel = dynamic(() => import("@/components/admin/panel"), {
  loading: () => <div>Loading admin panel...</div>,
});

Image Optimization

import Image from "next/image";
 
// Optimized image with placeholder
<Image
  src="/blog-images/example.webp"
  alt="Description"
  width={800}
  height={600}
  priority={false} // Only for above-the-fold images
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..."
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

Error Handling

Error Boundaries

// src/components/error-boundary.tsx
"use client";
 
import { ErrorBoundary } from "react-error-boundary";
 
function ErrorFallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) {
  return (
    <div role="alert" className="p-4 border border-red-200 rounded-md">
      <h2 className="text-lg font-semibold text-red-800">Something went wrong:</h2>
      <pre className="mt-2 text-sm text-red-600">{error.message}</pre>
      <button
        onClick={resetErrorBoundary}
        className="mt-4 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
      >
        Try again
      </button>
    </div>
  );
}
 
export function AppErrorBoundary({ children }: { children: React.ReactNode }) {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      {children}
    </ErrorBoundary>
  );
}

Not Found Pages

// src/app/not-found.tsx
import Link from "next/link";
 
export default function NotFound() {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <h1 className="text-4xl font-bold">404</h1>
      <p className="mt-4 text-lg">Page not found</p>
      <Link href="/" className="mt-4 text-blue-600 hover:underline">
        Go back home
      </Link>
    </div>
  );
}

Best Practices

File Organization

  1. Use descriptive file names - contact-form.tsx not form.tsx
  2. Group related files - Keep components, types, and utilities together
  3. Use index files - Export components from index files for clean imports
  4. Follow naming conventions - PascalCase for components, camelCase for utilities

Type Safety

  1. Define interfaces for all props and data structures
  2. Use type guards for runtime type checking
  3. Leverage TypeScript strict mode for better error catching
  4. Use generic types for reusable components

Performance

  1. Use static generation where possible
  2. Implement proper caching strategies
  3. Optimize images with next/image
  4. Lazy load heavy components
  5. Monitor bundle size and performance metrics

SEO

  1. Generate metadata for all pages
  2. Use semantic HTML elements
  3. Implement proper heading hierarchy
  4. Add structured data where appropriate
  5. Optimize for Core Web Vitals
For questions about Next.js and TypeScript development, check the other documentation sections or contact the development team.