├── .gitignore ├── .npmrc ├── .prettierignore ├── .vscode ├── extensions.json └── settings.json ├── app ├── _hooks │ ├── [categorySlug] │ │ ├── [subCategorySlug] │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── template.tsx │ ├── _components │ │ ├── router-context-layout.tsx │ │ └── router-context.tsx │ ├── layout.tsx │ ├── page.tsx │ ├── readme.mdx │ └── template.tsx ├── _internal │ ├── data.ts │ ├── demos.ts │ └── readme.md ├── _patterns │ ├── active-links │ │ ├── _components │ │ │ └── nav-links.tsx │ │ ├── community │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── profile │ │ │ └── page.tsx │ │ └── settings │ │ │ └── page.tsx │ ├── breadcrumbs │ │ ├── @breadcrumbs │ │ │ ├── [...all] │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── [categorySlug] │ │ │ ├── [subCategorySlug] │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── _components │ │ │ └── breadcrumbs.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── readme.mdx │ ├── layout.tsx │ ├── page.tsx │ └── search-params │ │ ├── active-link.tsx │ │ ├── client.tsx │ │ └── page.tsx ├── _streaming │ ├── _components │ │ ├── add-to-cart.tsx │ │ ├── cart-count-context.tsx │ │ ├── cart-count.tsx │ │ ├── header.tsx │ │ ├── pricing.tsx │ │ ├── recommended-products.tsx │ │ ├── reviews.tsx │ │ └── single-product.tsx │ ├── layout.tsx │ ├── node │ │ ├── layout.tsx │ │ ├── product │ │ │ └── [id] │ │ │ │ └── page.tsx │ │ └── readme.mdx │ ├── page.tsx │ └── readme.mdx ├── api │ ├── categories │ │ ├── category.d.ts │ │ └── getCategories.ts │ ├── og │ │ ├── Inter-SemiBold.ttf │ │ └── route.tsx │ ├── products │ │ └── product.d.ts │ ├── revalidate │ │ └── route.ts │ └── reviews │ │ ├── getReviews.ts │ │ └── review.d.ts ├── cacheable-components │ ├── layout.tsx │ ├── page.tsx │ └── readme.mdx ├── cacheable-functions │ ├── layout.tsx │ ├── page.tsx │ └── readme.mdx ├── cacheable-routes │ ├── layout.tsx │ ├── page.tsx │ └── readme.mdx ├── context │ ├── context-click-counter.tsx │ ├── counter-context.tsx │ ├── layout.tsx │ ├── page.tsx │ └── readme.mdx ├── error │ ├── [section] │ │ ├── [category] │ │ │ └── page.tsx │ │ ├── error.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── _ui │ │ └── buggy-button.tsx │ ├── error.tsx │ ├── layout.tsx │ ├── page.tsx │ └── readme.mdx ├── favicon.ico ├── layout.tsx ├── layouts │ ├── [section] │ │ ├── [category] │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── page.tsx │ └── readme.mdx ├── loading │ ├── [section] │ │ ├── [category] │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── loading.tsx │ ├── page.tsx │ └── readme.mdx ├── not-found.tsx ├── not-found │ ├── [section] │ │ ├── [category] │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── not-found.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── not-found.tsx │ ├── page.tsx │ └── readme.mdx ├── page.tsx ├── parallel-routes │ ├── @audience │ │ ├── default.tsx │ │ ├── demographics │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── subscribers │ │ │ └── page.tsx │ ├── @views │ │ ├── default.tsx │ │ ├── impressions │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── view-duration │ │ │ └── page.tsx │ ├── _ui │ │ └── current-route.tsx │ ├── default.tsx │ ├── layout.tsx │ ├── page.tsx │ └── readme.mdx ├── route-groups │ ├── (checkout) │ │ └── checkout │ │ │ └── page.tsx │ ├── (main) │ │ ├── (marketing) │ │ │ ├── blog │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── (shop) │ │ │ ├── [section] │ │ │ │ ├── [category] │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── layout.tsx │ └── readme.mdx └── use-link-status │ ├── [section] │ ├── [category] │ │ └── page.tsx │ ├── layout.tsx │ └── page.tsx │ ├── layout.tsx │ ├── loading.tsx │ ├── page.tsx │ └── readme.mdx ├── license.md ├── mdx-components.tsx ├── next-env.d.ts ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── public ├── shop │ ├── balls.png │ ├── gloves.png │ ├── laptop.png │ ├── phone.png │ ├── shoes.png │ ├── shorts.png │ ├── tablet.png │ ├── top.png │ └── weights.png └── visuals │ ├── cacheable-routes-client-cache.png │ ├── cacheable-routes-prerendering.png │ └── cacheable-routes-server-cache.png ├── readme.md ├── styles └── globals.css ├── tsconfig.json └── ui ├── boundary.tsx ├── button.tsx ├── byline.tsx ├── click-counter.tsx ├── codehike.tsx ├── count-up.tsx ├── external-link.tsx ├── footer.tsx ├── global-nav.tsx ├── header.tsx ├── link-status.tsx ├── mobile-nav-toggle.tsx ├── new ├── product-card.tsx └── skeleton.tsx ├── next-logo.tsx ├── ping.tsx ├── product-best-seller.tsx ├── product-card.tsx ├── product-currency-symbol.tsx ├── product-deal.tsx ├── product-estimated-arrival.tsx ├── product-lightening-deal.tsx ├── product-low-stock-warning.tsx ├── product-price.tsx ├── product-rating.tsx ├── product-review-card.tsx ├── product-split-payments.tsx ├── product-used-price.tsx ├── prose.tsx ├── rendered-time-ago.tsx ├── rendering-info.tsx ├── rendering-page-skeleton.tsx ├── section-link.tsx ├── skeleton-card.tsx ├── tabs.tsx └── vercel-logo.tsx /.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 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | pnpm-lock.yaml 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": ["bradlc.vscode-tailwindcss", "esbenp.prettier-vscode"], 7 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 8 | "unwantedRecommendations": [] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /app/_hooks/[categorySlug]/[subCategorySlug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCategory } from '#/app/api/categories/getCategories'; 2 | import { HooksClient } from '#/app/_hooks/_components/router-context'; 3 | 4 | export default async function Page(props: { 5 | params: Promise<{ categorySlug: string; subCategorySlug: string }>; 6 | }) { 7 | const params = await props.params; 8 | const category = await getCategory({ slug: params.subCategorySlug }); 9 | 10 | return ( 11 |
12 |

{category.name}

13 | 14 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/_hooks/[categorySlug]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getCategories, getCategory } from '#/app/api/categories/getCategories'; 2 | import { LayoutHooks } from '#/app/_hooks/_components/router-context-layout'; 3 | import { ClickCounter } from '#/ui/click-counter'; 4 | import { Tabs } from '#/ui/tabs'; 5 | 6 | export default async function Layout(props: { 7 | children: React.ReactNode; 8 | params: Promise<{ categorySlug: string }>; 9 | }) { 10 | const params = await props.params; 11 | 12 | const { children } = props; 13 | 14 | const category = await getCategory({ slug: params.categorySlug }); 15 | const categories = await getCategories({ parent: params.categorySlug }); 16 | 17 | return ( 18 |
19 |
20 | ({ text: x.name, slug: x.slug })), 25 | ]} 26 | /> 27 | 28 |
29 | 30 |
31 |
32 | 33 | 34 | 35 |
{children}
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/_hooks/[categorySlug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCategory } from '#/app/api/categories/getCategories'; 2 | import { HooksClient } from '#/app/_hooks/_components/router-context'; 3 | 4 | export default async function Page(props: { 5 | params: Promise<{ categorySlug: string }>; 6 | }) { 7 | const params = await props.params; 8 | const category = await getCategory({ slug: params.categorySlug }); 9 | 10 | return ( 11 |
12 |

13 | All {category.name} 14 |

15 | 16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/_hooks/[categorySlug]/template.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | import React from 'react'; 3 | 4 | export default function Template({ children }: { children: React.ReactNode }) { 5 | return {children}; 6 | } 7 | -------------------------------------------------------------------------------- /app/_hooks/_components/router-context-layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Boundary } from '#/ui/boundary'; 4 | import { 5 | useSelectedLayoutSegment, 6 | useSelectedLayoutSegments, 7 | } from 'next/navigation'; 8 | 9 | export function LayoutHooks() { 10 | const selectedLayoutSegment = useSelectedLayoutSegment(); 11 | const selectedLayoutSegments = useSelectedLayoutSegments(); 12 | 13 | return selectedLayoutSegment ? ( 14 | 15 |
16 |
17 |           {JSON.stringify(
18 |             {
19 |               useSelectedLayoutSegment: selectedLayoutSegment,
20 |               useSelectedLayoutSegments: selectedLayoutSegments,
21 |             },
22 |             null,
23 |             2,
24 |           )}
25 |         
26 |
27 |
28 | ) : null; 29 | } 30 | -------------------------------------------------------------------------------- /app/_hooks/_components/router-context.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Boundary } from '#/ui/boundary'; 4 | import { 5 | useParams, 6 | usePathname, 7 | useSearchParams, 8 | useSelectedLayoutSegment, 9 | useSelectedLayoutSegments, 10 | } from 'next/navigation'; 11 | 12 | export function HooksClient() { 13 | const pathname = usePathname(); 14 | const params = useParams(); 15 | const selectedLayoutSegment = useSelectedLayoutSegment(); 16 | const selectedLayoutSegments = useSelectedLayoutSegments(); 17 | const searchParams = useSearchParams(); 18 | 19 | return ( 20 | 21 |
22 |
23 |           {JSON.stringify(
24 |             {
25 |               usePathname: pathname,
26 |               useParams: params,
27 |               useSearchParams: searchParams
28 |                 ? Object.fromEntries(searchParams.entries())
29 |                 : {},
30 |               useSelectedLayoutSegment: selectedLayoutSegment,
31 |               useSelectedLayoutSegments: selectedLayoutSegments,
32 |             },
33 |             null,
34 |             2,
35 |           )}
36 |         
37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/_hooks/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getCategories } from '#/app/api/categories/getCategories'; 2 | import { LayoutHooks } from '#/app/_hooks/_components/router-context-layout'; 3 | import { ClickCounter } from '#/ui/click-counter'; 4 | import { Tabs } from '#/ui/tabs'; 5 | import React from 'react'; 6 | 7 | const title = 'Hooks'; 8 | 9 | export const metadata = { 10 | title, 11 | openGraph: { 12 | title, 13 | images: [`/api/og?title=${title}`], 14 | }, 15 | }; 16 | 17 | export default async function Layout({ 18 | children, 19 | }: { 20 | children: React.ReactNode; 21 | }) { 22 | const categories = await getCategories(); 23 | 24 | return ( 25 |
26 |
27 | ({ text: x.name, slug: x.slug })), 32 | ]} 33 | /> 34 | 35 |
36 | 37 |
38 |
39 | 40 | 41 | 42 |
{children}
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /app/_hooks/page.tsx: -------------------------------------------------------------------------------- 1 | import Readme from './readme.mdx'; 2 | import { Prose } from '#/ui/prose'; 3 | 4 | export default function Page() { 5 | return ( 6 |
7 | 8 | 9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/_hooks/readme.mdx: -------------------------------------------------------------------------------- 1 | # Client Component Hooks 2 | 3 | Next.js provides a number of hooks for accessing routing information 4 | from client components. 5 | 6 | Try navigating each page and observing the output of each hook 7 | called from the current routes `layout.js` and `page.js` files. 8 | 9 | ### Links 10 | 11 | - [Docs](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching#revalidating-data) 12 | - [Code](https://github.com/vercel/next-app-router-playground/tree/main/app/hooks) 13 | -------------------------------------------------------------------------------- /app/_hooks/template.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | import React from 'react'; 3 | 4 | export default function Template({ children }: { children: React.ReactNode }) { 5 | return {children}; 6 | } 7 | -------------------------------------------------------------------------------- /app/_internal/demos.ts: -------------------------------------------------------------------------------- 1 | export type Demo = { 2 | slug: string; 3 | name: string; 4 | nav_title?: string; 5 | description: string; 6 | }; 7 | 8 | type Category = { 9 | name: string; 10 | items: Demo[]; 11 | }; 12 | 13 | export const navigation = [ 14 | { 15 | name: 'Layouts', 16 | items: [ 17 | { 18 | slug: 'layouts', 19 | name: 'Nested Layouts', 20 | description: 'Create UI that is shared across routes', 21 | }, 22 | { 23 | slug: 'route-groups', 24 | name: 'Route Groups', 25 | description: 'Organize routes without affecting URL paths', 26 | }, 27 | { 28 | slug: 'parallel-routes', 29 | name: 'Parallel Routes', 30 | description: 'Render multiple pages in the same layout', 31 | }, 32 | ], 33 | }, 34 | { 35 | name: 'File Conventions', 36 | items: [ 37 | { 38 | slug: 'loading', 39 | name: 'Loading', 40 | description: 41 | 'Create meaningful Loading UI for specific parts of an app', 42 | }, 43 | { 44 | slug: 'error', 45 | name: 'Error', 46 | description: 'Create Error UI for specific parts of an app', 47 | }, 48 | { 49 | slug: 'not-found', 50 | name: 'Not Found', 51 | description: 'Create Not Found UI for specific parts of an app', 52 | }, 53 | ], 54 | }, 55 | // { 56 | // name: 'Data', 57 | // items: [ 58 | // { 59 | // slug: 'fetching', 60 | // name: 'Fetching', 61 | // description: '...', 62 | // }, 63 | // { 64 | // slug: 'updating', 65 | // name: 'Updating', 66 | // description: '...', 67 | // }, 68 | // ], 69 | // }, 70 | { 71 | name: 'Caching', 72 | items: [ 73 | { 74 | slug: 'cacheable-routes', 75 | name: 'Cacheable Route Segments', 76 | nav_title: 'Cacheable Routes', 77 | description: 'Cache the rendered output of a route segment', 78 | }, 79 | { 80 | slug: 'cacheable-components', 81 | name: 'Cacheable React Server Components', 82 | nav_title: 'Cacheable Components', 83 | description: 84 | 'Cache the rendered output of an individual React Server Component', 85 | }, 86 | { 87 | slug: 'cacheable-functions', 88 | name: 'Cacheable Functions', 89 | description: 'Cache the computed result of a regular function', 90 | }, 91 | ], 92 | }, 93 | { 94 | name: 'APIs', 95 | items: [ 96 | { 97 | slug: 'use-link-status', 98 | name: 'useLinkStatus', 99 | description: 'Create inline visual feedback for link interactions', 100 | }, 101 | ], 102 | }, 103 | { 104 | name: 'Misc', 105 | items: [ 106 | // { 107 | // slug: 'patterns', 108 | // name: 'Patterns', 109 | // description: 'A collection of useful App Router patterns', 110 | // }, 111 | // { 112 | // slug: 'hooks', 113 | // name: 'Client Component Hooks', 114 | // description: 'Preview the routing hooks available in Client Components', 115 | // }, 116 | { 117 | slug: 'context', 118 | name: 'Client Context', 119 | description: 120 | 'Pass context between Client Components that cross Server/Client Component boundary', 121 | }, 122 | ], 123 | }, 124 | ] as const satisfies Category[]; 125 | 126 | type DemoSlug = (typeof navigation)[number]['items'][number]['slug']; 127 | 128 | export function getDemoMeta(slug: DemoSlug) { 129 | for (const section of navigation) { 130 | const demo = section.items.find((demo) => demo.slug === slug); 131 | if (demo) return demo; 132 | } 133 | throw new Error(`Demo with slug "${slug}" not found`); 134 | } 135 | -------------------------------------------------------------------------------- /app/_internal/readme.md: -------------------------------------------------------------------------------- 1 | # Internal Demo Files 2 | 3 | The `app/_internal` directory contains files that are used to build demo functionality and are not intended to serve as learning materials or examples of best practices. 4 | -------------------------------------------------------------------------------- /app/_patterns/active-links/_components/nav-links.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import { usePathname } from 'next/navigation'; 5 | import clsx from 'clsx'; 6 | 7 | export function NavLinks({ 8 | links, 9 | }: { 10 | links: { href: string; name: string }[]; 11 | }) { 12 | // Alternatively, you could use `useParams` or `useSelectedLayoutSegment(s)` 13 | const pathname = usePathname(); 14 | 15 | return ( 16 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /app/_patterns/active-links/community/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return

Community

; 3 | } 4 | -------------------------------------------------------------------------------- /app/_patterns/active-links/layout.tsx: -------------------------------------------------------------------------------- 1 | import { NavLinks } from '#/app/_patterns/active-links/_components/nav-links'; 2 | import Image from 'next/image'; 3 | import Link from 'next/link'; 4 | 5 | export default function Layout({ children }: { children: React.ReactNode }) { 6 | // Hardcoded links or fetched from db 7 | const links = [ 8 | { href: '/patterns/active-links', name: 'Home' }, 9 | { href: '/patterns/active-links/profile', name: 'Profile' }, 10 | { href: '/patterns/active-links/community', name: 'Community' }, 11 | { href: '/patterns/active-links/settings', name: 'Settings' }, 12 | ]; 13 | 14 | return ( 15 |
16 |
17 | 18 | 19 | User 26 | 27 |
28 |
{children}
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/_patterns/active-links/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return

Home

; 3 | } 4 | -------------------------------------------------------------------------------- /app/_patterns/active-links/profile/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return

Profile

; 3 | } 4 | -------------------------------------------------------------------------------- /app/_patterns/active-links/settings/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return

Settings

; 3 | } 4 | -------------------------------------------------------------------------------- /app/_patterns/breadcrumbs/@breadcrumbs/[...all]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Breadcrumbs } from '#/app/_patterns/breadcrumbs/_components/breadcrumbs'; 2 | 3 | export default async function Page(props: { 4 | params: Promise<{ 5 | all: string[]; 6 | }>; 7 | }) { 8 | const params = await props.params; 9 | 10 | const { all } = params; 11 | 12 | console.log(all); 13 | 14 | // Note: you could fetch breadcrumb data based on params here 15 | // e.g. title, slug, children/siblings (for dropdowns) 16 | const items = [ 17 | ...all.map((param, index) => ({ 18 | text: param, 19 | // build cumulative path by joining all segments up to current index 20 | href: `/${all.slice(0, index + 1).join('/')}`, 21 | })), 22 | ]; 23 | 24 | console.log(items); 25 | return ; 26 | } 27 | -------------------------------------------------------------------------------- /app/_patterns/breadcrumbs/@breadcrumbs/page.tsx: -------------------------------------------------------------------------------- 1 | import { Breadcrumbs } from '#/app/_patterns/breadcrumbs/_components/breadcrumbs'; 2 | 3 | // Note: Next.js doesn't currently support optional catchAll segments in parallel routes. 4 | // In the mean time, this file will match the "/breadcrumb" route. 5 | export default function Page() { 6 | const items = [ 7 | { 8 | text: 'Patterns', 9 | href: '/patterns', 10 | }, 11 | { 12 | text: 'Breadcrumbs', 13 | href: '/patterns/breadcrumbs', 14 | }, 15 | ]; 16 | return ; 17 | } 18 | -------------------------------------------------------------------------------- /app/_patterns/breadcrumbs/[categorySlug]/[subCategorySlug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCategory } from '#/app/api/categories/getCategories'; 2 | import { SkeletonCard } from '#/ui/skeleton-card'; 3 | 4 | export default async function Page(props: { 5 | params: Promise<{ subCategorySlug: string }>; 6 | }) { 7 | const params = await props.params; 8 | const category = await getCategory({ slug: params.subCategorySlug }); 9 | 10 | return ( 11 |
12 |

{category.name}

13 | 14 |
15 | {Array.from({ length: category.count }).map((_, i) => ( 16 | 17 | ))} 18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/_patterns/breadcrumbs/[categorySlug]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getCategories, getCategory } from '#/app/api/categories/getCategories'; 2 | import { Tabs } from '#/ui/tabs'; 3 | 4 | export default async function Layout(props: { 5 | children: React.ReactNode; 6 | params: Promise<{ categorySlug: string }>; 7 | }) { 8 | const params = await props.params; 9 | 10 | const { children } = props; 11 | 12 | const category = await getCategory({ slug: params.categorySlug }); 13 | const categories = await getCategories({ parent: params.categorySlug }); 14 | 15 | return ( 16 |
17 |
18 | ({ text: x.name, slug: x.slug })), 23 | ]} 24 | /> 25 |
26 | 27 |
{children}
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/_patterns/breadcrumbs/[categorySlug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCategory } from '#/app/api/categories/getCategories'; 2 | import { SkeletonCard } from '#/ui/skeleton-card'; 3 | 4 | export default async function Page(props: { 5 | params: Promise<{ categorySlug: string }>; 6 | }) { 7 | const params = await props.params; 8 | const category = await getCategory({ slug: params.categorySlug }); 9 | 10 | return ( 11 |
12 |

13 | All {category.name} 14 |

15 | 16 |
17 | {Array.from({ length: 9 }).map((_, i) => ( 18 | 19 | ))} 20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/_patterns/breadcrumbs/_components/breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronRightIcon } from '@heroicons/react/24/outline'; 2 | import Link from 'next/link'; 3 | import { Fragment } from 'react'; 4 | 5 | export function Breadcrumbs({ 6 | items, 7 | }: { 8 | items: { text: string; href: string }[]; 9 | }) { 10 | return ( 11 |
12 | {items.map((item, i) => { 13 | return ( 14 | 15 | {i === 0 ? null : ( 16 | 17 | )} 18 | 19 | 24 | {item.text} 25 | 26 | 27 | ); 28 | })} 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/_patterns/breadcrumbs/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getCategories } from '#/app/api/categories/getCategories'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { Tabs } from '#/ui/tabs'; 4 | import React from 'react'; 5 | 6 | const title = 'Breadcrumbs with Parallel Routes'; 7 | 8 | export const metadata = { 9 | title, 10 | openGraph: { 11 | title, 12 | images: [`/api/og?title=${title}`], 13 | }, 14 | }; 15 | 16 | export default async function Layout({ 17 | children, 18 | breadcrumbs, 19 | }: { 20 | children: React.ReactNode; 21 | breadcrumbs: React.ReactNode; 22 | }) { 23 | const categories = await getCategories(); 24 | 25 | return ( 26 |
27 | {breadcrumbs} 28 | 29 |
30 | ({ text: x.name, slug: x.slug })), 35 | ]} 36 | /> 37 |
38 | 39 | {children} 40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/_patterns/breadcrumbs/page.tsx: -------------------------------------------------------------------------------- 1 | import Readme from './readme.mdx'; 2 | import { Prose } from '#/ui/prose'; 3 | 4 | export default function Page() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/_patterns/breadcrumbs/readme.mdx: -------------------------------------------------------------------------------- 1 | # Shared server-side UI that depends on URL information 2 | 3 | Typically, when you have shared UI, you'd put it inside a layout. However, layouts do not receive `searchParams` and `params` lower than the segment they exist in. This is a challenge for shared UI like breadcrumbs that depends on the URL information. 4 | 5 | For simple cases, you can move the UI to Client Components and use router hooks such as `usePathname` and `useSearchParams`. 6 | 7 | This example shows how to use Parallel Routes and a `page.js` in a catch all route to have pockets of shared UI across your app. 8 | 9 | ### Demo 10 | 11 | - Try navigating between categories and sub categories. Notice the breadcrumbs can derive URL information. 12 | 13 | ### Links 14 | 15 | - [Docs](https://nextjs.org/docs/app/building-your-application/routing/parallel-routes) 16 | - [Code](https://github.com/vercel/next-app-router-playground/tree/main/app/patterns/breadcrumbs) 17 | -------------------------------------------------------------------------------- /app/_patterns/layout.tsx: -------------------------------------------------------------------------------- 1 | const title = 'Snippets'; 2 | 3 | export const metadata = { 4 | title, 5 | openGraph: { 6 | title, 7 | images: [`/api/og?title=${title}`], 8 | }, 9 | }; 10 | 11 | export default function Layout({ children }: { children: React.ReactNode }) { 12 | return children; 13 | } 14 | -------------------------------------------------------------------------------- /app/_patterns/page.tsx: -------------------------------------------------------------------------------- 1 | import { ExternalLink } from '#/ui/external-link'; 2 | import Link from 'next/link'; 3 | 4 | const items = [ 5 | { 6 | name: 'Active links', 7 | slug: 'active-links', 8 | description: 'Update the style of the current active link', 9 | }, 10 | { 11 | name: 'Breadcrumbs', 12 | slug: 'breadcrumbs', 13 | description: 'Shared server-side Breadcrumb UI using Parallel Routes', 14 | }, 15 | { 16 | name: 'Updating URL search params', 17 | slug: 'search-params', 18 | description: 'Update searchParams using `useRouter` and ``', 19 | }, 20 | ]; 21 | 22 | export default function Page() { 23 | return ( 24 |
25 |

Patterns

26 | 27 |
28 | {items.map((item) => { 29 | return ( 30 | 35 |
36 | {item.name} 37 |
38 | 39 | {item.description ? ( 40 |
41 | {item.description} 42 |
43 | ) : null} 44 | 45 | ); 46 | })} 47 |
48 | 49 |
50 | 51 | Code 52 | 53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/_patterns/search-params/active-link.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import clsx from 'clsx'; 4 | import Link from 'next/link'; 5 | import { usePathname } from 'next/navigation'; 6 | 7 | export default function ActiveLink({ 8 | isActive, 9 | searchParams, 10 | children, 11 | }: { 12 | isActive: boolean; 13 | searchParams: string; 14 | children: React.ReactNode; 15 | }) { 16 | const pathname = usePathname(); 17 | 18 | return ( 19 | 27 | {children} 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/_patterns/search-params/client.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import clsx from 'clsx'; 4 | import { usePathname, useRouter, useSearchParams } from 'next/navigation'; 5 | import { useCallback, useMemo } from 'react'; 6 | 7 | export default function Client({ 8 | options, 9 | }: { 10 | options: { 11 | name: string; 12 | value: string; 13 | items: string[]; 14 | }[]; 15 | }) { 16 | const searchParams = useSearchParams()!; 17 | const pathname = usePathname(); 18 | const router = useRouter(); 19 | 20 | const selectedOptions = useMemo(() => { 21 | // Get the initial selected options from the URL's searchParams 22 | const params = new URLSearchParams(searchParams); 23 | 24 | // Preselect the first value of each option if its not 25 | // included in the current searchParams 26 | options.forEach((option) => { 27 | if (!searchParams.has(option.value)) { 28 | params.set(option.value, option.items[0]); 29 | } 30 | }); 31 | 32 | return params; 33 | }, [searchParams, options]); 34 | 35 | const updateSearchParam = useCallback( 36 | (name: string, value: string) => { 37 | // Merge the current searchParams with the new param set 38 | const params = new URLSearchParams(searchParams); 39 | params.set(name, value); 40 | 41 | // Perform a new navigation to the updated URL. The current `page.js` will 42 | // receive a new `searchParams` prop with the updated values. 43 | router.push(pathname + '?' + params.toString()); // or router.replace() 44 | }, 45 | [router, pathname, searchParams], 46 | ); 47 | 48 | return ( 49 | <> 50 |
51 | {options.map((option) => ( 52 |
53 |
{option.name}
54 | 55 |
56 | {option.items.map((item) => { 57 | const isActive = selectedOptions.get(option.value) === item; 58 | 59 | return ( 60 | 74 | ); 75 | })} 76 |
77 |
78 | ))} 79 |
80 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /app/_patterns/search-params/page.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | import { ExternalLink } from '#/ui/external-link'; 3 | import { Suspense } from 'react'; 4 | import ActiveLink from './active-link'; 5 | import Client from './client'; 6 | 7 | const options = [ 8 | { 9 | name: 'Sort', 10 | value: 'sort', 11 | items: ['asc', 'desc'], 12 | }, 13 | { 14 | name: 'Page', 15 | value: 'page', 16 | items: ['1', '2', '3'], 17 | }, 18 | { 19 | name: 'Items Per Page', 20 | value: 'perPage', 21 | items: ['10', '25', '100'], 22 | }, 23 | ]; 24 | 25 | export const dynamic = 'force-dynamic'; 26 | 27 | export default async function Page(props: { searchParams: Promise }) { 28 | const searchParams = await props.searchParams; 29 | return ( 30 |
31 |

32 | Updating searchParams 33 |

34 |

35 | The useSearchParams hook returns a read only version of{' '} 36 | URLSearchParams. You can use{' '} 37 | useRouter() or <Link> to set new{' '} 38 | searchParams. After a navigation is performed, the current{' '} 39 | page.js will receive an updated searchParams{' '} 40 | prop. 41 |

42 |
43 |
44 | 45 |

46 | Using useRouter() 47 |

48 | 49 | 50 | 51 | 52 |
53 | 54 | 55 | Docs 56 | 57 |
58 | 59 |
60 | 61 |

62 | Using <Link> 63 |

64 | 65 |
66 | {options.map((option) => { 67 | return ( 68 |
69 |
{option.name}
70 | 71 |
72 | {option.items.map((item, i) => { 73 | const isActive = 74 | // set the first item as active if no search param is set 75 | (!searchParams[option.value] && i === 0) || 76 | // otherwise check if the current item is the active one 77 | item === searchParams[option.value]; 78 | 79 | // create new searchParams object for easier manipulation 80 | const params = new URLSearchParams(searchParams); 81 | params.set(option.value, item); 82 | return ( 83 | 88 | {item} 89 | 90 | ); 91 | })} 92 |
93 |
94 | ); 95 | })} 96 |
97 |
98 | 99 | 100 | Docs 101 | 102 |
103 |
104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /app/_streaming/_components/add-to-cart.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRouter } from 'next/navigation'; 4 | import { useTransition } from 'react'; 5 | import { useCartCount } from './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(); 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 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /app/_streaming/_components/cart-count-context.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useState } from 'react'; 4 | 5 | const CartCountContext = React.createContext< 6 | [number, React.Dispatch>] | undefined 7 | >(undefined); 8 | 9 | export function CartCountProvider({ 10 | children, 11 | initialCartCount, 12 | }: { 13 | children: React.ReactNode; 14 | initialCartCount: number; 15 | }) { 16 | const [optimisticCartCount, setOptimisticCartCount] = useState( 17 | null, 18 | ); 19 | 20 | const count = 21 | optimisticCartCount !== null ? optimisticCartCount : initialCartCount; 22 | 23 | return ( 24 | 25 | {children} 26 | 27 | ); 28 | } 29 | 30 | export function useCartCount() { 31 | const context = React.useContext(CartCountContext); 32 | if (context === undefined) { 33 | throw new Error('useCartCount must be used within a CartCountProvider'); 34 | } 35 | return context; 36 | } 37 | -------------------------------------------------------------------------------- /app/_streaming/_components/cart-count.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCartCount } from './cart-count-context'; 4 | 5 | export function CartCount() { 6 | const [count] = useCartCount(); 7 | return {count}; 8 | } 9 | -------------------------------------------------------------------------------- /app/_streaming/_components/header.tsx: -------------------------------------------------------------------------------- 1 | import { NextLogoLight } from '#/ui/next-logo'; 2 | import { 3 | MagnifyingGlassIcon, 4 | ShoppingCartIcon, 5 | } from '@heroicons/react/24/solid'; 6 | import Image from 'next/image'; 7 | import Link from 'next/link'; 8 | import { CartCount } from './cart-count'; 9 | 10 | export function Header() { 11 | return ( 12 |
13 |
14 | 15 |
16 | 17 |
18 | 19 | 20 |
21 |
22 | 23 |
24 | 32 |
33 |
34 | 35 |
36 |
37 | 38 |
39 | 40 |
41 |
42 | 43 | User 50 |
51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /app/_streaming/_components/pricing.tsx: -------------------------------------------------------------------------------- 1 | import type { Product } from '#/app/api/products/product'; 2 | import { Ping } from '#/ui/ping'; 3 | import { ProductEstimatedArrival } from '#/ui/product-estimated-arrival'; 4 | import { ProductLowStockWarning } from '#/ui/product-low-stock-warning'; 5 | import { ProductPrice } from '#/ui/product-price'; 6 | import { ProductSplitPayments } from '#/ui/product-split-payments'; 7 | import { ProductUsedPrice } from '#/ui/product-used-price'; 8 | import { dinero, type DineroSnapshot } from 'dinero.js'; 9 | import { Suspense } from 'react'; 10 | import { AddToCart } from './add-to-cart'; 11 | 12 | function LoadingDots() { 13 | return ( 14 |
15 | 16 | 17 | • 18 | 19 | 20 | • 21 | 22 | 23 | • 24 | 25 | 26 |
27 | ); 28 | } 29 | 30 | async function UserSpecificDetails({ productId }: { productId: string }) { 31 | const data = await fetch( 32 | `https://app-playground-api.vercel.app/api/products?id=${productId}&delay=500&filter=price,usedPrice,leadTime,stock`, 33 | { 34 | // We intentionally disable Next.js Cache to better demo 35 | // streaming 36 | cache: 'no-store', 37 | }, 38 | ); 39 | 40 | const product = (await data.json()) as Product; 41 | 42 | const price = dinero(product.price as DineroSnapshot); 43 | 44 | return ( 45 | <> 46 | 47 | {product.usedPrice ? ( 48 | 49 | ) : null} 50 | 51 | {product.stock <= 1 ? ( 52 | 53 | ) : null} 54 | 55 | ); 56 | } 57 | 58 | export function Pricing({ 59 | product, 60 | cartCount, 61 | }: { 62 | product: Product; 63 | cartCount: string; 64 | }) { 65 | const price = dinero(product.price as DineroSnapshot); 66 | 67 | return ( 68 |
69 | 70 | 71 |
72 |
73 | 74 |
75 |
76 | 77 | }> 78 | 79 | 80 | 81 | 82 |
83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /app/_streaming/_components/recommended-products.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from '#/app/api/products/product'; 2 | import { ProductCard__DEPRECATED } from '#/ui/product-card'; 3 | 4 | export async function RecommendedProducts({ 5 | path, 6 | data, 7 | }: { 8 | path: string; 9 | data: Promise; 10 | }) { 11 | const products = (await data.then((res) => res.json())) as Product[]; 12 | 13 | return ( 14 |
15 |
16 |
17 | Recommended Products for You 18 |
19 |
20 | Based on your preferences and shopping habits 21 |
22 |
23 |
24 | {products.map((product) => ( 25 |
26 | 30 |
31 | ))} 32 |
33 |
34 | ); 35 | } 36 | 37 | const shimmer = `relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-linear-to-r before:from-transparent before:via-white/10 before:to-transparent`; 38 | 39 | function ProductSkeleton() { 40 | return ( 41 |
42 |
43 | 44 |
45 |
46 |
47 |
48 |
49 | ); 50 | } 51 | 52 | export function RecommendedProductsSkeleton() { 53 | return ( 54 |
55 |
56 |
57 |
58 |
59 | 60 |
61 | 62 | 63 | 64 | 65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /app/_streaming/_components/reviews.tsx: -------------------------------------------------------------------------------- 1 | import type { Review } from '#/app/api/reviews/review'; 2 | import { ProductReviewCard } from '#/ui/product-review-card'; 3 | 4 | export async function Reviews({ data }: { data: Promise }) { 5 | const reviews = (await data.then((res) => res.json())) as Review[]; 6 | 7 | return ( 8 |
9 |
Customer Reviews
10 |
11 | {reviews.map((review) => { 12 | return ; 13 | })} 14 |
15 |
16 | ); 17 | } 18 | 19 | const shimmer = `relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-linear-to-r before:from-transparent before:via-white/10 before:to-transparent`; 20 | 21 | function Skeleton() { 22 | return ( 23 |
24 |
25 |
26 |
27 |
28 |
29 | ); 30 | } 31 | 32 | export function ReviewsSkeleton() { 33 | return ( 34 |
35 |
36 | 37 |
38 | 39 | 40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/_streaming/_components/single-product.tsx: -------------------------------------------------------------------------------- 1 | import { Pricing } from '#/app/_streaming/_components/pricing'; 2 | import type { Product } from '#/app/api/products/product'; 3 | import { ProductRating } from '#/ui/product-rating'; 4 | import { cookies } from 'next/headers'; 5 | import Image from 'next/image'; 6 | 7 | export const SingleProduct = async ({ data }: { data: Promise }) => { 8 | const product = (await data.then((res) => res.json())) as Product; 9 | 10 | // Get the cart count from the users cookies and pass it to the client 11 | // AddToCart component 12 | const cartCount = (await cookies()).get('_cart_count')?.value || '0'; 13 | 14 | return ( 15 |
16 |
17 |
18 | {product.name} 25 | 26 |
27 |
28 | {product.name} 35 |
36 |
37 | {product.name} 44 |
45 |
46 | {product.name} 53 |
54 |
55 |
56 |
57 | 58 |
59 |
60 | {product.name} 61 |
62 | 63 | 64 | 65 |
66 |

{product.description}

67 |

{product.description}

68 |
69 |
70 | 71 |
72 | 73 |
74 |
75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /app/_streaming/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs } from '#/ui/tabs'; 2 | import React from 'react'; 3 | 4 | const title = 'Streaming'; 5 | 6 | export const metadata = { 7 | title, 8 | openGraph: { 9 | title, 10 | images: [`/api/og?title=${title}`], 11 | }, 12 | }; 13 | 14 | export default async function Layout({ 15 | children, 16 | }: { 17 | children: React.ReactNode; 18 | }) { 19 | return ( 20 |
21 |
22 | 33 |
34 | 35 |
{children}
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/_streaming/node/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | import { cookies } from 'next/headers'; 3 | import React from 'react'; 4 | import { CartCountProvider } from '../_components/cart-count-context'; 5 | import { Header } from '../_components/header'; 6 | import { Prose } from '#/ui/prose'; 7 | import Readme from './readme.mdx'; 8 | 9 | export const metadata = { 10 | title: 'Streaming (Node Runtime)', 11 | }; 12 | 13 | export default async function Layout({ 14 | children, 15 | }: { 16 | children: React.ReactNode; 17 | }) { 18 | const cartCount = Number((await cookies()).get('_cart_count')?.value || '0'); 19 | 20 | return ( 21 | <> 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 |
30 | 31 | {children} 32 |
33 |
34 |
35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /app/_streaming/node/product/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | RecommendedProducts, 3 | RecommendedProductsSkeleton, 4 | } from '#/app/_streaming/_components/recommended-products'; 5 | import { Reviews, ReviewsSkeleton } from '#/app/_streaming/_components/reviews'; 6 | import { SingleProduct } from '#/app/_streaming/_components/single-product'; 7 | import { Ping } from '#/ui/ping'; 8 | import { Suspense } from 'react'; 9 | 10 | export default async function Page(props: { params: Promise<{ id: string }> }) { 11 | const params = await props.params; 12 | return ( 13 |
14 | 19 | 20 |
21 |
22 | 23 |
24 |
25 | 26 | }> 27 | 40 | 41 | 42 |
43 |
44 | 45 |
46 |
47 | 48 | }> 49 | 61 | 62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /app/_streaming/node/readme.mdx: -------------------------------------------------------------------------------- 1 | - Primary product information is loaded first as part of the initial response. 2 | - Secondary, more personalized details (that might be slower) like ship date, other recommended products, and customer reviews are progressively streamed in. 3 | - Try refreshing or navigating to other recommended products. 4 | -------------------------------------------------------------------------------- /app/_streaming/page.tsx: -------------------------------------------------------------------------------- 1 | import Readme from './readme.mdx'; 2 | import { Prose } from '#/ui/prose'; 3 | 4 | export default async function Page() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/_streaming/readme.mdx: -------------------------------------------------------------------------------- 1 | # Streaming with Suspense 2 | 3 | - Streaming allows you to progressively render and send units of the UI 4 | from the server to the client. 5 | - This allows the user to see and interact with the most essential parts 6 | of the page while the rest of the content loads - instead of waiting 7 | for the whole page to load before they can interact with anything. 8 | 9 | ### Links 10 | 11 | - [Docs](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming) 12 | - [Code](https://github.com/vercel/next-app-router-playground/tree/main/app/streaming) 13 | -------------------------------------------------------------------------------- /app/api/categories/category.d.ts: -------------------------------------------------------------------------------- 1 | export type Category = { 2 | name: string; 3 | slug: string; 4 | count: number; 5 | parent: string | null; 6 | }; 7 | -------------------------------------------------------------------------------- /app/api/categories/getCategories.ts: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import type { Category } from './category'; 3 | 4 | // `server-only` guarantees any modules that import code in file 5 | // will never run on the client. Even though this particular API 6 | // doesn't currently use sensitive environment variables, it's 7 | // good practice to add `server-only` preemptively. 8 | import 'server-only'; 9 | 10 | export async function getCategories({ parent }: { parent?: string } = {}) { 11 | const res = await fetch( 12 | `https://app-playground-api.vercel.app/api/categories${ 13 | parent ? `?parent=${parent}` : '' 14 | }`, 15 | ); 16 | 17 | if (!res.ok) { 18 | // Render the closest `error.js` Error Boundary 19 | throw new Error('Something went wrong!'); 20 | } 21 | 22 | const categories = (await res.json()) as Category[]; 23 | 24 | if (categories.length === 0) { 25 | // Render the closest `not-found.js` Error Boundary 26 | notFound(); 27 | } 28 | 29 | return categories; 30 | } 31 | 32 | export async function getCategory({ slug }: { slug: string }) { 33 | const res = await fetch( 34 | `https://app-playground-api.vercel.app/api/categories${ 35 | slug ? `?slug=${slug}` : '' 36 | }`, 37 | ); 38 | 39 | if (!res.ok) { 40 | // Render the closest `error.js` Error Boundary 41 | throw new Error('Something went wrong!'); 42 | } 43 | 44 | const category = (await res.json()) as Category; 45 | 46 | if (!category) { 47 | // Render the closest `not-found.js` Error Boundary 48 | notFound(); 49 | } 50 | 51 | return category; 52 | } 53 | -------------------------------------------------------------------------------- /app/api/og/Inter-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel/next-app-router-playground/c89a8981d8057b401a3629b71e0ae25a6c85fece/app/api/og/Inter-SemiBold.ttf -------------------------------------------------------------------------------- /app/api/products/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/api/revalidate/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { revalidatePath, revalidateTag } from 'next/cache'; 3 | 4 | export async function GET(request: NextRequest) { 5 | const path = request.nextUrl.searchParams.get('path') || '/isr/[id]'; 6 | const collection = 7 | request.nextUrl.searchParams.get('collection') || 'collection'; 8 | revalidatePath(path); 9 | revalidateTag(collection); 10 | console.log('revalidated', path, collection); 11 | return NextResponse.json({ 12 | revalidated: true, 13 | now: Date.now(), 14 | cache: 'no-store', 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /app/api/reviews/getReviews.ts: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import type { Review } from './review'; 3 | 4 | // `server-only` guarantees any modules that import code in file 5 | // will never run on the client. Even though this particular api 6 | // doesn't currently use sensitive environment variables, it's 7 | // good practise to add `server-only` preemptively. 8 | import 'server-only'; 9 | 10 | export async function getReviews() { 11 | const res = await fetch(`https://app-playground-api.vercel.app/api/reviews`); 12 | 13 | if (!res.ok) { 14 | // Render the closest `error.js` Error Boundary 15 | throw new Error('Something went wrong!'); 16 | } 17 | 18 | const reviews = (await res.json()) as Review[]; 19 | 20 | if (reviews.length === 0) { 21 | // Render the closest `not-found.js` Error Boundary 22 | notFound(); 23 | } 24 | 25 | return reviews; 26 | } 27 | -------------------------------------------------------------------------------- /app/api/reviews/review.d.ts: -------------------------------------------------------------------------------- 1 | export type Review = { 2 | id: string; 3 | name: string; 4 | rating: number; 5 | text: string; 6 | }; 7 | -------------------------------------------------------------------------------- /app/cacheable-components/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getDemoMeta } from '#/app/_internal/demos'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { Mdx } from '#/ui/codehike'; 4 | import React from 'react'; 5 | import readme from './readme.mdx'; 6 | 7 | const demo = getDemoMeta('cacheable-components'); 8 | 9 | export const metadata = { 10 | title: demo.name, 11 | openGraph: { 12 | title: demo.name, 13 | images: [`/api/og?title=${demo.name}`], 14 | }, 15 | }; 16 | 17 | export default async function Layout({ 18 | children, 19 | }: { 20 | children: React.ReactNode; 21 | }) { 22 | return ( 23 | <> 24 | 25 | 26 | 27 | 28 | 33 | {children} 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /app/cacheable-components/page.tsx: -------------------------------------------------------------------------------- 1 | import { getProducts } from '#/app/_internal/data'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { ProductCard } from '#/ui/new/product-card'; 4 | 5 | export default async function Page() { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | async function ProductList() { 14 | 'use cache'; 15 | 16 | // DEMO: Add a delay to simulate a slow data request 17 | await new Promise((resolve) => setTimeout(resolve, 1000)); 18 | 19 | const products = getProducts({ limit: 9 }); 20 | 21 | return ( 22 | 23 |
24 |

25 | All{' '} 26 | 27 | ({products.length}) 28 | 29 |

30 |
31 | {products.map((product) => ( 32 | 37 | ))} 38 |
39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/cacheable-components/readme.mdx: -------------------------------------------------------------------------------- 1 | import { getDemoMeta } from '#/app/_internal/demos'; 2 | import { Grid } from '#/ui/codehike'; 3 | 4 | export const demo = getDemoMeta('cacheable-components'); 5 | 6 | # {demo.name} 7 | 8 | 9 | 10 | # !!col 11 | 12 | - Mark an individual React Server Component as _cacheable_ by adding the `use cache` directive to the top of the function definition. 13 | - When a cacheable component is called with the same inputs, it reuses the cached result if it exists, otherwise it renders and caches the output. 14 | 15 | # !!col 16 | 17 | ```tsx app/page.tsx 18 | async function ProductList() { 19 | // !mark 20 | 'use cache'; 21 | // ... 22 | } 23 | 24 | export default async function Page() { 25 | return ; 26 | } 27 | ``` 28 | 29 | 30 | 31 | ### Demo 32 | 33 | - A product list component is annotated with `use cache`. 34 | - An artificial one second delay is added to make the difference more noticeable. 35 | - Since the component is cacheable, the delay only happens the first time the component is rendered. 36 | - `layout.tsx` and `page.tsx` aren't explicitly annotated with `use cache`, but Next.js infers they're static because they do not use any Dynamic APIs. If they started to, Next.js will guide the developer to either add `use cache` or a `` boundary. 37 | 38 | ### Notes 39 | 40 | - This demo uses the experimental `use cache` directive and describes caching behavior once stable. 41 | -------------------------------------------------------------------------------- /app/cacheable-functions/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getDemoMeta } from '#/app/_internal/demos'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { Mdx } from '#/ui/codehike'; 4 | import React from 'react'; 5 | import readme from './readme.mdx'; 6 | 7 | const demo = getDemoMeta('cacheable-functions'); 8 | 9 | export const metadata = { 10 | title: demo.name, 11 | openGraph: { 12 | title: demo.name, 13 | images: [`/api/og?title=${demo.name}`], 14 | }, 15 | }; 16 | 17 | export default async function Layout({ 18 | children, 19 | }: { 20 | children: React.ReactNode; 21 | }) { 22 | return ( 23 | <> 24 | 25 | 26 | 27 | 28 | 33 | {children} 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /app/cacheable-functions/page.tsx: -------------------------------------------------------------------------------- 1 | import { getProducts } from '#/app/_internal/data'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { ProductCard } from '#/ui/new/product-card'; 4 | 5 | export default async function Page() { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | async function ProductList() { 14 | const products = await getData(); 15 | 16 | return ( 17 | 18 |
19 |

20 | All{' '} 21 | 22 | ({products.length}) 23 | 24 |

25 |
26 | {products.map((product) => ( 27 | 32 | ))} 33 |
34 |
35 |
36 | ); 37 | } 38 | 39 | async function getData() { 40 | 'use cache'; 41 | 42 | // DEMO: Add a delay to simulate a slow data request 43 | await new Promise((resolve) => setTimeout(resolve, 1000)); 44 | 45 | return getProducts({ limit: 9 }); 46 | } 47 | -------------------------------------------------------------------------------- /app/cacheable-functions/readme.mdx: -------------------------------------------------------------------------------- 1 | import { getDemoMeta } from '#/app/_internal/demos'; 2 | import { Grid } from '#/ui/codehike'; 3 | 4 | export const demo = getDemoMeta('cacheable-functions'); 5 | 6 | # {demo.name} 7 | 8 | 9 | 10 | # !!col 11 | 12 | - Mark a regular function as _cacheable_ by adding the `use cache` directive to the top of the function definition. 13 | - When a cacheable function is called with the same inputs, it reuses the cached result if it exists, otherwise it runs and caches the output. 14 | 15 | # !!col 16 | 17 | ```tsx app/page.tsx 18 | async function getData() { 19 | // !mark 20 | 'use cache'; 21 | // ... 22 | } 23 | 24 | async function ProductList() { 25 | const products = await getData(); 26 | // ... 27 | } 28 | 29 | export default async function Page() { 30 | return ; 31 | } 32 | ``` 33 | 34 | 35 | 36 | ### Demo 37 | 38 | - The data fetching function to get the list of products is annotated with `use cache`. 39 | - An artificial one second delay is added to the function to make the difference more noticeable. 40 | - Since the function is cacheable, the delay only happens the first time the function is called. 41 | - `layout.tsx`, `page.tsx` and `` aren't explicitly annotated with `use cache`, but Next.js infers they're static because they do not use any Dynamic APIs. If they started to, Next.js will guide the developer to either add `use cache` or a `` boundary. 42 | 43 | ### Notes 44 | 45 | - This demo uses the experimental `use cache` directive and describes caching behavior once stable. 46 | -------------------------------------------------------------------------------- /app/cacheable-routes/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { getDemoMeta } from '#/app/_internal/demos'; 4 | import { Boundary } from '#/ui/boundary'; 5 | import { Mdx } from '#/ui/codehike'; 6 | import React from 'react'; 7 | import readme from './readme.mdx'; 8 | 9 | const demo = getDemoMeta('cacheable-routes'); 10 | 11 | export const metadata = { 12 | title: demo.name, 13 | openGraph: { 14 | title: demo.name, 15 | images: [`/api/og?title=${demo.name}`], 16 | }, 17 | }; 18 | 19 | export default async function Layout({ 20 | children, 21 | }: { 22 | children: React.ReactNode; 23 | }) { 24 | return ( 25 | <> 26 | 27 | 28 | 29 | 30 | 35 | {children} 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/cacheable-routes/page.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { getProducts } from '#/app/_internal/data'; 4 | import { Boundary } from '#/ui/boundary'; 5 | import { ProductCard } from '#/ui/new/product-card'; 6 | 7 | export default async function Page() { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | 15 | async function ProductList() { 16 | // DEMO: Add a delay to simulate a slow data request 17 | await new Promise((resolve) => setTimeout(resolve, 1000)); 18 | 19 | const products = getProducts({ limit: 9 }); 20 | 21 | return ( 22 | 23 |
24 |

25 | All{' '} 26 | 27 | ({products.length}) 28 | 29 |

30 |
31 | {products.map((product) => ( 32 | 37 | ))} 38 |
39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/cacheable-routes/readme.mdx: -------------------------------------------------------------------------------- 1 | import { getDemoMeta } from '#/app/_internal/demos'; 2 | import { Grid } from '#/ui/codehike'; 3 | 4 | export const demo = getDemoMeta('cacheable-routes'); 5 | 6 | # {demo.name} 7 | 8 | 9 | 10 | # !!col 11 | 12 | Mark a route segment as _cacheable_ by adding the `use cache` directive to the top of the `layout.tsx` or `page.tsx` file. 13 | 14 | # !!col 15 | 16 | ```tsx app/page.tsx 17 | // !mark 18 | 'use cache'; 19 | 20 | export default async function Page() { 21 | // ... 22 | } 23 | ``` 24 | 25 | 26 | 27 | 28 | 29 | # !!col 30 | 31 | #### On the client 32 | 33 | - The router first checks the client cache for a valid cache entry before making a new request to the server. 34 | - Cache entries in the server response update the client cache. 35 | 36 | # !!col 37 | 38 | Client Cache 45 | 46 | 47 | 48 | 49 | 50 | # !!col 51 | 52 | #### On the server 53 | 54 | - The renderer first checks the server cache for a valid cache entry before rendering a new result and updating the server cache. 55 | 56 | # !!col 57 | 58 | Server Cache 65 | 66 | 67 | 68 | 69 | 70 | # !!col 71 | 72 | #### Prerendering 73 | 74 | - A cacheable route segment can be prerendered ahead of time, either at build time or during background revalidation. 75 | - The prerendered result is distributed across a global content delivery network (CDN). 76 | - At runtime, the CDN serves the closest static result to the client. 77 | - `layout.tsx` and `page.tsx` are independently cacheable. This means a `layout.tsx` can be prerendered, while its `page.tsx` can be dynamically rendered at request time. 78 | 79 | # !!col 80 | 81 | Prerendering 88 | 89 | 90 | 91 | ### Demo 92 | 93 | - An artificial one second delay is added to the `page.tsx` to make the difference more obvious. 94 | 95 | ```tsx app/page.tsx 96 | 'use cache'; 97 | 98 | export default async function Page() { 99 | // !mark 100 | await new Promise((resolve) => setTimeout(resolve, 1000)); 101 | 102 | const products = await getProducts(); 103 | // ... 104 | } 105 | ``` 106 | 107 | - Since the whole route is cacheable, this delay only happens the first time the function runs, during prerendering. 108 | 109 | ### Notes 110 | 111 | - This demo uses the experimental `use cache` directive and describes caching behavior once stable. 112 | -------------------------------------------------------------------------------- /app/context/context-click-counter.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCounter } from './counter-context'; 4 | import React from 'react'; 5 | import { Boundary } from '#/ui/boundary'; 6 | 7 | const ContextClickCounter = () => { 8 | const [count, setCount] = useCounter(); 9 | 10 | return ( 11 | 17 | 23 | 24 | ); 25 | }; 26 | 27 | export const Counter = () => { 28 | const [count] = useCounter(); 29 | 30 | return ( 31 | 37 |
38 | {count} Clicks 39 |
40 |
41 | ); 42 | }; 43 | 44 | export default ContextClickCounter; 45 | -------------------------------------------------------------------------------- /app/context/counter-context.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | 5 | const CounterContext = React.createContext< 6 | [number, React.Dispatch>] | undefined 7 | >(undefined); 8 | 9 | export function CounterProvider({ children }: { children: React.ReactNode }) { 10 | const [count, setCount] = React.useState(0); 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | } 17 | 18 | export function useCounter() { 19 | const context = React.useContext(CounterContext); 20 | if (context === undefined) { 21 | throw new Error('useCounter must be used within a CounterProvider'); 22 | } 23 | return context; 24 | } 25 | -------------------------------------------------------------------------------- /app/context/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | import { Mdx } from '#/ui/codehike'; 3 | import { CounterProvider } from 'app/context/counter-context'; 4 | import React from 'react'; 5 | import ContextClickCounter from './context-click-counter'; 6 | import Readme from './readme.mdx'; 7 | const title = 'Client Context'; 8 | 9 | export const metadata = { 10 | title, 11 | openGraph: { 12 | title, 13 | images: [`/api/og?title=${title}`], 14 | }, 15 | }; 16 | 17 | export default async function Layout({ 18 | children, 19 | }: { 20 | children: React.ReactNode; 21 | }) { 22 | return ( 23 | <> 24 | 25 | 26 | 27 | 28 | 33 | 38 | 39 |
40 | 41 | {children} 42 |
43 |
44 |
45 |
46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /app/context/page.tsx: -------------------------------------------------------------------------------- 1 | import { getProducts } from '#/app/_internal/data'; 2 | import ContextClickCounter from '#/app/context/context-click-counter'; 3 | import { Boundary } from '#/ui/boundary'; 4 | import { ProductCard } from '#/ui/new/product-card'; 5 | 6 | export default function Page() { 7 | const products = getProducts({ limit: 9 }); 8 | 9 | return ( 10 | 11 |
12 |

13 | All{' '} 14 | 15 | ({products.length}) 16 | 17 |

18 | 19 | 20 | 21 |
22 | {products.map((product) => ( 23 | 28 | ))} 29 |
30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app/context/readme.mdx: -------------------------------------------------------------------------------- 1 | # Client Context 2 | 3 | Importing a component into a file marked with `'use client'` makes it part of the client bundle. This means you can't import Server Components into Client Components. 4 | 5 | However, you can pass a Server Component as a prop (or child) to a Client Component. This lets you visually nest Server Components inside client-rendered UI. 6 | 7 | With this pattern, you can place a client context provider high in the tree and still nest Server Components inside it. Client Components deeper down can access the shared context—even if a Server Component sits between them. 8 | 9 | ### Demo 10 | 11 | - **Shared State**: Try incrementing the counter and navigating between categories. 12 | 13 | ### Links 14 | 15 | - [Docs](https://nextjs.org/docs/getting-started/react-essentials#context) 16 | - [Code](https://github.com/vercel/next-app-router-playground/tree/main/app/context) 17 | -------------------------------------------------------------------------------- /app/error/[section]/[category]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { notFound } from 'next/navigation'; 4 | import { Boundary } from '#/ui/boundary'; 5 | import { ProductCard } from '#/ui/new/product-card'; 6 | import { 7 | getCategories, 8 | getCategoryBySlug, 9 | getProductsByCategory, 10 | } from '#/app/_internal/data'; 11 | import BuggyButton from '#/app/error/_ui/buggy-button'; 12 | 13 | export async function generateStaticParams() { 14 | return getCategories().map(({ section, slug }) => ({ 15 | section, 16 | category: slug, 17 | })); 18 | } 19 | 20 | export default async function Page({ 21 | params, 22 | }: { 23 | params: Promise<{ section: string; category: string }>; 24 | }) { 25 | const { category: categorySlug } = await params; 26 | const category = getCategoryBySlug(categorySlug); 27 | if (!category) { 28 | notFound(); 29 | } 30 | 31 | const products = getProductsByCategory(category.id); 32 | 33 | return ( 34 | 35 |
36 |
37 |

38 | All{' '} 39 | 40 | ({products.length}) 41 | 42 |

43 | 44 |
45 | 46 |
47 |
48 | 49 |
50 | {products.map((product) => ( 51 | 52 | ))} 53 |
54 |
55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /app/error/[section]/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Boundary } from '#/ui/boundary'; 4 | import Button from '#/ui/button'; 5 | import React from 'react'; 6 | 7 | export default function Error({ error, reset }: any) { 8 | React.useEffect(() => { 9 | console.log('logging error:', error); 10 | }, [error]); 11 | 12 | return ( 13 | 14 |
15 |

Error

16 |
{error?.message}
17 |
18 | 19 |
20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/error/[section]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import { getDemoMeta } from '#/app/_internal/demos'; 3 | import { getCategoriesBySection, getSectionBySlug } from '#/app/_internal/data'; 4 | import { Boundary } from '#/ui/boundary'; 5 | import { Tabs } from '#/ui/tabs'; 6 | 7 | export default async function Layout({ 8 | params, 9 | children, 10 | }: { 11 | children: React.ReactNode; 12 | params: Promise<{ section: string }>; 13 | }) { 14 | const { section: sectionSlug } = await params; 15 | const section = getSectionBySlug(sectionSlug); 16 | if (!section) { 17 | notFound(); 18 | } 19 | 20 | const demo = getDemoMeta('error'); 21 | const categories = getCategoriesBySection(section?.id); 22 | 23 | return ( 24 | 25 | ({ text: x.name, slug: x.slug })), 30 | ]} 31 | /> 32 | 33 |
{children}
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/error/[section]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { notFound } from 'next/navigation'; 4 | import { 5 | getProductsBySection, 6 | getSectionBySlug, 7 | getSections, 8 | } from '#/app/_internal/data'; 9 | import { Boundary } from '#/ui/boundary'; 10 | import { ProductCard } from '#/ui/new/product-card'; 11 | import BuggyButton from '#/app/error/_ui/buggy-button'; 12 | 13 | export async function generateStaticParams() { 14 | return getSections().map(({ slug }) => ({ section: slug })); 15 | } 16 | 17 | export default async function Page({ 18 | params, 19 | }: { 20 | params: Promise<{ section: string }>; 21 | }) { 22 | const { section: sectionSlug } = await params; 23 | const section = getSectionBySlug(sectionSlug); 24 | if (!section) { 25 | notFound(); 26 | } 27 | 28 | const products = getProductsBySection(section?.id); 29 | 30 | return ( 31 | 32 |
33 |
34 |

35 | All{' '} 36 | 37 | ({products.length}) 38 | 39 |

40 | 41 |
42 | 43 |
44 |
45 | 46 |
47 | {products.map((product) => ( 48 | 49 | ))} 50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /app/error/_ui/buggy-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Button from '#/ui/button'; 4 | import React from 'react'; 5 | 6 | export default function BuggyButton() { 7 | const [clicked, setClicked] = React.useState(false); 8 | 9 | if (clicked) { 10 | throw new Error('Oh no! Something went wrong.'); 11 | } 12 | 13 | return ( 14 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/error/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Boundary } from '#/ui/boundary'; 4 | import Button from '#/ui/button'; 5 | import React from 'react'; 6 | 7 | export default function Error({ error, reset }: any) { 8 | React.useEffect(() => { 9 | console.log('logging error:', error); 10 | }, [error]); 11 | 12 | return ( 13 | 14 |
15 |

Error

16 |
{error?.message}
17 |
18 | 19 |
20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/error/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getSections } from '#/app/_internal/data'; 2 | import { getDemoMeta } from '#/app/_internal/demos'; 3 | import { Boundary } from '#/ui/boundary'; 4 | import { Prose } from '#/ui/prose'; 5 | import { Tabs } from '#/ui/tabs'; 6 | import { type Metadata } from 'next'; 7 | import React from 'react'; 8 | import Readme from './readme.mdx'; 9 | import { Mdx } from '#/ui/codehike'; 10 | 11 | export async function generateMetadata(): Promise { 12 | const demo = getDemoMeta('error'); 13 | 14 | return { 15 | title: demo.name, 16 | openGraph: { 17 | title: demo.name, 18 | images: [`/api/og?title=${demo.name}`], 19 | }, 20 | }; 21 | } 22 | 23 | export default async function Layout({ 24 | children, 25 | }: { 26 | children: React.ReactNode; 27 | }) { 28 | const demo = getDemoMeta('error'); 29 | const sections = getSections(); 30 | 31 | return ( 32 | <> 33 | 34 | 35 | 36 | 37 | 43 | ({ 48 | text: x.name, 49 | slug: x.slug, 50 | })), 51 | ]} 52 | /> 53 | 54 | {children} 55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /app/error/page.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { getProducts } from '#/app/_internal/data'; 4 | import { Boundary } from '#/ui/boundary'; 5 | import BuggyButton from '#/app/error/_ui/buggy-button'; 6 | import { ProductCard } from '#/ui/new/product-card'; 7 | 8 | export default async function Page() { 9 | const products = getProducts({ limit: 9 }); 10 | 11 | return ( 12 | 13 |
14 |
15 |

16 | All{' '} 17 | 18 | ({products.length}) 19 | 20 |

21 | 22 |
23 | 24 |
25 |
26 |
27 | {products.map((product) => ( 28 | 29 | ))} 30 |
31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/error/readme.mdx: -------------------------------------------------------------------------------- 1 | # Error.js 2 | 3 | `error.js` is a file convention that lets you define fallback UI for a route segment to show when unexpected runtime errors occur. 4 | 5 | When an error is thrown in a route segment or its children, the fallback UI from `error.js` renders instead. The rest of the application remains interactive and the user can attempt to reset or recover from the error. 6 | 7 | Use filesytem hierarchy to define more or less specific fallback UI. 8 | 9 | ### Demo 10 | 11 | - Navigate through the categories and trigger an error in one of the nested layouts. 12 | - **Error containment**: Error boundaries confine unexpected runtime errors to their segment, preventing them from affecting the rest of the app. 13 | 14 | ### Links 15 | 16 | - [Docs](https://nextjs.org/docs/app/building-your-application/routing/error-handling) 17 | - [Code](https://github.com/vercel/next-app-router-playground/tree/main/app/error-handling) 18 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel/next-app-router-playground/c89a8981d8057b401a3629b71e0ae25a6c85fece/app/favicon.ico -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '#/styles/globals.css'; 2 | import Byline from '#/ui/byline'; 3 | import { GlobalNav } from '#/ui/global-nav'; 4 | import { Metadata } from 'next'; 5 | import { Geist, Geist_Mono } from 'next/font/google'; 6 | 7 | const geistSans = Geist({ 8 | variable: '--font-geist-sans', 9 | subsets: ['latin'], 10 | }); 11 | 12 | const geistMono = Geist_Mono({ 13 | variable: '--font-geist-mono', 14 | subsets: ['latin'], 15 | }); 16 | 17 | export const metadata: Metadata = { 18 | title: { 19 | default: 'Next.js Playground', 20 | template: '%s | Next.js Playground', 21 | }, 22 | metadataBase: new URL('https://app-router.vercel.app'), 23 | description: 24 | 'A playground to explore Next.js features such as nested layouts, instant loading states, streaming, and component level data fetching.', 25 | openGraph: { 26 | title: 'Next.js Playground', 27 | description: 28 | 'A playground to explore Next.js features such as nested layouts, instant loading states, streaming, and component level data fetching.', 29 | images: [`/api/og?title=Next.js Playground`], 30 | }, 31 | twitter: { 32 | card: 'summary_large_image', 33 | }, 34 | }; 35 | 36 | export default function RootLayout({ 37 | children, 38 | }: { 39 | children: React.ReactNode; 40 | }) { 41 | return ( 42 | 43 | 46 |
47 | 48 |
49 | 50 |
51 |
52 | {children} 53 | 54 | 55 |
56 |
57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /app/layouts/[section]/[category]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { notFound } from 'next/navigation'; 4 | import { Boundary } from '#/ui/boundary'; 5 | import { ProductCard } from '#/ui/new/product-card'; 6 | import { 7 | getCategories, 8 | getCategoryBySlug, 9 | getProductsByCategory, 10 | } from '#/app/_internal/data'; 11 | 12 | export async function generateStaticParams() { 13 | return getCategories().map(({ section, slug }) => ({ 14 | section, 15 | category: slug, 16 | })); 17 | } 18 | 19 | export default async function Page({ 20 | params, 21 | }: { 22 | params: Promise<{ section: string; category: string }>; 23 | }) { 24 | const { category: categorySlug } = await params; 25 | const category = getCategoryBySlug(categorySlug); 26 | if (!category) { 27 | notFound(); 28 | } 29 | 30 | const products = getProductsByCategory(category.id); 31 | 32 | return ( 33 | 34 |
35 |

36 | All{' '} 37 | 38 | ({products.length}) 39 | 40 |

41 | 42 |
43 | {products.map((product) => ( 44 | 45 | ))} 46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/layouts/[section]/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { notFound } from 'next/navigation'; 4 | import { getDemoMeta } from '#/app/_internal/demos'; 5 | import { getCategoriesBySection, getSectionBySlug } from '#/app/_internal/data'; 6 | import { Boundary } from '#/ui/boundary'; 7 | import { Tabs } from '#/ui/tabs'; 8 | 9 | export default async function Layout({ 10 | params, 11 | children, 12 | }: { 13 | children: React.ReactNode; 14 | params: Promise<{ section: string }>; 15 | }) { 16 | const { section: sectionSlug } = await params; 17 | const section = getSectionBySlug(sectionSlug); 18 | if (!section) { 19 | notFound(); 20 | } 21 | 22 | const demo = getDemoMeta('layouts'); 23 | const categories = getCategoriesBySection(section?.id); 24 | 25 | return ( 26 | 27 | ({ text: x.name, slug: x.slug })), 32 | ]} 33 | /> 34 | 35 |
{children}
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/layouts/[section]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { notFound } from 'next/navigation'; 4 | import { 5 | getProductsBySection, 6 | getSectionBySlug, 7 | getSections, 8 | } from '#/app/_internal/data'; 9 | import { Boundary } from '#/ui/boundary'; 10 | import { ProductCard } from '#/ui/new/product-card'; 11 | 12 | export async function generateStaticParams() { 13 | return getSections().map(({ slug }) => ({ section: slug })); 14 | } 15 | 16 | export default async function Page({ 17 | params, 18 | }: { 19 | params: Promise<{ section: string }>; 20 | }) { 21 | const { section: sectionSlug } = await params; 22 | const section = getSectionBySlug(sectionSlug); 23 | if (!section) { 24 | notFound(); 25 | } 26 | 27 | const products = getProductsBySection(section?.id); 28 | 29 | return ( 30 | 31 |
32 |

33 | All{' '} 34 | 35 | ({products.length}) 36 | 37 |

38 | 39 |
40 | {products.map((product) => ( 41 | 42 | ))} 43 |
44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/layouts/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import React from 'react'; 4 | import { getSections } from '#/app/_internal/data'; 5 | import { getDemoMeta } from '#/app/_internal/demos'; 6 | import { Boundary } from '#/ui/boundary'; 7 | import { ClickCounter } from '#/ui/click-counter'; 8 | import { Tabs } from '#/ui/tabs'; 9 | import { type Metadata } from 'next'; 10 | import { Mdx } from '#/ui/codehike'; 11 | import readme from './readme.mdx'; 12 | 13 | export async function generateMetadata(): Promise { 14 | const demo = getDemoMeta('layouts'); 15 | 16 | return { 17 | title: demo.name, 18 | openGraph: { 19 | title: demo.name, 20 | images: [`/api/og?title=${demo.name}`], 21 | }, 22 | }; 23 | } 24 | 25 | export default async function Layout({ 26 | children, 27 | }: { 28 | children: React.ReactNode; 29 | }) { 30 | const demo = getDemoMeta('layouts'); 31 | const sections = getSections(); 32 | 33 | return ( 34 | <> 35 | 36 | 37 | 38 | 44 |
45 | ({ 50 | text: x.name, 51 | slug: x.slug, 52 | })), 53 | ]} 54 | /> 55 | 56 |
57 | 58 |
59 |
60 | 61 | {children} 62 |
63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /app/layouts/page.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { getProducts } from '#/app/_internal/data'; 4 | import { Boundary } from '#/ui/boundary'; 5 | import { ProductCard } from '#/ui/new/product-card'; 6 | 7 | export default async function Page() { 8 | const products = getProducts({ limit: 9 }); 9 | 10 | return ( 11 | 12 |
13 |

14 | All{' '} 15 | 16 | ({products.length}) 17 | 18 |

19 |
20 | {products.map((product) => ( 21 | 22 | ))} 23 |
24 |
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /app/layouts/readme.mdx: -------------------------------------------------------------------------------- 1 | # Nested layouts 2 | 3 | A layout is UI that is shared between multiple routes. On navigation, layouts preserve state, remain interactive, and do not re-render. 4 | 5 | ### Demo 6 | 7 | 1. Try navigating between categories and sub categories above. 8 | 2. Notice layouts do not re-render (visualized as the a pink border animating to gray) when navigating across sibling routes. 9 | 3. Notice client state (visualized as a click counter) is preserved between navigations. 10 | 4. Notice you can interact (e.g. click the counter) with the layout while a sibling route is loading. 11 | 12 | ### Links 13 | 14 | - [Docs](https://nextjs.org/docs/app/getting-started/layouts-and-pages) 15 | - [Code](https://github.com/vercel/next-app-router-playground/tree/main/app/layouts) 16 | -------------------------------------------------------------------------------- /app/loading/[section]/[category]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import { connection } from 'next/server'; 3 | import { Boundary } from '#/ui/boundary'; 4 | import { ProductCard } from '#/ui/new/product-card'; 5 | import { getCategoryBySlug, getProductsByCategory } from '#/app/_internal/data'; 6 | 7 | export default async function Page({ 8 | params, 9 | }: { 10 | params: Promise<{ section: string; category: string }>; 11 | }) { 12 | // DEMO: 13 | // This page would normally be prerendered at build time because it doesn't use dynamic APIs. 14 | // That means the loading state wouldn't show. To force one: 15 | // 1. We indicate that we require a user Request before continuing: 16 | await connection(); 17 | // 2. Add an artificial delay to make the loading state more noticeable: 18 | await new Promise((resolve) => setTimeout(resolve, 1000)); 19 | 20 | const { category: categorySlug } = await params; 21 | const category = getCategoryBySlug(categorySlug); 22 | if (!category) { 23 | notFound(); 24 | } 25 | 26 | const products = getProductsByCategory(category.id); 27 | 28 | return ( 29 | 30 |
31 |

32 | All{' '} 33 | 34 | ({products.length}) 35 | 36 |

37 | 38 |
39 | {products.map((product) => ( 40 | 45 | ))} 46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/loading/[section]/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { notFound } from 'next/navigation'; 4 | import { getDemoMeta } from '#/app/_internal/demos'; 5 | import { getCategoriesBySection, getSectionBySlug } from '#/app/_internal/data'; 6 | import { Boundary } from '#/ui/boundary'; 7 | import { Tabs } from '#/ui/tabs'; 8 | 9 | export default async function Layout({ 10 | params, 11 | children, 12 | }: { 13 | children: React.ReactNode; 14 | params: Promise<{ section: string }>; 15 | }) { 16 | const { section: sectionSlug } = await params; 17 | const section = getSectionBySlug(sectionSlug); 18 | if (!section) { 19 | notFound(); 20 | } 21 | 22 | const demo = getDemoMeta('loading'); 23 | const categories = getCategoriesBySection(section?.id); 24 | 25 | return ( 26 | 27 | ({ text: x.name, slug: x.slug })), 32 | ]} 33 | /> 34 | 35 |
{children}
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/loading/[section]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | import { ProductCardSkeleton } from '#/ui/new/product-card'; 3 | 4 | export default function Loading() { 5 | return ( 6 | 7 |
8 |

All

9 | 10 |
11 | 12 | 13 | 14 |
15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/loading/[section]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import { getProductsBySection, getSectionBySlug } from '#/app/_internal/data'; 3 | import { Boundary } from '#/ui/boundary'; 4 | import { ProductCard } from '#/ui/new/product-card'; 5 | import { connection } from 'next/server'; 6 | 7 | export default async function Page({ 8 | params, 9 | }: { 10 | params: Promise<{ section: string }>; 11 | }) { 12 | // DEMO: 13 | // This page would normally be prerendered at build time because it doesn't use dynamic APIs. 14 | // That means the loading state wouldn't show. To force one: 15 | // 1. We indicate that we require a user Request before continuing: 16 | await connection(); 17 | // 2. Add an artificial delay to make the loading state more noticeable: 18 | await new Promise((resolve) => setTimeout(resolve, 1000)); 19 | 20 | const { section: sectionSlug } = await params; 21 | const section = getSectionBySlug(sectionSlug); 22 | if (!section) { 23 | notFound(); 24 | } 25 | 26 | const products = getProductsBySection(section?.id); 27 | 28 | return ( 29 | 30 |
31 |

32 | All{' '} 33 | 34 | ({products.length}) 35 | 36 |

37 | 38 |
39 | {products.map((product) => ( 40 | 45 | ))} 46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/loading/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { getSections } from '#/app/_internal/data'; 4 | import { getDemoMeta } from '#/app/_internal/demos'; 5 | import { Boundary } from '#/ui/boundary'; 6 | import { Mdx } from '#/ui/codehike'; 7 | import { Tabs } from '#/ui/tabs'; 8 | import { type Metadata } from 'next'; 9 | import Readme from './readme.mdx'; 10 | 11 | export async function generateMetadata(): Promise { 12 | const demo = getDemoMeta('loading'); 13 | 14 | return { 15 | title: demo.name, 16 | openGraph: { 17 | title: demo.name, 18 | images: [`/api/og?title=${demo.name}`], 19 | }, 20 | }; 21 | } 22 | 23 | export default async function Layout({ 24 | children, 25 | }: { 26 | children: React.ReactNode; 27 | }) { 28 | const demo = getDemoMeta('loading'); 29 | const sections = getSections(); 30 | 31 | return ( 32 | <> 33 | 34 | 35 | 36 | 37 | 43 | ({ 48 | text: x.name, 49 | slug: x.slug, 50 | })), 51 | ]} 52 | /> 53 | 54 | {children} 55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /app/loading/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | import { ProductCardSkeleton } from '#/ui/new/product-card'; 3 | 4 | export default function Loading() { 5 | return ( 6 | 7 |
8 |

All

9 | 10 |
11 | 12 | 13 | 14 |
15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/loading/page.tsx: -------------------------------------------------------------------------------- 1 | import { getProducts } from '#/app/_internal/data'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { ProductCard } from '#/ui/new/product-card'; 4 | import { connection } from 'next/server'; 5 | 6 | export default async function Page() { 7 | // DEMO: 8 | // This page would normally be prerendered at build time because it doesn't use dynamic APIs. 9 | // That means the loading state wouldn't show. To force one: 10 | // 1. We indicate that we require a user Request before continuing: 11 | await connection(); 12 | // 2. Add an artificial delay to make the loading state more noticeable: 13 | await new Promise((resolve) => setTimeout(resolve, 1000)); 14 | 15 | const products = getProducts({ limit: 9 }); 16 | 17 | return ( 18 | 19 |
20 |

21 | All{' '} 22 | 23 | ({products.length}) 24 | 25 |

26 |
27 | {products.map((product) => ( 28 | 33 | ))} 34 |
35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/loading/readme.mdx: -------------------------------------------------------------------------------- 1 | # Loading.js 2 | 3 | `loading.js` is a file convention that lets you define fallback UI for a route segment when it's loading, enabling prefetching and instant navigation for dynamic routes. 4 | 5 | When a user navigates to a dynamic route with prefetched fallback UI, the URL updates immediately and the loading state is shown while the segment loads. 6 | 7 | Use filesytem hierarchy to define more or less specific fallback UI. 8 | 9 | ### Demo 10 | 11 | - Navigate between categories using the menu above. 12 | - **Instant navigation**: Even though the new route segment is still rendering on the server, we can instantly navigate (update the url and render fallback UI) to the new route. The actual segment content streams in once rendering is complete. 13 | 14 | #### Notes 15 | 16 | - An artificial delay is added to pages to simulate a slow data request and make the loading state more noticeable. 17 | 18 | ### Links 19 | 20 | - [Docs](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming) 21 | - [Code](https://github.com/vercel/next-app-router-playground/tree/main/app/loading) 22 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | 3 | export default function NotFound() { 4 | return ( 5 | 6 |
7 |

Not Found

8 |

Could not find requested resource

9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/not-found/[section]/[category]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { 4 | getCategories, 5 | getCategoryBySlug, 6 | getProductsByCategory, 7 | } from '#/app/_internal/data'; 8 | import { Boundary } from '#/ui/boundary'; 9 | import { ProductCard } from '#/ui/new/product-card'; 10 | import { notFound } from 'next/navigation'; 11 | 12 | export async function generateStaticParams() { 13 | return getCategories().map(({ section, slug }) => ({ 14 | section, 15 | category: slug, 16 | })); 17 | } 18 | 19 | export default async function Page({ 20 | params, 21 | }: { 22 | params: Promise<{ section: string; category: string }>; 23 | }) { 24 | const { category: categorySlug } = await params; 25 | const category = getCategoryBySlug(categorySlug); 26 | if (!category) { 27 | notFound(); 28 | } 29 | 30 | const products = getProductsByCategory(category.id); 31 | 32 | return ( 33 | 34 |
35 |

36 | All{' '} 37 | 38 | ({products.length}) 39 | 40 |

41 | 42 |
43 | {products.map((product) => ( 44 | 45 | ))} 46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/not-found/[section]/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { notFound } from 'next/navigation'; 4 | import { getDemoMeta } from '#/app/_internal/demos'; 5 | import { getCategoriesBySection, getSectionBySlug } from '#/app/_internal/data'; 6 | import { Boundary } from '#/ui/boundary'; 7 | import { Tabs } from '#/ui/tabs'; 8 | 9 | export default async function Layout({ 10 | params, 11 | children, 12 | }: { 13 | children: React.ReactNode; 14 | params: Promise<{ section: string }>; 15 | }) { 16 | const { section: sectionSlug } = await params; 17 | const section = getSectionBySlug(sectionSlug); 18 | if (!section) { 19 | notFound(); 20 | } 21 | 22 | const demo = getDemoMeta('not-found'); 23 | const categories = getCategoriesBySection(section?.id); 24 | 25 | return ( 26 | 27 | ({ text: x.name, slug: x.slug })), 32 | { text: 'Does Not Exist', slug: 'does-not-exist' }, 33 | ]} 34 | /> 35 | 36 |
{children}
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/not-found/[section]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { getDemoMeta } from '#/app/_internal/demos'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { Tab } from '#/ui/tabs'; 4 | 5 | export default function Loading() { 6 | const demo = getDemoMeta('not-found'); 7 | 8 | return ( 9 | 10 |
11 |

Not Found

12 |
13 | Sorry, the requested resource could not be found 14 |
15 |
16 | 17 |
18 | 19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app/not-found/[section]/page.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { 4 | getProductsBySection, 5 | getSectionBySlug, 6 | getSections, 7 | } from '#/app/_internal/data'; 8 | import { Boundary } from '#/ui/boundary'; 9 | import { ProductCard } from '#/ui/new/product-card'; 10 | import { notFound } from 'next/navigation'; 11 | 12 | export async function generateStaticParams() { 13 | return getSections().map(({ slug }) => ({ section: slug })); 14 | } 15 | 16 | export default async function Page({ 17 | params, 18 | }: { 19 | params: Promise<{ section: string }>; 20 | }) { 21 | const { section: sectionSlug } = await params; 22 | const section = getSectionBySlug(sectionSlug); 23 | if (!section) { 24 | notFound(); 25 | } 26 | 27 | const products = getProductsBySection(section?.id); 28 | 29 | return ( 30 | 31 |
32 |

33 | All{' '} 34 | 35 | ({products.length}) 36 | 37 |

38 | 39 |
40 | {products.map((product) => ( 41 | 42 | ))} 43 |
44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/not-found/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { getSections } from '#/app/_internal/data'; 4 | import { getDemoMeta } from '#/app/_internal/demos'; 5 | import { Boundary } from '#/ui/boundary'; 6 | import { Prose } from '#/ui/prose'; 7 | import { Tabs } from '#/ui/tabs'; 8 | import { type Metadata } from 'next'; 9 | import React from 'react'; 10 | import Readme from './readme.mdx'; 11 | import { Mdx } from '#/ui/codehike'; 12 | 13 | export async function generateMetadata(): Promise { 14 | const demo = getDemoMeta('not-found'); 15 | 16 | return { 17 | title: demo.name, 18 | openGraph: { 19 | title: demo.name, 20 | images: [`/api/og?title=${demo.name}`], 21 | }, 22 | }; 23 | } 24 | 25 | export default async function Layout({ 26 | children, 27 | }: { 28 | children: React.ReactNode; 29 | }) { 30 | const demo = getDemoMeta('not-found'); 31 | const sections = getSections().slice(0, 1); 32 | 33 | return ( 34 | <> 35 | 36 | 37 | 38 | 39 | 45 | ({ 50 | text: x.name, 51 | slug: x.slug, 52 | })), 53 | { text: 'Does Not Exist', slug: 'does-not-exist' }, 54 | ]} 55 | /> 56 | 57 | {children} 58 | 59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /app/not-found/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { getDemoMeta } from '#/app/_internal/demos'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { Tab } from '#/ui/tabs'; 4 | 5 | export default function Loading() { 6 | const demo = getDemoMeta('not-found'); 7 | 8 | return ( 9 | 10 |
11 |

Not Found

12 |
13 | Sorry, the requested resource could not be found 14 |
15 |
16 | 17 |
18 | 19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app/not-found/page.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { getProducts } from '#/app/_internal/data'; 4 | import { Boundary } from '#/ui/boundary'; 5 | import { ProductCard } from '#/ui/new/product-card'; 6 | 7 | export default async function Page() { 8 | const products = getProducts({ limit: 9 }); 9 | 10 | return ( 11 | 12 |
13 |

14 | All{' '} 15 | 16 | ({products.length}) 17 | 18 |

19 | 20 |
21 | {products.map((product) => ( 22 | 23 | ))} 24 |
25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/not-found/readme.mdx: -------------------------------------------------------------------------------- 1 | # Not Found 2 | 3 | `not-found.js` is a file convention that lets you define fallback UI for a route segment when the `notFound()` function is thrown or an unmatched URL is visited. 4 | 5 | Use filesytem hierarchy to define more or less specific fallback UI. 6 | 7 | ### Demo 8 | 9 | - Navigate to non-existent categories or sub-categories: 10 | - [Non-existent Category](/not-found/does-not-exist) 11 | - [Non-existent Sub-category](/not-found/electronics/does-not-exist) 12 | 13 | ### Links 14 | 15 | - [Docs](https://nextjs.org/docs/app/api-reference/file-conventions/not-found) 16 | - [Code](https://github.com/vercel/next-app-router-playground/tree/main/app/not-found) 17 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { navigation } from '#/app/_internal/demos'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { LinkStatus } from '#/ui/link-status'; 4 | import Link from 'next/link'; 5 | 6 | export default function Page() { 7 | return ( 8 | 14 | {navigation.map((section) => { 15 | return ( 16 |
17 |
18 | {section.name} 19 |
20 | 21 |
22 | {section.items.map((item) => { 23 | return ( 24 | 29 |
30 | {item.name} 31 |
32 | 33 | {item.description ? ( 34 |
35 | {item.description} 36 |
37 | ) : null} 38 | 39 | ); 40 | })} 41 |
42 |
43 | ); 44 | })} 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/parallel-routes/@audience/default.tsx: -------------------------------------------------------------------------------- 1 | import { getDemoMeta } from '#/app/_internal/demos'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { Tab } from '#/ui/tabs'; 4 | 5 | export default function Default() { 6 | const demo = getDemoMeta('parallel-routes'); 7 | return ( 8 | 13 |

Default

14 | 15 |
16 |
17 |
18 |
19 | 20 |
21 | 22 |
23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /app/parallel-routes/@audience/demographics/page.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | 3 | export default function Page() { 4 | return ( 5 | 10 |

11 | Audience demographics stats 12 |

13 | 14 |
15 |
16 |
17 |
18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/parallel-routes/@audience/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getDemoMeta } from '#/app/_internal/demos'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { Tabs } from '#/ui/tabs'; 4 | 5 | export default function Layout({ children }: { children: React.ReactNode }) { 6 | const demo = getDemoMeta('parallel-routes'); 7 | 8 | return ( 9 | 14 | 28 | 29 | {children} 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/parallel-routes/@audience/page.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | 3 | export default function Page() { 4 | return ( 5 | 10 |

Audience stats

11 | 12 |
13 |
14 |
15 |
16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/parallel-routes/@audience/subscribers/page.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | 3 | export default function Page() { 4 | return ( 5 | 10 |

Audience subscriber stats

11 | 12 |
13 |
14 |
15 |
16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/parallel-routes/@views/default.tsx: -------------------------------------------------------------------------------- 1 | import { getDemoMeta } from '#/app/_internal/demos'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { Tab } from '#/ui/tabs'; 4 | 5 | export default function Default() { 6 | const demo = getDemoMeta('parallel-routes'); 7 | return ( 8 | 13 |

Default

14 | 15 |
16 |
17 |
18 |
19 | 20 |
21 | 22 |
23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /app/parallel-routes/@views/impressions/page.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | 3 | export default function Page() { 4 | return ( 5 | 10 |

View impression stats

11 | 12 |
13 |
14 |
15 |
16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/parallel-routes/@views/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getDemoMeta } from '#/app/_internal/demos'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { Tabs } from '#/ui/tabs'; 4 | 5 | export default function Layout({ children }: { children: React.ReactNode }) { 6 | const demo = getDemoMeta('parallel-routes'); 7 | 8 | return ( 9 | 14 | 28 | {children} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/parallel-routes/@views/page.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | 3 | export default function Page() { 4 | return ( 5 | 10 |

View stats

11 | 12 |
13 |
14 |
15 |
16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/parallel-routes/@views/view-duration/page.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | 3 | export default function Page() { 4 | return ( 5 | 10 |

View duration stats

11 | 12 |
13 |
14 |
15 |
16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/parallel-routes/_ui/current-route.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { usePathname } from 'next/navigation'; 4 | 5 | export function CurrentRoute({ slice = 2 }: { slice?: number }) { 6 | const pathname = usePathname(); 7 | 8 | return <>{pathname?.split('/').slice(slice).join('/')}; 9 | } 10 | -------------------------------------------------------------------------------- /app/parallel-routes/default.tsx: -------------------------------------------------------------------------------- 1 | import { getDemoMeta } from '#/app/_internal/demos'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { Tab } from '#/ui/tabs'; 4 | 5 | export default function Default() { 6 | const demo = getDemoMeta('parallel-routes'); 7 | return ( 8 | 9 |

Default

10 | 11 |
12 |
13 |
14 |
15 | 16 |
17 | 18 |
19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/parallel-routes/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { getDemoMeta } from '#/app/_internal/demos'; 4 | import { Boundary } from '#/ui/boundary'; 5 | import { Mdx } from '#/ui/codehike'; 6 | import { type Metadata } from 'next'; 7 | import React from 'react'; 8 | import readme from './readme.mdx'; 9 | 10 | export async function generateMetadata(): Promise { 11 | const demo = getDemoMeta('parallel-routes'); 12 | 13 | return { 14 | title: demo.name, 15 | openGraph: { 16 | title: demo.name, 17 | images: [`/api/og?title=${demo.name}`], 18 | }, 19 | }; 20 | } 21 | 22 | export default async function Layout({ 23 | children, 24 | audience, 25 | views, 26 | }: { 27 | children: React.ReactNode; 28 | audience: React.ReactNode; 29 | views: React.ReactNode; 30 | }) { 31 | return ( 32 | <> 33 | 34 | 35 | 36 | 37 | 43 | {children} 44 | 45 |
46 | {audience} 47 | {views} 48 |
49 |
50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /app/parallel-routes/page.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | 3 | export default function Page() { 4 | return ( 5 | 6 |

Channel analytics

7 | 8 |
9 |
10 |
11 |
12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /app/parallel-routes/readme.mdx: -------------------------------------------------------------------------------- 1 | # Parallel Routes 2 | 3 | - Parallel Routes allow you to simultaneously or conditionally render 4 | multiple pages, with independent navigation, in the same layout. 5 | - Parallel Routes can be used for advanced routing patterns like [Conditional Routes](https://nextjs.org/docs/app/building-your-application/routing/parallel-routes#conditional-routes) and [Intercepted Routes](https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes). 6 | - Try using the tabs in one parallel route to navigate. Notice the URL 7 | changes but the unaffected parallel route is preserved. 8 | - Try using the browser's backwards and forwards navigation. 9 | Notice the browser's URL history state and active UI state is 10 | correctly synced. 11 | - Try navigating to a tab in one parallel route and refreshing the 12 | browser. Notice you can choose what UI to show parallel routes that 13 | don't match the initial URL. 14 | 15 | ### Links 16 | 17 | - [Docs](https://nextjs.org/docs/app/building-your-application/routing/parallel-routes) 18 | - [Code](https://github.com/vercel/next-app-router-playground/tree/main/app/parallel-routes) 19 | -------------------------------------------------------------------------------- /app/route-groups/(checkout)/checkout/page.tsx: -------------------------------------------------------------------------------- 1 | import { getDemoMeta } from '#/app/_internal/demos'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { Tab } from '#/ui/tabs'; 4 | 5 | const demo = getDemoMeta('route-groups'); 6 | 7 | export default function Page() { 8 | return ( 9 | 10 |
11 | 12 |
13 |
14 |

Checkout

15 | 16 |
17 |
18 |
19 |
20 |
21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/route-groups/(main)/(marketing)/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | 3 | export default function Page() { 4 | return ( 5 | 9 |

Blog

10 | 11 |
12 |
13 |
14 |
15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/route-groups/(main)/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | import React from 'react'; 3 | 4 | export default async function Layout({ 5 | children, 6 | }: { 7 | children: React.ReactNode; 8 | }) { 9 | return {children}; 10 | } 11 | -------------------------------------------------------------------------------- /app/route-groups/(main)/(shop)/[section]/[category]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { ProductCard } from '#/ui/new/product-card'; 4 | import { 5 | getCategories, 6 | getCategoryBySlug, 7 | getProductsByCategory, 8 | } from '#/app/_internal/data'; 9 | 10 | export async function generateStaticParams() { 11 | return getCategories().map(({ section, slug }) => ({ 12 | section, 13 | category: slug, 14 | })); 15 | } 16 | 17 | export default async function Page({ 18 | params, 19 | }: { 20 | params: Promise<{ section: string; category: string }>; 21 | }) { 22 | 'use cache'; 23 | 24 | const { category: categorySlug } = await params; 25 | const category = getCategoryBySlug(categorySlug); 26 | if (!category) { 27 | notFound(); 28 | } 29 | 30 | const products = getProductsByCategory(category.id); 31 | 32 | return ( 33 | 34 |
35 |

36 | All{' '} 37 | 38 | ({products.length}) 39 | 40 |

41 | 42 |
43 | {products.map((product) => ( 44 | 45 | ))} 46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/route-groups/(main)/(shop)/[section]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import { getDemoMeta } from '#/app/_internal/demos'; 3 | import { getCategoriesBySection, getSectionBySlug } from '#/app/_internal/data'; 4 | import { Boundary } from '#/ui/boundary'; 5 | import { Tabs } from '#/ui/tabs'; 6 | 7 | export default async function Layout({ 8 | params, 9 | children, 10 | }: { 11 | children: React.ReactNode; 12 | params: Promise<{ section: string }>; 13 | }) { 14 | const { section: sectionSlug } = await params; 15 | const section = getSectionBySlug(sectionSlug); 16 | if (!section) { 17 | notFound(); 18 | } 19 | 20 | const demo = getDemoMeta('route-groups'); 21 | const categories = getCategoriesBySection(section?.id); 22 | 23 | return ( 24 | 28 | ({ text: x.name, slug: x.slug })), 33 | ]} 34 | /> 35 | 36 |
{children}
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/route-groups/(main)/(shop)/[section]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import { 3 | getProductsBySection, 4 | getSectionBySlug, 5 | getSections, 6 | } from '#/app/_internal/data'; 7 | import { Boundary } from '#/ui/boundary'; 8 | import { ProductCard } from '#/ui/new/product-card'; 9 | 10 | export async function generateStaticParams() { 11 | return getSections().map(({ slug }) => ({ section: slug })); 12 | } 13 | 14 | export default async function Page({ 15 | params, 16 | }: { 17 | params: Promise<{ section: string }>; 18 | }) { 19 | 'use cache'; 20 | 21 | const { section: sectionSlug } = await params; 22 | const section = getSectionBySlug(sectionSlug); 23 | if (!section) { 24 | notFound(); 25 | } 26 | 27 | const products = getProductsBySection(section?.id); 28 | 29 | return ( 30 | 31 |
32 |

33 | All{' '} 34 | 35 | ({products.length}) 36 | 37 |

38 | 39 |
40 | {products.map((product) => ( 41 | 42 | ))} 43 |
44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/route-groups/(main)/(shop)/page.tsx: -------------------------------------------------------------------------------- 1 | import { getProducts } from '#/app/_internal/data'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { ProductCard } from '#/ui/new/product-card'; 4 | 5 | export default async function Page() { 6 | const products = getProducts({ limit: 9 }); 7 | 8 | return ( 9 | 10 |
11 |

12 | All{' '} 13 | 14 | ({products.length}) 15 | 16 |

17 |
18 | {products.map((product) => ( 19 | 20 | ))} 21 |
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /app/route-groups/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { Tabs } from '#/ui/tabs'; 4 | import { getDemoMeta } from '#/app/_internal/demos'; 5 | import { getSections } from '#/app/_internal/data'; 6 | 7 | const demo = getDemoMeta('route-groups'); 8 | 9 | export default async function Layout({ 10 | children, 11 | }: { 12 | children: React.ReactNode; 13 | }) { 14 | const sections = getSections().slice(0, 1); 15 | 16 | return ( 17 | 18 | ({ text: x.name, slug: x.slug })), 23 | { text: 'Checkout', slug: 'checkout' }, 24 | { text: 'Blog', slug: 'blog' }, 25 | ]} 26 | /> 27 | 28 |
{children}
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/route-groups/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { type Metadata } from 'next'; 3 | import { getDemoMeta } from '#/app/_internal/demos'; 4 | import { Boundary } from '#/ui/boundary'; 5 | import { Prose } from '#/ui/prose'; 6 | import Readme from './readme.mdx'; 7 | import { Mdx } from '#/ui/codehike'; 8 | 9 | export async function generateMetadata(): Promise { 10 | const demo = getDemoMeta('route-groups'); 11 | 12 | return { 13 | title: demo.name, 14 | openGraph: { 15 | title: demo.name, 16 | images: [`/api/og?title=${demo.name}`], 17 | }, 18 | }; 19 | } 20 | 21 | export default function Layout({ children }: { children: React.ReactNode }) { 22 | return ( 23 | <> 24 | 25 | 26 | 27 | 28 | 29 | {children} 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app/route-groups/readme.mdx: -------------------------------------------------------------------------------- 1 | # Route Groups 2 | 3 | Route groups can be used to: 4 | 5 | - Opt a route segment out of a shared layout. 6 | - Organize routes without affecting the URL structure. 7 | - Create multiple root layouts by partitioning the top level of the 8 | application. 9 | 10 | ### Demo 11 | 12 | - This example uses Route Groups to create layouts for different sections of an app. 13 | - (main): primary shared UI 14 | - (shop): ecommerce specific shared UI 15 | - (marketing): marketing specific shared UI 16 | - (checkout): top level group that opts out of primary shared UI 17 | - Try navigating pages and noting the different layouts used for each 18 | section. 19 | 20 | ### Links 21 | 22 | - [Docs](https://nextjs.org/docs/app/building-your-application/routing/route-groups) 23 | - [Code](https://github.com/vercel/next-app-router-playground/tree/main/app/route-groups) 24 | -------------------------------------------------------------------------------- /app/use-link-status/[section]/[category]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import { connection } from 'next/server'; 3 | import { Boundary } from '#/ui/boundary'; 4 | import { ProductCard } from '#/ui/new/product-card'; 5 | import { getCategoryBySlug, getProductsByCategory } from '#/app/_internal/data'; 6 | 7 | export default async function Page({ 8 | params, 9 | }: { 10 | params: Promise<{ section: string; category: string }>; 11 | }) { 12 | // DEMO: 13 | // This page would normally be prerendered at build time because it doesn't use dynamic APIs. 14 | // That means the loading state wouldn't show. To force one: 15 | // 1. We indicate that we require a user Request before continuing: 16 | await connection(); 17 | // 2. Add an artificial delay to make the loading state more noticeable: 18 | await new Promise((resolve) => setTimeout(resolve, 1000)); 19 | 20 | const { category: categorySlug } = await params; 21 | const category = getCategoryBySlug(categorySlug); 22 | if (!category) { 23 | notFound(); 24 | } 25 | 26 | const products = getProductsByCategory(category.id); 27 | 28 | return ( 29 | 30 |
31 |

32 | All{' '} 33 | 34 | ({products.length}) 35 | 36 |

37 | 38 |
39 | {products.map((product) => ( 40 | 45 | ))} 46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/use-link-status/[section]/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { notFound } from 'next/navigation'; 4 | import { getDemoMeta } from '#/app/_internal/demos'; 5 | import { getCategoriesBySection, getSectionBySlug } from '#/app/_internal/data'; 6 | import { Boundary } from '#/ui/boundary'; 7 | import { Tabs } from '#/ui/tabs'; 8 | 9 | export default async function Layout({ 10 | params, 11 | children, 12 | }: { 13 | children: React.ReactNode; 14 | params: Promise<{ section: string }>; 15 | }) { 16 | const { section: sectionSlug } = await params; 17 | const section = getSectionBySlug(sectionSlug); 18 | if (!section) { 19 | notFound(); 20 | } 21 | 22 | const demo = getDemoMeta('use-link-status'); 23 | const categories = getCategoriesBySection(section?.id); 24 | 25 | return ( 26 | 27 | ({ text: x.name, slug: x.slug })), 32 | ]} 33 | /> 34 | 35 |
{children}
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/use-link-status/[section]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import { getProductsBySection, getSectionBySlug } from '#/app/_internal/data'; 3 | import { Boundary } from '#/ui/boundary'; 4 | import { ProductCard } from '#/ui/new/product-card'; 5 | import { connection } from 'next/server'; 6 | 7 | export default async function Page({ 8 | params, 9 | }: { 10 | params: Promise<{ section: string }>; 11 | }) { 12 | // DEMO: 13 | // This page would normally be prerendered at build time because it doesn't use dynamic APIs. 14 | // That means the loading state wouldn't show. To force one: 15 | // 1. We indicate that we require a user Request before continuing: 16 | await connection(); 17 | // 2. Add an artificial delay to make the loading state more noticeable: 18 | await new Promise((resolve) => setTimeout(resolve, 1000)); 19 | 20 | const { section: sectionSlug } = await params; 21 | const section = getSectionBySlug(sectionSlug); 22 | if (!section) { 23 | notFound(); 24 | } 25 | 26 | const products = getProductsBySection(section?.id); 27 | 28 | return ( 29 | 30 |
31 |

32 | All{' '} 33 | 34 | ({products.length}) 35 | 36 |

37 | 38 |
39 | {products.map((product) => ( 40 | 45 | ))} 46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/use-link-status/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use cache'; 2 | 3 | import { getSections } from '#/app/_internal/data'; 4 | import { getDemoMeta } from '#/app/_internal/demos'; 5 | import { Boundary } from '#/ui/boundary'; 6 | import { Prose } from '#/ui/prose'; 7 | import { Tabs } from '#/ui/tabs'; 8 | import { type Metadata } from 'next'; 9 | import React from 'react'; 10 | import Readme from './readme.mdx'; 11 | import { Mdx } from '#/ui/codehike'; 12 | 13 | export async function generateMetadata(): Promise { 14 | const demo = getDemoMeta('use-link-status'); 15 | return { 16 | title: demo.name, 17 | openGraph: { 18 | title: demo.name, 19 | images: [`/api/og?title=${demo.name}`], 20 | }, 21 | }; 22 | } 23 | 24 | export default async function Layout({ 25 | children, 26 | }: { 27 | children: React.ReactNode; 28 | }) { 29 | const demo = getDemoMeta('use-link-status'); 30 | const sections = getSections(); 31 | 32 | return ( 33 | <> 34 | 35 | 36 | 37 | 38 | 44 | ({ 49 | text: x.name, 50 | slug: x.slug, 51 | })), 52 | ]} 53 | /> 54 | 55 | {children} 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /app/use-link-status/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '#/ui/boundary'; 2 | import { ProductCardSkeleton } from '#/ui/new/product-card'; 3 | 4 | export default function Loading() { 5 | return ( 6 | 7 |
8 |

All

9 | 10 |
11 | 12 | 13 | 14 |
15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/use-link-status/page.tsx: -------------------------------------------------------------------------------- 1 | import { getProducts } from '#/app/_internal/data'; 2 | import { Boundary } from '#/ui/boundary'; 3 | import { ProductCard } from '#/ui/new/product-card'; 4 | import { connection } from 'next/server'; 5 | 6 | export default async function Page() { 7 | // DEMO: 8 | // This page would normally be prerendered at build time because it doesn't use dynamic APIs. 9 | // That means the loading state wouldn't show. To force one: 10 | // 1. We indicate that we require a user Request before continuing: 11 | await connection(); 12 | // 2. Add an artificial delay to make the loading state more noticeable: 13 | await new Promise((resolve) => setTimeout(resolve, 1000)); 14 | 15 | const products = getProducts({ limit: 9 }); 16 | 17 | return ( 18 | 19 |
20 |

21 | All{' '} 22 | 23 | ({products.length}) 24 | 25 |

26 |
27 | {products.map((product) => ( 28 | 29 | ))} 30 |
31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/use-link-status/readme.mdx: -------------------------------------------------------------------------------- 1 | # useLinkStatus 2 | 3 | `useLinkStatus` is a Client Component hook that tracks the **pending** state of a ``. Use it to show inline visual feedback (like spinners or text glimmers) while a navigation to a new route completes. 4 | 5 | `useLinkStatus` is useful when: 6 | 7 | - Prefetching is disabled or in progress. 8 | - The destination route is dynamic **and** doesn't include a `loading.js` file that would allow an instant navigation. 9 | 10 | **Note:** We recommend using `loading.js` and not disabling prefetching. This allows Next.js to [instantly navigate](/loading) to the new route and show a loading state while the rest of the page loads. 11 | 12 | ### Demo 13 | 14 | 1. Starting from another [route](/), click the useLinkStatus nav item on the main menu. Notice the inline loading indicator next to the nav item before navigation completes. 15 | 2. Navigate across categories at the top. Notice the nav item is dimmed to show immediate visual feedback that an action is performed. 16 | 17 | #### Notes 18 | 19 | - An artificial delay is added to the category pages to make the pending state more noticeable. 20 | - `loading.js` is intentionally not used. 21 | 22 | ## Links 23 | 24 | - [Docs](https://nextjs.org/docs/app/api-reference/functions/use-link-status) 25 | - [Code](https://github.com/vercel/next-app-router-playground/tree/main/app/use-link-status) 26 | 27 | ## Pending UI design tips 28 | 29 | - **Keep subtle and avoid layout shift**: Pending indicators should feel lightweight and unobtrusive. To avoid layout shift, keep them out of the normal document flow (e.g. `position: absolute`) or use a CSS approach that doesn't create an element (e.g. animating the `background` position of a gradient). 30 | - **Handle fast navigations gracefully**: Prevent unnecessary flashes of pending UI on fast navigations by adding an initial animation delay (e.g. 150ms) and starting an animation as invisible (e.g. `opacity: 0`) or outside the parent's clipping boundary (e.g. `overflow: hidden` and `translate: -100%`). 31 | - **Place indicators near interaction**: Visual feedback should appear close to where the user clicked or tapped. This helps reinforce the connection between the feedback and their action. 32 | - **Design proactively**: It is fine to preemptively include pending indicators for key nav items even when most navigations are fast. If the transition is instant or fast, the indicator won't have a chance to show. If the navigation takes time for whatever reason, the user will get helpful feedback. 33 | 34 | ## Loading UI deep dive 35 | 36 | Next.js routing is server centric. This has many benefits, but means navigations require a round trip to the server to get information about the new route. 37 | 38 | Next.js alleviates this tradeoff through strategies like prerendering, prefetching and streaming. As well as API's developers can use like `loading.js` and `useLinkStatus`. 39 | 40 | ### Improving loading user experience 41 | 42 | When a user navigates (e.g. clicking a ``), there are two phases before the navigation is fully complete: 43 | 44 | 1. **Pending phase**: Before the navigation and the browser URL is updated, **while** the user is still on the current route. 45 | 2. **Loading phase**: After the navigation and the browser URL is updated but **before** all content of the new route is loaded. 46 | 47 | Next.js provides features and APIs to improve the loading experiences in both phases: 48 | 49 | - **Prefetching**: `` prefetches routes to enable instant navigation. Static routes are fully prefetched by default. Dynamic routes are partially prefetched up to the nearest segment with a `loading.js` file. Including the segments fallback UI in the prefetch allows instant navigation for dynamic routes. 50 | - **Pending UI**: Use `useLinkStatus` and `useFormStatus` to show visual feedback to the user during the pending phase. 51 | - **Instant navigations and Loading UI**: Use `loading.js` to define fallback UI for a route segment. On navigation, this essentially skips the pending phase and immediately shows something to the user while the rest of the content loads. 52 | - **Streaming**: Use `` boundaries to further split server-side work into smaller parts and show something to the user earlier in the loading phase. 53 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Vercel, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mdx-components.tsx: -------------------------------------------------------------------------------- 1 | import type { MDXComponents } from 'mdx/types'; 2 | import Link from 'next/link'; 3 | 4 | export function useMDXComponents(components: MDXComponents): MDXComponents { 5 | return { 6 | ...components, 7 | a: (props: any) => { 8 | if (!props.href) throw new Error('href is required'); 9 | return ; 10 | }, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next'; 2 | import createMDX from '@next/mdx'; 3 | import { type CodeHikeConfig } from 'codehike/mdx'; 4 | 5 | const nextConfig = { 6 | pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'], 7 | experimental: { 8 | inlineCss: true, 9 | dynamicIO: true, 10 | clientSegmentCache: true, 11 | viewTransition: true, 12 | prerenderEarlyExit: false, 13 | }, 14 | } satisfies NextConfig; 15 | 16 | const codeHikeConfig = { 17 | components: { code: 'MyCode', inlineCode: 'MyInlineCode' }, 18 | } satisfies CodeHikeConfig; 19 | 20 | const withMDX = createMDX({ 21 | options: { 22 | remarkPlugins: [['remark-codehike', codeHikeConfig]], 23 | recmaPlugins: [['recma-codehike', codeHikeConfig]], 24 | }, 25 | }); 26 | 27 | export default withMDX(nextConfig); 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "next dev --turbopack", 5 | "build": "next build", 6 | "start": "next start", 7 | "prettier": "prettier --write --ignore-unknown ." 8 | }, 9 | "dependencies": { 10 | "@heroicons/react": "2.2.0", 11 | "@mdx-js/loader": "3.1.0", 12 | "@mdx-js/react": "3.1.0", 13 | "@next/mdx": "15.4.0-canary.55", 14 | "@types/mdx": "2.0.13", 15 | "clsx": "2.1.1", 16 | "codehike": "1.0.7", 17 | "date-fns": "4.1.0", 18 | "dinero.js": "2.0.0-alpha.10", 19 | "ms": "3.0.0-canary.1", 20 | "next": "15.4.0-canary.55", 21 | "react": "19.1.0", 22 | "react-dom": "19.1.0", 23 | "recma-codehike": "0.0.1", 24 | "remark-codehike": "0.0.1", 25 | "server-only": "0.0.1", 26 | "styled-components": "6.1.15", 27 | "use-count-up": "3.0.1", 28 | "zod": "3.24.3" 29 | }, 30 | "devDependencies": { 31 | "@tailwindcss/forms": "0.5.10", 32 | "@tailwindcss/postcss": "4.1.4", 33 | "@tailwindcss/typography": "0.5.16", 34 | "@types/ms": "2.1.0", 35 | "@types/node": "22.13.1", 36 | "@types/react": "19.1.2", 37 | "@types/react-dom": "19.0.3", 38 | "postcss": "8.5.3", 39 | "prettier": "3.5.0", 40 | "prettier-plugin-tailwindcss": "0.6.11", 41 | "tailwindcss": "4.1.4", 42 | "typescript": "5.7.3" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | semi: true, 4 | trailingComma: 'all', 5 | singleQuote: true, 6 | // pnpm doesn't support plugin autoloading 7 | // https://github.com/tailwindlabs/prettier-plugin-tailwindcss#installation 8 | plugins: ['prettier-plugin-tailwindcss'], 9 | }; 10 | -------------------------------------------------------------------------------- /public/shop/balls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel/next-app-router-playground/c89a8981d8057b401a3629b71e0ae25a6c85fece/public/shop/balls.png -------------------------------------------------------------------------------- /public/shop/gloves.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel/next-app-router-playground/c89a8981d8057b401a3629b71e0ae25a6c85fece/public/shop/gloves.png -------------------------------------------------------------------------------- /public/shop/laptop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel/next-app-router-playground/c89a8981d8057b401a3629b71e0ae25a6c85fece/public/shop/laptop.png -------------------------------------------------------------------------------- /public/shop/phone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel/next-app-router-playground/c89a8981d8057b401a3629b71e0ae25a6c85fece/public/shop/phone.png -------------------------------------------------------------------------------- /public/shop/shoes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel/next-app-router-playground/c89a8981d8057b401a3629b71e0ae25a6c85fece/public/shop/shoes.png -------------------------------------------------------------------------------- /public/shop/shorts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel/next-app-router-playground/c89a8981d8057b401a3629b71e0ae25a6c85fece/public/shop/shorts.png -------------------------------------------------------------------------------- /public/shop/tablet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel/next-app-router-playground/c89a8981d8057b401a3629b71e0ae25a6c85fece/public/shop/tablet.png -------------------------------------------------------------------------------- /public/shop/top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel/next-app-router-playground/c89a8981d8057b401a3629b71e0ae25a6c85fece/public/shop/top.png -------------------------------------------------------------------------------- /public/shop/weights.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel/next-app-router-playground/c89a8981d8057b401a3629b71e0ae25a6c85fece/public/shop/weights.png -------------------------------------------------------------------------------- /public/visuals/cacheable-routes-client-cache.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel/next-app-router-playground/c89a8981d8057b401a3629b71e0ae25a6c85fece/public/visuals/cacheable-routes-client-cache.png -------------------------------------------------------------------------------- /public/visuals/cacheable-routes-prerendering.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel/next-app-router-playground/c89a8981d8057b401a3629b71e0ae25a6c85fece/public/visuals/cacheable-routes-prerendering.png -------------------------------------------------------------------------------- /public/visuals/cacheable-routes-server-cache.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel/next-app-router-playground/c89a8981d8057b401a3629b71e0ae25a6c85fece/public/visuals/cacheable-routes-server-cache.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Next.js App Router Playground 2 | 3 | This playground is used by the DX team to explore, test, and demo new features in Next.js. It serves as a starting point for writing documentation—helping us better understand features, identify bugs, and provide feedback to the Next.js team. 4 | 5 | ## Running Locally 6 | 7 | 1. Install dependencies: 8 | 9 | ```sh 10 | pnpm install 11 | ``` 12 | 13 | 2. Start the dev server: 14 | 15 | ```sh 16 | pnpm dev 17 | ``` 18 | 19 | ## Documentation 20 | 21 | https://nextjs.org/docs 22 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @layer base { 4 | *, 5 | ::after, 6 | ::before, 7 | ::backdrop, 8 | ::file-selector-button { 9 | border-color: var(--color-gray-200, currentColor); 10 | } 11 | } 12 | 13 | @theme inline { 14 | --font-sans: var(--font-geist-sans); 15 | --font-mono: var(--font-geist-mono); 16 | } 17 | 18 | @theme { 19 | --color-gray-50: var(--color-neutral-50); 20 | --color-gray-100: var(--color-neutral-100); 21 | --color-gray-200: var(--color-neutral-200); 22 | --color-gray-300: var(--color-neutral-300); 23 | --color-gray-400: var(--color-neutral-400); 24 | --color-gray-500: var(--color-neutral-500); 25 | --color-gray-600: var(--color-neutral-600); 26 | --color-gray-700: var(--color-neutral-700); 27 | --color-gray-800: var(--color-neutral-800); 28 | --color-gray-900: var(--color-neutral-900); 29 | --color-gray-950: var(--color-neutral-950); 30 | 31 | --animate-shimmer: shimmer 1.5s infinite; 32 | 33 | @keyframes shimmer { 34 | 0% { 35 | opacity: 0; 36 | } 37 | 30% { 38 | opacity: 1; 39 | } 40 | 70% { 41 | opacity: 1; 42 | } 43 | 100% { 44 | transform: translateX(150%); 45 | opacity: 0; 46 | } 47 | } 48 | } 49 | 50 | @keyframes rerender { 51 | 0%, 52 | 40% { 53 | border-color: currentColor; 54 | } 55 | } 56 | 57 | @keyframes highlight { 58 | 0%, 59 | 40% { 60 | background: var(--color-blue-600); 61 | color: var(--color-blue-100); 62 | } 63 | } 64 | 65 | @keyframes loading { 66 | 0% { 67 | opacity: 0.2; 68 | } 69 | 20% { 70 | opacity: 1; 71 | transform: translateX(1px); 72 | } 73 | 100% { 74 | opacity: 0.2; 75 | } 76 | } 77 | 78 | .spinner { 79 | background: conic-gradient(transparent 10deg, white, transparent 320deg); 80 | 81 | /* Mask to create a hollow center 🍩 */ 82 | --border-size: 3px; 83 | mask-image: radial-gradient( 84 | closest-side, 85 | transparent calc(100% - var(--border-size)), 86 | white calc(100% - var(--border-size)) 87 | ); 88 | 89 | /* Animation: 90 | - opacity: render invisible and use animations to reveal spinner 91 | - fade: fade in after delay to prevent flashes of UI on fast navigations 92 | - rotate: rotate indefinitely while rendered */ 93 | opacity: 0; 94 | animation: 95 | fade 500ms 150ms forwards, 96 | rotate 1s linear infinite; 97 | } 98 | 99 | @keyframes rotate { 100 | to { 101 | transform: rotate(360deg); 102 | } 103 | } 104 | 105 | .transition-enter { 106 | opacity: 1; 107 | transform: scale(1); 108 | transition: 109 | opacity 0.5s, 110 | transform 0.5s; 111 | 112 | @starting-style { 113 | opacity: 0; 114 | transform: scale(0.95); 115 | } 116 | } 117 | 118 | @plugin "@tailwindcss/forms"; 119 | @plugin "@tailwindcss/typography"; 120 | 121 | @keyframes fade { 122 | from { 123 | filter: blur(3px); 124 | opacity: 0; 125 | } 126 | to { 127 | filter: blur(0); 128 | opacity: 1; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "#/*": ["./*"] 20 | }, 21 | "plugins": [ 22 | { 23 | "name": "next" 24 | } 25 | ] 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /ui/boundary.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React from 'react'; 3 | 4 | type Color = 'gray' | 'pink' | 'blue' | 'violet' | 'cyan' | 'orange' | 'red'; 5 | type Border = 'dashed' | 'solid'; 6 | type Size = 'small' | 'medium'; 7 | 8 | export const Boundary = ({ 9 | children, 10 | label, 11 | size = 'medium', 12 | color = 'gray', 13 | kind = 'dashed', 14 | animateRerendering = true, 15 | corners, 16 | className, 17 | }: { 18 | children: React.ReactNode; 19 | label?: string | string[]; 20 | size?: Size; 21 | color?: Color; 22 | kind?: Border; 23 | animateRerendering?: boolean; 24 | corners?: boolean; 25 | className?: string; 26 | }) => { 27 | return ( 28 |
41 | {corners && ( 42 | <> 43 | 47 | 48 | 49 | 53 | 54 | 55 | 56 | )} 57 | 58 | {label ? ( 59 |
65 | {[...(typeof label === 'string' ? [label] : label)].map((label) => ( 66 | 73 | ))} 74 |
75 | ) : null} 76 | 77 |
83 | {children} 84 |
85 |
86 | ); 87 | }; 88 | 89 | const Label = ({ 90 | children, 91 | animateRerendering, 92 | color, 93 | }: { 94 | children: React.ReactNode; 95 | animateRerendering?: boolean; 96 | color?: Color; 97 | }) => { 98 | return ( 99 |
114 | {children} 115 |
116 | ); 117 | }; 118 | -------------------------------------------------------------------------------- /ui/button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | export default function Button({ 4 | kind = 'default', 5 | ...props 6 | }: React.ButtonHTMLAttributes & { 7 | kind?: 'default' | 'error'; 8 | }) { 9 | return ( 10 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /ui/count-up.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCountUp } from 'use-count-up'; 4 | 5 | const CountUp = ({ 6 | start, 7 | end, 8 | duration = 1, 9 | }: { 10 | start: number; 11 | end: number; 12 | duration?: number; 13 | }) => { 14 | const { value } = useCountUp({ 15 | isCounting: true, 16 | end, 17 | start, 18 | duration, 19 | decimalPlaces: 1, 20 | }); 21 | 22 | return {value}; 23 | }; 24 | 25 | export default CountUp; 26 | -------------------------------------------------------------------------------- /ui/external-link.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowRightIcon } from '@heroicons/react/24/outline'; 2 | 3 | export const ExternalLink = ({ 4 | children, 5 | href, 6 | }: { 7 | children: React.ReactNode; 8 | href: string; 9 | }) => { 10 | return ( 11 | 15 |
{children}
16 | 17 | 18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /ui/footer.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export default function Footer({ 4 | reactVersion, 5 | nextVersion, 6 | }: { 7 | reactVersion: string; 8 | nextVersion: string; 9 | }) { 10 | return ( 11 |
12 | 24 | 25 | 26 | Powered by 27 | 28 | 32 | 33 | 34 | 35 |
36 |
React: {reactVersion}
37 |
Next: {nextVersion}
38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /ui/global-nav.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Demo, navigation } from '#/app/_internal/demos'; 4 | import { LinkStatus } from '#/ui/link-status'; 5 | import { NextLogoDark } from '#/ui/next-logo'; 6 | import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/solid'; 7 | import clsx from 'clsx'; 8 | import Link from 'next/link'; 9 | import { useSelectedLayoutSegment } from 'next/navigation'; 10 | import { Suspense, useState } from 'react'; 11 | 12 | export function GlobalNav() { 13 | const [isOpen, setIsOpen] = useState(false); 14 | const close = () => setIsOpen(false); 15 | 16 | return ( 17 | <> 18 |
19 | 24 |
25 | 26 |
27 | 28 |

29 | Playground 30 |

31 | 32 |
33 | 47 | 48 |
54 | 76 |
77 | 78 | ); 79 | } 80 | 81 | function DynamicNavItem({ 82 | item, 83 | close, 84 | }: { 85 | item: Demo; 86 | close: () => false | void; 87 | }) { 88 | const segment = useSelectedLayoutSegment(); 89 | const isActive = item.slug === segment; 90 | 91 | return ; 92 | } 93 | 94 | function NavItem({ 95 | item, 96 | close, 97 | isActive, 98 | }: { 99 | item: Demo; 100 | close: () => false | void; 101 | isActive?: boolean; 102 | }) { 103 | return ( 104 | 115 | {item.nav_title || item.name} 116 | 117 | 118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /ui/header.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import styled from 'styled-components'; 4 | 5 | const HeadContainer = styled.header` 6 | position: relative; 7 | height: 64px; 8 | align-items: center; 9 | padding: 0px 8px; 10 | margin-bottom: 48px; 11 | display: flex; 12 | border: 0 solid #e5e7eb; 13 | color: rgb(244 244 245); 14 | grid-column-start: 2; 15 | grid-column-end: 4; 16 | `; 17 | 18 | const Title = styled.span` 19 | margin: 0 8px; 20 | `; 21 | 22 | const NextJsLogo = (props: any) => ( 23 | 29 | 33 | 34 | ); 35 | 36 | export default function Header() { 37 | return ( 38 | 39 | 40 | The React Framework 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /ui/link-status.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useLinkStatus } from 'next/link'; 4 | 5 | export function LinkStatus() { 6 | const { pending } = useLinkStatus(); 7 | return pending ? ( 8 |
13 | ) : null; 14 | } 15 | -------------------------------------------------------------------------------- /ui/mobile-nav-toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/solid'; 4 | import clsx from 'clsx'; 5 | import React from 'react'; 6 | 7 | const MobileNavContext = React.createContext< 8 | [boolean, React.Dispatch>] | undefined 9 | >(undefined); 10 | 11 | export function MobileNavContextProvider({ 12 | children, 13 | }: { 14 | children: React.ReactNode; 15 | }) { 16 | const [isOpen, setIsOpen] = React.useState(false); 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | 24 | export function useMobileNavToggle() { 25 | const context = React.useContext(MobileNavContext); 26 | if (context === undefined) { 27 | throw new Error( 28 | 'useMobileNavToggle must be used within a MobileNavContextProvider', 29 | ); 30 | } 31 | return context; 32 | } 33 | 34 | export function MobileNavToggle({ children }: { children: React.ReactNode }) { 35 | const [isOpen, setIsOpen] = useMobileNavToggle(); 36 | 37 | return ( 38 | <> 39 | 53 | 54 |
60 | {children} 61 |
62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /ui/new/product-card.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from '#/app/_internal/data'; 2 | import clsx from 'clsx'; 3 | import Image from 'next/image'; 4 | 5 | import { 6 | ElementType, 7 | ComponentPropsWithoutRef, 8 | PropsWithChildren, 9 | } from 'react'; 10 | 11 | export type PolymorphicProps< 12 | E extends ElementType, 13 | P = {}, 14 | > = PropsWithChildren

& { as?: E } & Omit< 15 | ComponentPropsWithoutRef, 16 | keyof P | 'as' | 'children' 17 | >; 18 | 19 | type ProductCardProps = PolymorphicProps< 20 | E, 21 | { 22 | product: Product; 23 | animateEnter?: boolean; 24 | } 25 | >; 26 | 27 | export function ProductCard({ 28 | as, 29 | product, 30 | animateEnter, 31 | ...rest 32 | }: ProductCardProps) { 33 | const Component = as || 'div'; 34 | return ( 35 | 36 |

37 | {product.name} 45 |
46 | 47 |
48 |
49 |
50 |
51 | 52 | ); 53 | } 54 | 55 | export function ProductCardSkeleton() { 56 | return ( 57 |
58 |
67 | 68 |
69 |
70 |
71 |
72 |
73 | ); 74 | } 75 | 76 | export function ProductList({ 77 | children, 78 | title, 79 | count, 80 | }: { 81 | children: React.ReactNode; 82 | title: string; 83 | count: number; 84 | }) { 85 | return ( 86 |
87 |

88 |
{title}
89 | 90 | ({count}) 91 | 92 |

93 |
{children}
94 |
95 | ); 96 | } 97 | 98 | export function ProductListSkeleton({ 99 | title, 100 | count = 3, 101 | }: { 102 | title: string; 103 | count?: number; 104 | }) { 105 | return ( 106 |
107 |

{title}

108 |
109 | {Array.from({ length: count }).map((_, i) => ( 110 | 111 | ))} 112 |
113 |
114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /ui/new/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | export function SkeletonText({ 4 | count = 10, 5 | minLength = 2, 6 | maxLength = 12, 7 | className = '', 8 | seed: _seed = '', 9 | }: { 10 | count?: number; 11 | minLength?: number; 12 | maxLength?: number; 13 | className?: string; 14 | seed?: string; 15 | }) { 16 | const words = Array.from({ length: count }, (_, i) => { 17 | const seed = _seed + count + i; 18 | 19 | return Math.floor(random(seed) * (maxLength - minLength + 1)) + minLength; 20 | }); 21 | 22 | return ( 23 |
26 | {words.map((width, i) => ( 27 |
32 | ))} 33 |
34 | ); 35 | } 36 | 37 | // Yoinked from https://github.com/remotion-dev/remotion/blob/main/packages/core/src/random.ts 38 | function mulberry32(a: number) { 39 | let t = a + 0x6d2b79f5; 40 | t = Math.imul(t ^ (t >>> 15), t | 1); 41 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61); 42 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; 43 | } 44 | 45 | function hashCode(str: string) { 46 | let i = 0; 47 | let chr = 0; 48 | let hash = 0; 49 | 50 | for (i = 0; i < str.length; i++) { 51 | chr = str.charCodeAt(i); 52 | hash = (hash << 5) - hash + chr; 53 | hash |= 0; // Convert to 32bit integer 54 | } 55 | 56 | return hash; 57 | } 58 | 59 | type RandomSeed = number | string; 60 | 61 | export const random = (seed: RandomSeed) => { 62 | if (typeof seed === 'string') { 63 | return mulberry32(hashCode(seed)); 64 | } 65 | 66 | if (typeof seed === 'number') { 67 | return mulberry32(seed * 10000000000); 68 | } 69 | 70 | throw new Error('random() argument must be a number or a string'); 71 | }; 72 | -------------------------------------------------------------------------------- /ui/next-logo.tsx: -------------------------------------------------------------------------------- 1 | export function NextLogoLight() { 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 | 56 | export function NextLogoDark() { 57 | return ( 58 | 59 | 68 | 69 | 70 | 71 | 75 | 82 | 83 | 84 | 92 | 93 | 94 | 95 | 103 | 104 | 105 | 106 | 107 | 108 | ); 109 | } 110 | -------------------------------------------------------------------------------- /ui/ping.tsx: -------------------------------------------------------------------------------- 1 | export function Ping() { 2 | return ( 3 | 4 | 5 | 6 | 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /ui/product-best-seller.tsx: -------------------------------------------------------------------------------- 1 | export const ProductBestSeller = () => { 2 | return ( 3 |
4 | Best Seller 5 |
6 | ); 7 | }; 8 | -------------------------------------------------------------------------------- /ui/product-card.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from '#/app/api/products/product'; 2 | import { ProductBestSeller } from '#/ui/product-best-seller'; 3 | import { ProductEstimatedArrival } from '#/ui/product-estimated-arrival'; 4 | import { ProductLowStockWarning } from '#/ui/product-low-stock-warning'; 5 | import { ProductPrice } from '#/ui/product-price'; 6 | import { ProductRating } from '#/ui/product-rating'; 7 | import { ProductUsedPrice } from '#/ui/product-used-price'; 8 | import { dinero, type DineroSnapshot } from 'dinero.js'; 9 | import Image from 'next/image'; 10 | import Link from 'next/link'; 11 | 12 | export const ProductCard__DEPRECATED = ({ 13 | product, 14 | href, 15 | }: { 16 | product: Product; 17 | href: string; 18 | }) => { 19 | const price = dinero(product.price as DineroSnapshot); 20 | 21 | return ( 22 | 23 |
24 |
25 | {product.isBestSeller ? ( 26 |
27 | 28 |
29 | ) : null} 30 | {product.name} 39 |
40 | 41 |
42 | {product.name} 43 |
44 | 45 | {product.rating ? : null} 46 | 47 | 48 | 49 | {/* */} 50 | 51 | {product.usedPrice ? ( 52 | 53 | ) : null} 54 | 55 | 56 | 57 | {product.stock <= 1 ? ( 58 | 59 | ) : null} 60 |
61 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /ui/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 | -------------------------------------------------------------------------------- /ui/product-deal.tsx: -------------------------------------------------------------------------------- 1 | import { ProductCurrencySymbol } from '#/ui/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 | -------------------------------------------------------------------------------- /ui/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 | -------------------------------------------------------------------------------- /ui/product-lightening-deal.tsx: -------------------------------------------------------------------------------- 1 | import { ProductDeal } from '#/ui/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 | -------------------------------------------------------------------------------- /ui/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 | -------------------------------------------------------------------------------- /ui/product-price.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from '#/app/api/products/product'; 2 | import { ProductCurrencySymbol } from '#/ui/product-currency-symbol'; 3 | import { ProductDeal } from '#/ui/product-deal'; 4 | import { ProductLighteningDeal } from '#/ui/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 | -------------------------------------------------------------------------------- /ui/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 | -------------------------------------------------------------------------------- /ui/product-review-card.tsx: -------------------------------------------------------------------------------- 1 | import type { Review } from '#/app/api/reviews/review'; 2 | import { ProductRating } from '#/ui/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 | -------------------------------------------------------------------------------- /ui/product-split-payments.tsx: -------------------------------------------------------------------------------- 1 | import { ProductCurrencySymbol } from '#/ui/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 | -------------------------------------------------------------------------------- /ui/product-used-price.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from '#/app/api/products/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 | -------------------------------------------------------------------------------- /ui/prose.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import clsx from 'clsx'; 4 | import React from 'react'; 5 | 6 | export function Prose({ 7 | children, 8 | className, 9 | collapsed, 10 | }: { 11 | children: React.ReactNode; 12 | className?: string; 13 | collapsed?: boolean; 14 | }) { 15 | const isCollapsible = typeof collapsed === 'boolean'; 16 | const [isCollapsed, setIsCollapsed] = React.useState(collapsed); 17 | const contentId = React.useId(); 18 | 19 | return ( 20 |
21 |
32 | {children} 33 |
34 | 35 | {isCollapsible && ( 36 | 44 | )} 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /ui/rendered-time-ago.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import ms from 'ms'; 4 | import { useEffect, useRef, useState } from 'react'; 5 | 6 | // https://github.com/streamich/react-use/blob/master/src/useInterval.ts 7 | const useInterval = (callback: Function, delay?: number | null) => { 8 | const savedCallback = useRef(() => {}); 9 | 10 | useEffect(() => { 11 | savedCallback.current = callback; 12 | }); 13 | 14 | useEffect(() => { 15 | if (delay !== null) { 16 | const interval = setInterval(() => savedCallback.current(), delay || 0); 17 | return () => clearInterval(interval); 18 | } 19 | 20 | return undefined; 21 | }, [delay]); 22 | }; 23 | 24 | export function RenderedTimeAgo({ timestamp }: { timestamp: number }) { 25 | const [msAgo, setMsAgo] = useState(0); 26 | 27 | // update on page change 28 | useEffect(() => { 29 | setMsAgo(Date.now() - timestamp); 30 | }, [timestamp]); 31 | 32 | // update every second 33 | useInterval(() => { 34 | setMsAgo(Date.now() - timestamp); 35 | }, 1000); 36 | 37 | return ( 38 |
42 | {msAgo ? ( 43 | <> 44 | 49 | {msAgo >= 1000 ? ms(msAgo) : '0s'} 50 | {' '} 51 | ago 52 | 53 | ) : null} 54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /ui/rendering-info.tsx: -------------------------------------------------------------------------------- 1 | import { RenderedTimeAgo } from '#/ui/rendered-time-ago'; 2 | 3 | export function RenderingInfo({ 4 | type, 5 | }: { 6 | type: 'ssg' | 'ssgod' | 'ssr' | 'isr'; 7 | }) { 8 | let msg = ''; 9 | switch (type) { 10 | case 'ssg': 11 | msg = 'Statically pre-rendered at build time'; 12 | break; 13 | case 'ssgod': 14 | msg = 'Statically rendered on demand'; 15 | break; 16 | case 'isr': 17 | msg = 18 | 'Statically pre-rendered at build time and periodically revalidated'; 19 | break; 20 | case 'ssr': 21 | msg = 'Dynamically rendered at request time'; 22 | break; 23 | } 24 | 25 | return ( 26 |
27 |
{msg}
28 | 29 |
30 | 31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /ui/rendering-page-skeleton.tsx: -------------------------------------------------------------------------------- 1 | const shimmer = `relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-linear-to-r before:from-transparent before:via-white/10 before:to-transparent`; 2 | 3 | export function RenderingPageSkeleton() { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /ui/section-link.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | export const SectionLink = ({ 4 | children, 5 | href, 6 | text, 7 | }: { 8 | children: React.ReactNode; 9 | href: string; 10 | text: string; 11 | }) => ( 12 | 13 |
14 | {children} 15 |
16 |
{text}
17 | 18 | ); 19 | -------------------------------------------------------------------------------- /ui/skeleton-card.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | export const SkeletonCard = ({ isLoading }: { isLoading?: boolean }) => ( 4 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | ); 17 | -------------------------------------------------------------------------------- /ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link, { useLinkStatus } from 'next/link'; 4 | import { usePathname } from 'next/navigation'; 5 | import clsx from 'clsx'; 6 | import { Suspense } from 'react'; 7 | 8 | export type Item = { 9 | text: string; 10 | slug?: string; 11 | segment?: string; 12 | }; 13 | 14 | export function Tabs({ basePath, items }: { basePath: string; items: Item[] }) { 15 | return ( 16 |
17 | {items.map((item) => ( 18 | 19 | ))} 20 |
21 | ); 22 | } 23 | 24 | export function Tab({ 25 | basePath = '', 26 | item, 27 | }: { 28 | basePath?: string; 29 | item: Item; 30 | }) { 31 | const href = item.slug ? `${basePath}/${item.slug}` : basePath; 32 | 33 | return ( 34 | 35 | {item.text}}> 36 | {item.text} 37 | 38 | 39 | ); 40 | } 41 | 42 | // Note: We create an additional component because useLinkStatus should be 43 | // called from a component that is rendered inside a `` 44 | function DynamicTabContent({ 45 | children, 46 | href, 47 | }: { 48 | children: React.ReactNode; 49 | href: string; 50 | }) { 51 | const pathname = usePathname(); 52 | const isActive = pathname === href; 53 | const { pending: isPending } = useLinkStatus(); 54 | 55 | return ( 56 | 57 | {children} 58 | 59 | ); 60 | } 61 | 62 | function TabContent({ 63 | children, 64 | isActive, 65 | isPending, 66 | }: { 67 | children: React.ReactNode; 68 | isActive?: boolean; 69 | isPending?: boolean; 70 | }) { 71 | return ( 72 | 80 | {children} 81 | 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /ui/vercel-logo.tsx: -------------------------------------------------------------------------------- 1 | export function VercelLogo() { 2 | return ( 3 | 8 | 9 | 10 | ); 11 | } 12 | --------------------------------------------------------------------------------