Build snippet list page
You have two apps (web and app) that both depend on packages/ui. But apps/snippet-manager doesn't actually import anything from the UI package yet. It's just configured to use it. Now let's prove that code sharing works.
When both apps use the same components, you'll see the monorepo advantage: change Button once in packages/ui, both apps update instantly.
Outcome
Build a snippet list page that imports and uses Button and Card from packages/ui, displaying mock snippet data.
Fast track
- Define Snippet interface and create mock data
- Import
ButtonandCardfrompackages/ui - Build header and snippet grid using shared components
- Test hot reloading across packages
Hands-on exercise 3.2
Build the snippet list page using shared components from packages/ui.
Requirements:
- Mark page as
'use client'for interactivity - Import
ButtonandCardfrom@geniusgarage/ui - Define Snippet interface (id, title, language, code, tags)
- Create array of 3 mock code snippets
- Build header with title and "+ New Snippet" button
- Display snippets in a responsive grid using
Cardcomponents - Verify hot reload works when editing packages/ui
Implementation hints:
- Use
'use client'directive at top of file for future useState - Import from
@geniusgarage/ui/buttonand@geniusgarage/ui/card(named exports) - Button onClick can be console.log for now
- Each Card shows snippet title, language, code preview, and tags
- Use Tailwind grid for responsive layout
Expected behavior:
- Snippet manager displays 3 code snippets in cards
- Shared
Buttoncomponent in header - Edit
Buttoninpackages/ui- both apps hot reload simultaneously
Update page with todos
Open apps/snippet-manager/app/page.tsx and replace it with this scaffold:
'use client'
// TODO: Import Button from '@geniusgarage/ui/button'
// TODO: Import Card from '@geniusgarage/ui/card'
// TODO: Define Snippet interface with these fields:
// - id: number
// - title: string
// - language: string
// - code: string
// - tags: string[]
// TODO: Create mockSnippets array with 3 snippets:
// 1. Array Reduce Pattern (javascript, reduce code, tags: javascript, array, functional)
// 2. React useEffect Cleanup (typescript, useEffect cleanup code, tags: react, hooks, typescript)
// 3. Promise.all Pattern (javascript, Promise.all code, tags: javascript, async, promises)
export default function Home() {
return (
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 p-8">
<div className="max-w-6xl mx-auto">
{/* TODO: Add header div with flex layout */}
{/* - h1 with "My Snippets" (text-4xl font-bold) */}
{/* - Button with "+ New Snippet" text and onClick console.log */}
{/* TODO: Add grid div that maps over mockSnippets */}
{/* - Use Tailwind classes: grid gap-6 md:grid-cols-2 lg:grid-cols-3 */}
{/* - Map each snippet to a Card component */}
{/* - Inside Card, show: title, language, code preview, tags */}
</div>
</div>
)
}Complete the todos
Step 1: Add imports
Add the imports at the top:
import { Button } from '@geniusgarage/ui/button'
import { Card } from '@geniusgarage/ui/card'These imports work because:
- packages/ui/package.json exports them via named exports
- next.config.mjs transpiles the package
- pnpm workspace links them locally
Step 2: Define snippet interface
Add the interface below imports:
interface Snippet {
id: number
title: string
language: string
code: string
tags: string[]
}This provides type safety for your snippet data.
Step 3: Create mock data
Add the mock snippets array:
const mockSnippets: Snippet[] = [
{
id: 1,
title: 'Array Reduce Pattern',
language: 'javascript',
code: 'const sum = arr.reduce((acc, n) => acc + n, 0)',
tags: ['javascript', 'array', 'functional'],
},
{
id: 2,
title: 'React useEffect Cleanup',
language: 'typescript',
code: `useEffect(() => {
const timer = setTimeout(() => {}, 1000)
return () => clearTimeout(timer)
}, [])`,
tags: ['react', 'hooks', 'typescript'],
},
{
id: 3,
title: 'Promise.all Pattern',
language: 'javascript',
code: 'const results = await Promise.all(promises.map(p => p()))',
tags: ['javascript', 'async', 'promises'],
},
]Step 4: Build header
Replace the first TODO comment with:
<div className="flex justify-between items-center mb-8">
<h1 className="text-4xl font-bold">My Snippets</h1>
<Button onClick={() => console.log('Create snippet')}>
+ New Snippet
</Button>
</div>This uses the shared Button component from packages/ui.
Step 5: Build snippet grid
Replace the second TODO comment with:
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{mockSnippets.map((snippet) => (
<Card key={snippet.id}>
<div className="space-y-3">
{/* Title and Language */}
<div>
<h3 className="text-lg font-semibold mb-1">{snippet.title}</h3>
<span className="text-sm text-gray-500 font-mono">
{snippet.language}
</span>
</div>
{/* Code Preview */}
<pre className="bg-gray-900 text-gray-100 p-3 rounded text-sm overflow-x-auto">
<code>{snippet.code}</code>
</pre>
{/* Tags */}
<div className="flex flex-wrap gap-2">
{snippet.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded"
>
{tag}
</span>
))}
</div>
</div>
</Card>
))}
</div>This maps over the mock data and renders each snippet in a Card component.
Complete solution
Click to see complete solution
'use client'
import { Button } from '@geniusgarage/ui/button'
import { Card } from '@geniusgarage/ui/card'
interface Snippet {
id: number
title: string
language: string
code: string
tags: string[]
}
const mockSnippets: Snippet[] = [
{
id: 1,
title: 'Array Reduce Pattern',
language: 'javascript',
code: 'const sum = arr.reduce((acc, n) => acc + n, 0)',
tags: ['javascript', 'array', 'functional'],
},
{
id: 2,
title: 'React useEffect Cleanup',
language: 'typescript',
code: `useEffect(() => {
const timer = setTimeout(() => {}, 1000)
return () => clearTimeout(timer)
}, [])`,
tags: ['react', 'hooks', 'typescript'],
},
{
id: 3,
title: 'Promise.all Pattern',
language: 'javascript',
code: 'const results = await Promise.all(promises.map(p => p()))',
tags: ['javascript', 'async', 'promises'],
},
]
export default function Home() {
return (
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 p-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<h1 className="text-4xl font-bold">My Snippets</h1>
<Button onClick={() => console.log('Create snippet')}>
+ New Snippet
</Button>
</div>
{/* Snippet Grid */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{mockSnippets.map((snippet) => (
<Card key={snippet.id}>
<div className="space-y-3">
{/* Title and Language */}
<div>
<h3 className="text-lg font-semibold mb-1">{snippet.title}</h3>
<span className="text-sm text-gray-500 font-mono">
{snippet.language}
</span>
</div>
{/* Code Preview */}
<pre className="bg-gray-900 text-gray-100 p-3 rounded text-sm overflow-x-auto">
<code>{snippet.code}</code>
</pre>
{/* Tags */}
<div className="flex flex-wrap gap-2">
{snippet.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded"
>
{tag}
</span>
))}
</div>
</div>
</Card>
))}
</div>
</div>
</div>
)
}Try it
1. Start the snippet manager app
pnpm --filter @geniusgarage/snippet-manager devOpen http://localhost:3001 - you should see:
- "My Snippets" header
- "+ New Snippet" button (same Button from packages/ui that web app uses)
- 3 snippet cards in a grid
- Each card shows title, language, code, and tags
2. Verify shared components work
Start both apps:
pnpm devVisit both:
- http://localhost:3000 - Marketing site with features page (uses Button and Card)
- http://localhost:3001 - Snippet manager (uses same Button and Card)
Both apps use the exact same components from packages/ui.
3. Test hot reload across packages
With both apps running, edit the Button component:
'use client'
interface ButtonProps {
children: React.ReactNode
variant?: 'primary' | 'secondary'
onClick?: () => void
}
export function Button({ children, variant = 'primary', onClick }: ButtonProps) {
const baseStyles = {
padding: '12px 24px',
borderRadius: '8px',
fontSize: '16px',
fontWeight: '600',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}
const variantStyles = {
primary: { background: '#d946ef', color: 'white' },
secondary: { background: '#f3f4f6', color: '#1f2937', border: '1px solid #e5e7eb' },
}
const hoverStyles = {
transform: 'translateY(-1px)',
boxShadow: '0 4px 8px rgba(0,0,0,0.15)',
}
return (
<button
style={{ ...baseStyles, ...variantStyles[variant] }}
onClick={onClick}
onMouseEnter={(e) => {
Object.assign(e.currentTarget.style, hoverStyles)
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'
}}
>
{children}
</button>
)
}Save the file. Watch what happens:
- Both apps hot reload simultaneously
- All buttons in both apps now have magenta background with hover lift effect
- No rebuild needed
- No version bumping
- Instant update across the monorepo
This is the monorepo superpower in action.
4. Revert the change
Restore the original blue button:
export function Button({ children, variant = 'primary', onClick }: ButtonProps) {
const baseStyles = {
padding: '12px 24px',
borderRadius: '8px',
fontSize: '16px',
fontWeight: '600',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s ease',
}
const variantStyles = {
primary: { background: '#2563eb', color: 'white' },
secondary: { background: '#e5e7eb', color: '#1f2937' },
}
return (
<button
style={{ ...baseStyles, ...variantStyles[variant] }}
onClick={onClick}
>
{children}
</button>
)
}Both apps reload again. Back to the original blue button.
Build both apps
Stop the dev servers and build:
turbo buildOutput:
@geniusgarage/ui:build: cache hit, replaying outputs 287ms
@geniusgarage/web:build: cache miss, executing 5.123s
@geniusgarage/snippet-manager:build: cache miss, executing 4.891s
Tasks: 3 successful, 3 total
Cached: 1 cached, 3 total
Time: 5.234s
Notice:
- UI package cached (hasn't changed since last build)
- Both apps rebuild (page.tsx changed in apps/snippet-manager)
- Apps build in parallel (independent tasks)
- Total time ~5s (not 10s) because UI was cached
How shared components work
Your monorepo now proves code sharing:
packages/ui/src/
├── button.tsx ← Shared component
└── card.tsx ← Shared component
↑ ↑
└────────┬───────────┘
│
┌────────┴────────┐
│ │
apps/web apps/snippet-manager
(features) (snippets)
One source of truth:
- Button defined once in packages/ui
- Card defined once in packages/ui
- Both apps import from the same package
- Change once, update everywhere
No duplication:
- No copy/paste between apps
- No version sync needed
- No publishing to npm
- Instant updates via workspace links
Commit
git add .
git commit -m "feat(app): add snippet list page with shared components"Done-when
Verify shared components work:
- Added
'use client'directive to page.tsx - Imported Button and Card from
@geniusgarage/ui - Defined Snippet interface with all required fields
- Created mockSnippets array with 3 code snippets
- Built header with h1 and Button component
- Mapped over snippets and displayed each in a Card
- Added title, language, code preview, and tags to each card
- Ran snippet manager app and saw snippet list at http://localhost:3001
- Ran both apps simultaneously with
pnpm dev - Edited Button component and saw both apps hot reload
- Reverted Button change and saw both apps reload again
- Built with
turbo buildand saw UI package cached
What's Next
Both apps now use Button and Card, but the snippet display is generic. Next lesson: Add CodeBlock and SnippetCard Components - you'll create specialized components in packages/ui for displaying code with syntax highlighting and properly formatted snippet cards.
Was this helpful?