portal-islands
Island architecture, PPR, Suspense boundaries, and provider placement. Read before adding providers, context, or "use client" boundaries.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install legacy3-wowlab-portal-islands
Repository
Skill path: .claude/skills/portal-islands
Island architecture, PPR, Suspense boundaries, and provider placement. Read before adding providers, context, or "use client" boundaries.
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Full Stack.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: legacy3.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install portal-islands into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/legacy3/wowlab before adding portal-islands to shared team environments
- Use portal-islands for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: portal-islands
description: Island architecture, PPR, Suspense boundaries, and provider placement. Read before adding providers, context, or "use client" boundaries.
---
# Portal Islands & PPR
How providers, Suspense boundaries, and client boundaries work in this codebase. Read this before touching any provider, context, or island component.
## Why Islands
With `cacheComponents: true` (Next.js 16), routes are **Partially Prerendered (PPR)**. At build time, Next.js renders the component tree and extracts a **static HTML shell**. Anything that accesses uncached data (network, cookies, DB) must be either:
1. **Wrapped in `<Suspense>`** — deferred to request time, streamed in
2. **Marked with `"use cache"`** — cached and included in the static shell
If uncached data is accessed outside `<Suspense>`, the build fails with:
```
Uncached data was accessed outside of <Suspense>
```
An **island** is a `"use client"` component that bundles a provider + `<Suspense>` boundary. Islands are the client boundary — everything inside renders on the client, everything outside stays in the static shell.
**Key insight:** The more content outside islands, the bigger the static shell, the faster the initial load.
## Islands in This Codebase
All islands live in `components/islands/`. No file is named "provider".
| Island | Wraps | Exports |
| ------------------ | --------------------------------------------- | ---------------------------- |
| `ThemeIsland` | `ThemeProvider` + `Suspense` | — |
| `RefinedIsland` | `Suspense` + `QueryClientProvider` + `Refine` | — |
| `WasmIsland` | `QueryClientProvider` + WASM loader | `useCommon()`, `useEngine()` |
| `NuqsIsland` | `Suspense` + `NuqsAdapter` | — |
| `DocsSearchIsland` | Search context + dialog | `useDocsSearch()` |
```tsx
import { RefinedIsland, WasmIsland } from "@/components/islands";
```
## Placement Rules
### Islands go INSIDE components, NOT in layouts
Layout files in `app/` never import islands. Islands are an implementation detail of the component that needs the context.
```tsx
// WRONG - island in layout
// app/[locale]/simulate/layout.tsx
import { RefinedIsland } from "@/components/islands";
export default function Layout({ children }) {
return <RefinedIsland>{children}</RefinedIsland>; // NO
}
// RIGHT - island in component
// components/simulate/simulate-content.tsx
export function SimulateContent({ children }) {
return (
<RefinedIsland>
<WasmIsland>{children ?? <SimulateWizard />}</WasmIsland>
</RefinedIsland>
);
}
```
**Sole exception:** `DocsSearchIsland` in `dev/docs/layout.tsx` because the search context spans sibling components (search button + search dialog).
### Components that use Refine hooks need the wrapper+inner pattern
If a component calls Refine/React Query hooks at the top level, split it:
```tsx
// Exported: thin wrapper with island
export function NodesPage() {
return (
<RefinedIsland>
<NodesPageInner />
</RefinedIsland>
);
}
// Private: all hooks and logic
function NodesPageInner() {
const { data } = useNodes(userId);
const { updateNode } = useNodeMutations();
// ... render
}
```
### Components that already wrap children just add the island
```tsx
export function SimulateContent({ children }) {
return (
<RefinedIsland>
<WasmIsland>{children ?? <SimulateWizard />}</WasmIsland>
</RefinedIsland>
);
}
```
### Global components self-wrap
Components rendered globally (e.g., ComputingDrawer in the shell) wrap themselves:
```tsx
export function ComputingDrawer() {
return (
<RefinedIsland>
<ComputingDrawerInner />
</RefinedIsland>
);
}
```
## Multiple Refine Instances Are Fine
Each `RefinedIsland` creates its own `<Refine>` instance, but they all share the same `getQueryClient()` singleton. React Query cache is coherent across all islands. The `liveProvider` wraps a singleton Centrifugo client (shared WebSocket). `dataProvider` and `authProvider` are stateless.
## Auth Gates + PPR
Auth gate layouts call `supabase.auth.getUser()` which is uncached dynamic data. With cache components, they need `await connection()` from `next/server` to signal "this route is dynamic":
```tsx
// app/[locale]/simulate/layout.tsx
import { connection } from "next/server";
import { unauthorized } from "next/navigation";
export default async function SimulateLayout({ children }) {
await connection(); // Signal: this layout is dynamic
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
unauthorized();
}
return children;
}
```
The `ThemeIsland` at the root layout has a `<Suspense>` around its children. This is required because the auth gate layouts below it access uncached data — without a Suspense boundary above them, PPR fails.
## Route Handler Pattern
API route handlers (`app/api/`) also need `await connection()` when they access dynamic data:
```tsx
import { connection } from "next/server";
export async function GET(request: Request) {
await connection();
// ... dynamic logic
}
```
## How PPR Works (Reference)
Source: [Next.js docs - Cache Components / Partial Prerendering](https://nextjs.org/docs/app/building-your-application/rendering/partial-prerendering)
### Build Time
1. Next.js renders the component tree
2. Components that only do synchronous/pure work → **static shell** (HTML)
3. Components that access uncached data → must be in `<Suspense>` or `"use cache"`
4. Suspense fallbacks are included in the static shell
### Request Time
1. Browser receives the static shell instantly
2. Dynamic content streams in as Suspense boundaries resolve
3. Multiple Suspense boundaries resolve in parallel
### What Goes in the Static Shell
- Synchronous computations, module imports
- `"use cache"` components (cached at build, revalidated on schedule)
- Suspense fallback UI
- Server components that don't access dynamic data
### What Streams at Request Time
- Components inside `<Suspense>` that access:
- Network requests (`fetch`, DB queries)
- Runtime data (`cookies()`, `headers()`, `searchParams`)
- `await connection()` (explicit dynamic signal)
### `connection()` vs `cookies()` vs `headers()`
| Function | What It Does | When to Use |
| -------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `cookies()` | Reads cookies, signals dynamic | When you need cookie data |
| `headers()` | Reads headers, signals dynamic | When you need header data |
| `connection()` | Just signals dynamic, reads nothing | When you need dynamic rendering but don't need cookies/headers (e.g., auth gates using Supabase client which reads cookies internally) |
### `"use cache"` (Not Used Yet)
For data that doesn't change often and doesn't need request context, `"use cache"` caches the result and includes it in the static shell:
```tsx
import { cacheLife } from "next/cache";
async function BlogPosts() {
"use cache";
cacheLife("hours");
const posts = await fetchPosts();
return <PostList posts={posts} />;
}
```
We don't use this yet but it's available for future optimization.
## Build Output
After `pnpm build`, check the route summary:
```
○ (Static) fully prerendered, no dynamic content
◐ (Partial Prerender) static shell + dynamic streaming
ƒ (Dynamic) fully server-rendered per request
```
All app pages should be `◐` (PPR). API routes should be `ƒ` (Dynamic).
## Adding a New Feature
### Does it use Refine hooks (useQuery, useUser, useClassesAndSpecs, etc.)?
Wrap with `RefinedIsland` using the wrapper+inner pattern.
### Does it use WASM (useCommon, useEngine)?
Wrap with `WasmIsland`. If it also needs Refine, nest: `<RefinedIsland><WasmIsland>...</WasmIsland></RefinedIsland>`.
### Does it use useQueryState from nuqs?
Wrap with `NuqsIsland`.
### Does it need auth protection?
Add an auth gate layout with `await connection()` + `supabase.auth.getUser()` + `unauthorized()`. Do NOT put `RefinedIsland` in the layout.
### Is it a pure server component?
No island needed. It becomes part of the static shell automatically.
## Never Do This
```tsx
// WRONG - island in layout
<RefinedIsland>{children}</RefinedIsland>
// WRONG - provider naming
export function SomeProvider() { ... }
// WRONG - wrapping entire app in a provider
<AppProviders>{children}</AppProviders>
// WRONG - Refine hooks outside an island
export function MyPage() {
const { data } = useUser(); // Will crash - no Refine context
}
```