# Senior Frontend

> Expert frontend engineering skill for React 19, Next.js 15, and Tailwind CSS v4. Use this skill whenever the user is building, debugging, or reviewing frontend code — React components, server components, client components, hooks, forms, routing, layouts, data fetching, bundle optimization, or anything related to the modern React/Next.js ecosystem. Trigger on any mention of React, Next.js, Tailwind, useActionState, useOptimistic, useFormStatus, Server Components (RSC), Turbopack, App Router, or frontend performance. Also trigger for questions about component architecture, hydration errors, code-splitting, or image/font optimization.

- Skill: `roedyrustam/senior-frontend-2` (Agent Skill)
- Install (CLI): `npx skillmds@latest add roedyrustam/senior-frontend-2`
- Raw SKILL.md: https://api.skillmd.com/api/skills/roedyrustam/senior-frontend-2/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: roedyrustam (https://skillmd.com/u/roedyrustam)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/roedyrustam/senior-frontend-2

---


# Senior Frontend Engineer Skill

Expert guidance for modern React 19 + Next.js 15 + Tailwind CSS v4 development.

---

## Core Stack

| Layer | Technology |
|-------|-----------|
| Framework | Next.js 15 (App Router) |
| UI Library | React 19 |
| Styling | Tailwind CSS v4 |
| Language | TypeScript 5.x (strict) |
| Bundler | Turbopack (dev), webpack (prod fallback) |
| Package Manager | pnpm or Bun |

---

## React 19 Patterns

### New Hooks — Use These, Not the Old Equivalents

#### `useActionState` (replaces `useFormState`)
```tsx
'use client'
import { useActionState } from 'react'
import { submitForm } from '@/app/actions'

type State = { error?: string; success?: boolean }

export function ContactForm() {
  const [state, formAction, isPending] = useActionState<State, FormData>(
    submitForm,
    { error: undefined, success: false }
  )

  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      {state.error && <p className="text-red-500 text-sm">{state.error}</p>}
      <SubmitButton />
    </form>
  )
}
```

#### `useOptimistic` — Instant UI feedback
```tsx
'use client'
import { useOptimistic, useTransition } from 'react'

type Todo = { id: string; text: string; done: boolean }

export function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (state, newTodo: Todo) => [...state, newTodo]
  )
  const [isPending, startTransition] = useTransition()

  const handleAdd = (text: string) => {
    const tempTodo = { id: crypto.randomUUID(), text, done: false }
    startTransition(async () => {
      addOptimistic(tempTodo)
      await createTodo(text)
    })
  }

  return (
    <ul>
      {optimisticTodos.map(todo => (
        <li key={todo.id} className={todo.done ? 'line-through opacity-50' : ''}>
          {todo.text}
        </li>
      ))}
    </ul>
  )
}
```

#### `useFormStatus` — Submit button state
```tsx
'use client'
import { useFormStatus } from 'react-dom'

export function SubmitButton({ label = 'Submit' }: { label?: string }) {
  const { pending } = useFormStatus()
  return (
    <button
      type="submit"
      disabled={pending}
      className="btn-primary disabled:opacity-60 disabled:cursor-not-allowed"
    >
      {pending ? 'Loading…' : label}
    </button>
  )
}
```

### React 19 `use()` Hook
```tsx
import { use, Suspense } from 'react'

// Pass a Promise directly to use()
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise) // suspends until resolved
  return <div>{user.name}</div>
}

// Usage: wrap in Suspense
export default function Page() {
  const userPromise = fetchUser(123)
  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  )
}
```

---

## Next.js 15 App Router Patterns

### File Structure
```
app/
├── layout.tsx          # Root layout (server component)
├── page.tsx            # Home page
├── globals.css         # Tailwind CSS v4 entry
├── (marketing)/        # Route group (no URL segment)
│   ├── layout.tsx
│   └── about/page.tsx
├── (app)/              # Protected route group
│   ├── layout.tsx      # Auth guard here
│   └── dashboard/
│       ├── page.tsx
│       └── loading.tsx # Automatic Suspense boundary
├── api/
│   └── [...route]/
│       └── route.ts    # Route handlers
└── _components/        # Private folder (not routable)
```

### Server vs Client Components

**Rule: Default to Server Components. Add `'use client'` only when needed.**

| Need | Use |
|------|-----|
| Data fetching, DB access, secrets | Server Component |
| `useState`, `useEffect`, event handlers | Client Component |
| Browser APIs (window, localStorage) | Client Component |
| Animations, gesture handling | Client Component |
| Everything else | Server Component |

```tsx
// ✅ Server Component — no directive needed
export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await db.product.findUnique({ where: { id: params.id } })
  if (!product) notFound()

  return (
    <div>
      <h1>{product.name}</h1>
      {/* Pass serializable data to client */}
      <AddToCartButton productId={product.id} />
    </div>
  )
}
```

### Data Fetching in Server Components
```tsx
// Next.js 15: fetch() is no longer cached by default!
// Must explicitly opt-in to caching.

// No cache (default in Next.js 15)
const data = await fetch('https://api.example.com/data')

// Cache permanently (like old Next.js default)
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache'
})

// Revalidate every N seconds
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 60 }
})

// Tag-based revalidation
const data = await fetch('https://api.example.com/data', {
  next: { tags: ['products'] }
})
// Then call: revalidateTag('products') in a Server Action
```

### Server Actions
```tsx
// app/actions.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'

const schema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
})

export async function createPost(prevState: unknown, formData: FormData) {
  const parsed = schema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  })

  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors }
  }

  try {
    await db.post.create({ data: parsed.data })
    revalidateTag('posts')
    revalidatePath('/blog')
  } catch {
    return { error: { _form: ['Database error. Please try again.'] } }
  }

  redirect('/blog') // Throws internally — don't wrap in try/catch
}
```

### Parallel Routes & Intercepting Routes
```
app/
├── @modal/           # Parallel slot
│   └── (.)photo/[id]/
│       └── page.tsx  # Intercepted route (modal)
├── layout.tsx        # Receives { children, modal }
└── photo/[id]/
    └── page.tsx      # Full page (on hard refresh)
```

```tsx
// app/layout.tsx
export default function Layout({
  children,
  modal,
}: {
  children: React.ReactNode
  modal: React.ReactNode
}) {
  return (
    <>
      {children}
      {modal}
    </>
  )
}
```

---

## Tailwind CSS v4

### Setup — CSS-First Configuration
```css
/* app/globals.css */
@import "tailwindcss";

/* Define theme in CSS, not tailwind.config.js */
@theme {
  --color-brand-50: oklch(0.97 0.02 260);
  --color-brand-500: oklch(0.55 0.22 260);
  --color-brand-900: oklch(0.25 0.12 260);

  --font-sans: "Inter Variable", sans-serif;
  --font-mono: "JetBrains Mono", monospace;

  --radius-card: 0.75rem;
  --shadow-card: 0 4px 24px oklch(0 0 0 / 0.08);

  --animate-fade-in: fade-in 0.2s ease-out;
}

@keyframes fade-in {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0); }
}
```

### v4 Breaking Changes to Know
```tsx
// ❌ v3 — JIT config in tailwind.config.js
// ✅ v4 — everything in CSS via @theme

// ❌ v3
<div className="bg-opacity-50">
// ✅ v4 — use slash syntax
<div className="bg-black/50">

// ❌ v3 — ring-offset
<div className="ring-2 ring-offset-2">
// ✅ v4 — outline or shadow instead

// New: arbitrary values with CSS vars
<div className="text-[var(--color-brand-500)]">
```

### Component Patterns with Tailwind v4
```tsx
// Use cva for variant-based components
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-brand-500 text-white hover:bg-brand-600',
        outline: 'border border-brand-500 text-brand-500 hover:bg-brand-50',
        ghost: 'hover:bg-brand-50 text-brand-500',
        destructive: 'bg-red-500 text-white hover:bg-red-600',
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        md: 'h-10 px-4 text-sm',
        lg: 'h-12 px-6 text-base',
      },
    },
    defaultVariants: { variant: 'default', size: 'md' },
  }
)

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

export function Button({ className, variant, size, ...props }: ButtonProps) {
  return (
    <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
  )
}
```

---

## Performance Optimization

### Image Optimization
```tsx
import Image from 'next/image'

// Always specify width/height or fill
<Image
  src="/hero.webp"
  alt="Hero image"
  width={1200}
  height={630}
  priority            // LCP image — load eagerly
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..."
/>

// Dynamic images — use fill + sized container
<div className="relative aspect-video w-full">
  <Image
    src={post.coverImage}
    alt={post.title}
    fill
    className="object-cover rounded-lg"
    sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  />
</div>
```

### Bundle Optimization
```tsx
// Dynamic imports for heavy components
import dynamic from 'next/dynamic'

const HeavyChart = dynamic(() => import('@/components/Chart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // Client-only components
})

const RichEditor = dynamic(() => import('@/components/RichEditor'), {
  loading: () => <EditorSkeleton />,
})
```

### Font Optimization
```tsx
// app/layout.tsx
import { Inter } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',  // Use as CSS variable
})

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={inter.variable}>
      <body className="font-sans">{children}</body>
    </html>
  )
}
```

### Metadata & SEO
```tsx
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getPost(params.slug)
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.ogImage, width: 1200, height: 630 }],
    },
    alternates: {
      canonical: `https://example.com/blog/${params.slug}`,
    },
  }
}

export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map(post => ({ slug: post.slug }))
}
```

---

## Error Handling Patterns

```tsx
// error.tsx — must be 'use client'
'use client'
import { useEffect } from 'react'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    console.error(error)
    // Log to Sentry, Datadog, etc.
  }, [error])

  return (
    <div className="flex flex-col items-center gap-4 p-8">
      <h2 className="text-xl font-semibold">Something went wrong</h2>
      <p className="text-muted-foreground text-sm">{error.message}</p>
      <button onClick={reset} className="btn-primary">Try again</button>
    </div>
  )
}
```

---

## Key Rules (Always Follow)

1. **Never use `any`** — use `unknown` and narrow types, or proper generics
2. **Server Components by default** — only `'use client'` when necessary
3. **Validate all Server Action inputs** with Zod before touching DB
4. **Never `redirect()` inside try/catch** — it throws internally
5. **Use `next/image`** for all `<img>` tags
6. **Use `next/font`** — never `<link>` to Google Fonts directly
7. **Suspense boundaries** around async data fetching trees
8. **`loading.tsx`** for route-level skeletons (automatic Suspense)
9. **`error.tsx`** for route-level error recovery (must be `'use client'`)
10. **`not-found.tsx`** for 404 states — call `notFound()` from server components

