@flightdev/ppr
v0.0.8
Published
Partial Pre-Rendering build plugin for Flight Framework
Maintainers
Readme
@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
- Installation
- Quick Start
- How It Works
- Route Configuration
- Build Plugins
- Runtime Usage
- Suspense Boundaries
- Static Shell Extraction
- Streaming Dynamic Content
- API Reference
- Best Practices
- License
What is PPR
Partial Pre-Rendering is a hybrid rendering strategy that:
- Pre-renders the static shell at build time for instant TTFB
- Streams dynamic content at request time within Suspense boundaries
- 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
npm install @flightdev/pprQuick Start
1. Enable PPR on a Route
// 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
// 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
flight build
flight startHow 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
// 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:
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
// Force full SSR for this route
export const ppr = false;Build Plugins
Vite
import { ppr } from '@flightdev/ppr/vite';
export default defineConfig({
plugins: [
ppr({
routes: 'src/routes',
output: '.flight/ppr',
// Options
include: ['/products/*', '/blog/*'],
exclude: ['/dashboard/*'],
}),
],
});esbuild
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
import { ppr } from '@flightdev/ppr/rolldown';
export default {
plugins: [
ppr({
routes: 'src/routes',
output: '.flight/ppr',
}),
],
};Runtime Usage
Load and Serve Shells
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
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
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:
<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:
- Renders each PPR route at build time
- Identifies Suspense boundaries
- Extracts the static shell (everything outside Suspense)
- Saves shell HTML and placeholder markers
- Generates manifest.json
Manifest Structure
{
"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
<!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:
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:
// 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
- Keep shells small - Minimize the static shell size
- Use meaningful fallbacks - Skeletons that match final content
- Prioritize above-the-fold - Critical content should be in shell
- Parallelize suspense - Multiple boundaries stream independently
- Cache aggressively - Shell generation is a build step
License
MIT
