Why Your Next.js App Is Slow: AI-Native Agency | WRKSHP
Js app is slow: Core Web Vitals killing your SEO? This deep dive into Next.js performance covers the patterns that move the needle: streaming, partial preren...
Founder, WRKSHP.DEV
Js app is slow: Core Web Vitals killing your SEO? This deep dive into Next.js performance covers the patterns that move the needle: streaming, partial preren... This guide explains Why Your Next.js App Is Slow: AI-Native Agency | WRKSHP with practical patterns WRKSHP uses on client builds. Use the checklist below to prioritize next steps and avoid common.
Core Web Vitals killing your SEO? This deep dive into Next.js performance covers the patterns that move the needle: streaming, partial prerendering, and intelligent caching strategies. This guide explains Why Your Next.js App Is Slow: AI-Native Agency | WRKSHP with practical patterns WRKSHP uses on client builds.
Js app is slow overview
Teams implementing js app is slow need clear ownership, observability, and rollout guardrails. The sections below walk through patterns WRKSHP uses on client builds.
Performance is a feature. In an era where Core Web Vitals directly impact search rankings and users abandon pages that take more than three seconds to load, a slow application is a broken application. Yet most Next.js performance advice focuses on micro-optimizations that don't move the needle while ignoring the architectural decisions that actually matter.
This guide focuses on the patterns that create measurable performance improvements. We'll cover server-side rendering strategies, data fetching optimization, caching architecture, and the newer features like Streaming and Partial Prerendering that represent the future of React performance.
Understanding What's Actually Slow
Before optimizing, you need to measure. Core Web Vitals provide the framework:
- Largest Contentful Paint (LCP): How long until the main content is visible? Target: under 2.5 seconds.
- First Input Delay (FID) / Interaction to Next Paint (INP): How responsive is the page to user input? Target: under 200ms.
- Cumulative Layout Shift (CLS): Does the page jump around while loading? Target: under 0.1.
Most performance problems in Next.js applications fall into a few categories:
- Slow data fetching: Waiting for API calls or database queries before rendering anything.
- Large JavaScript bundles: Shipping megabytes of JavaScript that must be parsed and executed before the page is interactive.
- Inefficient rendering: Re-rendering components unnecessarily or blocking the main thread.
- Poor caching: Fetching the same data repeatedly instead of caching intelligently.
Let's address each of these systematically.
Server Components: The Foundation of Performance
React Server Components (RSC) fundamentally change the performance equation. Code that runs on the server never ships to the client. Data fetching happens on the server, close to your data sources. The result is dramatically smaller JavaScript bundles and faster initial renders.
The Mental Model
Think of Server Components as the default. They render on the server, generate HTML, and stream to the client. They can directly access databases, file systems, and internal APIs. Their code never reaches the browser.
Client Components are the exception. Use them only when you need interactivity, browser APIs, or React hooks like useState and useEffect. Mark them with 'use client' at the top of the file.
// Server Component (default) - no directive needed
async function ProductList() {
// This runs on the server
const products = await db.products.findMany({
where: { inStock: true },
take: 20
});
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// Client Component - for interactivity
'use client'
import { useState } from 'react';
function AddToCartButton({ productId }) {
const [loading, setLoading] = useState(false);
async function handleClick() {
setLoading(true);
await addToCart(productId);
setLoading(false);
}
return (
<button disabled={loading}>
{loading ? 'Adding...' : 'Add to Cart'}
</button>
);
}Composition Pattern
The key insight is that Client Components can render Server Components as children. This lets you keep the interactive shell minimal while the content remains server-rendered:
// Server Component wrapper
async function ProductPage({ id }) {
const product = await getProduct(id);
return (
<div>
<h1>{product.name}</h1>
<ProductGallery images={product.images} /> {/* Client Component for carousel */}
<ProductDescription description={product.description} /> {/* Server Component */}
<AddToCartButton productId={id} /> {/* Client Component */}
<RelatedProducts categoryId={product.categoryId} /> {/* Server Component */}
</div>
);
}The Client Components (ProductGallery, AddToCartButton) ship JavaScript to the browser. The Server Components (ProductDescription, RelatedProducts) render on the server and send only HTML.
Streaming: Don't Wait for Everything
Traditional server-side rendering waits for all data before sending any HTML. If your page needs data from three APIs and one is slow, the entire page waits for that slow API.
Streaming changes this. Next.js can send HTML as it becomes available, showing fast parts of the page immediately while slower parts load. This dramatically improves perceived performance.
Implementing Streaming with Suspense
Wrap slow components in Suspense boundaries. Next.js will stream the fallback immediately, then replace it with actual content when ready:
import { Suspense } from 'react';
async function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Fast - renders immediately */}
<QuickStats />
{/* Slow API - streams when ready */}
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
{/* Very slow - streams independently */}
<Suspense fallback={<RecommendationsSkeleton />}>
<AIRecommendations />
</Suspense>
</div>
);
}
// This component takes 3 seconds to load
async function AIRecommendations() {
const recommendations = await fetch('https://slow-ai-api.com/recommend', {
next: { revalidate: 3600 } // Cache for 1 hour
}).then(r => r.json());
return (
<ul>
{recommendations.map(rec => (
<li key={rec.id}>{rec.title}</li>
))}
</ul>
);
}The user sees the dashboard header and QuickStats immediately. The chart skeleton appears while AnalyticsChart loads. AIRecommendations loads independently, it doesn't block anything else.
Nested Suspense Boundaries
You can nest Suspense boundaries for granular loading states. Each boundary streams independently:
async function ProductPage({ id }) {
return (
<div>
<Suspense fallback={<ProductHeaderSkeleton />}>
<ProductHeader id={id} />
{/* Nested - only loads after ProductHeader */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews id={id} />
</Suspense>
</Suspense>
</div>
);
}Partial Prerendering: The Best of Both Worlds
Partial Prerendering (PPR) combines static generation with dynamic streaming. The static shell is prerendered at build time for instant loading, while dynamic parts stream in at request time.
This is experimental as of Next.js 14 but represents the future of rendering strategies.
// next.config.js
module.exports = {
experimental: {
ppr: true
}
};
// Page with PPR
export default async function StorePage() {
return (
<div>
{/* Static - prerendered at build time */}
<Header />
<Navigation />
{/* Dynamic - streamed at request time */}
<Suspense fallback={<CartBadgeSkeleton />}>
<CartBadge /> {/* Shows user's cart count */}
</Suspense>
{/* Static - prerendered */}
<FeaturedProducts />
{/* Dynamic - personalized */}
<Suspense fallback={<RecommendationsSkeleton />}>
<PersonalizedRecommendations />
</Suspense>
{/* Static */}
<Footer />
</div>
);
}The static parts (Header, Navigation, FeaturedProducts, Footer) load instantly from the CDN edge. Dynamic parts (CartBadge, PersonalizedRecommendations) stream in as they compute. Users see meaningful content immediately while personalized content loads.
Data Fetching Optimization
Parallel vs. Sequential Fetching
A common performance mistake is sequential data fetching, waiting for one request before starting another. If you need data from three independent sources, fetch them in parallel:
// Bad: Sequential (9 seconds total if each takes 3 seconds)
async function Dashboard() {
const user = await getUser();
const orders = await getOrders(user.id);
const recommendations = await getRecommendations(user.id);
// ...
}
// Good: Parallel (3 seconds total)
async function Dashboard() {
const userPromise = getUser();
const ordersPromise = getOrders();
const recommendationsPromise = getRecommendations();
const [user, orders, recommendations] = await Promise.all([
userPromise,
ordersPromise,
recommendationsPromise
]);
// ...
}With streaming, you can take this further, don't even wait for Promise.all. Let each component fetch its own data and stream independently:
// Best: Independent streaming
async function Dashboard() {
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserProfile /> {/* Fetches user data internally */}
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<OrderHistory /> {/* Fetches orders internally */}
</Suspense>
<Suspense fallback={<RecsSkeleton />}>
<Recommendations /> {/* Fetches recommendations internally */}
</Suspense>
</div>
);
}Request Deduplication
Next.js automatically deduplicates fetch requests with the same URL within a single render. If multiple components need the same data, just fetch it in each, Next.js handles the optimization:
// Both components fetch user data - Next.js makes only one request
async function ProfileHeader() {
const user = await fetch('/api/user').then(r => r.json());
return <h1>{user.name}</h1>;
}
async function ProfileSidebar() {
const user = await fetch('/api/user').then(r => r.json());
return <aside>Member since {user.joinDate}</aside>;
}Caching Strategies
Next.js provides multiple caching layers. Understanding them is essential for performance:
Request Memoization (Single Request)
Within a single render, identical fetch calls are deduplicated. This happens automatically.
Data Cache (Across Requests)
Fetch responses can be cached and shared across requests. Control this with the next options:
// Cache indefinitely (default for static data)
fetch('https://api.example.com/products', {
cache: 'force-cache'
});
// Revalidate every hour
fetch('https://api.example.com/products', {
next: { revalidate: 3600 }
});
// Never cache (always fresh)
fetch('https://api.example.com/user', {
cache: 'no-store'
});
// Cache with tags for on-demand revalidation
fetch(`https://api.example.com/products/${id}`, {
next: { tags: ['products', `product-${id}`] }
});On-Demand Revalidation
When data changes, invalidate specific cache entries:
// API route that revalidates cache
import { revalidateTag, revalidatePath } from 'next/cache';
export async function POST(request) {
const { productId } = await request.json();
// Revalidate specific product
revalidateTag(`product-${productId}`);
// Or revalidate entire products list
revalidateTag('products');
// Or revalidate a specific page
revalidatePath('/products');
return Response.json({ revalidated: true });
}Full Route Cache
Static pages are cached at the route level. Use generateStaticParams for dynamic routes that should be prerendered:
// Generate static pages for all products at build time
export async function generateStaticParams() {
const products = await getProducts();
return products.map(product => ({
id: product.id.toString()
}));
}Bundle Optimization
Code Splitting with Dynamic Imports
Heavy components that aren't needed immediately should be dynamically imported:
import dynamic from 'next/dynamic';
// Only load chart library when needed
const Chart = dynamic(() => import('./Chart'), {
loading: () => <ChartSkeleton />,
ssr: false // Don't render on server if it's client-only
});
// Load admin components only for admins
const AdminPanel = dynamic(() => import('./AdminPanel'), {
loading: () => <Loading />
});
function Dashboard({ isAdmin }) {
return (
<div>
<Chart data={analyticsData} />
{isAdmin && <AdminPanel />}
</div>
);
}Analyze Your Bundles
Use @next/bundle-analyzer to visualize what's in your bundles:
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true'
});
module.exports = withBundleAnalyzer({
// your config
});Run with ANALYZE=true npm run build to generate a visualization. Look for:
- Duplicate dependencies
- Large libraries that could be replaced with smaller alternatives
- Dependencies included in client bundles that should be server-only
Image Optimization
Images are often the largest assets on a page. Next.js's Image component handles optimization automatically:
import Image from 'next/image';
function ProductCard({ product }) {
return (
<div>
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
priority={product.featured} // Load featured images first
placeholder="blur"
blurDataURL={product.thumbnailBase64}
/>
</div>
);
}Key props:
- priority: Preload above-the-fold images to improve LCP
- placeholder="blur": Show a blurred placeholder while loading to prevent layout shift
- sizes: Tell the browser how wide the image will be at different viewport sizes for responsive loading
<Image
src={heroImage}
alt="Hero"
fill
priority
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>Measuring Results
After implementing optimizations, measure the impact:
Lighthouse: Run in Chrome DevTools for Core Web Vitals scores.
Real User Monitoring: Use Vercel Analytics, Sentry, or similar to track actual user experience.
Web Vitals API: Report metrics from real users:
// app/components/WebVitals.tsx
'use client'
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
// Send to your analytics
analytics.track('web-vital', {
name: metric.name,
value: metric.value,
rating: metric.rating,
});
});
return null;
}Conclusion
Performance optimization in Next.js is about choosing the right rendering strategy, not micro-optimizations. The patterns that matter:
- Default to Server Components: Ship less JavaScript to the browser.
- Stream with Suspense: Don't let slow data block fast content.
- Cache aggressively: Use the data cache and understand revalidation.
- Parallelize data fetching: Never wait for sequential requests when parallel is possible.
- Optimize images: Use the Image component with priority for LCP-critical images.
Start with measurement. Identify what's actually slow. Apply these patterns systematically. Measure again. Performance is a continuous process, not a one-time fix.
Ready to implement these patterns? Explore enterprise software builds and Growth OS with WRKSHP, or start a conversation.
Work With WRKSHP
WRKSHP builds AI-native software, operated growth systems, and governance layers for teams that sell outcomes, not billable hours.
Explore enterprise software, Growth OS, GaaS governance, contact, or start a conversation about your next build.
Frequently Asked Questions
What is the main takeaway from "Why Your Next.js App Is Slow: AI-Native Agency | WRKSHP"?
Js app is slow: Core Web Vitals killing your SEO? This deep dive into Next. Use it as a production playbook for operators and engineers, not slide-deck theory.
How does "Js app is slow overview" fit into Why Your Next.js App Is Slow: AI-Native Agency | WRKSHP?
Js app is slow overview is a core section of this guide. Apply it after you pick one measurable KPI, then instrument the path that moves that KPI before expanding scope.
What should I watch when working through "Understanding What's Actually Slow"?
Treat "Understanding What's Actually Slow" as a decision checkpoint: name an owner, define success metrics, and refuse to automate steps that spend money or change production data without an audit trail.
When should I bring in a partner on Why Your Next.js App Is Slow: AI-Native Agency | WRKSHP?
Bring in help when you need a fixed-outcome delivery model, governance for agent actions, or a single platform spanning build and growth, patterns WRKSHP uses on enterprise software and Growth OS engagements.