Apply this skill when building, optimising, or reviewing components, data fetching, or database queries in a Next.js App Router + TypeScript + Tailwind + shadcn + Drizzle application where performance matters. Triggers on requests like "optimise this", "this is slow", "reduce re-renders", "improve load time", "this list is laggy", "bundle is too large", "slow query", or any time you are building features that involve large lists, frequent updates, heavy computations, or significant data volumes. Use proactively for anything rendering more than ~50 items or running on every keystroke.
87
Quality
87%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Measure before optimising. Use React DevTools Profiler, Lighthouse, and @next/bundle-analyzer before adding memoisation. Random useMemo and React.memo wrappers without profiling can make things slower.
Server Components ship zero JavaScript to the client. Use them for all data fetching and static rendering.
// ✅ Server Component — fetches data, renders HTML, sends no JS
export default async function UsersPage() {
const users = await db.select({ id: users.id, name: users.name }).from(users)
return <UserList users={users} />
}Only add "use client" for components that need state, effects, or browser APIs. Push client boundaries to leaf components — not entire pages.
React Compiler (stable since October 2025) handles memoisation automatically. Check if it's enabled before manually adding useMemo/useCallback/React.memo.
// next.config.ts
const nextConfig = {
experimental: {
reactCompiler: true,
},
}Add eslint-plugin-react-compiler to validate your code follows the Rules of React for compiler compatibility. If React Compiler is enabled, skip the manual memoisation sections below — the compiler does it for you.
Use granular <Suspense> boundaries so independent data streams in parallel — don't block the entire page on the slowest query.
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</div>
)
}Every route segment should have a loading.tsx for instant navigation feedback.
If you're not using React Compiler, these manual techniques apply:
const UserCard = React.memo(function UserCard({ user, onEdit }: UserCardProps) {
return (
<div className="rounded-lg border p-4">
<p className="font-medium">{user.name}</p>
<Button variant="ghost" onClick={() => onEdit(user.id)}>Edit</Button>
</div>
)
})const handleDelete = useCallback((id: string) => deleteUser(id), [])const processed = useMemo(() => {
return data
.filter(row => row.name.toLowerCase().includes(searchTerm.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name))
}, [data, searchTerm])Don't useMemo for: simple calculations, string formatting, anything that takes < 1ms.
// ❌ Global state for local concerns — widespread re-renders
const { searchTerm, setSearchTerm } = useGlobalStore()
// ✅ Keep state close to where it's used
function SearchableList() {
const [searchTerm, setSearchTerm] = useState('')
}// ❌ Single context — all consumers re-render on any change
const AppContext = createContext({ user, theme, notifications })
// ✅ Split by update frequency
const UserContext = createContext(user) // rarely changes
const NotificationContext = createContext(...) // frequent updatesconst [input, setInput] = useState('')
const deferredInput = useDeferredValue(input)
return (
<>
<Input value={input} onChange={e => setInput(e.target.value)} />
<SearchResults query={deferredInput} />
</>
)Never render more than ~100 items into the DOM at once. Use virtualisation.
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
})
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: virtualRow.start,
height: virtualRow.size,
width: '100%',
}}
>
<ItemRow item={items[virtualRow.index]} />
</div>
))}
</div>
</div>
)
}// ❌ Sequential — postsQuery waits for userQuery
const { data: user } = useQuery(userQueryOptions(userId))
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPosts(user!.id),
enabled: !!user,
})
// ✅ Parallel — both fire simultaneously
const [userQuery, postsQuery] = useQueries({
queries: [
userQueryOptions(userId),
{ queryKey: ['posts', userId], queryFn: () => fetchPosts(userId) },
],
})Only use enabled when query B genuinely needs data from query A's result.
useQuery({ queryKey: ['config'], queryFn: fetchConfig, staleTime: Infinity }) // static
useQuery({ queryKey: ['user', id], queryFn: ..., staleTime: 1000 * 60 * 5 }) // 5 min
useQuery({ queryKey: ['prices'], queryFn: ..., staleTime: 1000 * 30 }) // 30 sec<div onMouseEnter={() => queryClient.prefetchQuery(userQueryOptions(userId))}>
...
</div>const mutation = useMutation({
mutationFn: updateUser,
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: userKeys.all })
const previous = queryClient.getQueryData(userKeys.all)
queryClient.setQueryData(userKeys.all, (old: User[]) =>
old.map(u => u.id === newData.id ? { ...u, ...newData } : u)
)
return { previous }
},
onError: (err, vars, context) => {
queryClient.setQueryData(userKeys.all, context?.previous)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: userKeys.all }),
})const result = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(eq(users.active, true))
.limit(50)
.offset(page * 50)const getUserById = db
.select({ id: users.id, name: users.name })
.from(users)
.where(eq(users.id, placeholder('id')))
.prepare('get_user_by_id')
const user = await getUserById.execute({ id })export const posts = pgTable('posts', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id),
status: varchar('status', { length: 20 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => ({
userIdIdx: index('posts_user_id_idx').on(table.userId),
statusCreatedIdx: index('posts_status_created_idx').on(table.status, table.createdAt),
}))// ❌ N+1 — one query per user
for (const user of users) {
user.posts = await db.select().from(posts).where(eq(posts.userId, user.id))
}
// ✅ One query with relation
const usersWithPosts = await db.query.users.findMany({
with: { posts: true },
limit: 50,
})npx @next/bundle-analyzer # Analyse your bundleimport { debounce } from 'lodash-es' not import _ from 'lodash'next/dynamic to lazy load heavy client components (charts, editors, maps)content config covers all filesimport dynamic from 'next/dynamic'
const ChartPanel = dynamic(() => import('@/components/ChartPanel'), {
loading: () => <ChartSkeleton />,
})Install with Tessl CLI
npx tessl i product-factory/performance