Next.js 15 & React 19: Building Modern Web Applications
Explore the latest features in Next.js 15 and React 19, including App Router, Server Components, Partial Prerendering, and the new React Compiler.
Sambo Chea
Staff Engineer
The latest releases of Next.js 15 and React 19 bring revolutionary changes to how we build web applications. In this comprehensive guide, we'll explore the most impactful features and how they transform the development experience at CUBIS.
Next.js 15's App Router represents a fundamental shift in how we structure our applications. Built on React Server Components, it enables a new paradigm of server-first rendering with selective client-side interactivity.
// app/products/page.tsx async function ProductsPage() { // This runs on the server - no client-side bundle impact const products = await fetch('https://api.example.com/products'); return ( <div className="grid grid-cols-3 gap-6"> {products.map(product => ( <ProductCard key={product.id} product={product} /> ))} </div> ); }
Benefits we've experienced:
'use client'; import { useState } from 'react'; export function InteractiveSearch() { const [query, setQuery] = useState(''); return ( <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search products..." /> ); }
The 'use client' directive lets us opt-in to client-side interactivity exactly where we need it.
PPR is arguably the most exciting feature in Next.js 15. It combines static generation and dynamic rendering in a single page.
// app/dashboard/page.tsx export const experimental_ppr = true; export default async function Dashboard() { return ( <div> {/* Static shell - prerendered at build time */} <header> <Navigation /> </header> {/* Dynamic content - rendered per request */} <Suspense fallback={<SkeletonLoader />}> <UserStats /> {/* Fetches user-specific data */} </Suspense> {/* Static footer */} <footer> <Footer /> </footer> </div> ); }
Real-world impact at CUBIS:
React 19 introduces the React Compiler (formerly "React Forget"), which automatically optimizes your components without manual memoization.
Before (React 18):
function ProductList({ products }) { // Need to manually memoize const sortedProducts = useMemo( () => products.sort((a, b) => a.price - b.price), [products] ); return <div>{sortedProducts.map(renderProduct)}</div>; } const renderProduct = useCallback((product) => { return <ProductCard key={product.id} product={product} />; }, []);
After (React 19 with Compiler):
function ProductList({ products }) { // Compiler automatically optimizes this const sortedProducts = products.sort((a, b) => a.price - b.price); return <div>{sortedProducts.map(product => ( <ProductCard key={product.id} product={product} /> ))}</div>; }
use HookThe use hook enables asynchronous data fetching in components:
import { use } from 'react'; function ProductDetails({ productPromise }) { // use() unwraps the promise const product = use(productPromise); return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> </div> ); } // Parent component function ProductPage({ id }) { const productPromise = fetchProduct(id); return ( <Suspense fallback={<Loading />}> <ProductDetails productPromise={productPromise} /> </Suspense> ); }
Next.js 15 standardizes data fetching with native fetch caching:
// These two fetches are automatically deduplicated async function Header() { const user = await fetch('/api/user').then(r => r.json()); return <div>Welcome, {user.name}</div>; } async function Sidebar() { const user = await fetch('/api/user').then(r => r.json()); return <UserMenu user={user} />; }
// Revalidate every 60 seconds const data = await fetch('/api/data', { next: { revalidate: 60 } }); // No caching for user-specific data const userData = await fetch('/api/user', { cache: 'no-store' }); // Cache with tags for on-demand revalidation const posts = await fetch('/api/posts', { next: { tags: ['posts'] } });
import Image from 'next/image'; export function ProductImage({ src, alt }) { return ( <Image src={src} alt={alt} width={800} height={600} placeholder="blur" blurDataURL="/placeholder.jpg" loading="lazy" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" /> ); }
Results:
// app/layout.tsx import { Inter, Roboto_Mono } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-inter', }); const robotoMono = Roboto_Mono({ subsets: ['latin'], display: 'swap', variable: '--font-roboto-mono', }); export default function RootLayout({ children }) { return ( <html className={`${inter.variable} ${robotoMono.variable}`}> <body>{children}</body> </html> ); }
Type-safe metadata generation:
import { Metadata } from 'next'; export async function generateMetadata({ params }): Promise<Metadata> { const product = await fetchProduct(params.id); return { title: product.name, description: product.description, openGraph: { images: [product.image], type: 'product', }, twitter: { card: 'summary_large_image', }, }; }
Progressive page rendering improves perceived performance:
export default function ProductPage() { return ( <div> <Suspense fallback={<HeaderSkeleton />}> <Header /> </Suspense> <Suspense fallback={<ProductGridSkeleton />}> <ProductGrid /> </Suspense> <Suspense fallback={<ReviewsSkeleton />}> <Reviews /> </Suspense> </div> ); }
Components render and stream to the client as data becomes available, instead of blocking on the slowest query.
getServerSideProps with async componentsnext.config.jsuse() for async dataAfter migrating our main application to Next.js 15 + React 19:
Next.js 15 and React 19 represent a massive leap forward in web development. The combination of Server Components, PPR, and the React Compiler creates applications that are:
At CUBIS, we're all-in on this stack for new projects and actively migrating existing applications. The developer experience and performance gains are too significant to ignore.
Questions or feedback? Reach out to our engineering team: