Το Next.js 15 έφερε μια θεμελιώδη αλλαγή στον τρόπο που χειριζόμαστε το data fetching και το caching. Ενώ οι προηγούμενες εκδόσεις υιοθετούσαν μια προσέγγιση "cache-by-default", το Next.js 15 μετακινήθηκε σε ένα μοντέλο "dynamic-by-default". Αυτή η αλλαγή αντικατοπτρίζει την ωριμότητα του framework και την ανάγκη για πιο προβλέψιμη συμπεριφορά από προεπιλογή — και ειλικρινά, μόλις το συνηθίσεις, δεν γυρίζεις πίσω. Σε αυτόν τον οδηγό, θα εξερευνήσουμε διεξοδικά όλες τις πτυχές του data fetching και του caching στο App Router, από τα βασικά έως τις πιο προχωρημένες τεχνικές.
1. Εισαγωγή: Η Μετάβαση σε Dynamic-by-Default
Στο Next.js 13 και 14, κάθε fetch() request ήταν cached by default. Αυτό σήμαινε ότι αν καλούσατε το ίδιο URL δύο φορές, το Next.js θα επέστρεφε την cached απάντηση χωρίς να κάνει νέο network request. Αν και αυτή η προσέγγιση βελτιστοποιεί την απόδοση, συχνά οδηγούσε σε απροσδόκητη συμπεριφορά για developers που δεν ήταν εξοικειωμένοι με τους μηχανισμούς caching του framework.
Λοιπόν, το Next.js 15 αλλάζει αυτή την προεπιλογή: τα fetch() requests, τα Route Handlers και τα Client Router Cache δεν κάνουν πλέον cache by default. Αν θέλετε caching, πρέπει να το ζητήσετε ρητά. Αυτή η αλλαγή κάνει τη συμπεριφορά της εφαρμογής πιο διαφανή και κατανοητή.
// Next.js 14 - cached by default
const data = await fetch('https://api.example.com/data');
// Next.js 15 - dynamic by default (ισοδύναμο με no-store)
const data = await fetch('https://api.example.com/data');
// Next.js 15 - για caching, πρέπει να το ορίσετε ρητά
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }
});
Αυτή η αλλαγή φιλοσοφίας επηρεάζει και τις routes. Στο Next.js 14, οι static routes ήταν η προεπιλογή. Στο Next.js 15, αν η route χρησιμοποιεί dynamic APIs όπως cookies(), headers(), ή searchParams, αυτόματα γίνεται dynamic. Το caching πρέπει πλέον να είναι μια συνειδητή επιλογή.
2. Τα 4 Επίπεδα Caching στο Next.js
Για να κατανοήσουμε πλήρως το caching στο Next.js App Router, πρέπει να γνωρίζουμε ότι υπάρχουν τέσσερα διαφορετικά επίπεδα caching, το καθένα με διαφορετικό σκοπό και lifetime. Ας δούμε τώρα το καθένα ξεχωριστά.
2.1 Request Memoization (React Feature)
Το Request Memoization είναι ένα feature του ίδιου του React, όχι αποκλειστικά του Next.js. Κατά τη διάρκεια ενός single server render, αν πολλά components κάνουν fetch στο ίδιο URL με τις ίδιες παραμέτρους, το React θα εκτελέσει το request μόνο μια φορά και θα μοιραστεί το αποτέλεσμα.
// component-a.tsx
async function ComponentA() {
// Αυτό το request εκτελείται μόνο μια φορά
const user = await fetch('https://api.example.com/user/1');
return <div>{user.name}</div>;
}
// component-b.tsx
async function ComponentB() {
// Αυτό το request ΔΕΝ εκτελείται ξανά - χρησιμοποιείται το memoized αποτέλεσμα
const user = await fetch('https://api.example.com/user/1');
return <span>{user.email}</span>;
}
Σημαντικό: το Request Memoization ισχύει μόνο για τη διάρκεια ενός render pass. Μόλις ολοκληρωθεί το rendering, το cache καθαρίζεται — οπότε δεν χρειάζεται να ανησυχείτε για stale data μεταξύ requests.
2.2 Data Cache (Server-Side Persistent Cache)
Το Data Cache είναι ένα persistent cache που αποθηκεύει τα αποτελέσματα των fetch() requests μεταξύ server requests και deployments. Σε αντίθεση με το Request Memoization, το Data Cache επιβιώνει πολλαπλών requests.
// Αποθηκεύεται στο Data Cache για 1 ώρα
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }
});
// Αποθηκεύεται στο Data Cache indefinitely (μέχρι manual revalidation)
const staticData = await fetch('https://api.example.com/config', {
cache: 'force-cache'
});
// ΔΕΝ αποθηκεύεται - πάντα fresh data
const dynamicData = await fetch('https://api.example.com/live-prices', {
cache: 'no-store'
});
Το Data Cache είναι persistent και shared μεταξύ server instances (σε production environments με κατάλληλη υποδομή). Αυτό το κάνει ιδανικό για data που αλλάζει σπάνια αλλά χρειάζεται να είναι fresh σε τακτά διαστήματα.
2.3 Full Route Cache
Το Full Route Cache αποθηκεύει το πλήρες HTML output μιας static route κατά τη διάρκεια του build. Αυτό σημαίνει ότι για static pages, το Next.js δεν χρειάζεται να κάνει re-render κατά κάθε request — απλά σερβίρει το pre-rendered HTML.
// app/products/page.tsx
// Αυτή η σελίδα θα γίνει statically rendered αν δεν υπάρχουν dynamic APIs
export default async function ProductsPage() {
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 86400 } // revalidate every 24 hours
});
return (
<ul>
{products.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
Το Full Route Cache invalidate αυτόματα όταν το Data Cache της route invalidate. Επίσης, αν χρησιμοποιήσετε dynamic APIs (cookies, headers, searchParams), η route αποκλείεται αυτόματα από το Full Route Cache.
2.4 Router Cache (Client-Side)
Το Router Cache είναι ένα client-side in-memory cache που αποθηκεύει τα RSC (React Server Component) payloads καθώς ο χρήστης πλοηγείται στην εφαρμογή. Αυτό επιτρέπει instant navigation μεταξύ routes που έχουν ήδη επισκεφτεί.
// Στο Next.js 15, το Router Cache έχει διαφορετικό default behavior:
// - Static routes: cached για 5 λεπτά
// - Dynamic routes: ΔΕΝ cached by default (αλλαγή από Next.js 14)
// Για να κάνετε prefetch ρητά:
import Link from 'next/link';
function Navigation() {
return (
<nav>
{/* prefetch={true} για static routes (default) */}
<Link href="/about">About</Link>
{/* prefetch={false} για να απενεργοποιήσετε το prefetching */}
<Link href="/dashboard" prefetch={false}>Dashboard</Link>
</nav>
);
}
3. Data Fetching στα Server Components
Ένα από τα σημαντικότερα πλεονεκτήματα του App Router είναι η δυνατότητα να κάνετε data fetching απευθείας στα Server Components. Αυτό εξαλείφει την ανάγκη για API routes ως middleman και μειώνει σημαντικά το client-side JavaScript. Ειλικρινά, αυτό ήταν game changer για μένα όταν το ανακάλυψα — ο κώδικας γίνεται πολύ πιο καθαρός.
3.1 Fetching με το Native fetch()
// app/users/page.tsx
interface User {
id: number;
name: string;
email: string;
}
export default async function UsersPage() {
// Το fetch() στα Server Components τρέχει στον server
const response = await fetch('https://jsonplaceholder.typicode.com/users', {
next: { revalidate: 3600 } // Revalidate κάθε ώρα
});
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const users: User[] = await response.json();
return (
<main>
<h1>Users</h1>
<ul>
{users.map((user) => (
<li key={user.id}>
<strong>{user.name}</strong> - {user.email}
</li>
))}
</ul>
</main>
);
}
3.2 Fetching με Database ORMs
Στο App Router, μπορείτε να κάνετε queries απευθείας στη βάση δεδομένων χωρίς να περνάτε από API routes. Αυτό είναι ιδιαίτερα ισχυρό με ORMs όπως το Prisma ή το Drizzle.
// app/posts/page.tsx με Prisma
import { prisma } from '@/lib/prisma';
export default async function PostsPage() {
// Απευθείας database query στο Server Component
const posts = await prisma.post.findMany({
where: { published: true },
include: { author: true },
orderBy: { createdAt: 'desc' },
take: 10
});
return (
<div>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>By {post.author.name}</p>
</article>
))}
</div>
);
}
// app/posts/page.tsx με Drizzle ORM
import { db } from '@/lib/db';
import { posts, users } from '@/lib/schema';
import { eq, desc } from 'drizzle-orm';
export default async function PostsPage() {
const result = await db
.select({
id: posts.id,
title: posts.title,
authorName: users.name,
})
.from(posts)
.leftJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt))
.limit(10);
return (
<div>
{result.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>By {post.authorName}</p>
</article>
))}
</div>
);
}
4. Επιλογές Cache στο fetch()
Το Next.js επεκτείνει το native fetch() API με επιπλέον επιλογές που ελέγχουν τη συμπεριφορά του caching. Η κατανόηση αυτών των επιλογών είναι κρίσιμη για τη βελτιστοποίηση της εφαρμογής σας — και είναι λιγότερο περίπλοκες απ' ό,τι φαίνονται αρχικά.
4.1 cache: 'no-store'
Αυτή η επιλογή διασφαλίζει ότι το request δεν θα γίνει cached ποτέ. Κάθε φορά που το component κάνει render, θα γίνεται νέο network request. Χρησιμοποιείται για data που αλλάζει συνεχώς, όπως stock prices ή live feeds.
// Πάντα φρέσκα δεδομένα - χρήσιμο για real-time data
const liveData = await fetch('https://api.example.com/live-feed', {
cache: 'no-store'
});
// Ή με το next object (ισοδύναμο)
const liveData2 = await fetch('https://api.example.com/live-feed', {
next: { revalidate: 0 }
});
4.2 next.revalidate
Ορίζει τον χρόνο (σε δευτερόλεπτα) μετά τον οποίο το cached data θεωρείται stale και θα ζητηθεί ξανά. Αυτό είναι το Time-based Revalidation ή ISR (Incremental Static Regeneration).
// Revalidate κάθε 60 δευτερόλεπτα
const data = await fetch('https://api.example.com/news', {
next: { revalidate: 60 }
});
// Revalidate κάθε 24 ώρες
const config = await fetch('https://api.example.com/site-config', {
next: { revalidate: 86400 }
});
// Revalidate κάθε εβδομάδα
const staticContent = await fetch('https://api.example.com/terms', {
next: { revalidate: 604800 }
});
4.3 next.tags
Το tagging επιτρέπει on-demand revalidation. Μπορείτε να "ετικετάρετε" ένα cached response και μετά να το invalidate programmatically μέσω του revalidateTag().
// Fetch με tags
const products = await fetch('https://api.example.com/products', {
next: {
revalidate: 3600,
tags: ['products', 'catalog']
}
});
// Fetch που αναφέρεται σε συγκεκριμένο resource
const product = await fetch(`https://api.example.com/products/${id}`, {
next: {
tags: [`product-${id}`, 'products']
}
});
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const { tag, secret } = await request.json();
// Ελέγξτε το secret για ασφάλεια
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
revalidateTag(tag);
return Response.json({ revalidated: true, tag });
}
5. Route Segment Configuration
Το Next.js App Router παρέχει Route Segment Config options που επιτρέπουν τον έλεγχο της συμπεριφοράς μιας route σε επίπεδο αρχείου. Αυτά αντικαθιστούν τα παλαιά getServerSideProps και getStaticProps του Pages Router — και κατά τη γνώμη μου, η νέα σύνταξη είναι αρκετά πιο ξεκάθαρη.
5.1 Η export dynamic
// app/dashboard/page.tsx
// Αναγκάζει dynamic rendering (ισοδύναμο με getServerSideProps)
export const dynamic = 'force-dynamic';
// Αναγκάζει static rendering (ισοδύναμο με getStaticProps χωρίς revalidate)
export const dynamic = 'force-static';
// Προεπιλογή: 'auto' - το Next.js αποφασίζει βάσει των APIs που χρησιμοποιείτε
export const dynamic = 'auto';
// Κάνει error αν η route είναι dynamic (για safety checks)
export const dynamic = 'error';
export default async function DashboardPage() {
// ...
}
5.2 Η export revalidate
// app/blog/page.tsx
// Revalidate όλα τα fetch requests στη route κάθε ώρα
export const revalidate = 3600;
// Equivalent to force-static caching
export const revalidate = false;
// Equivalent to no-store (dynamic)
export const revalidate = 0;
export default async function BlogPage() {
// Αυτό το fetch θα inherit το revalidate = 3600
const posts = await fetch('https://api.example.com/posts');
return (
<div>
{/* ... */}
</div>
);
}
5.3 Η export fetchCache
// app/products/page.tsx
// Ελέγχει πώς το fetch caching συμπεριφέρεται για όλα τα fetches στη route
export const fetchCache = 'auto'; // προεπιλογή
export const fetchCache = 'default-cache';
export const fetchCache = 'only-cache';
export const fetchCache = 'force-cache';
export const fetchCache = 'force-no-store';
export const fetchCache = 'default-no-store';
export const fetchCache = 'only-no-store';
6. Incremental Static Regeneration (ISR)
Το ISR επιτρέπει την ενημέρωση static pages χωρίς rebuild ολόκληρης της εφαρμογής. Στο App Router, το ISR είναι πιο ευέλικτο και ενσωματωμένο από ποτέ.
6.1 Time-Based Revalidation
// app/news/page.tsx
export const revalidate = 300; // 5 λεπτά
export default async function NewsPage() {
const response = await fetch('https://api.example.com/news', {
next: { revalidate: 300 }
});
const articles = await response.json();
return (
<main>
<h1>Τελευταία Νέα</h1>
{articles.map(article => (
<article key={article.id}>
<h2>{article.title}</h2>
<p>{article.summary}</p>
</article>
))}
</main>
);
}
6.2 On-Demand Revalidation
Το On-Demand Revalidation επιτρέπει να ορίσετε ακριβώς τη στιγμή που θέλετε να invalidate το cache — π.χ. όταν δημοσιεύεται νέο περιεχόμενο στο CMS. Πολύ χρήσιμο σε workflows με editors που δεν θέλουν να περιμένουν revalidation windows.
// app/api/webhook/cms/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const payload = await request.json();
// Revalidate με βάση tags
if (payload.type === 'post.published') {
revalidateTag('posts');
revalidateTag(`post-${payload.id}`);
}
// Ή revalidate συγκεκριμένο path
if (payload.type === 'page.updated') {
revalidatePath(`/${payload.slug}`);
revalidatePath('/sitemap.xml');
}
return Response.json({
revalidated: true,
timestamp: new Date().toISOString()
});
}
6.3 generateStaticParams για Dynamic Routes
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}));
}
export const revalidate = 3600; // ISR: revalidate κάθε ώρα
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
next: { tags: [`post-${params.slug}`, 'posts'] }
}).then(r => r.json());
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
7. Η Οδηγία use cache
Το Next.js 15 εισάγει τη νέα οδηγία use cache, η οποία αντικαθιστά το unstable_cache και προσφέρει έναν πιο ergonomic τρόπο για να ορίσετε caching boundaries. Αυτή η οδηγία μπορεί να χρησιμοποιηθεί σε functions, components, ή ακόμα και σε ολόκληρα αρχεία.
7.1 Βασική Χρήση
// Caching μιας function
async function getExpensiveData() {
'use cache';
// Αυτή η function θα cached automatically
const data = await someExpensiveOperation();
return data;
}
// Caching ενός component
async function CachedComponent() {
'use cache';
const data = await fetchData();
return <div>{data.title}</div>;
}
// Caching ενός ολόκληρου αρχείου (στην κορυφή του αρχείου)
'use cache';
export async function getData() {
return await db.query('SELECT * FROM products');
}
7.2 cacheLife Profiles
Το cacheLife API σας επιτρέπει να ορίσετε detailed cache lifetimes χρησιμοποιώντας predefined profiles ή custom configurations.
import { unstable_cacheLife as cacheLife } from 'next/cache';
async function getHourlyData() {
'use cache';
cacheLife('hours'); // Cache για ώρες
return await fetch('https://api.example.com/hourly-stats').then(r => r.json());
}
async function getDailyData() {
'use cache';
cacheLife('days'); // Cache για ημέρες
return await fetch('https://api.example.com/daily-report').then(r => r.json());
}
async function getWeeklyData() {
'use cache';
cacheLife('weeks'); // Cache για εβδομάδες
return await fetch('https://api.example.com/weekly-summary').then(r => r.json());
}
// Custom cache profile
async function getCustomCachedData() {
'use cache';
cacheLife({
stale: 300, // 5 λεπτά - χρόνος που το browser μπορεί να χρησιμοποιεί stale data
revalidate: 3600, // 1 ώρα - πότε να ανανεωθεί από τον server
expire: 86400 // 24 ώρες - μέγιστος χρόνος πριν αναγκαστική ανανέωση
});
return await fetchComplexData();
}
7.3 cacheTag για On-Demand Invalidation
import { unstable_cacheTag as cacheTag, revalidateTag } from 'next/cache';
async function getProductData(productId: string) {
'use cache';
cacheTag(`product-${productId}`, 'products');
return await db.products.findUnique({ where: { id: productId } });
}
async function getCategoryProducts(categoryId: string) {
'use cache';
cacheTag(`category-${categoryId}`, 'products', 'catalog');
return await db.products.findMany({ where: { categoryId } });
}
// Όταν ένα product ενημερώνεται:
async function updateProduct(productId: string, data: ProductData) {
await db.products.update({ where: { id: productId }, data });
// Invalidate το συγκεκριμένο product και όλα τα products
revalidateTag(`product-${productId}`);
revalidateTag('products');
}
7.4 use cache: private για User-Specific Data
Για data που είναι specific σε έναν χρήστη και δεν πρέπει να γίνεται shared μεταξύ users, χρησιμοποιείτε το use cache: private. Μην το παραλείπετε αυτό — είναι κρίσιμο για την ασφάλεια.
import { cookies } from 'next/headers';
async function getUserPreferences() {
'use cache: private'; // Cached μόνο για τον τρέχοντα user
const cookieStore = await cookies();
const userId = cookieStore.get('userId')?.value;
if (!userId) return null;
return await db.userPreferences.findUnique({ where: { userId } });
}
// Χρήση σε component
async function PersonalizedDashboard() {
const preferences = await getUserPreferences();
return (
<div style={{ theme: preferences?.theme }}>
<h1>Καλώς ήρθατε</h1>
</div>
);
}
7.5 use cache: remote για Shared Caching
Το use cache: remote επιτρέπει το caching σε έναν shared remote cache (π.χ. Redis), κάτι που είναι χρήσιμο για multi-instance deployments.
async function getGlobalSettings() {
'use cache: remote'; // Shared cache μεταξύ όλων των server instances
return await fetch('https://api.example.com/global-config').then(r => r.json());
}
8. Παράλληλο vs Σειριακό Data Fetching
Ένας από τους πιο κοινούς τρόπους για να επιβραδύνετε μια εφαρμογή είναι το αποκαλούμενο "waterfall" πρόβλημα: η σειριακή εκτέλεση requests που θα μπορούσαν να εκτελεστούν παράλληλα. Το έχω δει σε αρκετές codebases και κάθε φορά η διόρθωση είναι πιο εύκολη απ' ό,τι περιμένεις.
8.1 Το Waterfall Πρόβλημα
// ΛΑΘΟΣ: Σειριακό fetching - κάθε request περιμένει το προηγούμενο
async function SlowPage() {
const user = await fetchUser(userId); // 200ms
const posts = await fetchUserPosts(userId); // 300ms (περιμένει user)
const comments = await fetchComments(postId); // 250ms (περιμένει posts)
// Συνολικά: ~750ms
return <div>...</div>;
}
8.2 Παράλληλο Fetching με Promise.all
// ΣΩΣΤΟ: Παράλληλο fetching
async function FastPage({ userId }: { userId: string }) {
// Ξεκινούν όλα ταυτόχρονα
const [user, posts, stats] = await Promise.all([
fetchUser(userId),
fetchUserPosts(userId),
fetchUserStats(userId),
]);
// Συνολικά: ~300ms (το μεγαλύτερο από τα τρία)
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
<StatsPanel stats={stats} />
</div>
);
}
8.3 Promise.allSettled για Ανεξάρτητα Requests
// Promise.allSettled: Συνεχίζει ακόμα και αν κάποιο request αποτύχει
async function ResilientPage() {
const results = await Promise.allSettled([
fetchPrimaryData(),
fetchOptionalSidebarData(),
fetchRecommendations(),
]);
const primaryData = results[0].status === 'fulfilled'
? results[0].value
: null;
const sidebarData = results[1].status === 'fulfilled'
? results[1].value
: getDefaultSidebarData();
const recommendations = results[2].status === 'fulfilled'
? results[2].value
: [];
return (
<div>
{primaryData && <MainContent data={primaryData} />}
<Sidebar data={sidebarData} />
<Recommendations items={recommendations} />
</div>
);
}
8.4 Preloading Pattern
Το Preloading pattern επιτρέπει την έναρξη data fetching πριν το React αρχίσει να κάνει render ένα component, εξαλείφοντας waterfalls ακόμα και σε deeply nested components.
// lib/data.ts
import { cache } from 'react';
export const preloadUser = (id: string) => {
// Ξεκινά το fetch αλλά δεν το await - αποθηκεύεται στο React cache
void getUser(id);
};
export const getUser = cache(async (id: string) => {
return fetch(`https://api.example.com/users/${id}`).then(r => r.json());
});
// app/page.tsx
import { preloadUser, getUser } from '@/lib/data';
export default async function Page({ params }: { params: { id: string } }) {
// Ξεκινά το fetch αμέσως
preloadUser(params.id);
// Κάνει άλλη δουλειά...
const someOtherData = await fetchSomethingElse();
// Τώρα await - το fetch ίσως έχει ήδη ολοκληρωθεί
const user = await getUser(params.id);
return <UserProfile user={user} />;
}
9. Revalidation Στρατηγικές
Το Next.js παρέχει πολλαπλούς τρόπους για να ελέγξετε πότε και πώς θα ανανεωθεί το cached data. Η κατανόηση των διαφορών μεταξύ αυτών είναι κρίσιμη για την ορθή αρχιτεκτονική.
9.1 revalidateTag
// server action ή route handler
import { revalidateTag } from 'next/cache';
// Invalidate όλα τα fetches με αυτό το tag
export async function invalidateProductCache(productId: string) {
revalidateTag(`product-${productId}`);
revalidateTag('products');
revalidateTag('catalog');
}
// Παράδειγμα σε Server Action
'use server';
export async function publishPost(postId: string) {
await db.posts.update({
where: { id: postId },
data: { published: true, publishedAt: new Date() }
});
revalidateTag('posts');
revalidateTag(`post-${postId}`);
revalidateTag('blog-index');
}
9.2 revalidatePath
import { revalidatePath } from 'next/cache';
// Invalidate συγκεκριμένη σελίδα
revalidatePath('/blog');
// Invalidate dynamic route
revalidatePath('/blog/[slug]', 'page');
// Invalidate ολόκληρο layout
revalidatePath('/dashboard', 'layout');
// Invalidate σε Server Action
'use server';
export async function updateUserProfile(userId: string, data: UserData) {
await db.users.update({ where: { id: userId }, data });
revalidatePath(`/users/${userId}`);
revalidatePath('/users'); // Το listing
revalidatePath('/dashboard'); // Αν εμφανίζεται εκεί
}
9.3 Διαφορές μεταξύ revalidateTag και revalidatePath
Η επιλογή μεταξύ revalidateTag και revalidatePath εξαρτάται από το πόσο granular θέλετε το invalidation. Το revalidateTag είναι πιο ακριβές καθώς στοχεύει συγκεκριμένα tagged data, ενώ το revalidatePath invalidate ολόκληρη τη route cache — συμπεριλαμβανομένου του Full Route Cache και όλων των Data Cache entries που σχετίζονται με αυτή τη route.
// revalidateTag - Ακριβές invalidation
// Invalidate μόνο τα fetch requests που έχουν το tag 'products'
revalidateTag('products');
// revalidatePath - Ευρύ invalidation
// Invalidate ολόκληρη τη route '/products' (Full Route Cache + Data Cache)
revalidatePath('/products');
// Βέλτιστη πρακτική: συνδυάστε τα δύο
export async function handleProductUpdate(productId: string) {
// Invalidate τα συγκεκριμένα cached data
revalidateTag(`product-${productId}`);
// Και ανανεώστε τη σελίδα
revalidatePath(`/products/${productId}`);
revalidatePath('/products'); // Listing page
}
10. Ενσωμάτωση με Βάσεις Δεδομένων
Η απευθείας επικοινωνία με τη βάση δεδομένων από Server Components είναι ένα από τα πιο ισχυρά features του App Router. Ωστόσο, απαιτεί προσοχή στη διαχείριση connections — ιδιαίτερα σε serverless περιβάλλοντα όπου τα connections μπορούν να εξαντληθούν γρήγορα.
10.1 Prisma Integration
// lib/prisma.ts - Singleton pattern για connection pooling
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
// lib/queries/products.ts - Data access layer με caching
import { cache } from 'react';
import { prisma } from '@/lib/prisma';
// Χρήση του React cache() για request memoization
export const getProductById = cache(async (id: string) => {
return prisma.product.findUnique({
where: { id },
include: {
category: true,
images: true,
variants: { where: { inStock: true } }
}
});
});
export const getProducts = cache(async (options: {
categoryId?: string;
limit?: number;
offset?: number;
}) => {
return prisma.product.findMany({
where: {
published: true,
...(options.categoryId && { categoryId: options.categoryId })
},
take: options.limit ?? 20,
skip: options.offset ?? 0,
orderBy: { createdAt: 'desc' }
});
});
10.2 Drizzle ORM Integration
// lib/db.ts - Drizzle setup με connection pooling
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
// Για serverless environments, χρησιμοποιείτε pooling
const connectionString = process.env.DATABASE_URL!;
// Για edge runtime ή serverless, χρησιμοποιείτε connection limit
const client = postgres(connectionString, {
max: 1, // Για serverless (λιγότερες connections)
idle_timeout: 20,
connect_timeout: 10,
});
export const db = drizzle(client, { schema });
// lib/queries/posts.ts με Drizzle
import { cache } from 'react';
import { db } from '@/lib/db';
import { posts, users, categories } from '@/lib/schema';
import { eq, desc, and, sql } from 'drizzle-orm';
export const getPublishedPosts = cache(async (limit = 10) => {
return db
.select({
id: posts.id,
title: posts.title,
slug: posts.slug,
excerpt: posts.excerpt,
publishedAt: posts.publishedAt,
authorName: users.name,
authorAvatar: users.avatar,
categoryName: categories.name,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.leftJoin(categories, eq(posts.categoryId, categories.id))
.where(eq(posts.published, true))
.orderBy(desc(posts.publishedAt))
.limit(limit);
});
// Με full-text search
export const searchPosts = cache(async (query: string) => {
return db
.select()
.from(posts)
.where(
and(
eq(posts.published, true),
sql`to_tsvector('greek', ${posts.title} || ' ' || ${posts.content})
@@ plainto_tsquery('greek', ${query})`
)
)
.limit(20);
});
10.3 Connection Pooling σε Serverless Environments
Σε serverless environments όπως το Vercel, η διαχείριση database connections είναι κρίσιμη. Κάθε serverless function μπορεί να δημιουργήσει νέα connection, εξαντλώντας γρήγορα τα connection limits της βάσης δεδομένων. Η λύση είναι η χρήση connection poolers.
// Για Vercel/serverless, χρησιμοποιείτε connection pooler
// π.χ. PgBouncer, Supabase Pooler, ή Neon Serverless
// lib/db-serverless.ts
import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
// Ή με Prisma Accelerate
// DATABASE_URL="prisma://accelerate.prisma-data.net/?api_key=YOUR_API_KEY"
11. Βέλτιστες Πρακτικές
11.1 Co-locate Data Fetching
Αντί να κάνετε fetch όλα τα data στην κορυφή του component tree και να τα περνάτε down ως props, κάντε fetch απευθείας στο component που χρειάζεται τα data. Αυτό απλοποιεί τον κώδικα και εκμεταλλεύεται το Request Memoization.
// ΣΥΝΙΣΤΑΤΑΙ: Co-located data fetching
// app/dashboard/page.tsx
export default async function DashboardPage() {
return (
<div>
<UserWidget /> {/* Φέρνει τα δικά του data */}
<StatsWidget /> {/* Φέρνει τα δικά του data */}
<RecentActivity /> {/* Φέρνει τα δικά του data */}
</div>
);
}
// components/user-widget.tsx
async function UserWidget() {
const user = await getCurrentUser(); // Memoized - ασφαλές να καλείται πολλές φορές
return <div>{user.name}</div>;
}
// components/stats-widget.tsx
async function StatsWidget() {
const user = await getCurrentUser(); // Ίδιο request, memoized result
const stats = await getUserStats(user.id);
return <div>{stats.totalViews}</div>;
}
11.2 Error Handling
// app/products/error.tsx - Error Boundary για Products
'use client';
import { useEffect } from 'react';
export default function ProductsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log το error σε error reporting service
console.error('Products error:', error);
}, [error]);
return (
<div>
<h2>Κάτι πήγε στραβά στη φόρτωση προϊόντων</h2>
<p>{error.message}</p>
<button onClick={reset}>Δοκιμάστε ξανά</button>
</div>
);
}
// Safe data fetching utility
async function safeFetch<T>(
url: string,
options?: RequestInit
): Promise<{ data: T | null; error: string | null }> {
try {
const response = await fetch(url, options);
if (!response.ok) {
return {
data: null,
error: `HTTP ${response.status}: ${response.statusText}`
};
}
const data: T = await response.json();
return { data, error: null };
} catch (err) {
return {
data: null,
error: err instanceof Error ? err.message : 'Unknown error'
};
}
}
// Χρήση
const { data: products, error } = await safeFetch<Product[]>(
'https://api.example.com/products',
{ next: { revalidate: 3600 } }
);
if (error) {
// Handle gracefully
return <ProductsError message={error} />;
}
11.3 Suspense για Progressive Loading
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserCard, UserCardSkeleton } from './user-card';
import { Analytics, AnalyticsSkeleton } from './analytics';
import { RecentOrders, OrdersSkeleton } from './recent-orders';
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
{/* Κάθε section φορτώνει ανεξάρτητα */}
<Suspense fallback={<UserCardSkeleton />}>
<UserCard />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</main>
);
}
// Streaming με nested Suspense
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* Αυτό φορτώνει αμέσως - critical above-the-fold content */}
<Suspense fallback={<ProductHeroSkeleton />}>
<ProductHero id={params.id} />
</Suspense>
{/* Αυτό μπορεί να φορτώσει αργότερα */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews id={params.id} />
</Suspense>
{/* Recommendations μπορεί να περιμένουν */}
<Suspense fallback={<RecommendationsSkeleton />}>
<ProductRecommendations id={params.id} />
</Suspense>
</div>
);
}
11.4 TypeScript για Type-Safe Data Fetching
// types/api.ts
export interface ApiResponse<T> {
data: T;
meta: {
total: number;
page: number;
perPage: number;
};
}
export interface Product {
id: string;
name: string;
price: number;
stock: number;
category: Category;
images: Image[];
}
// lib/api.ts - Type-safe fetching
export async function fetchProducts(params: {
page?: number;
categoryId?: string;
limit?: number;
}): Promise<ApiResponse<Product[]>> {
const searchParams = new URLSearchParams({
page: String(params.page ?? 1),
limit: String(params.limit ?? 20),
...(params.categoryId && { categoryId: params.categoryId }),
});
const response = await fetch(
`${process.env.API_URL}/products?${searchParams}`,
{ next: { tags: ['products'], revalidate: 3600 } }
);
if (!response.ok) {
throw new Error(`Failed to fetch products: ${response.status}`);
}
return response.json() as Promise<ApiResponse<Product[]>>;
}
12. Συνήθη Λάθη και Πώς να τα Αποφύγετε
12.1 Fetching σε Client Components όταν δεν χρειάζεται
// ΛΑΘΟΣ: Περιττό client-side fetching
'use client';
import { useState, useEffect } from 'react';
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setProducts);
}, []);
return <div>{products.map(...)}</div>;
}
// ΣΩΣΤΟ: Server Component fetching
async function ProductList() {
const products = await fetchProducts();
return <div>{products.map(...)}</div>;
}
12.2 Χρήση dynamic APIs σε "Static" Routes
// ΛΑΘΟΣ: Χρήση cookies() σε route που θέλετε static
import { cookies } from 'next/headers';
// Αυτό κάνει τη route dynamic ακόμα κι αν δεν χρειάζεστε τα cookies
export default async function BlogPage() {
const cookieStore = await cookies(); // Αυτό opt-out από static rendering!
const theme = cookieStore.get('theme');
const posts = await fetchPosts();
return <div className={theme?.value}>...</div>;
}
// ΣΩΣΤΟ: Διαχωρισμός static και dynamic parts
export default async function BlogPage() {
const posts = await fetchPosts(); // Static fetching
return (
<div>
<ThemeWrapper> {/* Client Component που διαβάζει cookie */}
<PostList posts={posts} />
</ThemeWrapper>
</div>
);
}
12.3 Λάθος χρήση του cache με sensitive data
// ΛΑΘΟΣ: Caching user-specific data χωρίς proper isolation
async function getUserData(userId: string) {
'use cache';
// ΠΡΟΒΛΗΜΑ: Όλοι οι users μπορεί να δουν τα δεδομένα κάποιου άλλου
// αν δεν γίνει σωστό cache key separation!
return await db.users.findUnique({ where: { id: userId } });
}
// ΣΩΣΤΟ: Explicit cache key ή χρήση 'use cache: private'
async function getUserData() {
'use cache: private'; // Isolated per user session
const session = await getSession();
if (!session) return null;
return await db.users.findUnique({ where: { id: session.userId } });
}
12.4 Αγνόηση της Cache Hierarchy
// Κατανόηση της ιεραρχίας:
// Request Memoization > Data Cache > Full Route Cache > Router Cache
// Αν κάνετε revalidatePath('/products'),
// invalidate το Full Route Cache αλλά ΟΧΙ αναγκαστικά το Data Cache
// Για πλήρη invalidation:
import { revalidatePath, revalidateTag } from 'next/cache';
async function fullInvalidation() {
revalidateTag('products'); // Invalidate Data Cache
revalidatePath('/products'); // Invalidate Full Route Cache
// Το Router Cache invalidate αυτόματα στην επόμενη navigation
}
12.5 Missing Error Boundaries
// ΛΑΘΟΣ: Καμία protection ενάντια σε fetch errors
export default async function Page() {
const data = await fetchCriticalData(); // Αν αποτύχει, crash ολόκληρης της σελίδας
return <div>{data.title}</div>;
}
// ΣΩΣΤΟ: Χρήση error.tsx + notFound() + try/catch
import { notFound } from 'next/navigation';
export default async function Page({ params }: { params: { id: string } }) {
try {
const data = await fetchData(params.id);
if (!data) {
notFound(); // Triggers not-found.tsx
}
return <div>{data.title}</div>;
} catch (error) {
// Το error.tsx boundary θα πιάσει unhandled errors
throw new Error('Failed to load page data');
}
}
12.6 Unnecessary Sequential Database Queries
// ΛΑΘΟΣ: N+1 query problem
async function PostsWithAuthors() {
const posts = await db.posts.findMany();
// Για κάθε post, ένα ξεχωριστό query!
const postsWithAuthors = await Promise.all(
posts.map(async (post) => ({
...post,
author: await db.users.findUnique({ where: { id: post.authorId } })
}))
);
return <PostList posts={postsWithAuthors} />;
}
// ΣΩΣΤΟ: Single query με join/include
async function PostsWithAuthors() {
const posts = await db.posts.findMany({
include: { author: true } // Prisma - single query με JOIN
});
return <PostList posts={posts} />;
}
Συμπέρασμα
Το data fetching και το caching στο Next.js App Router έχουν εξελιχθεί σε ένα εξαιρετικά ισχυρό και ευέλικτο σύστημα. Η μετάβαση σε dynamic-by-default στο Next.js 15 κάνει τη συμπεριφορά πιο προβλέψιμη, ενώ η νέα οδηγία use cache παρέχει granular έλεγχο όπου χρειάζεται caching.
Τα βασικά σημεία που πρέπει να θυμάστε:
- Το Next.js 15 είναι dynamic-by-default — το caching πρέπει να ζητηθεί ρητά.
- Υπάρχουν 4 επίπεδα caching: Request Memoization, Data Cache, Full Route Cache και Router Cache.
- Κάντε data fetching απευθείας στα Server Components που χρειάζονται τα data (co-location).
- Χρησιμοποιήστε
Promise.all()για παράλληλο fetching και αποφύγετε waterfalls. - Η νέα οδηγία
use cacheμεcacheLifeκαιcacheTagείναι ο μοντέρνος τρόπος για fine-grained caching. - Πάντα χρησιμοποιείτε error boundaries και handle gracefully τα fetch failures.
- Για database ORMs, χρησιμοποιείτε connection pooling και το
cache()του React για memoization. - Ο συνδυασμός
revalidateTag+next.tagsστο fetch είναι το πιο ακριβές εργαλείο για on-demand invalidation.
Με σωστή εφαρμογή αυτών των patterns, μπορείτε να δημιουργήσετε εφαρμογές που είναι ταυτόχρονα γρήγορες, fresh και αξιόπιστες, εκμεταλλευόμενοι πλήρως τις δυνατότητες του Next.js App Router.