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