Curriculum1 of 17
+ Module 13

Shipping UI, Next.js + Desktop/Electron

Ship a real Next.js app to a live domain, and know what runs where.

Phase 2 · Design engineer core · 17 sections · about 23 minutes

+ 01 / 17

The Premise

Building a feature is not the same as shipping a product. A shipped product means:

  • Users can actually reach it (deployed, DNS, HTTPS)
  • Forms don't silently fail or crash
  • The page loads in 2 seconds on 4G, not 8
  • Errors show helpful messages, not cryptic stack traces
  • The experience is consistent on mobile, tablet, desktop
  • (For desktop) It doesn't feel like a web app bolted onto a native shell

Shipping is where craft is the differentiator. Two apps with identical features feel completely different if one has thought through error states, loading states, and performance. Shipping is also where you learn that your code is only half the battle, the platform (Next.js, Vercel, Electron, the browser, the OS) shapes what's possible and what's expensive.

This module builds a real, complete product. Not a demo, not a tutorial app. Something you'd actually use, someone else would pay for, or you'd link in a portfolio. You'll deploy it yourself, monitor its performance, and optionally wrap it for desktop. By the end, you'll understand what separates a shipped product from a hobby project.


+ 02 / 17

Section 1: The Shape of a Shipped Product

What "Shipping" Means

A shipped product has three layers:

  1. 1The code layer, your React components, Next.js routes, API handlers
  2. 2The platform layer, Next.js, Vercel, the browser, the OS
  3. 3The human layer, your actual users, their devices, their network, their expectations

Most tutorials focus on layer 1. This module teaches layers 2 and 3.

What Makes a Product Feel Shipped

Load performance. When users click the link, does the page appear in < 1s on 4G? Or does it hang for 5s while JavaScript downloads and hydrates? The difference is perceived quality.

Error resilience. What happens when the API is slow? The server crashes? The user is offline? A shipped product doesn't crash; it degrades gracefully.

Mobile responsiveness. Your design looks great on desktop. Does it look designed on mobile, or does it look responsive-by-accident? Shipped products feel native on every screen size.

State clarity. When a form is submitting, does the user know? When it fails, do they see why? Loading states, error messages, success confirmations, these are not nice-to-haves. They're what separate a shipped product from an experiment.

Attention to detail. Animations that don't drop frames. Form fields that remember their state. Keyboard navigation that works. The back button that does the right thing. Shipped products have noticed these things.

The Platform's Role

Next.js, Vercel, React, the browser, they all make promises:

  • Next.js promises file-based routing, automatic code-splitting, SSR/SSG
  • React promises reusable components and predictable state
  • Vercel promises instant deploys, automatic CDN, edge functions
  • The browser promises consistent DOM APIs and async rendering

But the platform also imposes constraints:

  • JavaScript has to download, parse, and execute before anything interactive happens (hydration is expensive)
  • Network latency is real, an API call that takes 2 seconds makes a form feel broken
  • The bundle size is a hard limit, every kilobyte of JavaScript is paid for at load time
  • Mobile and desktop are different environments (touch vs. pointer, battery vs. plugged in, WiFi vs. 4G)

A shipped product doesn't fight the platform; it works with it. Server components let you keep heavy logic off the client. API routes let you talk to your database without exposing credentials. Vercel's edge functions let you serve API responses from a server near the user.


+ 03 / 17

Section 2: Next.js Fundamentals

File-Based Routing (App Router)

In Next.js 13+, the App Router replaced the Pages Router. It's based on React Server Components by default, which changes everything.

+ JavaScript
1app/
2├── layout.tsx # Root layout, wraps all pages
3├── page.tsx # Home page (/)
4├── dashboard/
5│ ├── layout.tsx # Dashboard layout
6│ └── page.tsx # /dashboard
7├── api/
8│ ├── books/
9│ │ ├── route.ts # GET/POST /api/books
10│ │ └── [id]/
11│ │ └── route.ts # GET/PUT/DELETE /api/books/[id]
12└── (marketing)/
13 ├── about/page.tsx # /about (grouped in parens, doesn't affect URL)
14 └── blog/[slug]/page.tsx # /blog/[slug]

Routes are files. app/blog/[slug]/page.tsx automatically handles /blog/my-post, /blog/another-post, etc. Dynamic segments like [slug] and [id] become route parameters.

Key feature: Route groups in parentheses don't add to the URL. Use them to organise layouts:

+ TSX
1// app/(auth)/login/page.tsx → /login (not /auth/login)
2// app/(auth)/layout.tsx wraps both login and signup with a centered card
3 
4export default function AuthLayout({
5 children,
6}: {
7 children: React.ReactNode;
8}) {
9 return (
10 <div className="flex items-center justify-center min-h-screen bg-gray-100">
11 <div className="w-full max-w-md">{children}</div>
12 </div>
13 );
14}

Server Components (Default)

By default, every component is a Server Component. It runs on the server, never ships JavaScript to the browser. A server component renders to HTML once, and that HTML is sent to the client.

+ TSX
1// app/dashboard/page.tsx, Server Component (no 'use client')
2// This code runs on the server, not in the browser
3 
4import { db } from "@/lib/db";
5 
6export default async function DashboardPage() {
7 const books = await db.book.findMany({ limit: 50 });
8 
9 return (
10 <div>
11 <h1>Your Books</h1>
12 <ul>
13 {books.map((book) => (
14 <li key={book.id}>{book.title}</li>
15 ))}
16 </ul>
17 </div>
18 );
19}

The database query runs on the server. The HTML is generated on the server. The client receives finished HTML and renders it instantly. No JavaScript needed.

Advantages:

  • No secrets exposed to the browser (API keys, database URLs stay safe)
  • Faster initial page load (no JavaScript hydration delay)
  • Smaller bundle size (database queries aren't shipped as code)
  • Direct database access (no need for an API route in between)

Trade-off: Server components can't use browser APIs (event listeners, localStorage, useState). They also can't use hooks.

Client Components ('use client')

When you need interactivity (form state, button clicks, animations), you add 'use client' at the top of the file. Everything below it runs in the browser.

+ TSX
1"use client";
2 
3import { useState } from "react";
4 
5export default function AddBookForm() {
6 const [title, setTitle] = useState("");
7 const [isSubmitting, setIsSubmitting] = useState(false);
8 
9 const handleSubmit = async (e: React.FormEvent) => {
10 e.preventDefault();
11 setIsSubmitting(true);
12 
13 const res = await fetch("/api/books", {
14 method: "POST",
15 body: JSON.stringify({ title }),
16 });
17 
18 setIsSubmitting(false);
19 if (!res.ok) alert("Failed to add book");
20 };
21 
22 return (
23 <form onSubmit={handleSubmit}>
24 <input
25 value={title}
26 onChange={(e) => setTitle(e.target.value)}
27 placeholder="Book title"
28 />
29 <button disabled={isSubmitting}>
30 {isSubmitting ? "Adding..." : "Add Book"}
31 </button>
32 </form>
33 );
34}

The 'use client' directive only affects this file and its descendants. If a server component imports a client component, the client component renders on the browser, but its parent stays on the server.

Key rule: Keep 'use client' components small and deep in the tree. A huge root component as 'use client' forces the entire app to the browser, defeating the purpose.

+ TSX
1// Good: Server component wraps client component
2export default function BooksPage() {
3 // runs on server, queries database
4 const books = await db.book.findMany();
5 
6 return (
7 <div>
8 <BookList books={books} /> {/* Server component */}
9 <AddBookForm /> {/* Client component */}
10 </div>
11 );
12}
13 
14// Bad: Everything becomes client-rendered
15("use client");
16 
17export default function BooksPage() {
18 // Now this entire page and its children run in the browser
19 // You lose the speed and security benefits
20}

+ 04 / 17

Section 3: Server vs Client, The Architectural Pattern

The Mental Model

  • Server components are for display and fetching. They're fast, they're safe, they're the default.
  • Client components are for interaction. They handle user input, state, navigation, animations.

Most pages follow this pattern:

+ JavaScript
1Page (Server)
2├── Header (Server), fetches user profile once
3├── Sidebar (Server), fetches menu data once
4└── MainContent (Client)
5 ├── FilterBar (Client), handles filter state
6 ├── SearchInput (Client), handles search input, debounces
7 └── ResultsList (Server), passed results from parent, just renders

The server fetches data once. The client manages UI state (filters, search, sorting). When the user changes a filter, the client makes a fetch to /api/books?category=fiction, the server responds, the client updates the list.

Why This Matters for Performance

In the old Pages Router (or a traditional SPA), everything is JavaScript. The entire page is client code. When the user lands, the browser:

  1. 1Downloads the JavaScript bundle (100KB–500KB)
  2. 2Parses and compiles it (takes ~1s on slow devices)
  3. 3Runs the JavaScript (hydration, React initializes, re-renders, attaches event listeners)
  4. 4Makes an API call to fetch data
  5. 5Re-renders again with the data
  6. 6Now the page is interactive

That's 3–5 seconds before anything interactive happens.

With Server Components:

  1. 1Server fetches data and renders HTML
  2. 2Browser receives HTML and renders it instantly
  3. 3Browser downloads the client component JavaScript (only what's needed for interactivity)
  4. 4Browser attaches event listeners (hydration is fast because most of the page doesn't need it)

That's 0.5–1 second before something interactive appears. The content is visible immediately.

Fetching Data

In a server component, use async/await directly:

+ TSX
1// app/books/page.tsx
2export default async function BooksPage() {
3 const res = await fetch("https://api.example.com/books");
4 const books = await res.json();
5 
6 return (
7 <div>
8 {books.map((book) => (
9 <BookCard key={book.id} book={book} />
10 ))}
11 </div>
12 );
13}

In a client component, use an effect hook:

+ TSX
1"use client";
2 
3import { useEffect, useState } from "react";
4 
5export default function BooksPage() {
6 const [books, setBooks] = useState([]);
7 const [isLoading, setIsLoading] = useState(true);
8 
9 useEffect(() => {
10 const fetchBooks = async () => {
11 const res = await fetch("/api/books");
12 const data = await res.json();
13 setBooks(data);
14 setIsLoading(false);
15 };
16 
17 fetchBooks();
18 }, []);
19 
20 if (isLoading) return <div>Loading...</div>;
21 
22 return (
23 <div>
24 {books.map((book) => (
25 <BookCard key={book.id} book={book} />
26 ))}
27 </div>
28 );
29}

The server component is faster and cleaner. Use client components only when you need state or event listeners.


+ 05 / 17

Section 4: Forms and Validation

Forms are where products are built or broken. A good form:

  • Validates client-side (instant feedback, no round trip)
  • Validates server-side (can't trust the browser)
  • Shows clear error messages
  • Prevents double-submission
  • Handles network errors gracefully

A Complete Form Example

+ TSX
1"use client";
2 
3import { useState } from "react";
4 
5interface AddBookFormProps {
6 onSuccess?: () => void;
7}
8 
9export function AddBookForm({ onSuccess }: AddBookFormProps) {
10 const [formData, setFormData] = useState({
11 title: "",
12 author: "",
13 pages: "",
14 });
15 const [errors, setErrors] = useState<Record<string, string>>({});
16 const [isSubmitting, setIsSubmitting] = useState(false);
17 const [submitError, setSubmitError] = useState("");
18 const [submitSuccess, setSubmitSuccess] = useState(false);
19 
20 // Client-side validation
21 const validateForm = () => {
22 const newErrors: Record<string, string> = {};
23 
24 if (!formData.title.trim()) {
25 newErrors.title = "Title is required";
26 }
27 if (!formData.author.trim()) {
28 newErrors.author = "Author is required";
29 }
30 if (formData.pages && isNaN(Number(formData.pages))) {
31 newErrors.pages = "Pages must be a number";
32 }
33 
34 setErrors(newErrors);
35 return Object.keys(newErrors).length === 0;
36 };
37 
38 const handleSubmit = async (e: React.FormEvent) => {
39 e.preventDefault();
40 setSubmitError("");
41 setSubmitSuccess(false);
42 
43 // Validate before submitting
44 if (!validateForm()) return;
45 
46 setIsSubmitting(true);
47 
48 try {
49 const res = await fetch("/api/books", {
50 method: "POST",
51 headers: { "Content-Type": "application/json" },
52 body: JSON.stringify(formData),
53 });
54 
55 if (!res.ok) {
56 // Server responded with an error
57 const error = await res.json();
58 setSubmitError(error.message || "Failed to add book");
59 return;
60 }
61 
62 // Success
63 setSubmitSuccess(true);
64 setFormData({ title: "", author: "", pages: "" });
65 
66 // Clear success message after 3s
67 setTimeout(() => setSubmitSuccess(false), 3000);
68 
69 // Callback to parent (e.g. to refresh the book list)
70 onSuccess?.();
71 } catch (err) {
72 // Network error
73 setSubmitError("Network error. Please try again.");
74 console.error(err);
75 } finally {
76 setIsSubmitting(false);
77 }
78 };
79 
80 return (
81 <form onSubmit={handleSubmit} className="space-y-4 max-w-md mx-auto">
82 <h2 className="text-lg font-bold">Add a Book</h2>
83 
84 {/* Title field */}
85 <div>
86 <label className="block text-sm font-medium mb-1">Title</label>
87 <input
88 type="text"
89 value={formData.title}
90 onChange={(e) => setFormData({ ...formData, title: e.target.value })}
91 className={`w-full px-3 py-2 border rounded ${
92 errors.title ? "border-red-500" : "border-gray-300"
93 }`}
94 disabled={isSubmitting}
95 />
96 {errors.title && (
97 <p className="text-sm text-red-600 mt-1">{errors.title}</p>
98 )}
99 </div>
100 
101 {/* Author field */}
102 <div>
103 <label className="block text-sm font-medium mb-1">Author</label>
104 <input
105 type="text"
106 value={formData.author}
107 onChange={(e) => setFormData({ ...formData, author: e.target.value })}
108 className={`w-full px-3 py-2 border rounded ${
109 errors.author ? "border-red-500" : "border-gray-300"
110 }`}
111 disabled={isSubmitting}
112 />
113 {errors.author && (
114 <p className="text-sm text-red-600 mt-1">{errors.author}</p>
115 )}
116 </div>
117 
118 {/* Pages field */}
119 <div>
120 <label className="block text-sm font-medium mb-1">
121 Pages (optional)
122 </label>
123 <input
124 type="text"
125 value={formData.pages}
126 onChange={(e) => setFormData({ ...formData, pages: e.target.value })}
127 className={`w-full px-3 py-2 border rounded ${
128 errors.pages ? "border-red-500" : "border-gray-300"
129 }`}
130 disabled={isSubmitting}
131 />
132 {errors.pages && (
133 <p className="text-sm text-red-600 mt-1">{errors.pages}</p>
134 )}
135 </div>
136 
137 {/* Success message */}
138 {submitSuccess && (
139 <div className="p-3 bg-green-100 text-green-800 rounded">
140 Book added successfully!
141 </div>
142 )}
143 
144 {/* Error message */}
145 {submitError && (
146 <div className="p-3 bg-red-100 text-red-800 rounded">{submitError}</div>
147 )}
148 
149 {/* Submit button */}
150 <button
151 type="submit"
152 disabled={isSubmitting}
153 className={`w-full px-4 py-2 rounded font-medium ${
154 isSubmitting
155 ? "bg-gray-400 text-gray-600 cursor-not-allowed"
156 : "bg-blue-600 text-white hover:bg-blue-700"
157 }`}
158 >
159 {isSubmitting ? "Adding..." : "Add Book"}
160 </button>
161 </form>
162 );
163}

The Corresponding API Route

+ TypeScript
1// app/api/books/route.ts
2 
3export async function POST(request: Request) {
4 try {
5 const body = await request.json();
6 const { title, author, pages } = body;
7 
8 // Server-side validation (never trust the client)
9 if (!title || typeof title !== "string" || title.trim().length === 0) {
10 return new Response(
11 JSON.stringify({ message: "Title is required and must be a string" }),
12 { status: 400, headers: { "Content-Type": "application/json" } },
13 );
14 }
15 
16 if (!author || typeof author !== "string" || author.trim().length === 0) {
17 return new Response(JSON.stringify({ message: "Author is required" }), {
18 status: 400,
19 });
20 }
21 
22 // Save to database
23 const book = await db.book.create({
24 title: title.trim(),
25 author: author.trim(),
26 pages: pages ? parseInt(pages, 10) : null,
27 });
28 
29 return new Response(JSON.stringify(book), {
30 status: 201,
31 headers: { "Content-Type": "application/json" },
32 });
33 } catch (err) {
34 console.error("POST /api/books failed:", err);
35 return new Response(JSON.stringify({ message: "Internal server error" }), {
36 status: 500,
37 });
38 }
39}

Key lessons:

  1. 1Validate client-side for UX. Validate server-side for security.
  2. 2Show loading state (button text changes, button is disabled).
  3. 3Show error messages (network, validation, server errors).
  4. 4Show success message (user knows something happened).
  5. 5Handle edge cases (what if the network is slow? What if the server crashes?).

+ 06 / 17

Section 5: Performance, The Lighthouse Mentality

Lighthouse is the browser's report card for your site. It measures:

  • First Contentful Paint (FCP): How long until something visible appears?
  • Largest Contentful Paint (LCP): How long until the main content is visible?
  • Cumulative Layout Shift (CLS): Does the layout jump around while loading?
  • Time to Interactive (TTI): How long until the user can click?
  • Total Blocking Time (TBT): How long is JavaScript blocking the main thread?

A Lighthouse score of 90+ means your app feels fast. Below 50 means users will abandon it.

Bundle Size Matters

Every kilobyte of JavaScript shipped to the browser is JavaScript the user must download, parse, and execute. Here's the math on a typical 4G connection (1.5 Mbps):

  • 100 KB bundle: 670 ms download + 100 ms parse/compile + 500 ms execution = ~1.3 seconds before interactive
  • 500 KB bundle: 3.3 seconds download + 500 ms parse/compile + 2 seconds execution = ~6 seconds before interactive

The difference between a fast and slow app is often just 100–200 KB of JavaScript.

Strategies

1. Code splitting: Next.js does this automatically. Each route only loads the code for that route, not the entire app.

+ TSX
1// ✓ Good: Only loaded when the user visits /dashboard
2// app/dashboard/page.tsx
3import { HeavyChart } from "@/components/HeavyChart";
4 
5export default function Dashboard() {
6 return <HeavyChart />;
7}

2. Dynamic imports: For code that's not needed on the initial page, use dynamic imports.

+ TSX
1// ✓ Good: HeavyModal only loads when the user clicks "open modal"
2"use client";
3 
4import { useState } from "react";
5import dynamic from "next/dynamic";
6 
7const HeavyModal = dynamic(() => import("@/components/HeavyModal"), {
8 loading: () => <div>Loading...</div>,
9});
10 
11export function PageWithModal() {
12 const [isOpen, setIsOpen] = useState(false);
13 
14 return (
15 <div>
16 <button onClick={() => setIsOpen(true)}>Open Modal</button>
17 {isOpen && <HeavyModal />}
18 </div>
19 );
20}

3. Server components: Keep heavy logic on the server. Don't ship database queries as client code.

+ TSX
1// ✓ Good: Database query runs on server, only results are sent to client
2export default async function BookList() {
3 const books = await db.book.findMany(); // Runs on server, doesn't ship to browser
4 return <BookListClient books={books} />;
5}

4. Image optimization: Use <Image /> instead of <img />. Next.js serves optimized sizes for different devices.

+ TSX
1import Image from "next/image";
2 
3export function BookCard({ book }: { book: Book }) {
4 return (
5 <div>
6 <Image
7 src={book.coverUrl}
8 alt={book.title}
9 width={200}
10 height={300}
11 // Next.js automatically serves smaller versions for mobile
12 // and modern formats (WebP) for supported browsers
13 />
14 <h3>{book.title}</h3>
15 </div>
16 );
17}

5. Minimize layout shifts: Declare image dimensions or skeleton loaders so the layout doesn't jump.

+ TSX
1"use client";
2 
3import { useState, useEffect } from "react";
4import Image from "next/image";
5 
6export function BookCover({ url }: { url: string }) {
7 const [isLoading, setIsLoading] = useState(true);
8 
9 return (
10 <div className="relative w-48 h-72 bg-gray-200">
11 {isLoading && (
12 <div className="absolute inset-0 bg-gray-300 animate-pulse" />
13 )}
14 <Image
15 src={url}
16 alt="Cover"
17 fill
18 onLoad={() => setIsLoading(false)}
19 className="object-cover"
20 />
21 </div>
22 );
23}

Analyzing Performance

+ Terminal
1# Build the app and measure bundle size
2npm run build
3 
4# Next.js will show you:
5# ✓ Route (pages) Size First Load JS
6# ├ /_app [shared runtime]
7# ├ /_document - [shared]
8# ├ / (index) 2 kB 85 kB
9# ├ /dashboard 3 kB 120 kB
10# └ /books/[id] 4 kB 92 kB
11# + First Load JS shared by all 80 kB
12 
13# 80 kB = third-party libraries + Next.js runtime
14# 85 kB = homepage (80 + 5)
15# 120 kB = dashboard (80 + 40 for HeavyChart)

If a route is much larger than others, investigate what's being imported. Use the bundle analyzer:

+ Terminal
1npm install --save-dev @next/bundle-analyzer
2 
3# In next.config.js:
4const withBundleAnalyzer = require('@next/bundle-analyzer')({
5 enabled: process.env.ANALYZE === 'true',
6});
7 
8module.exports = withBundleAnalyzer({
9 // ... rest of config
10});
11 
12# Run with analysis:
13ANALYZE=true npm run build
14# Opens a browser showing what's in your bundle

+ 07 / 17

Section 6: Deployment to Vercel

Vercel is purpose-built for Next.js. It handles deployment, CI/CD, automatic previews on pull requests, and edge functions. It's the easiest way to ship a web app.

Step 1: Create a Git Repository

+ Terminal
1# If you haven't already
2git init
3git add .
4git commit -m "Initial commit"
5git remote add origin https://github.com/yourusername/your-app.git
6git push -u origin main

Step 2: Sign Up for Vercel

Go to vercel.com and sign up with your GitHub account. Vercel will ask for permission to read your GitHub repos.

Step 3: Import Your Project

In the Vercel dashboard, click "New Project" and select your repo. Vercel will detect that it's a Next.js app and configure it automatically.

+ JavaScript
1Project settings:
2Framework: Next.js
3Root directory: ./
4Build command: next build
5Output directory: .next
6Environment variables: (add any .env.local secrets here)

Step 4: Add Environment Variables

If your app uses secrets (database URL, API keys), add them in the Vercel dashboard under Settings → Environment Variables.

+ JavaScript
1DATABASE_URL=postgres://...
2NEXT_PUBLIC_API_URL=https://api.example.com
3STRIPE_SECRET_KEY=sk_...

Important: Any variable starting with NEXT_PUBLIC_ is visible in the browser. Don't put secrets there. Variables without that prefix are server-only and safe.

Step 5: Deploy

Push to main, and Vercel deploys automatically. You'll get a URL: https://your-app.vercel.app.

+ Terminal
1git push origin main
2# Vercel sees the push, runs npm install + npm run build, deploys
3# You can watch the build in the Vercel dashboard

Step 6: Connect Your Domain

In Vercel's dashboard, go to Settings → Domains and add your custom domain. Vercel will show you the DNS records to update with your registrar.

+ JavaScript
1Nameservers (easiest):
2ns1.vercel.com
3ns2.vercel.com
4ns3.vercel.com

Or CNAME records if you want to keep your registrar as the authoritative DNS provider.

Preview Deployments

Every pull request gets its own preview URL. Before merging, you can see the live version and share it with others.

+ Terminal
1git checkout -b new-feature
2# Make changes, push
3git push origin new-feature
4 
5# In GitHub, create a pull request. Vercel automatically deploys a preview.
6# You get a comment on the PR: "Preview deployment ready!"
7# Click the link to see the live preview.

This is invaluable for feedback before shipping.

Monitoring and Logs

In the Vercel dashboard, you can see:

  • Deployment history: Every build, success/fail, who triggered it
  • Real-time logs: Live console.log() output during builds and requests
  • Analytics: Page load times, edge function performance, which routes are visited most
  • Usage: API calls, data transfer, compute time

+ 08 / 17

Section 7: Building the Project (Checkpoint)

Project Options

Choose one:

1. Reading List / Book Tracker Track books you want to read, are reading, and have finished. Features: add books (manual or from an API), rate and review, set reading goals, export list.

2. Personal Portfolio Showcase your work with projects, skills, testimonials. Features: project gallery with images, contact form, downloadable resume, blog or case studies.

3. Expense Tracker / Budget App Track spending across categories. Features: add transactions, categorise, monthly summaries, charts, recurring transactions, export to CSV.

4. Learning Dashboard Track your progress on courses or learning goals. Features: courses/topics, mark lessons complete, streak counter, quiz or flashcards, progress graphs.

Pick one based on:

  • What would you actually use? If you won't use it, don't build it.
  • Scope. You have 6–8 weeks. A portfolio is smaller than an expense tracker.
  • Shipping value. Can you deploy it and share the link? Does someone else want to use it?

Minimum Features for Shipping

Every project needs:

  1. 1Display data (at least one server component fetching and rendering data)
  2. 2Create/edit data (a form with validation, error handling, success message)
  3. 3Delete data (a button that removes an item with a confirmation)
  4. 4Mobile responsive (looks good on phone and desktop)
  5. 5Error handling (show errors, not crashes)
  6. 6Performance (Lighthouse 90+ on all metrics)

Example structure for the reading list:

+ JavaScript
1app/
2├── layout.tsx # Root, nav, theme
3├── page.tsx # Home / landing
4├── books/
5│ ├── page.tsx # List all books (server component)
6│ ├── [id]/page.tsx # Book detail view
7│ └── components/
8│ ├── AddBookForm.tsx # Add book form (client)
9│ ├── BookCard.tsx # Book display (server)
10│ └── DeleteButton.tsx # Delete with confirmation (client)
11├── api/
12│ └── books/
13│ ├── route.ts # GET/POST /api/books
14│ └── [id]/route.ts # PUT/DELETE /api/books/[id]
15└── components/
16 ├── Header.tsx # Site header (server)
17 ├── Footer.tsx # Site footer
18 └── ThemeToggle.tsx # Dark mode toggle (client)

Checkpoint Rubric

Your project ships when:

+ Checklist0 / 13

Example: Building the Book Tracker (Abbreviated)

+ TSX
1// app/books/page.tsx, Server component
2import { db } from "@/lib/db";
3import { AddBookForm } from "./components/AddBookForm";
4import { BookCard } from "./components/BookCard";
5 
6export default async function BooksPage() {
7 const books = await db.book.findMany({
8 orderBy: { createdAt: "desc" },
9 });
10 
11 return (
12 <div className="max-w-4xl mx-auto py-8">
13 <h1 className="text-3xl font-bold mb-8">My Books</h1>
14 
15 <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
16 <div>
17 <h2 className="text-lg font-semibold mb-4">Add a Book</h2>
18 <AddBookForm />
19 </div>
20 
21 <div>
22 <h2 className="text-lg font-semibold mb-4">{books.length} Books</h2>
23 {books.length === 0 ? (
24 <p className="text-gray-500">No books yet. Add one!</p>
25 ) : (
26 <div className="space-y-4">
27 {books.map((book) => (
28 <BookCard key={book.id} book={book} />
29 ))}
30 </div>
31 )}
32 </div>
33 </div>
34 </div>
35 );
36}

+ 09 / 17

Section 8: Optional, Electron & Desktop

Building a desktop app is optional, but valuable if you want your product to work offline, access the file system, or live as a native app on Mac/Windows.

Why Electron?

Electron wraps your web app in a native browser (Chromium) and gives you access to:

  • File system: Read/write local files, open file dialogs
  • Notifications: Native OS notifications
  • Menus: Native top menu bar (Mac) or app menu (Windows)
  • Offline: App works without internet (if designed for it)
  • Native feel: Taskbar integration, app icon, window chrome

The trade-off: bundle size increases (Chromium is ~150 MB), startup is slower, and you need to handle Mac/Windows differences.

Electron + Next.js Architecture

The typical setup:

  • Main process (Node.js, can access file system and OS)
  • Renderer process (Chromium, runs your Next.js app)
  • IPC (Inter-Process Communication) bridge them
+ JavaScript
1main/
2 index.ts # Electron main process
3 
4out/
5 main/index.js # Compiled main process
6 
7public/
8 app/ # Next.js build output (.next)
9 
10electron-builder.yml # Configuration for building installers
11 
12package.json
13 "main": "out/main/index.js"
14 "homepage": "file://__dirname/../public/app"

Setting Up Electron

+ Terminal
1npm install electron --save-dev
2npm install electron-builder --save-dev

Create a main process:

+ TypeScript
1// main/index.ts
2import { app, BrowserWindow, Menu } from "electron";
3import path from "path";
4 
5let mainWindow: BrowserWindow | null;
6 
7const createWindow = () => {
8 mainWindow = new BrowserWindow({
9 width: 1200,
10 height: 800,
11 webPreferences: {
12 preload: path.join(__dirname, "preload.js"),
13 nodeIntegration: false,
14 contextIsolation: true,
15 },
16 });
17 
18 // In development, load from localhost:3000
19 // In production, load from file://
20 const isDev = process.env.NODE_ENV === "development";
21 const url = isDev
22 ? "http://localhost:3000"
23 : `file://${path.join(__dirname, "../public/app/index.html")}`;
24 
25 mainWindow.loadURL(url);
26 
27 if (isDev) {
28 mainWindow.webContents.openDevTools();
29 }
30};
31 
32app.on("ready", createWindow);
33 
34// Quit when all windows close (except on macOS)
35app.on("window-all-closed", () => {
36 if (process.platform !== "darwin") {
37 app.quit();
38 }
39});
40 
41// On macOS, re-create the window when the dock icon is clicked
42app.on("activate", () => {
43 if (mainWindow === null) {
44 createWindow();
45 }
46});

Key Differences from Web

  1. 1No backend needed. The desktop app is all client-side (unless you ship a server). Data lives in ~/.config/yourapp/ or the system's application data folder.
  1. 1File system access. Use Electron's file dialogs:
+ TypeScript
1// preload.ts (safe bridge between renderer and main)
2import { contextBridge, ipcMain } from "electron";
3import { dialog } from "electron";
4 
5contextBridge.exposeInMainWorld("electron", {
6 openFile: () =>
7 dialog.showOpenDialog({
8 properties: ["openFile"],
9 filters: [{ name: "Text", extensions: ["txt", "md"] }],
10 }),
11});

Then in your React component:

+ TSX
1"use client";
2 
3export function ImportFile() {
4 const handleImport = async () => {
5 const result = await window.electron.openFile();
6 if (result.canceled) return;
7 
8 const filePath = result.filePaths[0];
9 // Read the file, import data
10 };
11 
12 return <button onClick={handleImport}>Import from File</button>;
13}
  1. 1Native menus. Use Electron's Menu API instead of building a dropdown:
+ TypeScript
1// main/index.ts
2const menu = [
3 {
4 label: "File",
5 submenu: [
6 { label: "Open", accelerator: "CmdOrCtrl+O", click: () => {} },
7 { label: "Save", accelerator: "CmdOrCtrl+S", click: () => {} },
8 ],
9 },
10 {
11 label: "Edit",
12 submenu: [{ label: "Undo", accelerator: "CmdOrCtrl+Z", click: () => {} }],
13 },
14];
15 
16Menu.setApplicationMenu(Menu.buildFromTemplate(menu));
  1. 1Offline-first data. If the app should work offline, store data locally (SQLite, JSON files) instead of relying on a server.
+ TSX
1// Example: use IndexedDB (same in web and Electron)
2async function saveBook(book: Book) {
3 const db = await openDB("books");
4 await db.add("books", book);
5}

Building Installers

Use electron-builder to create .dmg (Mac) and .exe (Windows) installers:

+ Terminal
1npm run build # Build Next.js
2npm run electron:build # Package into installers

The installers live in dist/ and are ready to distribute.

Platform-Specific Considerations

macOS:

  • Window chrome is minimal (traffic light buttons)
  • Menu bar is at the top
  • Cmd+Q quits the app, Cmd+W closes the window
  • Notarization required for distribution (Apple's code signing)

Windows:

  • Menu bar is in the window (or hidden and accessed via Alt)
  • System tray integration is common
  • Users expect .exe and .msi installers

Always test on both platforms. The "works on my Mac" problem is real.


+ 10 / 17

Section 9: Shipping Checklist

Before deploying to production, run through this list:

Code Quality

+ Checklist0 / 7

Performance

+ Checklist0 / 7

User Experience

+ Checklist0 / 8

Security

+ Checklist0 / 6

Monitoring

+ Checklist0 / 5

Documentation

+ Checklist0 / 5

+ 11 / 17

Section 10: Craft and constraint

Craft is the differentiator

Features are commodities. Everyone can build a todo list or expense tracker. What separates a shipped product from a tutorial app is craft.

Craft is:

  • The loading state that appears instantly when a form submits
  • The success message that stays for exactly 3 seconds, then fades smoothly
  • The validation error that appears below the field without changing the layout
  • The skeleton loader that matches the shape of the content
  • The button that's disabled during submission (so you can't double-click)
  • The error message that tells you why something failed, not just "error"
  • The animation that guides your eye without being distracting
  • The corner radius that's consistent throughout the app
  • The color palette that was carefully chosen, not copied
  • The empty state that's actually useful, not just a placeholder

Most apps fail not because the feature is wrong, but because the details are missing. A shipped product notices everything.

In your project: Spend 20% of your time on features, 80% on refinement. Polish the forms. Animate the transitions. Test on mobile. Write helpful error messages. A perfectly polished simple app beats a feature-rich mess.

You write React, the browser runs it

You write React. React doesn't run in production, the browser does. Next.js doesn't run in production, Vercel does. Understanding the platform shapes every decision you make.

JavaScript shipping costs. Every byte of JavaScript the user downloads is JavaScript they must execute. If your app is 500 KB of JavaScript, it doesn't matter how well you wrote it, it'll feel slow on 4G. The answer isn't to optimize your code; it's to ship less code. This is why server components matter: code that runs on the server doesn't ship to the browser.

The network is the bottleneck. An API call that takes 1 second makes a form feel broken, no matter how fast your code is. The answer isn't to optimize the API; it's to design around latency. Optimistic updates (show the success state before the server responds), local caching, and careful request batching all protect users from slow networks.

The platform gives you superpowers. Next.js gives you file-based routing (no setup), automatic code splitting (less shipped), server components (fast), edge functions (fast APIs), and middleware. Vercel gives you global CDN (faster serving), preview deployments (safer shipping), and built-in monitoring. These are not free, they're earned through understanding what the platform does and how to use it.

Constraints breed innovation. Mobile has no hover state. Desktop has no touch. The browser has security boundaries. These aren't problems; they're design parameters. Understanding them means you design for what's possible, not for an imaginary perfect device.

In your project: Don't fight the platform. You're not writing generic JavaScript; you're writing for Next.js, which is built on React, which is for browsers, which run on devices. Each layer has rules. Follow them, and you get speed and reliability for free. Fight them, and you burn time and ship a slow app.


+ 12 / 17

Section 11: Shipping Your Project

Week 1–2: Design and Plan

  • Sketch the app on paper or in Figma
  • List the core features (don't over-scope)
  • Set up the Next.js project with TypeScript, Tailwind, and your database
  • Create the data model (what tables do you need?)

Week 2–3: Build the Essentials

  • Create routes for displaying data
  • Build the main form (add/edit)
  • Wire up the database
  • Deploy to Vercel (yes, this early, so you can share a live link)

Week 3–4: Refine

  • Add delete/edit functionality
  • Improve mobile responsive
  • Add error handling and loading states
  • Test on actual devices

Week 4–6: Polish

  • Optimize bundle size and performance
  • Audit with Lighthouse, hit 90+
  • Refine animations and transitions
  • Test all edge cases (empty state, errors, slow network)
  • Write README and deployment docs

Week 6–8: Launch

  • Final testing on Mac, Windows, mobile, tablet
  • Set up monitoring/error tracking
  • Custom domain (if applicable)
  • Write a launch post, share on Twitter/LinkedIn
  • Collect feedback from early users

Optional (if doing Electron)

  • Extract main process and set up Electron
  • Build installers and test on both platforms
  • Add file system features (import/export)
  • Test offline functionality

+ 13 / 17

Section 12: Common Mistakes to Avoid

  1. 1Building in isolation. Deploy early, get feedback early. A live app is better than perfect code that no one sees.
  1. 1Ignoring performance until the end. By then, the bundle is 500 KB and you're fighting optimization. Measure from day one.
  1. 1Shipping without error handling. What happens if the server is down? The network is slow? The user is offline? Plan for these. They're not edge cases; they're normal.
  1. 1Over-engineering. A todo list doesn't need a complex state management library. Start simple. Add complexity only when you hit its limits.
  1. 1Forgetting the mobile experience. "Works on my Mac" is not shipping. Test on actual phones (or at least Chrome DevTools mobile mode).
  1. 1Mixing concerns. Keep components small. Keep forms separate from display logic. Keep API routes focused on one job.
  1. 1Not documenting as you go. Future you will forget how this works. Leave yourself notes (README, inline comments for non-obvious decisions).

+ 14 / 17

Section 13: Final Thoughts

Shipping a product is an entirely different skill from building a feature. It's about understanding the full stack, from the React code you write to the browser that runs it to the network it travels on to the device it lands on. It's about noticing everything: the loading state, the error message, the animation, the performance, the edge case.

This module gives you the tools. The only thing left is to build something you're proud of, deploy it, and ship it to real people. That's the differentiator.


+ 15 / 17

Checkpoint Rubric (Final Assessment)

Your project is shipping-ready when:

Functionality (40 points)

+ Checklist0 / 4

Performance (20 points)

+ Checklist0 / 2

User Experience (20 points)

+ Checklist0 / 4

Deployment (10 points)

+ Checklist0 / 2

Extra Credit (up to 10 points)

+ Checklist0 / 3

Total: 100 points


+ 16 / 17

Resources

Next.js:

Performance:

Deployment:

Electron:

Forms & Validation:

  • React Hook Form, lightweight form library (optional, not required)
  • Zod, TypeScript-first schema validation

+ 17 / 17

Next Steps

  1. 1Choose a project idea (reading list, portfolio, expense tracker, etc.)
  2. 2Sketch the features and data model
  3. 3Initialize a Next.js app with TypeScript and Tailwind
  4. 4Deploy to Vercel immediately (get the live URL)
  5. 5Build the core features (display, create, delete)
  6. 6Measure performance and iterate
  7. 7Polish the UI and refine error handling
  8. 8Checkpoint: submit your live link and Lighthouse report
  9. 9(Optional) Wrap in Electron and build installers

The checkpoint is a live product. Not a demo, not a tutorial app. Something you'd actually use or show to a friend. Make it simple, but make it shipped.

+ Up nextDesigning AI-native interaction patternsPreviouslyMotion & Interaction Engineering