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
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.
Section 1: The Shape of a Shipped Product
What "Shipping" Means
A shipped product has three layers:
- 1The code layer, your React components, Next.js routes, API handlers
- 2The platform layer, Next.js, Vercel, the browser, the OS
- 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.
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.
app/├── layout.tsx # Root layout, wraps all pages├── page.tsx # Home page (/)├── dashboard/│ ├── layout.tsx # Dashboard layout│ └── page.tsx # /dashboard├── api/│ ├── books/│ │ ├── route.ts # GET/POST /api/books│ │ └── [id]/│ │ └── route.ts # GET/PUT/DELETE /api/books/[id]└── (marketing)/ ├── about/page.tsx # /about (grouped in parens, doesn't affect URL) └── 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:
// app/(auth)/login/page.tsx → /login (not /auth/login)// app/(auth)/layout.tsx wraps both login and signup with a centered card export default function AuthLayout({ children,}: { children: React.ReactNode;}) { return ( <div className="flex items-center justify-center min-h-screen bg-gray-100"> <div className="w-full max-w-md">{children}</div> </div> );}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.
// app/dashboard/page.tsx, Server Component (no 'use client')// This code runs on the server, not in the browser import { db } from "@/lib/db"; export default async function DashboardPage() { const books = await db.book.findMany({ limit: 50 }); return ( <div> <h1>Your Books</h1> <ul> {books.map((book) => ( <li key={book.id}>{book.title}</li> ))} </ul> </div> );}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.
"use client"; import { useState } from "react"; export default function AddBookForm() { const [title, setTitle] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsSubmitting(true); const res = await fetch("/api/books", { method: "POST", body: JSON.stringify({ title }), }); setIsSubmitting(false); if (!res.ok) alert("Failed to add book"); }; return ( <form onSubmit={handleSubmit}> <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Book title" /> <button disabled={isSubmitting}> {isSubmitting ? "Adding..." : "Add Book"} </button> </form> );}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.
// Good: Server component wraps client componentexport default function BooksPage() { // runs on server, queries database const books = await db.book.findMany(); return ( <div> <BookList books={books} /> {/* Server component */} <AddBookForm /> {/* Client component */} </div> );} // Bad: Everything becomes client-rendered("use client"); export default function BooksPage() { // Now this entire page and its children run in the browser // You lose the speed and security benefits}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:
Page (Server)├── Header (Server), fetches user profile once├── Sidebar (Server), fetches menu data once└── MainContent (Client) ├── FilterBar (Client), handles filter state ├── SearchInput (Client), handles search input, debounces └── ResultsList (Server), passed results from parent, just rendersThe 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:
- 1Downloads the JavaScript bundle (100KB–500KB)
- 2Parses and compiles it (takes ~1s on slow devices)
- 3Runs the JavaScript (hydration, React initializes, re-renders, attaches event listeners)
- 4Makes an API call to fetch data
- 5Re-renders again with the data
- 6Now the page is interactive
That's 3–5 seconds before anything interactive happens.
With Server Components:
- 1Server fetches data and renders HTML
- 2Browser receives HTML and renders it instantly
- 3Browser downloads the client component JavaScript (only what's needed for interactivity)
- 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:
// app/books/page.tsxexport default async function BooksPage() { const res = await fetch("https://api.example.com/books"); const books = await res.json(); return ( <div> {books.map((book) => ( <BookCard key={book.id} book={book} /> ))} </div> );}In a client component, use an effect hook:
"use client"; import { useEffect, useState } from "react"; export default function BooksPage() { const [books, setBooks] = useState([]); const [isLoading, setIsLoading] = useState(true); useEffect(() => { const fetchBooks = async () => { const res = await fetch("/api/books"); const data = await res.json(); setBooks(data); setIsLoading(false); }; fetchBooks(); }, []); if (isLoading) return <div>Loading...</div>; return ( <div> {books.map((book) => ( <BookCard key={book.id} book={book} /> ))} </div> );}The server component is faster and cleaner. Use client components only when you need state or event listeners.
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
"use client"; import { useState } from "react"; interface AddBookFormProps { onSuccess?: () => void;} export function AddBookForm({ onSuccess }: AddBookFormProps) { const [formData, setFormData] = useState({ title: "", author: "", pages: "", }); const [errors, setErrors] = useState<Record<string, string>>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(""); const [submitSuccess, setSubmitSuccess] = useState(false); // Client-side validation const validateForm = () => { const newErrors: Record<string, string> = {}; if (!formData.title.trim()) { newErrors.title = "Title is required"; } if (!formData.author.trim()) { newErrors.author = "Author is required"; } if (formData.pages && isNaN(Number(formData.pages))) { newErrors.pages = "Pages must be a number"; } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSubmitError(""); setSubmitSuccess(false); // Validate before submitting if (!validateForm()) return; setIsSubmitting(true); try { const res = await fetch("/api/books", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(formData), }); if (!res.ok) { // Server responded with an error const error = await res.json(); setSubmitError(error.message || "Failed to add book"); return; } // Success setSubmitSuccess(true); setFormData({ title: "", author: "", pages: "" }); // Clear success message after 3s setTimeout(() => setSubmitSuccess(false), 3000); // Callback to parent (e.g. to refresh the book list) onSuccess?.(); } catch (err) { // Network error setSubmitError("Network error. Please try again."); console.error(err); } finally { setIsSubmitting(false); } }; return ( <form onSubmit={handleSubmit} className="space-y-4 max-w-md mx-auto"> <h2 className="text-lg font-bold">Add a Book</h2> {/* Title field */} <div> <label className="block text-sm font-medium mb-1">Title</label> <input type="text" value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} className={`w-full px-3 py-2 border rounded ${ errors.title ? "border-red-500" : "border-gray-300" }`} disabled={isSubmitting} /> {errors.title && ( <p className="text-sm text-red-600 mt-1">{errors.title}</p> )} </div> {/* Author field */} <div> <label className="block text-sm font-medium mb-1">Author</label> <input type="text" value={formData.author} onChange={(e) => setFormData({ ...formData, author: e.target.value })} className={`w-full px-3 py-2 border rounded ${ errors.author ? "border-red-500" : "border-gray-300" }`} disabled={isSubmitting} /> {errors.author && ( <p className="text-sm text-red-600 mt-1">{errors.author}</p> )} </div> {/* Pages field */} <div> <label className="block text-sm font-medium mb-1"> Pages (optional) </label> <input type="text" value={formData.pages} onChange={(e) => setFormData({ ...formData, pages: e.target.value })} className={`w-full px-3 py-2 border rounded ${ errors.pages ? "border-red-500" : "border-gray-300" }`} disabled={isSubmitting} /> {errors.pages && ( <p className="text-sm text-red-600 mt-1">{errors.pages}</p> )} </div> {/* Success message */} {submitSuccess && ( <div className="p-3 bg-green-100 text-green-800 rounded"> Book added successfully! </div> )} {/* Error message */} {submitError && ( <div className="p-3 bg-red-100 text-red-800 rounded">{submitError}</div> )} {/* Submit button */} <button type="submit" disabled={isSubmitting} className={`w-full px-4 py-2 rounded font-medium ${ isSubmitting ? "bg-gray-400 text-gray-600 cursor-not-allowed" : "bg-blue-600 text-white hover:bg-blue-700" }`} > {isSubmitting ? "Adding..." : "Add Book"} </button> </form> );}The Corresponding API Route
// app/api/books/route.ts export async function POST(request: Request) { try { const body = await request.json(); const { title, author, pages } = body; // Server-side validation (never trust the client) if (!title || typeof title !== "string" || title.trim().length === 0) { return new Response( JSON.stringify({ message: "Title is required and must be a string" }), { status: 400, headers: { "Content-Type": "application/json" } }, ); } if (!author || typeof author !== "string" || author.trim().length === 0) { return new Response(JSON.stringify({ message: "Author is required" }), { status: 400, }); } // Save to database const book = await db.book.create({ title: title.trim(), author: author.trim(), pages: pages ? parseInt(pages, 10) : null, }); return new Response(JSON.stringify(book), { status: 201, headers: { "Content-Type": "application/json" }, }); } catch (err) { console.error("POST /api/books failed:", err); return new Response(JSON.stringify({ message: "Internal server error" }), { status: 500, }); }}Key lessons:
- 1Validate client-side for UX. Validate server-side for security.
- 2Show loading state (button text changes, button is disabled).
- 3Show error messages (network, validation, server errors).
- 4Show success message (user knows something happened).
- 5Handle edge cases (what if the network is slow? What if the server crashes?).
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.
// ✓ Good: Only loaded when the user visits /dashboard// app/dashboard/page.tsximport { HeavyChart } from "@/components/HeavyChart"; export default function Dashboard() { return <HeavyChart />;}2. Dynamic imports: For code that's not needed on the initial page, use dynamic imports.
// ✓ Good: HeavyModal only loads when the user clicks "open modal""use client"; import { useState } from "react";import dynamic from "next/dynamic"; const HeavyModal = dynamic(() => import("@/components/HeavyModal"), { loading: () => <div>Loading...</div>,}); export function PageWithModal() { const [isOpen, setIsOpen] = useState(false); return ( <div> <button onClick={() => setIsOpen(true)}>Open Modal</button> {isOpen && <HeavyModal />} </div> );}3. Server components: Keep heavy logic on the server. Don't ship database queries as client code.
// ✓ Good: Database query runs on server, only results are sent to clientexport default async function BookList() { const books = await db.book.findMany(); // Runs on server, doesn't ship to browser return <BookListClient books={books} />;}4. Image optimization: Use <Image /> instead of <img />. Next.js serves optimized sizes for different devices.
import Image from "next/image"; export function BookCard({ book }: { book: Book }) { return ( <div> <Image src={book.coverUrl} alt={book.title} width={200} height={300} // Next.js automatically serves smaller versions for mobile // and modern formats (WebP) for supported browsers /> <h3>{book.title}</h3> </div> );}5. Minimize layout shifts: Declare image dimensions or skeleton loaders so the layout doesn't jump.
"use client"; import { useState, useEffect } from "react";import Image from "next/image"; export function BookCover({ url }: { url: string }) { const [isLoading, setIsLoading] = useState(true); return ( <div className="relative w-48 h-72 bg-gray-200"> {isLoading && ( <div className="absolute inset-0 bg-gray-300 animate-pulse" /> )} <Image src={url} alt="Cover" fill onLoad={() => setIsLoading(false)} className="object-cover" /> </div> );}Analyzing Performance
# Build the app and measure bundle sizenpm run build # Next.js will show you:# ✓ Route (pages) Size First Load JS# ├ /_app [shared runtime]# ├ /_document - [shared]# ├ / (index) 2 kB 85 kB# ├ /dashboard 3 kB 120 kB# └ /books/[id] 4 kB 92 kB# + First Load JS shared by all 80 kB # 80 kB = third-party libraries + Next.js runtime# 85 kB = homepage (80 + 5)# 120 kB = dashboard (80 + 40 for HeavyChart)If a route is much larger than others, investigate what's being imported. Use the bundle analyzer:
npm install --save-dev @next/bundle-analyzer # In next.config.js:const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true',}); module.exports = withBundleAnalyzer({ // ... rest of config}); # Run with analysis:ANALYZE=true npm run build# Opens a browser showing what's in your bundleSection 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
# If you haven't alreadygit initgit add .git commit -m "Initial commit"git remote add origin https://github.com/yourusername/your-app.gitgit push -u origin mainStep 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.
Project settings:✓ Framework: Next.js✓ Root directory: ./✓ Build command: next build✓ Output directory: .next✓ Environment 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.
DATABASE_URL=postgres://...NEXT_PUBLIC_API_URL=https://api.example.comSTRIPE_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.
git push origin main# Vercel sees the push, runs npm install + npm run build, deploys# You can watch the build in the Vercel dashboardStep 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.
Nameservers (easiest):ns1.vercel.comns2.vercel.comns3.vercel.comOr 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.
git checkout -b new-feature# Make changes, pushgit push origin new-feature # In GitHub, create a pull request. Vercel automatically deploys a preview.# You get a comment on the PR: "Preview deployment ready!"# 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
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:
- 1Display data (at least one server component fetching and rendering data)
- 2Create/edit data (a form with validation, error handling, success message)
- 3Delete data (a button that removes an item with a confirmation)
- 4Mobile responsive (looks good on phone and desktop)
- 5Error handling (show errors, not crashes)
- 6Performance (Lighthouse 90+ on all metrics)
Example structure for the reading list:
app/├── layout.tsx # Root, nav, theme├── page.tsx # Home / landing├── books/│ ├── page.tsx # List all books (server component)│ ├── [id]/page.tsx # Book detail view│ └── components/│ ├── AddBookForm.tsx # Add book form (client)│ ├── BookCard.tsx # Book display (server)│ └── DeleteButton.tsx # Delete with confirmation (client)├── api/│ └── books/│ ├── route.ts # GET/POST /api/books│ └── [id]/route.ts # PUT/DELETE /api/books/[id]└── components/ ├── Header.tsx # Site header (server) ├── Footer.tsx # Site footer └── ThemeToggle.tsx # Dark mode toggle (client)Checkpoint Rubric
Your project ships when:
Example: Building the Book Tracker (Abbreviated)
// app/books/page.tsx, Server componentimport { db } from "@/lib/db";import { AddBookForm } from "./components/AddBookForm";import { BookCard } from "./components/BookCard"; export default async function BooksPage() { const books = await db.book.findMany({ orderBy: { createdAt: "desc" }, }); return ( <div className="max-w-4xl mx-auto py-8"> <h1 className="text-3xl font-bold mb-8">My Books</h1> <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div> <h2 className="text-lg font-semibold mb-4">Add a Book</h2> <AddBookForm /> </div> <div> <h2 className="text-lg font-semibold mb-4">{books.length} Books</h2> {books.length === 0 ? ( <p className="text-gray-500">No books yet. Add one!</p> ) : ( <div className="space-y-4"> {books.map((book) => ( <BookCard key={book.id} book={book} /> ))} </div> )} </div> </div> </div> );}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
main/ index.ts # Electron main process out/ main/index.js # Compiled main process public/ app/ # Next.js build output (.next) electron-builder.yml # Configuration for building installers package.json "main": "out/main/index.js" "homepage": "file://__dirname/../public/app"Setting Up Electron
npm install electron --save-devnpm install electron-builder --save-devCreate a main process:
// main/index.tsimport { app, BrowserWindow, Menu } from "electron";import path from "path"; let mainWindow: BrowserWindow | null; const createWindow = () => { mainWindow = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, "preload.js"), nodeIntegration: false, contextIsolation: true, }, }); // In development, load from localhost:3000 // In production, load from file:// const isDev = process.env.NODE_ENV === "development"; const url = isDev ? "http://localhost:3000" : `file://${path.join(__dirname, "../public/app/index.html")}`; mainWindow.loadURL(url); if (isDev) { mainWindow.webContents.openDevTools(); }}; app.on("ready", createWindow); // Quit when all windows close (except on macOS)app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); }}); // On macOS, re-create the window when the dock icon is clickedapp.on("activate", () => { if (mainWindow === null) { createWindow(); }});Key Differences from Web
- 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.
- 1File system access. Use Electron's file dialogs:
// preload.ts (safe bridge between renderer and main)import { contextBridge, ipcMain } from "electron";import { dialog } from "electron"; contextBridge.exposeInMainWorld("electron", { openFile: () => dialog.showOpenDialog({ properties: ["openFile"], filters: [{ name: "Text", extensions: ["txt", "md"] }], }),});Then in your React component:
"use client"; export function ImportFile() { const handleImport = async () => { const result = await window.electron.openFile(); if (result.canceled) return; const filePath = result.filePaths[0]; // Read the file, import data }; return <button onClick={handleImport}>Import from File</button>;}- 1Native menus. Use Electron's Menu API instead of building a dropdown:
// main/index.tsconst menu = [ { label: "File", submenu: [ { label: "Open", accelerator: "CmdOrCtrl+O", click: () => {} }, { label: "Save", accelerator: "CmdOrCtrl+S", click: () => {} }, ], }, { label: "Edit", submenu: [{ label: "Undo", accelerator: "CmdOrCtrl+Z", click: () => {} }], },]; Menu.setApplicationMenu(Menu.buildFromTemplate(menu));- 1Offline-first data. If the app should work offline, store data locally (SQLite, JSON files) instead of relying on a server.
// Example: use IndexedDB (same in web and Electron)async function saveBook(book: Book) { const db = await openDB("books"); await db.add("books", book);}Building Installers
Use electron-builder to create .dmg (Mac) and .exe (Windows) installers:
npm run build # Build Next.jsnpm run electron:build # Package into installersThe 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+Qquits the app,Cmd+Wcloses 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
.exeand.msiinstallers
Always test on both platforms. The "works on my Mac" problem is real.
Section 9: Shipping Checklist
Before deploying to production, run through this list:
Code Quality
Performance
User Experience
Security
Monitoring
Documentation
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.
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
Section 12: Common Mistakes to Avoid
- 1Building in isolation. Deploy early, get feedback early. A live app is better than perfect code that no one sees.
- 1Ignoring performance until the end. By then, the bundle is 500 KB and you're fighting optimization. Measure from day one.
- 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.
- 1Over-engineering. A todo list doesn't need a complex state management library. Start simple. Add complexity only when you hit its limits.
- 1Forgetting the mobile experience. "Works on my Mac" is not shipping. Test on actual phones (or at least Chrome DevTools mobile mode).
- 1Mixing concerns. Keep components small. Keep forms separate from display logic. Keep API routes focused on one job.
- 1Not documenting as you go. Future you will forget how this works. Leave yourself notes (README, inline comments for non-obvious decisions).
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.
Checkpoint Rubric (Final Assessment)
Your project is shipping-ready when:
Functionality (40 points)
Performance (20 points)
User Experience (20 points)
Deployment (10 points)
Extra Credit (up to 10 points)
Total: 100 points
Resources
Next.js:
- Next.js Documentation, authoritative, well-written
- React Server Components RFC, understand the philosophy
- App Router Migration Guide, upgrading from Pages Router
Performance:
- Web.dev Core Web Vitals, official guide
- Lighthouse Documentation
- Bundle Analyzer
Deployment:
Electron:
- Electron Documentation
- electron-builder, building installers
- Electron Security, important
Forms & Validation:
- React Hook Form, lightweight form library (optional, not required)
- Zod, TypeScript-first schema validation
Next Steps
- 1Choose a project idea (reading list, portfolio, expense tracker, etc.)
- 2Sketch the features and data model
- 3Initialize a Next.js app with TypeScript and Tailwind
- 4Deploy to Vercel immediately (get the live URL)
- 5Build the core features (display, create, delete)
- 6Measure performance and iterate
- 7Polish the UI and refine error handling
- 8Checkpoint: submit your live link and Lighthouse report
- 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.