| name | arcanea-next-best-practices |
| description | Next.js 16 App Router best practices for Arcanea. Use when writing or reviewing Next.js page files, layouts, route handlers, middleware, metadata, image/font optimization, RSC boundaries, async params/cookies, or any App Router pattern. Triggers on: layout.tsx, page.tsx, route.ts, loading.tsx, error.tsx, generateMetadata, Server Actions, 'use client', 'use server', next/image, next/font. Sourced from Vercel's official next-skills repository. |
Arcanea Next.js 16 Best Practices
"Aiyami guards the Crown Gate at 741 Hz — Enlightenment. Correct App Router patterns are enlightenment. Wrong patterns are Malachar's confusion."
File Conventions
Route Structure
apps/web/app/
├── layout.tsx # Root layout (html + body)
├── page.tsx # Home page
├── (marketing)/ # Route group (no URL segment)
│ ├── about/page.tsx
│ └── pricing/page.tsx
├── lore/
│ ├── layout.tsx # Nested layout
│ ├── page.tsx # /lore
│ ├── [element]/
│ │ └── page.tsx # /lore/fire, /lore/water...
│ └── [...slug]/
│ └── page.tsx # Catch-all: /lore/fire/draconia/...
├── academy/
│ └── gates/
│ └── [gate]/
│ └── page.tsx # /academy/gates/foundation
└── api/
└── guardians/
└── route.ts # GET/POST /api/guardians
Special Files (all must be .tsx for JSX)
| File | Purpose |
|---|
layout.tsx | Shared UI, persists across navigation |
page.tsx | Unique route UI |
loading.tsx | Suspense boundary automatically |
error.tsx | Error boundary (must be 'use client') |
not-found.tsx | 404 handling |
global-error.tsx | Root error boundary |
route.ts | API endpoint (no JSX) |
middleware.ts | App root only (not in app/) |
RSC Boundaries
Server Component (default — no directive needed)
import { supabase } from '@/lib/supabase-server'
export default async function ElementPage({ params }: { params: Promise<{ element: string }> }) {
const { element } = await params
const { data } = await supabase.from('elements').select().eq('slug', element).single()
return <ElementProfile data={data} />
}
Client Component — only when needed
'use client'
import { useState } from 'react'
export function GateQuiz() {
const [selectedGate, setSelectedGate] = useState<string | null>(null)
return <div onClick={() => setSelectedGate('foundation')}>...</div>
}
Invalid Patterns — RSC Errors
'use client'
export default async function Invalid() {
const data = await fetch(...)
}
<ClientComp fn={() => console.log('hi')} />
<ClientComp data={JSON.parse(JSON.stringify(complexObject))} />
Async APIs (Next.js 16 BREAKING CHANGES)
Next.js 16 made these APIs async. Always await them:
import { cookies, headers } from 'next/headers'
export default async function AcademyPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }> // NOW A PROMISE
searchParams: Promise<{ gate?: string }> // NOW A PROMISE
}) {
const { slug } = await params
const { gate } = await searchParams
const cookieStore = await cookies()
const headersList = await headers()
const token = cookieStore.get('arcanea-session')
}
Server Actions — async params too
'use server'
import { cookies } from 'next/headers'
export async function unlockGate(gate: string) {
const cookieStore = await cookies()
cookieStore.set('gate-unlocked', gate)
}
Error Handling
error.tsx — Must be Client Component
'use client'
import { useEffect } from 'react'
export default function LoreError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('[Lore Error]', error)
}, [error])
return (
<div className="glass-card text-center">
<h2 className="text-gradient-aurora">The Lore is Veiled</h2>
<p className="text-white/70">{error.message}</p>
<button onClick={reset} className="btn-cosmic">Try Again</button>
</div>
)
}
Redirect and notFound in Server Components
import { redirect, notFound } from 'next/navigation'
export default async function GuardianPage({ params }: { params: Promise<{ gate: string }> }) {
const { gate } = await params
const guardian = await getGuardian(gate)
if (!guardian) notFound()
if (guardian.deprecated) redirect(`/lore/guardians/${guardian.newSlug}`)
return <GuardianProfile guardian={guardian} />
}
Metadata & SEO
Static Metadata
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Lore | Arcanea',
description: 'The mythology of the Ten Gates and Five Elements',
openGraph: {
title: 'Arcanea Lore',
description: 'Enter the mythology.',
images: ['/og/lore.png'],
},
}
Dynamic Metadata
export async function generateMetadata({
params,
}: {
params: Promise<{ element: string }>
}): Promise<Metadata> {
const { element } = await params
const data = await getElement(element)
return {
title: `${data.name} | Arcanea Lore`,
description: data.description,
openGraph: {
images: [data.ogImage],
},
}
}
OG Image Generation
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export const size = { width: 1200, height: 630 }
export default async function OGImage({ params }: { params: Promise<{ element: string }> }) {
const { element } = await params
return new ImageResponse(
<div style={{ background: 'linear-gradient(135deg, #1a0a2e, #0d1b40)', width: '100%', height: '100%' }}>
<h1 style={{ color: '#7fffd4', fontSize: 72 }}>{element}</h1>
</div>
)
}
Image Optimization
Always next/image — Never raw
import Image from 'next/image'
<Image src="/godbeast/draconis.png" alt="Draconis" width={800} height={600} />
<Image
src="https://cdn.arcanea.ai/guardians/draconia.jpg"
alt="Draconia"
fill // fills container — parent needs position: relative
sizes="(max-width: 768px) 100vw, 50vw"
priority // add for LCP images (above-the-fold)
/>
Remote images config
const config: NextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.arcanea.ai' },
{ protocol: 'https', hostname: '*.supabase.co' },
],
},
}
Font Optimization
next/font — Always, never link tags
import { Inter, JetBrains_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
})
const jetbrains = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-code',
display: 'swap',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${jetbrains.variable}`}>
<body>{children}</body>
</html>
)
}
Route Handlers
import { NextRequest, NextResponse } from 'next/server'
import { createServerClient } from '@/lib/supabase-server'
export async function GET(request: NextRequest) {
const supabase = createServerClient()
const { searchParams } = new URL(request.url)
const gate = searchParams.get('gate')
const query = supabase.from('guardians').select()
if (gate) query.eq('gate', gate)
const { data, error } = await query
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json(data)
}
export async function POST(request: NextRequest) {
const supabase = createServerClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await request.json()
const { data, error } = await supabase.from('user_progress').insert(body).select().single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json(data, { status: 201 })
}
Bundling Gotchas
import '@/styles/globals.css'
const config: NextConfig = {
transpilePackages: ['@arcanea/ui'],
}
Quick Checklist
Before any Next.js 16 page/route in Arcanea: