Skip to main content Home Creators joelhooks joelclaw nextjs-static-shells
nextjs-static-shells Static-first Next.js 16 architecture patterns: cached shells with dynamic slots, provider islands, 'use cache' boundaries, and link preloading strategy. Use when building or refactoring Next.js routes to maximize static rendering, implementing 'use cache' with dynamic personalization, splitting entry vs static renderers, scoping client providers, or tuning prefetch behavior. Triggers on 'static shell', 'use cache pattern', 'dynamic slots', 'provider island', 'prefetch strategy', 'static first', 'cache boundary', 'route goes dynamic unexpectedly', or any Next.js architecture work involving mixed static/dynamic rendering.
Jump to install Skills Marketplace Discover and explore AI skills built by the community.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Copy promptShow prompt details A direct command skips the review prompt. Inspect the source before running it.
npx skills add https://github.com/joelhooks/joelclaw --skill nextjs-static-shellsThe command stays on one line. Scroll horizontally to inspect it before copying.
Prefer a local copy? Download the files currently available to SkillsMP.
Download Zip Downloading... More from this repository Build, debug, and maintain Pi extensions safely. Use when editing ~/.pi/agent/extensions, joelclaw/pi/extensions, pi-tools extensions, or any code that uses the Pi ExtensionAPI, hooks, tools, commands, widgets, session replacement, reload, custom messages, or extension package updates.
Set up and repair thin joelclaw satellite Machines such as blaine/Dark-Tower. Use when adding joelclaw CLI, Typesense access, session search/capture, Central relay, or satellite health on non-Panda machines. Triggers: satellite, blaine, Dark-Tower, setup joelclaw on another machine, Typesense access on satellite, joelclaw satellite health.
Canonical joelclaw topology, Central/Relay vocabulary, and wiring map. Use when reasoning about architecture, Panda/Flagg Central migration, satellites, run capture, tracing event flow, debugging why something ran/didn't run, identifying which worker executes a function, checking what listens on a port, or following an event end-to-end.
Related occupations SOC
Based on SOC occupation classification
name nextjs-static-shells displayName Next.js Static Shells description Static-first Next.js 16 architecture patterns: cached shells with dynamic slots, provider islands, 'use cache' boundaries, and link preloading strategy. Use when building or refactoring Next.js routes to maximize static rendering, implementing 'use cache' with dynamic personalization, splitting entry vs static renderers, scoping client providers, or tuning prefetch behavior. Triggers on 'static shell', 'use cache pattern', 'dynamic slots', 'provider island', 'prefetch strategy', 'static first', 'cache boundary', 'route goes dynamic unexpectedly', or any Next.js architecture work involving mixed static/dynamic rendering. version 1.0.0 author Joel Hooks tags ["nextjs","architecture","caching","performance","rsc","static"] disable-model-invocation true
Static-First Next.js 16 Patterns
Build a static shell first , then cut small dynamic holes where personalization or request-specific behavior is required.
Static shell = deterministic, cacheable, fast first paint
Dynamic holes = isolated request/user behavior streamed with Suspense
Client interactivity = provider islands, not global client sprawl
Route Architecture: Entry + Static + Slots
Pattern
Entry component (server, request-aware)
Reads params/search/auth/session/cookies
Validates access, resolves IDs, prepares dynamic props
Static renderer (server, 'use cache')
Renders deterministic layout/content
Accepts dynamic UI as slot props (ReactNode)
Dynamic slots
Injected from entry component
Suspense-wrapped where rendered in static shell
Why This Works
Static shell stays cacheable
Dynamic behavior is explicit and narrow
Streaming keeps UI responsive
No accidental full-route dynamic bailout
import { Suspense , type ReactNode } from 'react' ;
type PageProps = { params : Promise <{ slug : string }> };
export default async function PageEntry ({ params }: PageProps ) {
const { slug } = await params;
const staticData = await getStaticData (slug);
userData = ();
dynamicPanel = ;
;
}
= {
: ;
?: ;
};
( ) {
;
(
);
}
const
await
getUserData
const
<PersonalizedPanel userData ={userData} />
return
<PageStatic data ={staticData} panel ={dynamicPanel} />
type
PageStaticProps
data
StaticData
panel
ReactNode
async
function
PageStatic
{ data, panel }: PageStaticProps
'use cache'
return
<main >
<Hero data ={data.hero} />
<Content data ={data.content} />
<Suspense fallback ={ <PanelSkeleton /> }>{panel}</Suspense >
</main >
Cache Components Setup & Mechanics
Enable
import type { NextConfig } from 'next'
const nextConfig : NextConfig = {
cacheComponents : true ,
}
export default nextConfig
Replaces the old experimental.ppr flag.
Three Content Types Type Characteristic Example Static Synchronous, pure computation <header><h1>Our Blog</h1></header>Cached ('use cache')Async but deterministic for given inputs db.posts.findMany() with cacheLife('hours')Dynamic (Suspense)Runtime/request-specific, must be fresh cookies(), user session, notifications
'use cache' Scope Levels
'use cache'
export default async function Page ( ) { }
export async function CachedComponent ( ) {
'use cache'
const data = await fetchData ()
return <div > {data}</div >
}
export async function getData ( ) {
'use cache'
return db.query ('SELECT * FROM posts' )
}
Cache Profiles with cacheLife() import { cacheLife } from 'next/cache'
async function getData ( ) {
'use cache'
cacheLife ('hours' )
return fetch ('/api/data' )
}
async function getDataCustom ( ) {
'use cache'
cacheLife ({
stale : 3600 ,
revalidate : 7200 ,
expire : 86400 ,
})
return fetch ('/api/data' )
}
Built-in profile shortcuts: 'use cache' alone → 5m stale / 15m revalidate. 'use cache: remote' → platform KV. 'use cache: private' → allows runtime APIs (compliance escape hatch).
Cache Invalidation import { cacheTag } from 'next/cache'
async function getProduct (id : string ) {
'use cache'
cacheTag ('products' , `product-${id} ` )
return db.products .findUnique ({ where : { id } })
}
updateTag() — immediate, same-request invalidation:
'use server'
import { updateTag } from 'next/cache'
export async function updateProduct (id : string , data : FormData ) {
await db.products .update ({ where : { id }, data })
updateTag (`product-${id} ` )
}
revalidateTag() — background stale-while-revalidate:
'use server'
import { revalidateTag } from 'next/cache'
export async function createPost (data : FormData ) {
await db.posts .create ({ data })
revalidateTag ('posts' )
}
Cache Key Generation (Automatic) Keys derived from: build ID + function location hash + serializable arguments + closure variables. No manual keyParts like unstable_cache.
async function Component ({ userId }: { userId: string } ) {
const getData = async (filter : string ) => {
'use cache'
return fetch (`/api/users/${userId} ?filter=${filter} ` )
}
return getData ('active' )
}
What Cannot Live Inside 'use cache' Hard rule: No per-request volatility inside cached boundaries.
Banned inside 'use cache' Why cookies(), headers()Request-specific searchParamsRequest-specific Session/auth reads User-specific Hidden user logic in helper calls Invisible request dependency Side effects tied to request lifecycle Non-deterministic Math.random(), Date.now()Execute once at build time inside cache
Fix: Extract Outside, Pass as Arguments
async function CachedProfile ( ) {
'use cache'
const session = (await cookies ()).get ('session' )?.value
return <div > {session}</div >
}
async function ProfilePage ( ) {
const session = (await cookies ()).get ('session' )?.value
return <CachedProfile sessionId ={session} />
}
async function CachedProfile ({ sessionId }: { sessionId: string } ) {
'use cache'
const data = await fetchUserData (sessionId)
return <div > {data.name}</div >
}
Exception: 'use cache: private' allows cookies() / headers() for compliance cases where refactoring is impractical.
RSC Boundary Rules These interact directly with the static shell pattern.
Async Client Components Are Invalid Client components cannot be async. Only Server Components can be async.
'use client'
export default async function UserProfile ( ) {
const user = await getUser ()
return <div > {user.name}</div >
}
export default async function Page ( ) {
const user = await getUser ()
return <UserProfile user ={user} />
}
'use client'
export function UserProfile ({ user }: { user: User } ) {
return <div > {user.name}</div >
}
Non-Serializable Props Kill the Boundary Props from Server → Client must be JSON-serializable.
Cannot pass Fix Functions (except Server Actions) Define inside client component Date objects.toISOString() on serverMap, SetObject.fromEntries() / Array.from()Class instances Pass plain object
Server Actions ('use server') can be passed to client components — they're the exception.
Async Patterns (Next.js 15+) params, searchParams, cookies(), headers() are all async. Type them as Promise<...> and await in the entry component.
type PageProps = {
params : Promise <{ slug : string }>
searchParams : Promise <{ query ?: string }>
}
export default async function Page ({ params, searchParams }: PageProps ) {
const { slug } = await params
const { query } = await searchParams
}
For synchronous client components that need params, use React.use():
import { use } from 'react'
export default function Page ({ params }: { params: Promise <{ slug: string }> } ) {
const { slug } = use (params)
}
Suspense Boundary Requirements
useSearchParams Always Needs SuspenseWithout Suspense, the entire page becomes CSR:
'use client'
import { useSearchParams } from 'next/navigation'
export default function SearchBar ( ) {
const searchParams = useSearchParams ()
return <div > Query: {searchParams.get('q')}</div >
}
import { Suspense } from 'react'
export default function Page ( ) {
return (
<Suspense fallback ={ <SearchSkeleton /> }>
<SearchBar />
</Suspense >
)
}
Quick Reference Hook Suspense Required useSearchParams()Always usePathname()Yes in dynamic routes useParams()No useRouter()No
Provider Islands (Client Providers Done Cleanly)
Rule Mount client providers as low as possible and only where interactivity is needed.
Good: feature-level provider island
Bad: global root provider for local feature state
Pattern Server entry passes typed initial state. Client provider resolves inside 'use client' boundary. Hooks stay inside island.
'use client' ;
import { createContext, useContext, useMemo } from 'react' ;
type FeatureState = { enabled : boolean };
type FeatureContextValue = { state : FeatureState };
const FeatureContext = createContext<FeatureContextValue | null >(null );
export function FeatureProvider ({
children,
initialState,
}: {
children: React.ReactNode;
initialState: FeatureState;
} ) {
const value = useMemo (() => ({ state : initialState }), [initialState]);
return <FeatureContext.Provider value ={value} > {children}</FeatureContext.Provider > ;
}
export function useFeature ( ) {
const ctx = useContext (FeatureContext );
if (!ctx) throw new Error ('useFeature must be used within FeatureProvider' );
return ctx;
}
Data Fetching in the Static Shell Model
Decision Tree Need Pattern Read data in server component Fetch directly — no API layer Mutation from UI Server Action ('use server') External API / webhook / mobile client Route Handler Client component needs data Pass from server parent (preferred) or Route Handler
Avoiding Waterfalls
const user = await getUser ();
const posts = await getPosts ();
const [user, posts] = await Promise .all ([getUser (), getPosts ()]);
<Suspense fallback ={ <UserSkeleton /> }><UserSection /> </Suspense >
<Suspense fallback ={ <PostsSkeleton /> }><PostsSection /> </Suspense >
Preload Pattern import { cache } from 'react' ;
export const getUser = cache (async (id : string ) => {
return db.user .findUnique ({ where : { id } });
});
export const preloadUser = (id : string ) => {
void getUser (id);
};
Link Preloading Strategy
Rules
Preload static/common routes aggressively
Disable prefetch for personalized/query-heavy/volatile URLs
Preload shell, defer user-specific data behind dynamic boundaries
generateStaticParams boosts prefetch hit quality for common paths
import Link from 'next/link' ;
<Link href ={ `/docs /${slug }`}> Read next</Link >
<Link href ={ `/certificate /${userId }?name =${encodeURIComponent(name)} `} prefetch ={false} >
View certificate
</Link >
Preload Decision Checklist
Is route static and frequently visited? → preload
Is route personalized or volatile? → don't preload
Is user data deferred behind Suspense/dynamic island? → preload shell only
Is there measured nav improvement? → keep prefetch; otherwise cut it
Decision Matrix Scenario Pattern Static content + personalized controls Entry (dynamic) + cached static renderer + slot injection Cacheable deterministic server work 'use cache' boundaryPure client interactivity Local 'use client' provider island Faster navigation Targeted prefetch + static params coverage
Common Failure Modes + Fixes
Whole route goes dynamic unexpectedly
Cause: request-bound reads (cookies(), headers()) leak into static shell
Fix: move those reads to entry component, pass slot props
Client hydration is too heavy
Cause: global provider mounted at root for feature-local state
Fix: push provider down to feature/layout segment
Prefetch waste and noisy network
Cause: prefetching personalized/query-heavy links
Fix: prefetch={false} for volatile URLs
Static shell blocked by dynamic work
Cause: dynamic components rendered without Suspense seams
Fix: wrap dynamic slots in Suspense with small fallbacks
Unclear ownership of data flow
Cause: mixed static/dynamic logic in one component
Fix: enforce Entry vs Static renderer split with strict prop contracts
useSearchParams causes full-page CSR bailout
Cause: missing Suspense boundary around search-param-reading component
Fix: always wrap useSearchParams consumers in Suspense
Date/Map/class props silently break client components
Cause: non-serializable props passed across RSC→client boundary
Fix: serialize on server (.toISOString(), Object.fromEntries(), plain objects)
unstable_cache still in codebase
Cause: pre-v16 caching pattern not migrated
Fix: replace with 'use cache' + cacheTag() + cacheLife() — no manual key arrays needed
Migration from Previous Versions Old Config Replacement experimental.pprcacheComponents: truedynamic = 'force-dynamic'Remove (default behavior) dynamic = 'force-static''use cache' + cacheLife('max')revalidate = NcacheLife({ revalidate: N })unstable_cache()'use cache' directive
unstable_cache → 'use cache'
const getCachedUser = unstable_cache (
async (id) => getUser (id),
['my-app-user' ],
{ tags : ['users' ], revalidate : 60 }
)
async function getCachedUser (id : string ) {
'use cache'
cacheTag ('users' )
cacheLife ({ revalidate : 60 })
return getUser (id)
}
Key differences: no manual cache keys (auto from args + closures), tags via cacheTag(), revalidation via cacheLife().
Limitations
Edge runtime not supported — requires Node.js
Static export not supported — needs server
Non-deterministic values (Math.random(), Date.now()) execute once at build time inside 'use cache'
For request-time randomness outside cache:
import { connection } from 'next/server'
async function DynamicContent ( ) {
await connection ()
const id = crypto.randomUUID ()
return <div > {id}</div >
}
Implementation Sequence
Identify static vs dynamic inputs per route
Split pages into Entry + cached Static renderer
Convert personalized bits into typed slots
Add Suspense around slot render points
Refactor providers into client islands
Apply prefetch rules to navigation links
Add static params for high-traffic static routes
Measure before/after (TTFB, shell paint, nav latency, prefetch traffic)
PR Acceptance Criteria