Next.js Essentials¶
Patterns and conventions for the Next.js 14+ App Router used across Sartiq frontend projects.
App Router Fundamentals¶
The App Router uses a file-system based router where folders define routes and special files define UI.
Route Files¶
| File | Purpose |
|---|---|
page.tsx |
Unique UI for a route segment, makes route publicly accessible |
layout.tsx |
Shared UI that wraps child segments (persists across navigations) |
loading.tsx |
Instant loading state shown while segment content loads |
error.tsx |
Error boundary scoped to a segment |
not-found.tsx |
UI for notFound() calls or unmatched routes |
template.tsx |
Like layout but re-mounts on navigation (no state persistence) |
Basic Routing Structure¶
app/
├── layout.tsx # Root layout (required)
├── page.tsx # Home route (/)
├── products/
│ ├── page.tsx # /products
│ ├── [id]/
│ │ └── page.tsx # /products/:id
│ └── loading.tsx # Loading state for /products/*
└── (dashboard)/ # Route group (no URL impact)
├── layout.tsx # Shared dashboard layout
├── settings/
│ └── page.tsx # /settings
└── analytics/
└── page.tsx # /analytics
Layouts¶
Layouts wrap child segments and preserve state across navigations:
// app/layout.tsx — Root layout (required)
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<header>
<Nav />
</header>
<main>{children}</main>
</body>
</html>
);
}
Loading and Error States¶
// app/products/loading.tsx
export default function Loading() {
return <ProductListSkeleton />;
}
// app/products/error.tsx
"use client"; // Error boundaries must be client components
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div>
<h2>Something went wrong</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
Server vs Client Components¶
All components in the App Router are Server Components by default. Add "use client" only when you need browser APIs, event handlers, or React hooks that manage state.
Decision Table¶
| Need | Component Type |
|---|---|
| Fetch data, access backend resources | Server |
| Static content, no interactivity | Server |
useState, useEffect, useRef |
Client |
Event handlers (onClick, onChange) |
Client |
Browser APIs (localStorage, IntersectionObserver) |
Client |
| Third-party libs that use hooks | Client |
Interleaving Pattern¶
Server Components can import and render Client Components, but not the reverse. Pass Server Components to Client Components as children or props:
// app/dashboard/page.tsx — Server Component
import { Sidebar } from "@/components/Sidebar"; // Client
import { UserStats } from "@/components/UserStats"; // Server
export default async function DashboardPage() {
const stats = await getStats();
return (
<Sidebar>
{/* Server Component passed as children to Client Component */}
<UserStats data={stats} />
</Sidebar>
);
}
// components/Sidebar.tsx — Client Component
"use client";
import { useState } from "react";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false);
return (
<aside className={collapsed ? "w-16" : "w-64"}>
<button onClick={() => setCollapsed(!collapsed)}>Toggle</button>
{children}
</aside>
);
}
Preventing Accidental Client Bundling¶
Use the server-only package to prevent server code from being imported into Client Components:
// lib/db.ts
import "server-only";
import { prisma } from "./prisma";
export async function getUsers() {
return prisma.user.findMany();
}
If a Client Component imports this file, the build will fail with a clear error.
Data Fetching¶
Server Component Fetch¶
Server Components can use async/await directly — no hooks, no client-side waterfalls:
// app/products/page.tsx
interface Product {
id: string;
name: string;
price: number;
}
export default async function ProductsPage() {
const products: Product[] = await fetch(
"https://api.sartiq.com/v1/products",
{ next: { revalidate: 60 } } // Revalidate every 60 seconds
).then((res) => res.json());
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} — ${product.price}
</li>
))}
</ul>
);
}
Direct Database/ORM Access¶
Server Components can access backend resources directly, with no API layer needed:
import { prisma } from "@/lib/prisma";
export default async function UsersPage() {
const users = await prisma.user.findMany({
select: { id: true, name: true, email: true },
orderBy: { createdAt: "desc" },
});
return <UserTable users={users} />;
}
Parallel vs Sequential Fetching¶
// Sequential — each request waits for the previous one
async function SequentialPage() {
const user = await getUser(); // Starts first
const posts = await getPosts(user.id); // Waits for user
return <Profile user={user} posts={posts} />;
}
// Parallel — fire all requests at once
async function ParallelPage() {
const [user, products, analytics] = await Promise.all([
getUser(),
getProducts(),
getAnalytics(),
]);
return (
<Dashboard user={user} products={products} analytics={analytics} />
);
}
Request Memoization¶
React automatically deduplicates fetch calls with the same URL and options within a single server render. This means you can call the same fetch in multiple components without worrying about redundant requests:
// Both components call getUser() — React deduplicates to one request
async function UserName() {
const user = await getUser(); // fetch('/api/user')
return <h1>{user.name}</h1>;
}
async function UserAvatar() {
const user = await getUser(); // Same fetch — deduplicated
return <img src={user.avatar} alt={user.name} />;
}
Memoization scope
Request memoization only applies during a single server render pass. It does not persist across requests or pages.
Metadata & SEO¶
Static Metadata¶
Export a metadata object from a layout.tsx or page.tsx:
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
template: "%s | Sartiq",
default: "Sartiq — AI Product Photography",
},
description: "Generate studio-quality product photos with AI",
openGraph: {
type: "website",
siteName: "Sartiq",
},
};
Dynamic Metadata¶
Use generateMetadata when metadata depends on route params or fetched data:
// app/products/[id]/page.tsx
import type { Metadata } from "next";
interface Props {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const product = await getProduct(id);
return {
title: product.name,
description: product.description,
openGraph: {
images: [{ url: product.imageUrl, width: 1200, height: 630 }],
},
};
}
export default async function ProductPage({ params }: Props) {
const { id } = await params;
const product = await getProduct(id);
return <ProductDetail product={product} />;
}
Sharing Data Between generateMetadata and Page¶
Use React cache() to deduplicate the data fetch:
import { cache } from "react";
const getProduct = cache(async (id: string) => {
return prisma.product.findUniqueOrThrow({ where: { id } });
});
// Both generateMetadata and the page component call getProduct —
// React cache ensures the query runs only once.
Image Optimization¶
next/image automatically serves optimized, responsive images in modern formats (WebP/AVIF).
Basic Usage¶
import Image from "next/image";
import heroImg from "@/public/hero.jpg"; // Static import
export function Hero() {
return (
<Image
src={heroImg}
alt="Product showcase"
priority // Preload above-the-fold images
placeholder="blur" // Auto blur placeholder from static import
/>
);
}
Remote Images¶
Configure allowed domains in next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**.sartiq.com",
},
{
protocol: "https",
hostname: "cdn.shopify.com",
},
],
},
};
export default nextConfig;
Fill Mode for Unknown Dimensions¶
// Parent must have position: relative and defined dimensions
<div className="relative h-64 w-full">
<Image
src={product.imageUrl}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover rounded-lg"
/>
</div>
| Prop | When to Use |
|---|---|
priority |
Above-the-fold images (hero, LCP element) |
placeholder="blur" |
Static imports or when blurDataURL is provided |
fill |
Unknown or responsive dimensions (parent must be relative) |
sizes |
Always pair with fill or responsive layouts to avoid oversized downloads |
Font Optimization¶
next/font automatically self-hosts fonts with zero layout shift.
Google Fonts¶
// app/layout.tsx
import { Inter, JetBrains_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}
Local Fonts¶
import localFont from "next/font/local";
const calSans = localFont({
src: "./fonts/CalSans-SemiBold.woff2",
display: "swap",
variable: "--font-cal",
});
Tailwind Integration¶
Use CSS variables to wire fonts into Tailwind:
// tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
theme: {
extend: {
fontFamily: {
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
mono: ["var(--font-mono)", "monospace"],
cal: ["var(--font-cal)", "sans-serif"],
},
},
},
};
export default config;
Link & Navigation¶
The <Link> Component¶
next/link prefetches linked routes in the background when they enter the viewport:
import Link from "next/link";
export function Nav() {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/products">Products</Link>
<Link href="/products/new" prefetch={false}>
{/* Disable prefetch for rarely visited pages */}
New Product
</Link>
</nav>
);
}
Active Link Highlighting¶
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
interface NavLinkProps {
href: string;
children: React.ReactNode;
}
export function NavLink({ href, children }: NavLinkProps) {
const pathname = usePathname();
const isActive = pathname === href || pathname.startsWith(`${href}/`);
return (
<Link
href={href}
className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
{children}
</Link>
);
}
Programmatic Navigation¶
"use client";
import { useRouter } from "next/navigation";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh(); // Invalidate all cached server data
}
return <button onClick={handleLogout}>Logout</button>;
}
| Method | Purpose |
|---|---|
router.push(url) |
Navigate to a new URL (adds to history) |
router.replace(url) |
Navigate without adding history entry |
router.back() |
Go back one step |
router.refresh() |
Re-fetch server data for current route without hard reload |
router.prefetch(url) |
Manually prefetch a route |