| name | senior-frontend |
| description | Senior frontend development skill for the AroundMe Next.js application. Use when building React components with TypeScript and Tailwind CSS, implementing responsive designs, optimizing frontend performance, managing React state, or reviewing UI/UX code. Triggers on component creation, styling tasks, dark mode implementation, form building, or frontend refactoring. |
Senior Frontend - AroundMe
Expert frontend development skill tailored for the AroundMe Next.js 16 + React 18 + Tailwind CSS 4 + TypeScript stack.
Tech Stack Context
- Framework: Next.js 16.1.7 with App Router
- React: 18.3.1 (NOT React 19 - compatibility requirement)
- Styling: Tailwind CSS 4 with custom dark mode variant
- State: TanStack Query v4.36.1
- Maps: React Leaflet v5.0.0
- Icons: Lucide React
- Testing: Vitest (unit) + Playwright (E2E)
Component Development Workflow
1. Analyze Requirements
- What data does this component need?
- Is it server or client rendered?
- Does it need interactivity (state, effects, events)?
- What are the loading/error states?
- Does it need dark mode support?
2. Choose Component Type
Server Component (default):
import { prisma } from '@/lib/prisma';
export async function EventList() {
const events = await prisma.event.findMany({ take: 10 });
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{events.map(event => <EventCard key={event.id} event={event} />)}
</div>
);
}
Client Component:
'use client';
import { useState } from 'react';
export function SearchInput() {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full px-4 py-2 border rounded-lg"
placeholder="Search..."
/>
);
}
3. Component Structure
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { MapPin, Calendar, Clock } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { useAuth } from '@/hooks/useAuth';
import { formatDate } from '@/lib/utils';
import type { Event } from '@/types';
interface EventCardProps {
event: Event;
showActions?: boolean;
onSave?: (id: string) => void;
testId?: string;
}
export function EventCard({ event, showActions = false, onSave, testId }: EventCardProps) {
const [isHovered, setIsHovered] = useState(false);
const isUpcoming = useMemo(() => new Date(event.date) > new Date(), [event.date]);
const handleSave = useCallback(() => {
onSave?.(event.id);
}, [event.id, onSave]);
return (
<article data-testid={testId} className="...">
{/* ... */}
</article>
);
}
Tailwind CSS 4 Patterns
Dark Mode
The app uses .dark class on <html> with custom variant:
@custom-variant dark (&:where(.dark, .dark *));
Always pair light and dark classes:
bg-white dark:bg-gray-800
bg-gray-50 dark:bg-gray-900
bg-gray-100 dark:bg-gray-800
text-gray-900 dark:text-gray-100
text-gray-600 dark:text-gray-400
text-gray-500 dark:text-gray-400
border-gray-200 dark:border-gray-700
border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-800
border-gray-300 dark:border-gray-600
text-gray-900 dark:text-gray-100
placeholder:text-gray-400 dark:placeholder:text-gray-500
hover:bg-gray-50 dark:hover:bg-gray-700
hover:text-indigo-600 dark:hover:text-indigo-400
Color Schemes
primary: 'bg-indigo-600 text-white hover:bg-indigo-700'
light: 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300'
border: 'border-indigo-500'
ring: 'focus:ring-indigo-500'
primary: 'bg-teal-600 text-white hover:bg-teal-700'
light: 'bg-teal-50 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300'
border: 'border-teal-500'
ring: 'focus:ring-teal-500'
primary: 'bg-amber-600 text-white hover:bg-amber-700'
light: 'bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300'
border: 'border-amber-500'
ring: 'focus:ring-amber-500'
Spacing System
className="space-y-6"
className="space-y-4"
className="space-y-2"
className="gap-4"
className="gap-6"
className="p-4"
className="p-6"
className="px-4 py-2.5"
className="px-4 py-3"
className="px-6 py-4"
className="mb-2"
className="mb-4"
className="mb-6"
Responsive Breakpoints
className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
className="text-sm md:text-base lg:text-lg"
className="p-4 md:p-6 lg:p-8"
className="flex-col md:flex-row"
Image Handling
import Image from 'next/image';
<div className="relative h-48">
{!imageLoaded && (
<div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse" />
)}
<Image
src={imageUrl}
alt={title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className={`object-cover transition-opacity duration-300 ${
imageLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
</div>
<div className="relative w-10 h-10 rounded-full overflow-hidden">
<Image src={avatar} alt={name} fill className="object-cover" />
</div>
Form Patterns
interface FormInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
helperText?: string;
}
export function FormInput({ label, error, helperText, className, ...props }: FormInputProps) {
return (
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
{helperText && (
<p className="text-xs text-gray-500 dark:text-gray-400">{helperText}</p>
)}
<input
className={cn(
"w-full px-4 py-2.5 rounded-lg border transition-colors",
"bg-white dark:bg-gray-800",
"text-gray-900 dark:text-gray-100",
"border-gray-300 dark:border-gray-600",
"focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500",
"placeholder:text-gray-400 dark:placeholder:text-gray-500",
error && "border-red-500 focus:ring-red-500 focus:border-red-500",
className
)}
data-testid={`${props.name}-input`}
{...props}
/>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
);
}
Data Fetching Patterns
Server Components
import { prisma } from '@/lib/prisma';
import { notFound } from 'next/navigation';
export default async function EventPage({ params }: { params: { id: string } }) {
const event = await prisma.event.findUnique({
where: { id: params.id },
include: { organizer: true, venue: true },
});
if (!event) notFound();
return <EventDetail event={event} />;
}
Client with TanStack Query
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function useEvent(id: string) {
return useQuery({
queryKey: ['event', id],
queryFn: async () => {
const res = await fetch(`/api/events/${id}`);
if (!res.ok) throw new Error('Failed to fetch event');
return res.json();
},
staleTime: 1000 * 60 * 5,
});
}
function useRSVP() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ eventId, status }: { eventId: string; status: string }) => {
const res = await fetch(`/api/events/${eventId}/rsvp`, {
method: 'POST',
body: JSON.stringify({ status }),
});
if (!res.ok) throw new Error('RSVP failed');
return res.json();
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['event', variables.eventId] });
queryClient.invalidateQueries({ queryKey: ['rsvps'] });
},
});
}
Performance Optimization
1. Memoization
const sortedEvents = useMemo(() => {
return [...events].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}, [events]);
const handleDelete = useCallback((id: string) => {
deleteEvent(id);
}, [deleteEvent]);
const MemoizedEventCard = memo(EventCard);
2. Code Splitting
const EventMap = dynamic(() => import('@/components/map/EventMap'), {
ssr: false,
loading: () => <div className="h-96 bg-gray-100 dark:bg-gray-800 animate-pulse rounded-xl" />,
});
3. Image Optimization
4. List Virtualization
For lists >50 items:
import { Virtualizer } from '@tanstack/react-virtual';
function VirtualEventList({ events }: { events: Event[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: events.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 200,
});
return (...);
}
Accessibility Requirements
<button
onClick={handleClick}
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
tabIndex={0}
role="button"
>
<Image src={src} alt={descriptiveAlt} />
<label htmlFor="email">Email</label>
<input id="email" aria-describedby="email-error" />
<span id="email-error" role="alert">{error}</span>
<article> for cards
<nav> for navigation
<main> for main content
<aside> for sidebars
<time> for dates
Testing Requirements
Unit Tests (Vitest)
import { render, screen } from '@testing-library/react';
import { EventCard } from './EventCard';
describe('EventCard', () => {
const mockEvent = { ... };
it('renders event title', () => {
render(<EventCard event={mockEvent} />);
expect(screen.getByText(mockEvent.title)).toBeInTheDocument();
});
it('has correct test id', () => {
render(<EventCard event={mockEvent} testId="test-event" />);
expect(screen.getByTestId('test-event')).toBeInTheDocument();
});
});
E2E Test IDs
Always add data-testid:
<div data-testid="event-detail-page-container">
data-testid={`event-card-${event.id}`}
data-testid={`place-card-${place.id}`}
<input data-testid="email-input" />
<button data-testid="submit-button" />
<button data-testid="filter-category-music" />
<a data-testid="nav-events-link" />
Common Anti-Patterns to Avoid
- Prop drilling deeper than 2 levels - Use composition or context
- useEffect for derived state - Use useMemo instead
- Multiple useState when useReducer is cleaner
- Passing objects/functions inline - Causes unnecessary re-renders
- Using index as key - Use stable IDs
- Fetching in useEffect without cleanup - Use TanStack Query
- Not handling loading/error states
- Using div for everything - Use semantic HTML
- Magic strings for className combinations - Create reusable cn() utilities
- Forgetting dark mode on new components
Quick Reference
python skills/senior-frontend/scripts/component_generator.py src/components/ui MyComponent
python skills/senior-frontend/scripts/bundle_analyzer.py .
npm run dev
npm run build
npm run test
npm run lint