├── .vscode └── settings.json ├── app ├── favicon.ico ├── new │ ├── settings │ │ ├── layout.tsx │ │ ├── display │ │ │ └── page.tsx │ │ └── profile │ │ │ └── page.tsx │ └── page.tsx └── layout.tsx ├── styles └── globals.css ├── postcss.config.js ├── next.config.js ├── pages ├── _app.tsx ├── _document.tsx ├── settings │ ├── display.tsx │ └── profile.tsx ├── api │ └── submit.ts └── index.tsx ├── tailwind.config.js ├── package.json ├── .gitignore ├── components ├── navbar.tsx ├── home-layout.tsx ├── minimal.tsx ├── table.tsx ├── cards.tsx └── settings-layout.tsx ├── tsconfig.json ├── README.md ├── lib └── db.ts └── pnpm-lock.yaml /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leerob/nextjs-metamorphosis/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | serverActions: true, 5 | }, 6 | }; 7 | 8 | module.exports = nextConfig; 9 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import 'styles/globals.css'; 2 | import type { AppProps } from 'next/app'; 3 | 4 | export default function App({ Component, pageProps }: AppProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /app/new/settings/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Layout } from 'components/settings-layout'; 2 | 3 | export default function SettingsLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode; 7 | }) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /app/new/settings/display/page.tsx: -------------------------------------------------------------------------------- 1 | import MinimalMode from 'components/minimal'; 2 | import Table from 'components/table'; 3 | 4 | export default function DisplayPage() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './app/**/*.{js,ts,jsx,tsx,mdx}', 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | ], 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [], 12 | }; 13 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import 'styles/globals.css'; 2 | import Navbar from 'components/navbar'; 3 | 4 | export default function RootLayout({ 5 | children, 6 | }: { 7 | children: React.ReactNode; 8 | }) { 9 | return ( 10 | 11 | 12 | 13 | {children} 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /pages/settings/display.tsx: -------------------------------------------------------------------------------- 1 | import MinimalMode from 'components/minimal'; 2 | import { Layout } from 'components/settings-layout'; 3 | import Table from 'components/table'; 4 | 5 | export default function DisplayPage() { 6 | return ( 7 | 8 | 9 |
10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /pages/api/submit.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | type ResponseData = { 4 | message: string; 5 | }; 6 | 7 | export default function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | console.log(req.body); // { name: 'Lee Rob' } 12 | 13 | res.status(200).json({ message: 'Success!' }); 14 | } 15 | -------------------------------------------------------------------------------- /app/new/page.tsx: -------------------------------------------------------------------------------- 1 | import Cards from 'components/cards'; 2 | import Layout from 'components/home-layout'; 3 | import Table from 'components/table'; 4 | import { Suspense } from 'react'; 5 | 6 | export default async function HomePage() { 7 | return ( 8 | 9 | }> 10 | 11 | 12 |
13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/new/settings/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import { Bio, Button, Name } from 'components/settings-layout'; 2 | 3 | export default function ProfilePage() { 4 | async function handleSubmit(formData: FormData) { 5 | 'use server'; 6 | const name = formData.get('name'); 7 | const bio = formData.get('bio'); 8 | console.log(name, bio); 9 | } 10 | 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "next dev", 5 | "build": "next build", 6 | "start": "next start" 7 | }, 8 | "dependencies": { 9 | "@types/node": "20.2.5", 10 | "@types/react": "18.2.8", 11 | "@types/react-dom": "18.2.4", 12 | "autoprefixer": "^10.4.14", 13 | "next": "13.4.4", 14 | "postcss": "^8.4.24", 15 | "react": "18.2.0", 16 | "react-dom": "18.2.0", 17 | "tailwindcss": "^3.3.2", 18 | "typescript": "5.1.3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Layout from 'components/home-layout'; 2 | import Cards from 'components/cards'; 3 | import Table from 'components/table'; 4 | import { getCardData, Card } from 'lib/db'; 5 | 6 | HomePage.getInitialProps = async () => { 7 | const cardData = await getCardData(2000); 8 | return { cardData }; 9 | }; 10 | 11 | export default function HomePage({ cardData }: { cardData: Card[] }) { 12 | return ( 13 | 14 | 15 |
16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | export default function Navbar() { 4 | return ( 5 |
6 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /components/home-layout.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from './navbar'; 2 | 3 | export default function Layout({ children }: { children: React.ReactNode }) { 4 | return ( 5 | <> 6 | 7 |
8 |
9 |

Lee's Account

10 |

View your recent invoices.

11 |
12 |
17 | {children} 18 |
19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /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 | "paths": { 18 | "*": ["./*"] 19 | }, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ] 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /pages/settings/profile.tsx: -------------------------------------------------------------------------------- 1 | import { Bio, Button, Layout, Name } from 'components/settings-layout'; 2 | 3 | export default function ProfilePage() { 4 | function handleSubmit(event: React.FormEvent) { 5 | event.preventDefault(); 6 | const form = event.currentTarget; 7 | const nameElement = form.elements.namedItem('name') as HTMLInputElement; 8 | const bioElement = form.elements.namedItem('bio') as HTMLInputElement; 9 | 10 | fetch('/api/submit', { 11 | method: 'POST', 12 | headers: { 13 | 'Content-Type': 'application/json', 14 | }, 15 | body: JSON.stringify({ 16 | name: nameElement.value, 17 | bio: bioElement.value, 18 | }), 19 | }); 20 | } 21 | 22 | return ( 23 | 24 |
25 | 26 | 27 | 28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /components/minimal.tsx: -------------------------------------------------------------------------------- 1 | export default function MinimalMode({ 2 | children, 3 | }: { 4 | children: React.ReactNode; 5 | }) { 6 | return ( 7 | <> 8 |
9 |
10 | 13 |

14 | Display only the most important information for invoices. 15 |

16 |
17 | 23 |
24 |
25 |
{children}
26 |
27 |
28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js Metamorphosis (React Summit 2023) 2 | 3 | [🎥 **Watch Talk**](https://www.youtube.com/watch?v=5HaX0Q_Do1I) 4 | 5 | This was the source for my talk about React Summit 2023. In the talk, I show how to incrementally adopt to new App Router, and some of the benefits of using the new router versus the previous Pages Router approach. 6 | 7 | This includes: 8 | 9 | - Simplified data fetching 10 | 1. `getInitialProps` (Pages Router) 11 | 1. `getStaticProps` / `getServerSideProps` (Pages Router) 12 | 1. `async` / `await` (App Router) 13 | - Colocating data fetching with components (React Server Components) 14 | - Defining layouts through the file system (`layout.tsx`) 15 | - Granular control over data freshness (`revalidate` for components) 16 | - Out-of-order streaming of data (Suspense for SSR) 17 | - Simplified form submissions (Server Actions) 18 | 19 | ## `NEXT_DATA` vs RSC Payload 20 | 21 | The data fetching methods in the Pages Router, like `getServerSideProps`, forward `props` to the default exported React component. This `NEXT_DATA` payload also includes rendering instructions. 22 | 23 | Inside the App Router, there is no longer `NEXT_DATA`. Instead, the React Server Components payload includes the already rendered result, meaning no additional rendering work needs to be done on the client. This also means that the rendering instructions remain on the server, and just need to be "slotted" into the right place. 24 | 25 | You'll also notice that in the App Router, the baseline client-side JavaScript of Next.js + React is smaller than the Pages Router. 26 | -------------------------------------------------------------------------------- /components/table.tsx: -------------------------------------------------------------------------------- 1 | import { getTableData, Status } from 'lib/db'; 2 | 3 | export default function Table() { 4 | const tableData = getTableData(); 5 | 6 | return ( 7 |
8 | 9 | 10 | 13 | 16 | 19 | 22 | 23 | 24 | 25 | {tableData.map((rowData, index) => ( 26 | 27 | ))} 28 | 29 |
11 | Invoice 12 | 14 | Status 15 | 17 | Method 18 | 20 | Amount 21 |
30 | ); 31 | } 32 | 33 | function TableRow({ 34 | invoice, 35 | status, 36 | method, 37 | amount, 38 | }: { 39 | invoice: string; 40 | status: Status; 41 | method: string; 42 | amount: string; 43 | }) { 44 | let statusBadge; 45 | switch (status) { 46 | case Status.PENDING: 47 | statusBadge = ; 48 | break; 49 | case Status.UNPAID: 50 | statusBadge = ; 51 | break; 52 | case Status.PAID: 53 | statusBadge = ; 54 | break; 55 | default: 56 | statusBadge = null; 57 | break; 58 | } 59 | 60 | return ( 61 | 62 | {invoice} 63 | {statusBadge} 64 | {method} 65 | {amount} 66 | 67 | ); 68 | } 69 | 70 | function PendingBadge() { 71 | return ( 72 |
73 | Pending 74 |
75 | ); 76 | } 77 | 78 | function UnpaidBadge() { 79 | return ( 80 |
81 | Unpaid 82 |
83 | ); 84 | } 85 | 86 | function PaidBadge() { 87 | return ( 88 |
89 | Paid 90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | export enum Icon { 2 | Revenue = 'revenue', 3 | Subscriptions = 'subscriptions', 4 | Sales = 'sales', 5 | ActiveNow = 'active-now', 6 | } 7 | 8 | export type Card = { 9 | id: number; 10 | title: string; 11 | icon: Icon; 12 | value: string; 13 | description: string; 14 | }; 15 | 16 | export async function getCardData(delay?: number) { 17 | if (delay) { 18 | await new Promise((resolve) => setTimeout(resolve, delay)); 19 | } 20 | 21 | return [ 22 | { 23 | id: 1, 24 | title: 'Total Revenue', 25 | icon: Icon.Revenue, 26 | value: '$45,231.89', 27 | description: '+20.1% from last month', 28 | }, 29 | { 30 | id: 2, 31 | title: 'Subscriptions', 32 | icon: Icon.Subscriptions, 33 | value: '+2350', 34 | description: '+180.1% from last month', 35 | }, 36 | { 37 | id: 3, 38 | title: 'Sales', 39 | icon: Icon.Sales, 40 | value: '+12,234', 41 | description: '+19% from last month', 42 | }, 43 | { 44 | id: 4, 45 | title: 'Active Now', 46 | icon: Icon.ActiveNow, 47 | value: '+573', 48 | description: '+201 since last hour', 49 | }, 50 | ]; 51 | } 52 | 53 | export function getPlaceholderCardData() { 54 | return [ 55 | { 56 | id: 1, 57 | title: '—', 58 | icon: Icon.Revenue, 59 | value: '?', 60 | description: ' ', 61 | }, 62 | { 63 | id: 2, 64 | title: '—', 65 | icon: Icon.Subscriptions, 66 | value: '?', 67 | description: ' ', 68 | }, 69 | { 70 | id: 3, 71 | title: '—', 72 | icon: Icon.Sales, 73 | value: '?', 74 | description: ' ', 75 | }, 76 | { 77 | id: 4, 78 | title: '—', 79 | icon: Icon.ActiveNow, 80 | value: '?', 81 | description: ' ', 82 | }, 83 | ]; 84 | } 85 | 86 | export enum Status { 87 | PENDING = 'PENDING', 88 | UNPAID = 'UNPAID', 89 | PAID = 'PAID', 90 | } 91 | 92 | export function getTableData() { 93 | return [ 94 | { 95 | invoice: 'INV001', 96 | status: Status.PAID, 97 | method: 'Credit Card', 98 | amount: '$250.00', 99 | }, 100 | { 101 | invoice: 'INV002', 102 | status: Status.PENDING, 103 | method: 'PayPal', 104 | amount: '$150.00', 105 | }, 106 | { 107 | invoice: 'INV003', 108 | status: Status.UNPAID, 109 | method: 'Bank Transfer', 110 | amount: '$350.00', 111 | }, 112 | { 113 | invoice: 'INV004', 114 | status: Status.PAID, 115 | method: 'Credit Card', 116 | amount: '$450.00', 117 | }, 118 | { 119 | invoice: 'INV005', 120 | status: Status.PAID, 121 | method: 'PayPal', 122 | amount: '$550.00', 123 | }, 124 | { 125 | invoice: 'INV006', 126 | status: Status.PENDING, 127 | method: 'Bank Transfer', 128 | amount: '$200.00', 129 | }, 130 | { 131 | invoice: 'INV007', 132 | status: Status.UNPAID, 133 | method: 'Credit Card', 134 | amount: '$300.00', 135 | }, 136 | ]; 137 | } 138 | -------------------------------------------------------------------------------- /components/cards.tsx: -------------------------------------------------------------------------------- 1 | import { getPlaceholderCardData, getCardData, Card, Icon } from 'lib/db'; 2 | 3 | export default function Cards({ 4 | data, 5 | loading, 6 | slow, 7 | }: { 8 | data?: Card[]; 9 | loading?: boolean; 10 | slow?: boolean; 11 | }) { 12 | if (data === undefined) { 13 | data = getPlaceholderCardData(); 14 | } 15 | if (slow === true) { 16 | // data = await getCardData(2000); 17 | } 18 | 19 | return ( 20 |
21 | {data.map((card) => ( 22 | 23 | ))} 24 |
25 | ); 26 | } 27 | 28 | function Card({ card, isLoading }: { card: any; isLoading?: boolean }) { 29 | const getIcon = (icon: Icon) => { 30 | switch (icon) { 31 | case Icon.Revenue: 32 | return ( 33 | 45 | 46 | 47 | 48 | ); 49 | case Icon.Subscriptions: 50 | return ( 51 | 63 | 64 | 65 | 66 | 67 | 68 | ); 69 | case Icon.Sales: 70 | return ( 71 | 83 | 84 | 85 | 86 | ); 87 | case Icon.ActiveNow: 88 | return ( 89 | 101 | 102 | 103 | ); 104 | default: 105 | return null; 106 | } 107 | }; 108 | 109 | return ( 110 |
111 |
112 | {isLoading ? ( 113 |
114 | ) : ( 115 | <> 116 |

{card.title}

117 | {getIcon(card.icon)} 118 | 119 | )} 120 |
121 |
122 | {isLoading ? ( 123 | <> 124 |
125 |
126 | 127 | ) : ( 128 | <> 129 |
{card.value}
130 |

{card.description}

131 | 132 | )} 133 |
134 |
135 | ); 136 | } 137 | -------------------------------------------------------------------------------- /components/settings-layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Navbar from './navbar'; 4 | import Link from 'next/link'; 5 | import { usePathname } from 'next/navigation'; 6 | 7 | function classNames(...classes: any) { 8 | return classes.filter(Boolean).join(' '); 9 | } 10 | 11 | export function Layout({ children }: { children: React.ReactNode }) { 12 | const pathname = usePathname() || ''; 13 | const settingsUrl = pathname.includes('/new') 14 | ? '/new/settings/profile' 15 | : '/settings/profile'; 16 | const displayUrl = pathname.includes('/new') 17 | ? '/new/settings/display' 18 | : '/settings/display'; 19 | 20 | return ( 21 | <> 22 | 23 |
24 |
25 |

Settings

26 |

27 | Manage your account settings and set e-mail preferences. 28 |

29 |
30 |
35 |
36 | 78 |
79 |
{children}
80 |
81 |
82 |
83 | 84 | ); 85 | } 86 | 87 | export function Name() { 88 | return ( 89 |
90 | 93 | 99 |

100 | This is your public display name. It can be your real name or a 101 | pseudonym. You can only change this once every 30 days. 102 |

103 |
104 | ); 105 | } 106 | 107 | export function Bio() { 108 | return ( 109 |
110 | 113 |