nextjs-data-provider
Next.js Data Provider
Implement data fetching and state management in Next.js 15 App Router projects following Sellernote conventions.
Convention Loading
Before starting any work, read the relevant reference files from references/ within this skill directory:
-
Always read first (core rules):
references/STATE_CONVENTION.md- State classification, TanStack Query patterns, Zustand patternsreferences/NEXTJS_CONVENTION.md- Server/Client Components, data fetching strategies, caching
-
Read when relevant:
references/API_CLIENT_CONVENTION.md- API client common rules, token management, error handlingreferences/API_CLIENT_AXIOS_CONVENTION.md- Axios implementation, interceptors, refresh token flowreferences/FRONTEND_CONVENTION.md- Component design, import rules, anti-patternsreferences/TYPESCRIPT_CONVENTION.md- Type system, async/await, import orderingreferences/COMMON_CONVENTION.md- Naming, error handling, logging
Workflow
Follow these steps sequentially. Skip a step only when it does not apply to the task.
Step 1: Classify the State Type
Every piece of state MUST fall into exactly one category:
| State Type | Tool | When to Use |
|---|---|---|
| Server state | TanStack Query | Data from APIs (product lists, user profiles, order history) |
| Client state | Zustand | Shared UI state, user settings (sidebar open/closed, theme, notifications) |
| Local state | useState | Single-component state (modal open, input value, toggle) |
| URL state | useSearchParams | Pagination, filters, sort order |
Key rules:
- [MUST] Server data is managed exclusively by TanStack Query
- [MUST NOT] Duplicate server state into Zustand
- [MUST NOT] Store local state (e.g.,
isDeleteModalOpen) in Zustand
Step 2: Determine Fetching Strategy
| Scenario | Method |
|---|---|
| Initial page load + SEO | Server Component fetch |
| Form submission, create/update/delete | Server Actions |
| External webhooks, third-party API integration | Route Handlers |
| Client interaction-driven data refresh | TanStack Query |
| Real-time data (polling, infinite scroll) | TanStack Query |
If initial page data: go to Step 3. If mutations: go to Step 4. If client-side data: go to Step 5. If client UI state: go to Step 6.
Step 3: Server Component Fetch
Follow the patterns in references/NEXTJS_CONVENTION.md sections 4 and 5.
Key reminders:
- [MUST] Set explicit
cacheorrevalidateoption on every fetch call - [SHOULD] Use
Suspenseboundaries for independent data sections
Step 4: Server Actions
Follow the patterns in references/NEXTJS_CONVENTION.md section 4.
Key reminders:
- [MUST] Call
revalidatePath()orrevalidateTag()after mutations - [SHOULD] Validate input with Zod before processing
Step 5: TanStack Query (Client-Side Data)
5a: Define Query Key Factory
[MUST] Use @lukemorales/query-key-factory for all query keys. Place in queries/queryKeys.ts:
import { createQueryKeys, mergeQueryKeys } from '@lukemorales/query-key-factory';
export const productKeys = createQueryKeys('products', {
all: null,
list: (filters: ProductFilters) => ({ queryKey: [filters] }),
detail: (id: string) => ({ queryKey: [id] }),
});
export const userKeys = createQueryKeys('users', {
all: null,
me: null,
detail: (id: string) => ({ queryKey: [id] }),
orders: (userId: string) => ({ queryKey: [userId] }),
});
export const queryKeys = mergeQueryKeys(productKeys, userKeys);
5b: Custom Query Hooks
[MUST] Place hooks in queries/ directory, one file per domain. Encapsulate all useQuery/useMutation calls in custom hooks -- never call them directly in components. Spread the key factory result to integrate:
// queries/useProducts.ts
export function useProducts(filters: ProductFilters) {
return useQuery({
...productKeys.list(filters), // spreads queryKey from factory
queryFn: () => fetchProducts(filters),
staleTime: 5 * 60 * 1000,
});
}
For cache timing guidance (staleTime/gcTime by data type), see references/STATE_CONVENTION.md.
5c: Optimistic Updates with Rollback
Apply for UX-critical mutations. This pattern integrates the query-key-factory with cancel/snapshot/rollback:
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateProduct,
onMutate: async (updatedProduct) => {
const detailKey = productKeys.detail(updatedProduct.id).queryKey;
// 1. Cancel in-flight refetches
await queryClient.cancelQueries({ queryKey: detailKey });
// 2. Snapshot previous value
const previousProduct = queryClient.getQueryData(detailKey);
// 3. Optimistically update cache
queryClient.setQueryData(detailKey, (old: Product) => ({
...old,
...updatedProduct,
}));
return { previousProduct };
},
onError: (_err, updatedProduct, context) => {
// Rollback on error
if (context?.previousProduct) {
queryClient.setQueryData(
productKeys.detail(updatedProduct.id).queryKey,
context.previousProduct,
);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: productKeys.all.queryKey });
},
});
}
Key rules:
- [MUST] Invalidate related queries after mutation success
- [MUST] Implement rollback in
onErrorwhen using optimistic updates - [MUST] Call
cancelQueriesbeforesetQueryDatato prevent race conditions
Step 6: Zustand (Client UI State)
6a: Slice Pattern with StateCreator
[MUST] Use StateCreator type for slices in store/slices/. The generic signature is the Sellernote-specific pattern:
// store/slices/uiSlice.ts
import type { StateCreator } from 'zustand';
export interface UISlice {
isSidebarOpen: boolean;
notifications: Notification[];
toggleSidebar: () => void;
addNotification: (notification: Notification) => void;
}
// Generic: StateCreator<FullStoreType, [], [], ThisSlice>
export const createUISlice: StateCreator<UISlice & UserSlice, [], [], UISlice> = (set) => ({
isSidebarOpen: true,
notifications: [],
toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
addNotification: (notification) =>
set((state) => ({ notifications: [...state.notifications, notification] })),
});
6b: Store with Partialize
[MUST] Apply devtools (outermost) + persist with partialize to exclude transient data:
// store/index.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
type StoreState = UserSlice & UISlice;
export const useStore = create<StoreState>()(
devtools(
persist(
(...a) => ({
...createUserSlice(...a),
...createUISlice(...a),
}),
{
name: 'app-store',
partialize: (state) => ({
user: state.user,
isSidebarOpen: state.isSidebarOpen,
// Exclude transient data like notifications
}),
},
),
{ name: 'AppStore' },
),
);
6c: Selectors
[MUST] Export individual selectors. Never destructure the entire store:
// store/selectors.ts
export const useUser = () => useStore((state) => state.user);
export const useIsSidebarOpen = () => useStore((state) => state.isSidebarOpen);
For full Zustand patterns and anti-patterns, see references/STATE_CONVENTION.md.
Step 7: Verify
- Every fetch call has explicit
cacheorrevalidateoption - Server data uses only TanStack Query (not duplicated in Zustand)
- Query keys use
@lukemorales/query-key-factory - Mutations invalidate related queries on success
- Zustand uses slice pattern with
devtools+persist+partialize