| name | senior-frontend |
| description | 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.
|
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)
'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
'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
'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
import { use, Suspense } from 'react'
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise)
return <div>{user.name}</div>
}
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 |
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
const data = await fetch('https://api.example.com/data')
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache'
})
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
})
const data = await fetch('https://api.example.com/data', {
next: { tags: ['products'] }
})
Server Actions
'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')
}
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)
export default function Layout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}
Tailwind CSS v4
Setup — CSS-First Configuration
@import "tailwindcss";
@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
<div className="bg-opacity-50">
<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
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
import Image from 'next/image'
<Image
src="/hero.webp"
alt="Hero image"
width={1200}
height={630}
priority
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
<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
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('@/components/Chart'), {
loading: () => <ChartSkeleton />,
ssr: false,
})
const RichEditor = dynamic(() => import('@/components/RichEditor'), {
loading: () => <EditorSkeleton />,
})
Font Optimization
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body className="font-sans">{children}</body>
</html>
)
}
Metadata & SEO
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
'use client'
import { useEffect } from 'react'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error(error)
}, [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)
- Never use
any — use unknown and narrow types, or proper generics
- Server Components by default — only
'use client' when necessary
- Validate all Server Action inputs with Zod before touching DB
- Never
redirect() inside try/catch — it throws internally
- Use
next/image for all <img> tags
- Use
next/font — never <link> to Google Fonts directly
- Suspense boundaries around async data fetching trees
loading.tsx for route-level skeletons (automatic Suspense)
error.tsx for route-level error recovery (must be 'use client')
not-found.tsx for 404 states — call notFound() from server components