npm package: @flightdev/ppr
Description: Partial Pre-Rendering build plugin for Flight Framework
Latest version: 0.0.8

# @flightdev/ppr

Partial Pre-Rendering (PPR) for Flight Framework. Combine the speed of static sites with the dynamism of server rendering.

## Table of Contents

- [What is PPR](#what-is-ppr)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [How It Works](#how-it-works)
- [Route Configuration](#route-configuration)
- [Build Plugins](#build-plugins)
- [Runtime Usage](#runtime-usage)
- [Suspense Boundaries](#suspense-boundaries)
- [Static Shell Extraction](#static-shell-extraction)
- [Streaming Dynamic Content](#streaming-dynamic-content)
- [API Reference](#api-reference)
- [Best Practices](#best-practices)
- [License](#license)

---

## What is PPR

Partial Pre-Rendering is a hybrid rendering strategy that:

1. **Pre-renders the static shell** at build time for instant TTFB
2. **Streams dynamic content** at request time within Suspense boundaries
3. **Delivers the best of both worlds**: fast initial load + personalized content

This is ideal for pages that have both static and dynamic parts:
- Product pages with personalized recommendations
- Dashboards with cached layout and live data
- Blog posts with dynamic comments

---

## Installation

```bash
npm install @flightdev/ppr
```

---

## Quick Start

### 1. Enable PPR on a Route

```tsx
// src/routes/products/[id].page.tsx
import { Suspense } from 'react';

// Enable PPR for this route
export const ppr = true;

export default function ProductPage({ id }) {
    return (
        <div>
            {/* Static shell (pre-rendered at build) */}
            <header>
                <h1>Product Details</h1>
                <nav>...</nav>
            </header>
            
            {/* Dynamic content (streamed at request) */}
            <Suspense fallback={<ProductSkeleton />}>
                <ProductDetails id={id} />
            </Suspense>
            
            <Suspense fallback={<RecommendationsSkeleton />}>
                <PersonalizedRecommendations />
            </Suspense>
            
            {/* Static footer (pre-rendered) */}
            <footer>...</footer>
        </div>
    );
}
```

### 2. Add the Build Plugin

```typescript
// vite.config.ts
import { defineConfig } from 'vite';
import { ppr } from '@flightdev/ppr/vite';

export default defineConfig({
    plugins: [
        ppr({
            routes: 'src/routes',
            output: '.flight/ppr',
        }),
    ],
});
```

### 3. Build and Run

```bash
flight build
flight start
```

---

## How It Works

```
Build Time:
┌────────────────────────────────────────┐
│  Static Shell                          │
│  ┌──────────────────────────────────┐  │
│  │  <header>Product Details</header>│  │
│  │  <div data-suspense="1">         │  │
│  │    <ProductSkeleton />           │  │
│  │  </div>                          │  │
│  │  <footer>...</footer>            │  │
│  └──────────────────────────────────┘  │
└────────────────────────────────────────┘
         Saved to: products__[id].html

Request Time:
┌────────────────────────────────────────┐
│  1. Send pre-rendered shell instantly  │
│  2. Start streaming dynamic chunks     │
│  3. Replace suspense with real content │
└────────────────────────────────────────┘
```

### Performance Benefits

| Metric | Traditional SSR | PPR |
|--------|----------------|-----|
| TTFB | ~200-500ms | ~10-50ms |
| FCP | ~200-500ms | ~10-50ms |
| LCP | After all data | Shell + stream |
| Interactivity | After hydration | Progressive |

---

## Route Configuration

### Enable PPR

```tsx
// Mark route for PPR
export const ppr = true;

// With configuration
export const ppr = {
    revalidate: 3600,  // Revalidate shell every hour
};
```

### Static Params

For dynamic routes, define which paths to pre-render:

```tsx
export const ppr = true;

export async function generateStaticParams() {
    const products = await getPopularProducts();
    return products.map(p => ({ id: p.id }));
}

// Pre-renders:
// /products/product-1
// /products/product-2
// etc.
```

### Opt Out of PPR

```tsx
// Force full SSR for this route
export const ppr = false;
```

---

## Build Plugins

### Vite

```typescript
import { ppr } from '@flightdev/ppr/vite';

export default defineConfig({
    plugins: [
        ppr({
            routes: 'src/routes',
            output: '.flight/ppr',
            // Options
            include: ['/products/*', '/blog/*'],
            exclude: ['/dashboard/*'],
        }),
    ],
});
```

### esbuild

```typescript
import * as esbuild from 'esbuild';
import { ppr } from '@flightdev/ppr/esbuild';

await esbuild.build({
    entryPoints: ['src/server.ts'],
    plugins: [
        ppr({
            routes: 'src/routes',
            output: '.flight/ppr',
        }),
    ],
});
```

### Rolldown

```typescript
import { ppr } from '@flightdev/ppr/rolldown';

export default {
    plugins: [
        ppr({
            routes: 'src/routes',
            output: '.flight/ppr',
        }),
    ],
};
```

---

## Runtime Usage

### Load and Serve Shells

```typescript
import { loadShell, loadManifest } from '@flightdev/ppr';

const manifest = await loadManifest('.flight/ppr/manifest.json');

export async function handleRequest(request: Request) {
    const url = new URL(request.url);
    const route = matchRoute(url.pathname);
    
    // Check if route has PPR shell
    const shellPath = manifest.shells[route.pattern];
    
    if (shellPath) {
        // Load pre-rendered shell
        const shell = await loadShell(shellPath);
        
        // Stream response with shell first
        return new Response(streamWithShell(shell, route), {
            headers: { 'Content-Type': 'text/html' },
        });
    }
    
    // Fall back to full SSR
    return renderSSR(route);
}
```

### Streaming Helper

```typescript
import { createPPRStream } from '@flightdev/ppr';

const stream = createPPRStream({
    shell: preRenderedShell,
    renderDynamic: async function* () {
        // Yield dynamic chunks as they resolve
        yield await renderSuspense('recommendations', <Recommendations />);
        yield await renderSuspense('comments', <Comments />);
    },
});
```

---

## Suspense Boundaries

PPR uses Suspense boundaries to identify dynamic content:

### React

```tsx
import { Suspense } from 'react';

export default function Page() {
    return (
        <main>
            {/* Static: always in shell */}
            <h1>Dashboard</h1>
            
            {/* Dynamic: streamed at request */}
            <Suspense fallback={<CardSkeleton />}>
                <UserCard />
            </Suspense>
            
            {/* Multiple boundaries stream independently */}
            <Suspense fallback={<MetricsSkeleton />}>
                <LiveMetrics />
            </Suspense>
        </main>
    );
}
```

### Nested Suspense

Inner boundaries resolve independently:

```tsx
<Suspense fallback={<DashboardSkeleton />}>
    <DashboardShell>
        {/* Streams when ready, independent of parent */}
        <Suspense fallback={<ChartSkeleton />}>
            <RevenueChart />
        </Suspense>
        
        <Suspense fallback={<TableSkeleton />}>
            <RecentOrders />
        </Suspense>
    </DashboardShell>
</Suspense>
```

---

## Static Shell Extraction

The build process:

1. Renders each PPR route at build time
2. Identifies Suspense boundaries
3. Extracts the static shell (everything outside Suspense)
4. Saves shell HTML and placeholder markers
5. Generates manifest.json

### Manifest Structure

```json
{
  "version": 1,
  "generatedAt": "2026-01-11T00:00:00Z",
  "shells": {
    "/products/[id]": ".flight/ppr/products__[id].html",
    "/blog/[slug]": ".flight/ppr/blog__[slug].html"
  },
  "suspenseIds": {
    "/products/[id]": ["product-details", "recommendations"],
    "/blog/[slug]": ["content", "comments"]
  }
}
```

### Shell HTML

```html
<!DOCTYPE html>
<html>
<head>
    <title>Product | MyStore</title>
    <!-- Pre-rendered meta tags -->
</head>
<body>
    <header><!-- Static header --></header>
    
    <!-- Suspense placeholder (replaced at request time) -->
    <div data-flight-suspense="product-details">
        <div class="skeleton">Loading...</div>
    </div>
    
    <div data-flight-suspense="recommendations">
        <div class="skeleton">Loading...</div>
    </div>
    
    <footer><!-- Static footer --></footer>
    
    <!-- Streaming script -->
    <script>/* Flight streaming runtime */</script>
</body>
</html>
```

---

## Streaming Dynamic Content

At request time, dynamic content streams into placeholders:

```typescript
import { streamDynamicContent } from '@flightdev/ppr';

// In your request handler
const stream = streamDynamicContent({
    shell: preRenderedShell,
    
    async resolvers() {
        return {
            'product-details': renderProductDetails(params.id),
            'recommendations': renderRecommendations(userId),
        };
    },
    
    // Stream as each resolves (don't wait for all)
    streaming: true,
});

return new Response(stream, {
    headers: {
        'Content-Type': 'text/html',
        'Transfer-Encoding': 'chunked',
    },
});
```

### Streaming Script Injection

The shell includes a script that handles replacing placeholders:

```javascript
// Automatically included in shell
window.__FLIGHT_PPR__ = {
    replace(id, html) {
        const placeholder = document.querySelector(`[data-flight-suspense="${id}"]`);
        if (placeholder) {
            const template = document.createElement('template');
            template.innerHTML = html;
            placeholder.replaceWith(template.content);
        }
    }
};
```

---

## API Reference

### Build Functions

| Function | Description |
|----------|-------------|
| `buildPPR(options)` | Run PPR build for all routes |
| `scanRoutes(dir)` | Scan directory for PPR-enabled routes |
| `extractShell(route)` | Extract static shell from route |

### Runtime Functions

| Function | Description |
|----------|-------------|
| `loadManifest(path)` | Load PPR manifest |
| `loadShell(path)` | Load pre-rendered shell HTML |
| `createPPRStream(options)` | Create streaming response |
| `streamDynamicContent(options)` | Stream content into shell |

### Plugin Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `routes` | `string` | `'src/routes'` | Routes directory |
| `output` | `string` | `'.flight/ppr'` | Output directory |
| `include` | `string[]` | `['**/*']` | Patterns to include |
| `exclude` | `string[]` | `[]` | Patterns to exclude |

---

## Best Practices

### Do Use PPR For

- Product pages with personalized sections
- Blog posts with user-specific content (comments, related)
- Dashboards with cached layouts and live data
- Marketing pages with personalization
- Any page with a clear static/dynamic split

### Avoid PPR For

- Fully static pages (use SSG instead)
- Fully dynamic pages (use SSR instead)
- Pages where all content depends on the user
- Real-time collaborative apps

### Performance Tips

1. **Keep shells small** - Minimize the static shell size
2. **Use meaningful fallbacks** - Skeletons that match final content
3. **Prioritize above-the-fold** - Critical content should be in shell
4. **Parallelize suspense** - Multiple boundaries stream independently
5. **Cache aggressively** - Shell generation is a build step

---

## License

MIT