| name | data-fetching-patterns |
| description | Explains data fetching strategies including fetch on render, fetch then render, render as you fetch, and server-side data fetching. Use when implementing data loading, optimizing loading performance, or choosing between client and server data fetching. |
Data Fetching Patterns
Overview
Data fetching is how your application retrieves data from APIs or databases. The pattern you choose affects performance, user experience, and code complexity.
Fetching Locations
| Where | When Executed | Use Case |
|---|
| Server (build) | Build time | Static content (SSG) |
| Server (request) | Each request | Dynamic content (SSR) |
| Client (browser) | After hydration | Interactive, real-time |
| Edge | At CDN edge | Personalization, A/B tests |
Client-Side Patterns
1. Fetch on Render (Waterfall)
Components fetch data when they mount.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
if (!user) return <Loading />;
return <Profile user={user} />;
}
Timeline:
Component renders → useEffect runs → Fetch starts → Data arrives → Re-render
[-- Waiting --] [-- Display --]
Problem: Waterfalls
function Dashboard() {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then(setUser);
}, []);
if (!user) return <Loading />;
return (
<UserPosts userId={user.id} />
);
}
2. Fetch Then Render
Fetch all data before rendering any component.
function Dashboard() {
const [data, setData] = useState(null);
useEffect(() => {
Promise.all([fetchUser(), fetchPosts(), fetchStats()])
.then(([user, posts, stats]) => setData({ user, posts, stats }));
}, []);
if (!data) return <Loading />;
return (
<>
<UserInfo user={data.user} />
<Posts posts={data.posts} />
<Stats stats={data.stats} />
</>
);
}
Timeline:
[Fetch User ]
[Fetch Posts ]──►All Complete───►Render All
[Fetch Stats ]
↑ Parallel, but wait for slowest
Problem: All-or-nothing loading. Fast data waits for slow data.
3. Render as You Fetch (Concurrent)
Start fetching immediately, render components as data arrives.
const userPromise = fetchUser();
const postsPromise = fetchPosts();
function Dashboard() {
return (
<Suspense fallback={<UserSkeleton />}>
<UserInfo userPromise={userPromise} />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<Posts postsPromise={postsPromise} />
</Suspense>
);
}
function UserInfo({ userPromise }) {
const user = use(userPromise);
return <div>{user.name}</div>;
}
Timeline:
[Fetch User ]───►Render User
[Fetch Posts ]─────────►Render Posts
↑ Independent, progressive rendering
Server-Side Patterns
Server Data Fetching (SSR)
Data fetched on server before sending HTML:
async function ProductPage({ params }) {
const product = await db.products.findById(params.id);
return <ProductDetails product={product} />;
}
Benefits:
- No loading state (data already in HTML)
- SEO-friendly (content in initial HTML)
- Direct database/API access
Parallel Data Fetching
Fetch multiple resources simultaneously:
const user = await getUser();
const posts = await getPosts(user.id);
const comments = await getComments(posts[0].id);
const [user, stats] = await Promise.all([
getUser(),
getStats(),
]);
const posts = await getPosts(user.id);
Streaming and Suspense
Stream HTML to the browser as soon as each part of your UI is ready—even if it's out of order—letting users see content without having to wait for everything.
- Progressive & Out-of-Order Streaming: With React's server components and Suspense, the server can send parts of the page (like headers or footers) immediately, even if other parts (like a slow data section) are still loading. This means sections of your UI don't have to stream in their coded order—each streams independently as soon as its data is ready.
- Suspense Boundaries & Placeholders: The
<Suspense> boundary tells React where some data may take longer. While waiting, a fallback component (such as <Loading />) is sent as a placeholder in the HTML.
- Client-Side JS Swapping: Along with the initial HTML and placeholders, React also sends a small client-side JavaScript "runtime" that listens for streamed content. When the slow component's data arrives from the server, this client JS automatically swaps out the placeholder fallback for the real content—right in place, without a full page reload.
async function Page() {
return (
<div>
<Header />
{/* <Header> streams to client immediately */}
<Suspense fallback={<Loading />}>
<SlowComponent />
{/* Placeholder <Loading /> is shown, and client JS
will replace it with <SlowComponent> content
as soon as the server streams it */}
</Suspense>
<Footer />
{/* <Footer> can stream immediately,
before or after <SlowComponent>, depending on what finishes first */}
</div>
);
}
React's streaming SSR means the server sends "islands" of ready UI as soon as they're done, wherever they are in the code. The browser shows the page piecemeal, filling in slow spots later. The React client runtime handles swapping placeholders for finished components when each chunk of streamed content completes, providing a smooth and efficient user experience.
## Caching Patterns
### Request Deduplication
Multiple components requesting same data should share request:
```javascript
// Without deduplication
// Component A: fetch('/api/user')
// Component B: fetch('/api/user')
// = 2 requests
// With deduplication (React cache / TanStack Query)
// Component A: useQuery(['user'], fetchUser)
// Component B: useQuery(['user'], fetchUser)
// = 1 request, shared result
Stale-While-Revalidate
Show cached data immediately, update in background:
const { data } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 60000,
refetchOnMount: true,
});
Cache Invalidation
Clear or update cache after mutations:
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries(['posts']);
queryClient.setQueryData(['posts'], (old) => [...old, newPost]);
},
});
Loading State Patterns
Skeleton Screens
Show content structure while loading:
function ProductCard({ product }) {
if (!product) {
return (
<div className="card">
<div className="skeleton-image" />
<div className="skeleton-text" />
<div className="skeleton-text short" />
</div>
);
}
return (
<div className="card">
<img src={product.image} />
<h3>{product.name}</h3>
<p>{product.price}</p>
</div>
);
}
Optimistic Updates
Update UI immediately, rollback on error:
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries(['todos']);
const previous = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map(t => t.id === newTodo.id ? newTodo : t)
);
return { previous };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previous);
},
});
Placeholder Data
Show estimated/placeholder data while fetching:
const { data } = useQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
placeholderData: {
name: 'Loading...',
price: '--',
image: '/placeholder.jpg',
},
});
Error Handling
Error Boundaries
Catch and display errors gracefully:
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<Loading />}>
<DataComponent />
</Suspense>
</ErrorBoundary>
Retry Strategies
const { data } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
});
Pattern Comparison
| Pattern | First Content | Data Freshness | Complexity |
|---|
| Client fetch (useEffect) | Slow | Real-time capable | Low |
| SSR | Fast | Request-time | Medium |
| SSG | Fastest | Build-time | Low |
| ISR | Fastest | Periodic | Medium |
| Streaming | Progressive | Request-time | Medium |
Decision Framework
Is the data static or rarely changes?
├── Yes → SSG or ISR
│
└── No → Does the page need SEO?
├── Yes → SSR or Streaming
│
└── No → Does it need real-time updates?
├── Yes → Client-side + WebSocket/polling
│
└── No → Is initial load critical?
├── Yes → SSR + Client hydration
└── No → Client-side fetching
Anti-Patterns
1. Fetching in useEffect Without Cleanup
useEffect(() => {
fetchData().then(setData);
}, [id]);
useEffect(() => {
let cancelled = false;
fetchData().then(data => {
if (!cancelled) setData(data);
});
return () => { cancelled = true; };
}, [id]);
2. Not Handling Loading/Error States
const { data } = useQuery(['products'], fetchProducts);
return <ProductList products={data} />;
if (isLoading) return <Loading />;
if (error) return <Error message={error.message} />;
return <ProductList products={data} />;
3. Over-fetching
const { data: user } = useQuery(['user'], () =>
fetch('/api/users/full-profile')
);
const { data: user } = useQuery(['user-summary'], () =>
fetch('/api/users/summary')
);
Deep Dive: Understanding Data Fetching From First Principles
The Network Request Lifecycle
Every data fetch involves multiple steps:
CLIENT NETWORK SERVER
1. fetch() called
│
2. DNS Lookup ─────────────────► DNS Server
│ │
│◄──────────────────── IP: 93.184.216.34
│
3. TCP Handshake ─────────────────────────────────────────────► SYN
│ │
│◄─────────────────────────────────────────────────── SYN-ACK
│
│───────────────────────────────────────────────────► ACK
│
4. TLS Handshake (HTTPS) ─────────────────────────────────────►
│◄───────────────────────────────────────────────────
│
5. HTTP Request ──────────────────────────────────────────────►
│ GET /api/data HTTP/1.1
│ Host: example.com │
│ ▼
│ 6. Server processes
│ │
│ 7. Query database
│ │
│ 8. Build response
│ │
│◄────────────────────────────────────────────────────
│ HTTP/1.1 200 OK
│ {"data": [...]}
│
9. Parse response
│
10. Update state
│
11. Re-render
Time breakdown (typical):
DNS Lookup: 10-100ms (cached: 0ms)
TCP Handshake: 50-100ms (1 round trip)
TLS Handshake: 50-150ms (2 round trips)
Request/Response: 50-500ms (depends on server + data size)
Parsing: 1-10ms
Rendering: 5-50ms
────────────────────────────────
TOTAL: 166-910ms
The Waterfall Problem Explained
Waterfalls occur when requests depend on each other:
async function loadDashboard() {
const user = await fetchUser();
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
}
async function loadDashboard() {
const [user, globalPosts] = await Promise.all([
fetchUser(),
fetchGlobalPosts(),
]);
const userPosts = await fetchUserPosts(user.id);
}
Why Caching is Essential
Without caching, you make redundant requests:
function Header() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
}, []);
return <span>{user?.name}</span>;
}
function Sidebar() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
}, []);
return <img src={user?.avatar} />;
}
function Dashboard() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
}, []);
return <span>Welcome, {user?.name}</span>;
}
Request deduplication:
function Header() {
const { data: user } = useQuery(['user'], fetchUser);
}
function Sidebar() {
const { data: user } = useQuery(['user'], fetchUser);
}
function Dashboard() {
const { data: user } = useQuery(['user'], fetchUser);
}
How Cache Invalidation Works
Caches must be invalidated when data changes:
await updateProfile({ name: 'New Name' });
const queryClient = useQueryClient();
async function handleUpdate(newData) {
await updateProfile(newData);
queryClient.invalidateQueries(['user']);
}
async function handleUpdate(newData) {
queryClient.setQueryData(['user'], (old) => ({
...old,
...newData,
}));
try {
await updateProfile(newData);
queryClient.invalidateQueries(['user']);
} catch (error) {
queryClient.setQueryData(['user'], originalData);
}
}
async function handleUpdate(newData) {
const updatedUser = await updateProfile(newData);
queryClient.setQueryData(['user'], updatedUser);
}
Race Conditions in Data Fetching
When fetches can overlap, you get race conditions:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(r => r.json())
.then(setResults);
}, [query]);
return <ResultList items={results} />;
}
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(r => r.json())
.then(setResults)
.catch(e => {
if (e.name === 'AbortError') return;
throw e;
});
return () => controller.abort();
}, [query]);
return <ResultList items={results} />;
}
function SearchResults({ query }) {
const { data: results } = useQuery({
queryKey: ['search', query],
queryFn: () => fetch(`/api/search?q=${query}`).then(r => r.json()),
});
}
Suspense: A New Data Fetching Paradigm
Traditional approach: component manages its loading state
Suspense approach: component delegates loading to boundary
function ProductPage() {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchProduct()
.then(setProduct)
.finally(() => setLoading(false));
}, []);
if (loading) return <Spinner />;
return <Product data={product} />;
}
function ProductPage() {
const product = use(fetchProduct());
return <Product data={product} />;
}
function App() {
return (
<Suspense fallback={<Spinner />}> {/* Loading UI here */}
<ProductPage />
</Suspense>
);
}
How Suspense works internally:
function use(promise) {
if (promise.status === 'fulfilled') {
return promise.value;
}
if (promise.status === 'rejected') {
throw promise.reason;
}
throw promise;
}
Streaming: Progressive Data Loading
Streaming sends data as it becomes available:
async function ProductPage() {
const product = await getProduct();
const reviews = await getReviews();
const related = await getRelatedProducts();
return (
<div>
<Product data={product} />
<Reviews data={reviews} />
<Related data={related} />
</div>
);
}
async function ProductPage() {
const product = await getProduct();
return (
<div>
<Product data={product} /> {/* Sent immediately */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews /> {/* Streams when ready */}
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<Related /> {/* Streams when ready */}
</Suspense>
</div>
);
}
Optimistic Updates: The UX Advantage
Optimistic updates show expected result before server confirms:
async function handleLike() {
setIsLoading(true);
await api.likePost(postId);
await refetchPost();
setIsLoading(false);
}
async function handleLike() {
setIsLiked(true);
setLikeCount(c => c + 1);
try {
await api.likePost(postId);
} catch (error) {
setIsLiked(false);
setLikeCount(c => c - 1);
showError('Failed to like post');
}
}
GraphQL vs REST: Data Fetching Implications
Different protocols have different fetching patterns:
const user = await fetch('/api/users/123');
const posts = await fetch('/api/users/123/posts');
const comments = await fetch('/api/users/123/posts/comments/count');
const query = `
query UserProfile($id: ID!) {
user(id: $id) {
name # Only fields you need
avatar
posts {
title
commentCount
}
}
}
`;
const { user } = await graphqlFetch(query, { id: '123' });
Error Handling Strategies
Different errors need different handling:
try {
await fetch('/api/data');
} catch (e) {
if (!navigator.onLine) {
showToast('You appear to be offline');
return cache.get('data');
}
if (e.name === 'AbortError') {
return;
}
throw e;
}
const response = await fetch('/api/data');
if (!response.ok) {
if (response.status === 401) {
router.push('/login');
return;
}
if (response.status === 404) {
setNotFound(true);
return;
}
if (response.status >= 500) {
throw new Error('Server error, please try again');
}
}
const data = await response.json();
if (data.error) {
showError(data.error.message);
return;
}
<ErrorBoundary
fallback={<ErrorPage />}
onError={(error) => logToSentry(error)}
>
<Suspense fallback={<Loading />}>
<DataComponent />
</Suspense>
</ErrorBoundary>
The Request Deduplication Deep Dive
How libraries prevent duplicate requests:
const cache = new Map();
const inFlight = new Map();
async function dedupedFetch(key, fetchFn) {
if (cache.has(key)) {
const { data, timestamp } = cache.get(key);
if (Date.now() - timestamp < STALE_TIME) {
return data;
}
}
if (inFlight.has(key)) {
return inFlight.get(key);
}
const promise = fetchFn().then(data => {
cache.set(key, { data, timestamp: Date.now() });
inFlight.delete(key);
return data;
});
inFlight.set(key, promise);
return promise;
}
const data1 = await dedupedFetch('user', () => fetch('/api/user'));
const data2 = await dedupedFetch('user', () => fetch('/api/user'));
For Framework Authors: Building Data Fetching Systems
Implementation Note: The patterns and code examples below represent one proven approach to building data fetching systems. Different frameworks take different approaches—Remix uses loaders, React Query focuses on caching, and Next.js integrates with its rendering model. The direction shown here covers core primitives most data systems need. Adapt based on your framework's server/client boundaries, caching strategy, and how you handle loading states.
Implementing Request Deduplication
class RequestDeduplicator {
constructor() {
this.inflight = new Map();
this.cache = new Map();
}
async fetch(key, fetcher, options = {}) {
const { ttl = 0, forceRefresh = false } = options;
const keyStr = typeof key === 'string' ? key : JSON.stringify(key);
if (!forceRefresh && this.cache.has(keyStr)) {
const cached = this.cache.get(keyStr);
if (Date.now() - cached.timestamp < ttl) {
return cached.data;
}
}
if (this.inflight.has(keyStr)) {
return this.inflight.get(keyStr);
}
const promise = fetcher()
.then(data => {
this.cache.set(keyStr, { data, timestamp: Date.now() });
this.inflight.delete(keyStr);
return data;
})
.catch(error => {
this.inflight.delete(keyStr);
throw error;
});
this.inflight.set(keyStr, promise);
return promise;
}
invalidate(key) {
const keyStr = typeof key === 'string' ? key : JSON.stringify(key);
this.cache.delete(keyStr);
}
invalidateMatching(predicate) {
for (const key of this.cache.keys()) {
if (predicate(key)) {
this.cache.delete(key);
}
}
}
}
function createRequestScopedCache() {
const cache = new Map();
return function cachedFetch(url, options) {
const key = `${options?.method || 'GET'}:${url}`;
if (cache.has(key)) {
return cache.get(key);
}
const promise = fetch(url, options).then(r => r.json());
cache.set(key, promise);
return promise;
};
}
Building a Data Loader System
class DataLoader {
constructor() {
this.loaders = new Map();
this.cache = new Map();
}
register(routeId, loader) {
this.loaders.set(routeId, loader);
}
async loadRoute(matches, request) {
const results = await Promise.all(
matches.map(async (match) => {
const loader = this.loaders.get(match.route.id);
if (!loader) return { routeId: match.route.id, data: null };
const data = await loader({
params: match.params,
request,
context: {},
});
return { routeId: match.route.id, data };
})
);
return new Map(results.map(r => [r.routeId, r.data]));
}
async loadWithDeps(loaderGraph, request) {
const results = new Map();
const pending = new Set(loaderGraph.keys());
while (pending.size > 0) {
const ready = [...pending].filter(id => {
const deps = loaderGraph.get(id).dependsOn || [];
return deps.every(d => results.has(d));
});
if (ready.length === 0 && pending.size > 0) {
throw new Error('Circular dependency detected');
}
await Promise.all(
ready.map(async (id) => {
const { loader, dependsOn = [] } = loaderGraph.get(id);
const depData = Object.fromEntries(
dependsOn.map(d => [d, results.get(d)])
);
const data = await loader({ request, deps: depData });
results.set(id, data);
pending.delete(id);
})
);
}
return results;
}
}
Implementing Suspense-Compatible Data Fetching
function createResource(fetcher) {
let status = 'pending';
let result;
const promise = fetcher()
.then(data => {
status = 'success';
result = data;
})
.catch(error => {
status = 'error';
result = error;
});
return {
read() {
switch (status) {
case 'pending':
throw promise;
case 'error':
throw result;
case 'success':
return result;
}
},
};
}
const resourceCache = new Map();
function getResource(key, fetcher) {
if (!resourceCache.has(key)) {
resourceCache.set(key, createResource(fetcher));
}
return resourceCache.get(key);
}
function preloadResource(key, fetcher) {
if (!resourceCache.has(key)) {
resourceCache.set(key, createResource(fetcher));
}
}
function invalidateResource(key) {
resourceCache.delete(key);
}
function UserProfile({ userId }) {
const resource = getResource(
['user', userId],
() => fetch(`/api/users/${userId}`).then(r => r.json())
);
const user = resource.read();
return <div>{user.name}</div>;
}
Streaming Data Implementation
async function* streamJSON(url) {
const response = await fetch(url);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.trim()) {
yield JSON.parse(line);
}
}
}
if (buffer.trim()) {
yield JSON.parse(buffer);
}
}
function createSSEConnection(url) {
const eventSource = new EventSource(url);
const listeners = new Set();
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
listeners.forEach(l => l(data));
};
return {
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
close() {
eventSource.close();
},
};
}
function useStreamingData(url) {
const [items, setItems] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function stream() {
for await (const item of streamJSON(url)) {
if (cancelled) break;
setItems(prev => [...prev, item]);
}
setIsLoading(false);
}
stream();
return () => { cancelled = true; };
}, [url]);
return { items, isLoading };
}
Building an Optimistic Update System
class OptimisticUpdateManager {
constructor(queryClient) {
this.queryClient = queryClient;
this.pendingUpdates = new Map();
}
async mutate(key, mutationFn, options = {}) {
const {
optimisticUpdate,
rollback = true,
invalidateKeys = [],
} = options;
const keyStr = JSON.stringify(key);
const previousData = this.queryClient.getQueryData(key);
const mutationId = Date.now().toString();
try {
if (optimisticUpdate) {
const optimisticData = optimisticUpdate(previousData);
this.queryClient.setQueryData(key, optimisticData);
this.pendingUpdates.set(mutationId, { key, previousData });
}
const result = await mutationFn();
if (result !== undefined) {
this.queryClient.setQueryData(key, result);
}
for (const invalidateKey of invalidateKeys) {
this.queryClient.invalidateQueries(invalidateKey);
}
this.pendingUpdates.delete(mutationId);
return result;
} catch (error) {
if (rollback && this.pendingUpdates.has(mutationId)) {
const { previousData } = this.pendingUpdates.get(mutationId);
this.queryClient.setQueryData(key, previousData);
this.pendingUpdates.delete(mutationId);
}
throw error;
}
}
async batchMutate(mutations) {
const snapshots = mutations.map(m => ({
key: m.key,
previous: this.queryClient.getQueryData(m.key),
}));
mutations.forEach((m, i) => {
if (m.optimisticUpdate) {
const data = m.optimisticUpdate(snapshots[i].previous);
this.queryClient.setQueryData(m.key, data);
}
});
try {
const results = await Promise.all(
mutations.map(m => m.mutationFn())
);
return results;
} catch (error) {
snapshots.forEach(s => {
this.queryClient.setQueryData(s.key, s.previous);
});
throw error;
}
}
}
Cache Invalidation Strategies
class CacheInvalidator {
constructor(cache) {
this.cache = cache;
this.tags = new Map();
}
setWithTags(key, value, tags = []) {
this.cache.set(key, value);
for (const tag of tags) {
if (!this.tags.has(tag)) {
this.tags.set(tag, new Set());
}
this.tags.get(tag).add(key);
}
}
invalidateKey(key) {
this.cache.delete(key);
}
invalidateTag(tag) {
const keys = this.tags.get(tag);
if (keys) {
for (const key of keys) {
this.cache.delete(key);
}
this.tags.delete(tag);
}
}
invalidatePattern(pattern) {
const regex = new RegExp(pattern);
for (const key of this.cache.keys()) {
if (regex.test(key)) {
this.cache.delete(key);
}
}
}
setWithTTL(key, value, ttlMs) {
this.cache.set(key, {
value,
expiresAt: Date.now() + ttlMs,
});
setTimeout(() => this.cache.delete(key), ttlMs);
}
}
async function handleRevalidationWebhook(request) {
const { type, id, tags } = await request.json();
const signature = request.headers.get('x-webhook-signature');
if (!verifySignature(signature, request.body)) {
return new Response('Unauthorized', { status: 401 });
}
if (id) {
await revalidatePath(`/${type}/${id}`);
}
if (tags) {
for (const tag of tags) {
await revalidateTag(tag);
}
}
return new Response('OK');
}
Related Skills