Skill
Next.js PWA
name: nextjs-pwa-skill description: Modern Next.js 15 with App Router and PWA capabilities for production web applications. Use when building (1) Next.js applications with App Router, (2) PWA manifest and service workers, (3) server vs client component decisions, (4) real-time WebSocket integration, (5) shadcn/ui component library setup, (6) authentication flows with JWT, or (7) offline-first strategies. Triggers on Next.js, PWA, App Router, React Server Components, shadcn/ui.
Next.js 15 PWA Production Patterns
Project Structure
src/
├── app/ # App Router
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Home page
│ ├── loading.tsx # Loading UI
│ ├── error.tsx # Error boundary
│ ├── not-found.tsx # 404 page
│ ├── api/ # API routes
│ │ └── auth/
│ ├── (auth)/ # Auth route group
│ │ ├── login/
│ │ └── register/
│ └── (dashboard)/ # Protected routes
│ ├── layout.tsx
│ └── jobs/
├── components/
│ ├── ui/ # shadcn/ui components
│ └── features/ # Feature components
├── lib/
│ ├── api-client.ts # API wrapper
│ ├── auth.ts # Auth utilities
│ └── websocket.ts # WS client
├── hooks/
│ ├── use-auth.ts
│ └── use-websocket.ts
└── types/
└── index.ts
Root Layout with PWA
// app/layout.tsx
import type { Metadata, Viewport } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'ProYaro AI',
description: 'Arabic-first AI Platform',
manifest: '/manifest.json',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'ProYaro AI',
},
}
export const viewport: Viewport = {
themeColor: '#1a1a2e',
width: 'device-width',
initialScale: 1,
maximumScale: 1,
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ar" dir="rtl">
<head>
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
</head>
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
)
}
PWA Manifest
// public/manifest.json
{
"name": "ProYaro AI Platform",
"short_name": "ProYaro",
"description": "Arabic-first AI Platform",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#1a1a2e",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
Service Worker (next-pwa)
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.proyaro\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 },
},
},
],
})
module.exports = withPWA({
// Next.js config
})
Server vs Client Components
// Server Component (default) - No "use client"
// ✅ Can: fetch data, access backend, use async/await
// ❌ Cannot: use hooks, browser APIs, event handlers
// app/jobs/page.tsx (Server Component)
async function JobsPage() {
const jobs = await fetch('http://api:8000/jobs', {
cache: 'no-store', // Always fresh
}).then(r => r.json())
return (
<div>
<h1>Jobs</h1>
<JobList jobs={jobs} /> {/* Client component */}
</div>
)
}
// components/features/JobList.tsx (Client Component)
'use client'
import { useState } from 'react'
export function JobList({ jobs }: { jobs: Job[] }) {
const [selected, setSelected] = useState<string | null>(null)
return (
<ul>
{jobs.map(job => (
<li key={job.id} onClick={() => setSelected(job.id)}>
{job.name}
</li>
))}
</ul>
)
}
Authentication with JWT
// lib/auth.ts
import { cookies } from 'next/headers'
export async function getSession() {
const token = cookies().get('access_token')?.value
if (!token) return null
try {
const res = await fetch(`${process.env.API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return null
return res.json()
} catch {
return null
}
}
// hooks/use-auth.ts
'use client'
import { createContext, useContext, useState, useEffect } from 'react'
const AuthContext = createContext<AuthState | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
checkAuth().then(setUser).finally(() => setLoading(false))
}, [])
const login = async (email: string, password: string) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
if (!res.ok) throw new Error('Login failed')
const { user } = await res.json()
setUser(user)
}
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)!
Protected Route Layout
// app/(dashboard)/layout.tsx
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getSession()
if (!session) {
redirect('/login')
}
return (
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 p-6">{children}</main>
</div>
)
}
WebSocket Hook
// hooks/use-websocket.ts
'use client'
import { useEffect, useRef, useCallback, useState } from 'react'
export function useWebSocket(url: string) {
const ws = useRef<WebSocket | null>(null)
const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('connecting')
const [lastMessage, setLastMessage] = useState<any>(null)
useEffect(() => {
const connect = () => {
ws.current = new WebSocket(url)
ws.current.onopen = () => setStatus('open')
ws.current.onclose = () => {
setStatus('closed')
// Reconnect after 3 seconds
setTimeout(connect, 3000)
}
ws.current.onmessage = (e) => {
setLastMessage(JSON.parse(e.data))
}
}
connect()
return () => ws.current?.close()
}, [url])
const send = useCallback((data: any) => {
if (ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(data))
}
}, [])
return { status, lastMessage, send }
}
// Usage in component
function JobProgress({ jobId }: { jobId: string }) {
const { lastMessage } = useWebSocket(
`wss://api.proyaro.com/ws/jobs/${jobId}?token=${token}`
)
return (
<Progress value={lastMessage?.progress ?? 0} />
)
}
shadcn/ui Setup
# Initialize shadcn/ui
npx shadcn-ui@latest init
# Add components
npx shadcn-ui@latest add button card dialog form input
// Usage
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
export function JobCard({ job }: { job: Job }) {
return (
<Card>
<CardHeader>
<CardTitle>{job.name}</CardTitle>
</CardHeader>
<CardContent>
<Button onClick={() => submitJob(job.id)}>
Process
</Button>
</CardContent>
</Card>
)
}
RTL Support (Arabic)
// app/layout.tsx
<html lang="ar" dir="rtl">
// tailwind.config.ts
module.exports = {
theme: {
extend: {
fontFamily: {
arabic: ['Noto Sans Arabic', 'sans-serif'],
},
},
},
plugins: [
require('tailwindcss-rtl'), // RTL utilities
],
}
// Component usage
<div className="text-right rtl:text-left">
{/* Automatically flips for RTL */}
</div>
API Client
// lib/api-client.ts
class ApiClient {
private baseUrl: string
constructor() {
this.baseUrl = process.env.NEXT_PUBLIC_API_URL!
}
private async fetch<T>(path: string, options?: RequestInit): Promise<T> {
const token = typeof window !== 'undefined'
? localStorage.getItem('token')
: null
const res = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options?.headers,
},
})
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new ApiError(res.status, error.message || 'Request failed')
}
return res.json()
}
jobs = {
list: () => this.fetch<Job[]>('/api/v1/jobs'),
get: (id: string) => this.fetch<Job>(`/api/v1/jobs/${id}`),
submit: (data: JobCreate) => this.fetch<Job>('/api/v1/jobs', {
method: 'POST',
body: JSON.stringify(data),
}),
}
}
export const api = new ApiClient()
Loading and Error States
// app/jobs/loading.tsx
import { Skeleton } from '@/components/ui/skeleton'
export default function Loading() {
return (
<div className="space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-32 w-full" />
</div>
)
}
// app/jobs/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div className="text-center p-8">
<h2>Something went wrong</h2>
<Button onClick={reset}>Try again</Button>
</div>
)
}
Production Checklist
- PWA manifest with correct icons and theme
- Service worker with offline caching strategy
- Server components for data fetching
- Client components only where interactivity needed
- JWT stored in httpOnly cookies (not localStorage)
- Protected routes check session server-side
- WebSocket reconnection logic
- RTL support for Arabic content
- Loading and error boundaries
- shadcn/ui for consistent design system
ProYaro AI Infrastructure Documentation • Version 1.2