CtrlK
BlogDocsLog inGet started
Tessl Logo

product-factory/performance

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

Overview
Skills
Evals
Files
name:
performance
description:
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.

Performance: Next.js App Router + TypeScript + Tailwind + shadcn/ui + Drizzle

The Golden Rule

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 — The Biggest Win

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 (React 19+)

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.


Streaming with Suspense

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.


Rendering Performance (Without React Compiler)

If you're not using React Compiler, these manual techniques apply:

React.memo — Prevent Unnecessary Re-renders

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>
  )
})

useCallback — Stabilise Function References

const handleDelete = useCallback((id: string) => deleteUser(id), [])

useMemo — Cache Expensive Computations

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.


State Management Performance

Collocate State — Don't Lift Too High

// ❌ 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('')
}

Split Context by Update Frequency

// ❌ 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 updates

Debounce Frequent Input

const [input, setInput] = useState('')
const deferredInput = useDeferredValue(input)

return (
  <>
    <Input value={input} onChange={e => setInput(e.target.value)} />
    <SearchResults query={deferredInput} />
  </>
)

Large List Rendering

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>
  )
}

TanStack Query Performance

Avoid Request Waterfalls — Fetch in Parallel

// ❌ 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.

Set staleTime — Default 0 Hammers Your API

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

Prefetch on Hover for Instant Navigation

<div onMouseEnter={() => queryClient.prefetchQuery(userQueryOptions(userId))}>
  ...
</div>

Optimistic Updates for Instant Feedback

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 }),
})

Drizzle Query Performance

Select Only What You Need

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)

Use Prepared Statements for Hot Queries

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 })

Index Common Query Columns

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),
}))

Avoid N+1 Queries

// ❌ 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,
})

Bundle Size

npx @next/bundle-analyzer     # Analyse your bundle
  • Import only what you need — import { debounce } from 'lodash-es' not import _ from 'lodash'
  • Use next/dynamic to lazy load heavy client components (charts, editors, maps)
  • Tailwind purges unused classes automatically — ensure content config covers all files
import dynamic from 'next/dynamic'

const ChartPanel = dynamic(() => import('@/components/ChartPanel'), {
  loading: () => <ChartSkeleton />,
})

Install with Tessl CLI

npx tessl i product-factory/performance@0.2.0
Workspace
product-factory
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
product-factory/performance badge