├── .prettierrc ├── .eslintrc.json ├── app ├── favicon.ico ├── twitter-image.png ├── opengraph-image.png ├── not-found.tsx ├── page.tsx ├── layout.tsx └── styles.tsx ├── postcss.config.js ├── types ├── review.d.ts └── product.d.ts ├── public ├── nextjs-icon-light-background.png ├── patrick-OIFgeLnjwrM-unsplash.jpg ├── eniko-kis-KsLPTsYaqIQ-unsplash.jpg ├── prince-akachi-LWkFHEGpleE-unsplash.jpg ├── yoann-siloine-_T4w3JDm6ug-unsplash.jpg ├── guillaume-coupy-6HuoHgK7FN8-unsplash.jpg ├── alexander-andrews-brAkTCdnhW8-unsplash.jpg └── grid.svg ├── components ├── product-best-seller.tsx ├── cart-count.tsx ├── product-low-stock-warning.tsx ├── ping.tsx ├── product-rating.tsx ├── product-currency-symbol.tsx ├── product-used-price.tsx ├── product-split-payments.tsx ├── product-estimated-arrival.tsx ├── product-review-card.tsx ├── product-lightening-deal.tsx ├── cart-count-context.tsx ├── product-deal.tsx ├── byline.tsx ├── product-price.tsx ├── next-logo.tsx ├── vercel-logo.tsx ├── reviews.tsx ├── add-to-cart.tsx ├── product-card.tsx ├── header.tsx ├── recommended-products.tsx ├── single-product.tsx ├── pricing.tsx └── sidebar.tsx ├── next.config.js ├── next-env.d.ts ├── lib └── delay.ts ├── .gitignore ├── tsconfig.json ├── package.json ├── README.md └── tailwind.config.ts /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["next/core-web-vitals"] 4 | } 5 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/next-partial-prerendering/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /app/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/next-partial-prerendering/HEAD/app/twitter-image.png -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/next-partial-prerendering/HEAD/app/opengraph-image.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /types/review.d.ts: -------------------------------------------------------------------------------- 1 | export type Review = { 2 | id: string; 3 | name: string; 4 | rating: number; 5 | text: string; 6 | }; 7 | -------------------------------------------------------------------------------- /public/nextjs-icon-light-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/next-partial-prerendering/HEAD/public/nextjs-icon-light-background.png -------------------------------------------------------------------------------- /public/patrick-OIFgeLnjwrM-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/next-partial-prerendering/HEAD/public/patrick-OIFgeLnjwrM-unsplash.jpg -------------------------------------------------------------------------------- /public/eniko-kis-KsLPTsYaqIQ-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/next-partial-prerendering/HEAD/public/eniko-kis-KsLPTsYaqIQ-unsplash.jpg -------------------------------------------------------------------------------- /public/prince-akachi-LWkFHEGpleE-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/next-partial-prerendering/HEAD/public/prince-akachi-LWkFHEGpleE-unsplash.jpg -------------------------------------------------------------------------------- /public/yoann-siloine-_T4w3JDm6ug-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/next-partial-prerendering/HEAD/public/yoann-siloine-_T4w3JDm6ug-unsplash.jpg -------------------------------------------------------------------------------- /public/guillaume-coupy-6HuoHgK7FN8-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/next-partial-prerendering/HEAD/public/guillaume-coupy-6HuoHgK7FN8-unsplash.jpg -------------------------------------------------------------------------------- /public/alexander-andrews-brAkTCdnhW8-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/next-partial-prerendering/HEAD/public/alexander-andrews-brAkTCdnhW8-unsplash.jpg -------------------------------------------------------------------------------- /components/product-best-seller.tsx: -------------------------------------------------------------------------------- 1 | export const ProductBestSeller = () => { 2 | return ( 3 |
4 | Best Seller 5 |
6 | ); 7 | }; 8 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | // Removed experimental.ppr as it's only available in canary versions 4 | // and not compatible with Next.js 15.0.5+ 5 | }; 6 | 7 | module.exports = nextConfig; 8 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFound() { 2 | return ( 3 |
4 |

Not Found

5 |

Could not find requested resource

6 |
7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | import "./.next/types/routes.d.ts"; 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 7 | -------------------------------------------------------------------------------- /components/cart-count.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCartCount } from '#/components/cart-count-context'; 4 | 5 | export function CartCount({ initialCartCount }: { initialCartCount: number }) { 6 | const [count] = useCartCount(initialCartCount); 7 | return {count}; 8 | } 9 | -------------------------------------------------------------------------------- /components/product-low-stock-warning.tsx: -------------------------------------------------------------------------------- 1 | export const ProductLowStockWarning = ({ stock }: { stock: number }) => { 2 | if (stock > 3) { 3 | return null; 4 | } 5 | 6 | if (stock === 0) { 7 | return
Out of stock
; 8 | } 9 | 10 | return ( 11 |
Only {stock} left in stock
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /lib/delay.ts: -------------------------------------------------------------------------------- 1 | // Times are in milliseconds 2 | export const delayShippingEstimate = 200; 3 | export const delayRecommendedProducts = 500; 4 | export const delayReviews = 600; 5 | 6 | export async function withDelay( 7 | promise: Promise, 8 | delay: number 9 | ): Promise { 10 | // Ensure we throw if this throws 11 | const ret = await promise; 12 | return new Promise((resolve) => { 13 | setTimeout(() => { 14 | resolve(ret); 15 | }, delay); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /components/ping.tsx: -------------------------------------------------------------------------------- 1 | export function Ping() { 2 | return ( 3 |
4 |
5 | 6 | 7 | 8 | 9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /components/product-rating.tsx: -------------------------------------------------------------------------------- 1 | import { StarIcon } from '@heroicons/react/24/solid'; 2 | import clsx from 'clsx'; 3 | 4 | export const ProductRating = ({ rating }: { rating: number }) => { 5 | return ( 6 |
7 | {Array.from({ length: 5 }).map((_, i) => { 8 | return ( 9 | 13 | ); 14 | })} 15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | /.yarn 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .pnpm-debug.log* 28 | 29 | # local env files 30 | .env* 31 | !.env*.example 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | -------------------------------------------------------------------------------- /components/product-currency-symbol.tsx: -------------------------------------------------------------------------------- 1 | import { toFormat, type Dinero } from 'dinero.js'; 2 | 3 | export const ProductCurrencySymbol = ({ 4 | dinero, 5 | }: { 6 | dinero: Dinero; 7 | }) => { 8 | let symbol = ''; 9 | switch (toFormat(dinero, ({ currency }) => currency.code)) { 10 | case 'GBP': { 11 | symbol = '£'; 12 | break; 13 | } 14 | 15 | case 'EUR': { 16 | symbol = '€'; 17 | break; 18 | } 19 | 20 | default: { 21 | symbol = '$'; 22 | break; 23 | } 24 | } 25 | 26 | return <>{symbol}; 27 | }; 28 | -------------------------------------------------------------------------------- /components/product-used-price.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from '#/types/product'; 2 | import { dinero, toUnit, up, type DineroSnapshot } from 'dinero.js'; 3 | 4 | export const ProductUsedPrice = ({ 5 | usedPrice: usedPriceRaw, 6 | }: { 7 | usedPrice: Product['usedPrice']; 8 | }) => { 9 | const usedPrice = dinero(usedPriceRaw as DineroSnapshot); 10 | 11 | return ( 12 |
13 |
More buying choices
14 |
15 | ${toUnit(usedPrice, { digits: 0, round: up })} (used) 16 |
17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /components/product-split-payments.tsx: -------------------------------------------------------------------------------- 1 | import { ProductCurrencySymbol } from '#/components/product-currency-symbol'; 2 | import { allocate, toUnit, up, type Dinero } from 'dinero.js'; 3 | 4 | export const ProductSplitPayments = ({ price }: { price: Dinero }) => { 5 | // only offer split payments for more expensive items 6 | if (toUnit(price) < 150) { 7 | return null; 8 | } 9 | 10 | const [perMonth] = allocate(price, [1, 2]); 11 | return ( 12 |
13 | Or 14 | {toUnit(perMonth, { digits: 0, round: up })}/month for 3 months 15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /components/product-estimated-arrival.tsx: -------------------------------------------------------------------------------- 1 | import { add, format, isTomorrow } from 'date-fns'; 2 | 3 | export const ProductEstimatedArrival = ({ 4 | leadTime, 5 | hasDeliveryTime = false, 6 | }: { 7 | leadTime: number; 8 | hasDeliveryTime?: boolean; 9 | }) => { 10 | const date = add(new Date(), { 11 | days: leadTime, 12 | }); 13 | 14 | return ( 15 |
16 | Get it{' '} 17 | 18 | {isTomorrow(date) ? 'tomorrow, ' : null} 19 | {format(date, 'MMM d')} 20 | 21 | {hasDeliveryTime ? <> by 5pm : null} 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /components/product-review-card.tsx: -------------------------------------------------------------------------------- 1 | import type { Review } from '#/types/review'; 2 | import { ProductRating } from '#/components/product-rating'; 3 | 4 | export const ProductReviewCard = ({ review }: { review: Review }) => { 5 | return ( 6 |
7 |
8 |
9 |
10 |
{review.name}
11 |
12 | 13 | {review.rating ? : null} 14 |
15 | 16 |
{review.text}
17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /types/product.d.ts: -------------------------------------------------------------------------------- 1 | export type Product = { 2 | id: string; 3 | stock: number; 4 | rating: number; 5 | name: string; 6 | description: string; 7 | price: Price; 8 | isBestSeller: boolean; 9 | leadTime: number; 10 | image?: string; 11 | imageBlur?: string; 12 | discount?: Discount; 13 | usedPrice?: UsedPrice; 14 | }; 15 | 16 | type Price = { 17 | amount: number; 18 | currency: Currency; 19 | scale: number; 20 | }; 21 | 22 | type Currency = { 23 | code: string; 24 | base: number; 25 | exponent: number; 26 | }; 27 | 28 | type Discount = { 29 | percent: number; 30 | expires?: number; 31 | }; 32 | 33 | type UsedPrice = { 34 | amount: number; 35 | currency: Currency; 36 | scale: number; 37 | }; 38 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { 3 | RecommendedProducts, 4 | RecommendedProductsSkeleton, 5 | } from '#/components/recommended-products'; 6 | import { Reviews, ReviewsSkeleton } from '#/components/reviews'; 7 | import { SingleProduct } from '#/components/single-product'; 8 | import { Ping } from '#/components/ping'; 9 | 10 | export default function Page() { 11 | return ( 12 |
13 | 14 | 15 | 16 | 17 | }> 18 | 19 | 20 | 21 | 22 | 23 | }> 24 | 25 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /components/product-lightening-deal.tsx: -------------------------------------------------------------------------------- 1 | import { ProductDeal } from '#/components/product-deal'; 2 | import { add, formatDistanceToNow } from 'date-fns'; 3 | import { type Dinero } from 'dinero.js'; 4 | 5 | export const ProductLighteningDeal = ({ 6 | price, 7 | discount, 8 | }: { 9 | price: Dinero; 10 | discount: { 11 | amount: Dinero; 12 | expires?: number; 13 | }; 14 | }) => { 15 | const date = add(new Date(), { days: discount.expires }); 16 | 17 | return ( 18 | <> 19 |
20 |
21 | Expires in {formatDistanceToNow(date)} 22 |
23 |
24 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "react-jsx", 20 | "incremental": true, 21 | "baseUrl": ".", 22 | "paths": { 23 | "#/*": [ 24 | "./*" 25 | ] 26 | }, 27 | "plugins": [ 28 | { 29 | "name": "next" 30 | } 31 | ] 32 | }, 33 | "include": [ 34 | "next-env.d.ts", 35 | "**/*.ts", 36 | "**/*.tsx", 37 | ".next/types/**/*.ts" 38 | ], 39 | "exclude": [ 40 | "node_modules" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "type-check": "tsc --noEmit", 5 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 6 | "build": "next build", 7 | "dev": "next dev --turbo", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@heroicons/react": "2.1.5", 12 | "clsx": "2.1.1", 13 | "date-fns": "3.6.0", 14 | "dinero.js": "2.0.0-alpha.8", 15 | "geist": "1.3.1", 16 | "next": "15.6.0-canary.60", 17 | "react": "19.0.0-rc-8b08e99e-20240713", 18 | "react-dom": "19.0.0-rc-8b08e99e-20240713" 19 | }, 20 | "devDependencies": { 21 | "@tailwindcss/forms": "0.5.7", 22 | "@tailwindcss/typography": "0.5.13", 23 | "@types/node": "20.14.10", 24 | "@types/react": "18.3.3", 25 | "@types/react-dom": "18.3.0", 26 | "autoprefixer": "10.4.19", 27 | "eslint": "8.57.1", 28 | "eslint-config-next": "15.6.0-canary.58", 29 | "postcss": "8.4.39", 30 | "tailwindcss": "3.4.5", 31 | "typescript": "5.5.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /components/cart-count-context.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useState } from 'react'; 4 | 5 | const CartCountContext = React.createContext< 6 | | [null | number, React.Dispatch>] 7 | | undefined 8 | >(undefined); 9 | 10 | export function CartCountProvider({ children }: { children: React.ReactNode }) { 11 | const [optimisticCartCount, setOptimisticCartCount] = useState( 12 | null, 13 | ); 14 | 15 | return ( 16 | 19 | {children} 20 | 21 | ); 22 | } 23 | 24 | export function useCartCount( 25 | initialCount: number, 26 | ): [null | number, React.Dispatch>] { 27 | const context = React.useContext(CartCountContext); 28 | if (context === undefined) { 29 | throw new Error('useCartCount must be used within a CartCountProvider'); 30 | } 31 | if (context[0] === null) { 32 | return [initialCount, context[1]]; 33 | } 34 | return context; 35 | } 36 | -------------------------------------------------------------------------------- /components/product-deal.tsx: -------------------------------------------------------------------------------- 1 | import { ProductCurrencySymbol } from '#/components/product-currency-symbol'; 2 | import { toUnit, type Dinero } from 'dinero.js'; 3 | 4 | export const ProductDeal = ({ 5 | price: priceRaw, 6 | discount: discountRaw, 7 | }: { 8 | price: Dinero; 9 | discount: { 10 | amount: Dinero; 11 | }; 12 | }) => { 13 | const discount = toUnit(discountRaw.amount); 14 | const price = toUnit(priceRaw); 15 | const percent = Math.round(100 - (discount / price) * 100); 16 | 17 | return ( 18 |
19 |
20 | -{percent}% 21 |
22 |
23 |
24 | 25 |
26 |
27 | {discount} 28 |
29 |
30 |
31 | 32 | {price} 33 |
34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /components/byline.tsx: -------------------------------------------------------------------------------- 1 | import { VercelLogo } from '#/components/vercel-logo'; 2 | 3 | export default function Byline({ className }: { className: string }) { 4 | return ( 5 |
8 |
9 |
10 |
By
11 | 12 |
13 | 14 |
15 |
16 |
17 | 18 | 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Next.js Partial Prerendering 2 | 3 | This is a demo of [Next.js](https://nextjs.org) using [Partial Prerendering](https://nextjs.org/docs/app/api-reference/next-config-js/ppr). 4 | 5 | This template uses the new Next.js [App Router](https://nextjs.org/docs/app). This includes support for enhanced layouts, colocation of components, tests, and styles, component-level data fetching, and more. 6 | 7 | It also uses the experimental Partial Prerendering feature available in Next.js 14. Partial Prerendering combines ultra-quick static edge delivery with fully dynamic capabilities and we believe it has the potential to [become the default rendering model for web applications](https://vercel.com/blog/partial-prerendering-with-next-js-creating-a-new-default-rendering-model), bringing together the best of static site generation and dynamic delivery. 8 | 9 | > ⚠️ Please note that PPR is an experimental technology that is not recommended for production. You may run into some DX issues, especially on larger code bases. 10 | 11 | ## How it works 12 | 13 | The index route `/` uses Partial Prerendering through: 14 | 15 | 1. Enabling the experimental flag in `next.config.js`. 16 | 17 | ```js 18 | experimental: { 19 | ppr: true, 20 | }, 21 | ``` 22 | 23 | 2. Using `` to wrap Dynamic content. 24 | -------------------------------------------------------------------------------- /public/grid.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/product-price.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from '#/types/product'; 2 | import { ProductCurrencySymbol } from '#/components/product-currency-symbol'; 3 | import { ProductDeal } from '#/components/product-deal'; 4 | import { ProductLighteningDeal } from '#/components/product-lightening-deal'; 5 | import { multiply, toUnit, type Dinero } from 'dinero.js'; 6 | 7 | function isDiscount(obj: any): obj is { percent: number; expires?: number } { 8 | return typeof obj?.percent === 'number'; 9 | } 10 | 11 | function formatDiscount( 12 | price: Dinero, 13 | discountRaw: Product['discount'], 14 | ) { 15 | return isDiscount(discountRaw) 16 | ? { 17 | amount: multiply(price, { 18 | amount: discountRaw.percent, 19 | scale: 2, 20 | }), 21 | expires: discountRaw.expires, 22 | } 23 | : undefined; 24 | } 25 | 26 | export const ProductPrice = ({ 27 | price, 28 | discount: discountRaw, 29 | }: { 30 | price: Dinero; 31 | discount: Product['discount']; 32 | }) => { 33 | const discount = formatDiscount(price, discountRaw); 34 | 35 | if (discount) { 36 | if (discount?.expires && typeof discount.expires === 'number') { 37 | return ; 38 | } 39 | return ; 40 | } 41 | 42 | return ( 43 |
44 |
45 | 46 |
47 |
48 | {toUnit(price)} 49 |
50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /components/next-logo.tsx: -------------------------------------------------------------------------------- 1 | export function NextLogo() { 2 | return ( 3 | 4 | 12 | 13 | 14 | 15 | 16 | 20 | 27 | 28 | 29 | 37 | 38 | 39 | 40 | 48 | 49 | 50 | 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { CartCountProvider } from "#/components/cart-count-context"; 2 | import { Header } from "#/components/header"; 3 | import { Sidebar } from "#/components/sidebar"; 4 | import { Metadata } from "next"; 5 | import { GlobalStyles } from "./styles"; 6 | 7 | export const metadata: Metadata = { 8 | metadataBase: new URL("https://partialprerendering.com"), 9 | title: "Next.js Partial Prerendering", 10 | description: "A demo of Next.js using Partial Prerendering.", 11 | openGraph: { 12 | title: "Next.js Partial Prerendering", 13 | description: "A demo of Next.js using Partial Prerendering.", 14 | }, 15 | twitter: { 16 | card: "summary_large_image", 17 | }, 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: { 23 | children: React.ReactNode; 24 | }) { 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 |
34 |
35 |
36 | 37 |
38 |
39 | 40 | {children} 41 |
42 |
43 |
44 |
45 |
46 |
47 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /components/vercel-logo.tsx: -------------------------------------------------------------------------------- 1 | export function VercelLogo() { 2 | return ( 3 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /components/reviews.tsx: -------------------------------------------------------------------------------- 1 | import type { Review } from '#/types/review'; 2 | import { ProductReviewCard } from '#/components/product-review-card'; 3 | import { delayReviews, withDelay } from '#/lib/delay'; 4 | 5 | export async function Reviews() { 6 | let reviews: Review[] = await withDelay( 7 | fetch( 8 | // We intentionally delay the response to simulate a slow data 9 | // request that would benefit from streaming 10 | `https://app-router-api.vercel.app/api/reviews`, 11 | { 12 | // We intentionally disable Next.js Cache to better demo 13 | // streaming 14 | cache: 'no-store', 15 | } 16 | ).then((res) => res.json()), 17 | delayReviews 18 | ); 19 | 20 | return ( 21 |
22 |
Customer Reviews
23 |
24 | {reviews.map((review) => { 25 | return ; 26 | })} 27 |
28 |
29 | ); 30 | } 31 | 32 | const shimmer = `relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent`; 33 | 34 | function Skeleton() { 35 | return ( 36 |
37 |
38 |
39 |
40 |
41 |
42 | ); 43 | } 44 | 45 | export function ReviewsSkeleton() { 46 | return ( 47 |
48 |
49 |
50 | 51 | 52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /components/add-to-cart.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRouter } from 'next/navigation'; 4 | import { useTransition } from 'react'; 5 | import { useCartCount } from '#/components/cart-count-context'; 6 | 7 | export function AddToCart({ initialCartCount }: { initialCartCount: number }) { 8 | const router = useRouter(); 9 | const [isPending, startTransition] = useTransition(); 10 | 11 | const [, setOptimisticCartCount] = useCartCount(initialCartCount); 12 | 13 | const addToCart = () => { 14 | setOptimisticCartCount(initialCartCount + 1); 15 | 16 | // update the cart count cookie 17 | document.cookie = `_cart_count=${initialCartCount + 1}; path=/; max-age=${ 18 | 60 * 60 * 24 * 30 19 | }};`; 20 | 21 | // Normally you would also send a request to the server to add the item 22 | // to the current users cart 23 | // await fetch(`https://api.acme.com/...`); 24 | 25 | // Use a transition and isPending to create inline loading UI 26 | startTransition(() => { 27 | setOptimisticCartCount(null); 28 | 29 | // Refresh the current route and fetch new data from the server without 30 | // losing client-side browser or React state. 31 | router.refresh(); 32 | 33 | // We're working on more fine-grained data mutation and revalidation: 34 | // https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions 35 | }); 36 | }; 37 | 38 | return ( 39 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /components/product-card.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from '#/types/product'; 2 | import { ProductBestSeller } from '#/components/product-best-seller'; 3 | import { ProductEstimatedArrival } from '#/components/product-estimated-arrival'; 4 | import { ProductLowStockWarning } from '#/components/product-low-stock-warning'; 5 | import { ProductPrice } from '#/components/product-price'; 6 | import { ProductRating } from '#/components/product-rating'; 7 | import { ProductUsedPrice } from '#/components/product-used-price'; 8 | import { dinero, type DineroSnapshot } from 'dinero.js'; 9 | import Image from 'next/image'; 10 | 11 | export const ProductCard = ({ product }: { product: Product }) => { 12 | const price = dinero(product.price as DineroSnapshot); 13 | 14 | return ( 15 |
16 |
17 |
18 | {product.isBestSeller ? ( 19 |
20 | 21 |
22 | ) : null} 23 | {product.name} 32 |
33 | 34 |
35 | {product.name} 36 |
37 | 38 | {product.rating ? : null} 39 | 40 | 41 | 42 | {product.usedPrice ? ( 43 | 44 | ) : null} 45 | 46 | 47 | 48 | {product.stock <= 1 ? ( 49 | 50 | ) : null} 51 |
52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /components/header.tsx: -------------------------------------------------------------------------------- 1 | import { NextLogo } from '#/components/next-logo'; 2 | import { 3 | MagnifyingGlassIcon, 4 | ShoppingCartIcon, 5 | } from '@heroicons/react/24/solid'; 6 | import Image from 'next/image'; 7 | import { CartCount } from '#/components/cart-count'; 8 | import { cookies } from 'next/headers'; 9 | import { Suspense } from 'react'; 10 | 11 | async function CartCountFromCookies() { 12 | const cartCount = Number((await cookies()).get('_cart_count')?.value || '0'); 13 | return ; 14 | } 15 | 16 | export function Header() { 17 | return ( 18 |
19 |
20 |
21 | 22 |
23 | 24 |
25 |
26 | 27 |
28 | 36 |
37 |
38 | 39 |
40 |
41 | 42 |
43 | }> 44 | 45 | 46 |
47 |
48 | 49 | User 57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import colors from 'tailwindcss/colors'; 2 | import { Config } from 'tailwindcss'; 3 | 4 | export default { 5 | content: [ 6 | './app/**/*.{js,ts,jsx,tsx,mdx}', 7 | './components/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | future: { 10 | hoverOnlyWhenSupported: true, 11 | }, 12 | darkMode: 'class', 13 | theme: { 14 | extend: { 15 | // https://vercel.com/design/color 16 | colors: { 17 | gray: colors.zinc, 18 | 'gray-1000': 'rgb(17,17,19)', 19 | 'gray-1100': 'rgb(10,10,11)', 20 | vercel: { 21 | pink: '#FF0080', 22 | blue: '#0070F3', 23 | cyan: '#50E3C2', 24 | orange: '#F5A623', 25 | violet: '#7928CA', 26 | }, 27 | }, 28 | backgroundImage: ({ theme }) => ({ 29 | 'vc-border-gradient': `radial-gradient(at left top, ${theme( 30 | 'colors.gray.500', 31 | )}, 50px, ${theme('colors.gray.800')} 50%)`, 32 | }), 33 | keyframes: ({ theme }) => ({ 34 | rerender: { 35 | '0%': { 36 | ['border-color']: theme('colors.vercel.pink'), 37 | }, 38 | '40%': { 39 | ['border-color']: theme('colors.vercel.pink'), 40 | }, 41 | }, 42 | highlight: { 43 | '0%': { 44 | background: theme('colors.vercel.pink'), 45 | color: theme('colors.white'), 46 | }, 47 | '40%': { 48 | background: theme('colors.vercel.pink'), 49 | color: theme('colors.white'), 50 | }, 51 | }, 52 | loading: { 53 | '0%': { 54 | opacity: '.2', 55 | }, 56 | '20%': { 57 | opacity: '1', 58 | transform: 'translateX(1px)', 59 | }, 60 | to: { 61 | opacity: '.2', 62 | }, 63 | }, 64 | shimmer: { 65 | '100%': { 66 | transform: 'translateX(100%)', 67 | }, 68 | }, 69 | translateXReset: { 70 | '100%': { 71 | transform: 'translateX(0)', 72 | }, 73 | }, 74 | fadeToTransparent: { 75 | '0%': { 76 | opacity: '1', 77 | }, 78 | '40%': { 79 | opacity: '1', 80 | }, 81 | '100%': { 82 | opacity: '0', 83 | }, 84 | }, 85 | }), 86 | }, 87 | }, 88 | plugins: [require('@tailwindcss/typography'), require('@tailwindcss/forms')], 89 | } satisfies Config; 90 | -------------------------------------------------------------------------------- /components/recommended-products.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from '#/types/product'; 2 | import { ProductCard } from '#/components/product-card'; 3 | import { delayRecommendedProducts, withDelay } from '#/lib/delay'; 4 | 5 | export async function RecommendedProducts() { 6 | let products: Product[] = await withDelay( 7 | fetch( 8 | // We intentionally delay the response to simulate a slow data 9 | // request that would benefit from streaming 10 | `https://app-router-api.vercel.app/api/products?filter=1`, 11 | { 12 | // We intentionally disable Next.js Cache to better demo 13 | // streaming 14 | cache: 'no-store', 15 | } 16 | ).then((res) => res.json()), 17 | delayRecommendedProducts 18 | ); 19 | 20 | return ( 21 |
22 |
23 |
24 | Recommended Products for You 25 |
26 |
27 | Based on your preferences and shopping habits 28 |
29 |
30 |
31 | {products.map((product) => ( 32 |
33 | 34 |
35 | ))} 36 |
37 |
38 | ); 39 | } 40 | 41 | const shimmer = `relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent`; 42 | 43 | function ProductSkeleton() { 44 | return ( 45 |
46 |
47 | 48 |
49 |
50 |
51 |
52 |
53 | ); 54 | } 55 | 56 | export function RecommendedProductsSkeleton() { 57 | return ( 58 |
59 |
60 |
61 |
62 |
63 | 64 |
65 | 66 | 67 | 68 | 69 |
70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /components/single-product.tsx: -------------------------------------------------------------------------------- 1 | import { Pricing } from "#/components/pricing"; 2 | import type { Product } from "#/types/product"; 3 | import { ProductRating } from "#/components/product-rating"; 4 | import Image from "next/image"; 5 | 6 | export async function SingleProduct() { 7 | const product: Product = await fetch( 8 | `https://app-router-api.vercel.app/api/products?id=1` 9 | ).then((res) => res.json()); 10 | 11 | return ( 12 |
13 |
14 |
15 |
16 | {product.name} 24 |
25 | 26 |
27 |
28 | {product.name} 36 |
37 |
38 | {product.name} 46 |
47 |
48 | {product.name} 56 |
57 |
58 |
59 |
60 | 61 |
62 | 63 |
64 | 65 |
66 |
67 | {product.name} 68 |
69 | 70 | 71 | 72 |
73 |

{product.description}

74 |

{product.description}

75 |
76 |
77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /components/pricing.tsx: -------------------------------------------------------------------------------- 1 | import type { Product } from '#/types/product'; 2 | import { Ping } from '#/components/ping'; 3 | import { ProductEstimatedArrival } from '#/components/product-estimated-arrival'; 4 | import { ProductLowStockWarning } from '#/components/product-low-stock-warning'; 5 | import { ProductPrice } from '#/components/product-price'; 6 | import { ProductSplitPayments } from '#/components/product-split-payments'; 7 | import { ProductUsedPrice } from '#/components/product-used-price'; 8 | import { dinero, type DineroSnapshot } from 'dinero.js'; 9 | import { Suspense } from 'react'; 10 | import { AddToCart } from '#/components/add-to-cart'; 11 | import { delayShippingEstimate, withDelay } from '#/lib/delay'; 12 | import { cookies } from 'next/headers'; 13 | 14 | async function AddToCartFromCookies() { 15 | // Get the cart count from the users cookies and pass it to the client 16 | // AddToCart component 17 | const cartCount = Number((await cookies()).get('_cart_count')?.value || '0'); 18 | return ; 19 | } 20 | 21 | function LoadingDots() { 22 | return ( 23 |
24 | 25 | 26 | • 27 | 28 | 29 | • 30 | 31 | 32 | • 33 | 34 | 35 |
36 | ); 37 | } 38 | 39 | async function UserSpecificDetails({ productId }: { productId: string }) { 40 | const data = await withDelay( 41 | fetch( 42 | `https://app-router-api.vercel.app/api/products?id=${productId}&filter=price,usedPrice,leadTime,stock`, 43 | { 44 | // We intentionally disable Next.js Cache to better demo 45 | // streaming 46 | cache: 'no-store', 47 | } 48 | ), 49 | delayShippingEstimate 50 | ); 51 | 52 | const product = (await data.json()) as Product; 53 | 54 | const price = dinero(product.price as DineroSnapshot); 55 | 56 | return ( 57 | <> 58 | 59 | {product.usedPrice ? ( 60 | 61 | ) : null} 62 | 63 | {product.stock <= 1 ? ( 64 | 65 | ) : null} 66 | 67 | ); 68 | } 69 | 70 | export function Pricing({ product }: { product: Product }) { 71 | const price = dinero(product.price as DineroSnapshot); 72 | 73 | return ( 74 |
75 | 76 | 77 | 78 | 79 | }> 80 | 81 | 82 | 83 | }> 84 | 85 | 86 |
87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /components/sidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { NextLogo } from '#/components/next-logo'; 4 | import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/solid'; 5 | 6 | import clsx from 'clsx'; 7 | import { useState } from 'react'; 8 | import Byline from '#/components/byline'; 9 | import { 10 | delayRecommendedProducts, 11 | delayReviews, 12 | delayShippingEstimate, 13 | } from '#/lib/delay'; 14 | 15 | export function Sidebar() { 16 | const [isOpen, setIsOpen] = useState(false); 17 | 18 | return ( 19 |
20 |
21 |
22 |
23 | 24 |
25 | 26 |

27 | Partial Prerendering 28 |

29 |
30 |
31 | 45 | 46 |
52 |
53 |
54 |

55 | Pink dots{' '} 56 | denote artificially delayed responses for demo purposes: 57 |

58 |
    59 |
  • Shipping estimate → {delayShippingEstimate}ms
  • 60 |
  • Recommended products → {delayRecommendedProducts}ms
  • 61 |
  • Reviews → {delayReviews}ms
  • 62 |
63 |
64 | 65 |

66 | 70 | Partial Prerendering 71 | {' '} 72 | combines ultra-quick static edge delivery with fully dynamic 73 | capabilities. This is different from how your application behaves 74 | today, where entire routes are either fully static or dynamic. 75 |

76 |

How it works:

77 |
    78 |
  • 79 | A static route shell is served immediately, this makes 80 | the initial load fast. 81 |
  • 82 |
  • 83 | The shell leaves holes where dynamic content (that might 84 | be slower) will be streamed in to minimize the perceived overall 85 | page load time. 86 |
  • 87 |
  • 88 | The async holes are loaded in parallel, reducing the overall load 89 | time of the page. 90 |
  • 91 |
92 |

93 | Try refreshing the page to restart the demo. 94 |

95 |
96 | 97 |
98 |
99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /app/styles.tsx: -------------------------------------------------------------------------------- 1 | export function GlobalStyles() { 2 | return ( 3 |